これは フェンリル デザインとテクノロジー Advent Calendar 2023 6日目の記事です。
Layoutとは
Layoutは、WWDC22で発表されたiOS 16以降で利用できるプロトコルです。
Layoutに準拠した型を実装することで、HStackやVStack、ViewModifierでは実装が難しい複雑なレイアウトのレイアウトコンテナを定義することができます。
Layoutに準拠するためには、以下の2つのメソッドを実装する必要があります。
/// Returns the size of the composite view, given a proposed size and the view’s subviews.
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Self.Subviews,
cache: inout Self.Cache
) -> CGSize
/// Assigns positions to each of the layout’s subviews.
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Self.Subviews,
cache: inout Self.Cache
)
また、定義したCustom Layoutは以下のような形で呼び出すことができます。
struct CustomLayout: Layout {
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
// Calculate and return the size of the layout container.
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
// Tell each subview where to appear.
}
}
CustomLayout {
Text("A Subview")
Text("Another Subview")
}
SwiftUIのレイアウトシステムについて
Layoutの動作について理解するために、SwiftUIのレイアウトシステムについて簡単に振り返ります。
SwiftUIでは以下のプロセスでレイアウト処理が実行されます。
- 親Viewから子Viewのサイズを提案
- 子Viewは自身のサイズを決定
- 親Viewが子Viewを配置
- Viewの端を最も近いピクセルに丸める
Layoutの準拠に必要な2つのメソッドについて
structがLayoutに準拠するためには、2つのメソッドが必要です。それぞれの役割について説明します。
ここでは、VStackのように渡されたViewを上から順番に並べて表示するレイアウトを実装します。
sizeThatFits
sizeThatFitsでは、レイアウトコンテナがViewを表示するために使用する大きさを返します。
VStackを実装する場合、必要な大きさは以下の通りです。
- 幅は、最大の幅を持つViewの幅
- 高さは、各Viewの高さの総和+View間のスペース
/// レイアウトコンテナが子Viewを表示するのに必要なサイズを返す。
///
/// - Parameters:
/// - proposal: 親Viewから渡されるコンテナの提案サイズ
/// - subviews: コンテナ内の子View
/// - cache: 計算結果のキャッシュ
/// - Returns: CGSize
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
guard !subviews.isEmpty else {
return .zero
}
// それぞれの子Viewが必要なサイズを取得
let subviewSizes = subviews.map { $0.sizeThatFits(.unspecified) }
// 一番幅が大きな子Viewのwidthを取得
let maxWidth = subviewSizes.reduce(0.0) { partialResult, subviewSize in
max(partialResult, subviewSize.width)
}
// 子Viewの高さの総和を取得
var totalHeight = subviewSizes.reduce(0.0) { partialResult, subviewSize in
partialResult + subviewSize.height
}
// totalHeightに子View間の縦方向のspacingを加算
subviews.indices.dropLast().forEach { index in
totalHeight += subviews[index].spacing.distance(to: subviews[index + 1].spacing, along: .vertical)
}
return CGSize(width: maxWidth, height: totalHeight)
}
placeSubviews
placeSubviewsでは、子Viewを配置する処理を実装します。
subviewのメソッドであるplace(at:anchor:proposal:)
を使って、子Viewを配置します。
https://developer.apple.com/documentation/swiftui/layoutsubview/place(atproposal:)
ここでは、子Viewを上から順番に配置し、その後に次のViewの配置位置を計算する処理を実装しています。
/// Subviewをレイアウトコンテナに配置する。
///
/// - Parameters:
/// - bounds: 親Viewがコンテナを配置する領域
/// - proposal: 親Viewから渡される提案サイズ
/// - subviews: コンテナ内の子View
/// - cache: 計算結果のキャッシュ
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
guard !subviews.isEmpty else {
return
}
// 最初に配置するy座標は、boundsとして与えられる矩形のminY
var y = bounds.minY
subviews.indices.forEach { index in
// subviewを配置
subviews[index].place(
at: CGPoint(x: bounds.midX, y: y),
anchor: .top,
proposal: .unspecified
)
// 配置したViewの高さの分だけy座標をずらす
y += subviews[index].sizeThatFits(.unspecified).height
// 次のViewとの間のspacingを計算し、y座標に加える
let nextIndex = subviews.index(after: index)
if nextIndex < subviews.endIndex {
y += subviews[index].spacing.distance(to: subviews[nextIndex].spacing, along: .vertical)
}
}
}
Layoutを使ったカスタムレイアウトの例
Appleが公開しているサンプルコードの中でFlowLayout
というカスタムレイアウトが定義されていました。
これを使うことで、以下のようなViewを簡単に実装できます。
左端揃え | 中央揃え | 右端揃え |
---|---|---|
struct ContentView: View {
private let keywords = ["Swift", "iOS開発", "SwiftUI", "UIKit", "WWDC", "Python", "JavaScript", "PHP", "Ruby", "Flutter", "Dart", "Android", "iPhone"]
var body: some View {
FlowLayout(alignment: .trailing, spacing: 8) {
ForEach(keywords, id: \.self) { keyword in
Text(keyword)
.padding(.vertical, 5)
.padding(.horizontal, 12)
.background(Color(.systemGroupedBackground))
.cornerRadius(15)
}
}
.padding(20)
.background(Color.black)
}
}
参考資料