10
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

SwiftUIのLayout protocolについて

Last updated at Posted at 2023-12-06

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では以下のプロセスでレイアウト処理が実行されます。

  1. 親Viewから子Viewのサイズを提案
  2. 子Viewは自身のサイズを決定
  3. 親Viewが子Viewを配置
  4. Viewの端を最も近いピクセルに丸める

layout_procedure.png

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(at:anchor:proposal:)

ここでは、子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を簡単に実装できます。

左端揃え 中央揃え 右端揃え
leading.png center.png trailing.png
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)
    }
}

参考資料

10
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?