SwiftUIのLayout protocolについて

:::note info
これは [フェンリル デザインとテクノロジー Advent Calendar 2023](https://adventar.org/calendars/9360) 6日目の記事です。
:::

## Layoutとは

Layoutは、WWDC22で発表されたiOS 16以降で利用できるプロトコルです。
Layoutに準拠した型を実装することで、HStackやVStack、ViewModifierでは実装が難しい複雑なレイアウトのレイアウトコンテナを定義することができます。

https://developer.apple.com/documentation/swiftui/layout

https://developer.apple.com/videos/play/wwdc2022/10056/

Layoutに準拠するためには、以下の2つのメソッドを実装する必要があります。

```swift
/// 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は以下のような形で呼び出すことができます。

```swift
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.
    }
}
```

```swift
CustomLayout {
    Text("A Subview")
    Text("Another Subview")
}
```


## SwiftUIのレイアウトシステムについて

Layoutの動作について理解するために、SwiftUIのレイアウトシステムについて簡単に振り返ります。

SwiftUIでは以下のプロセスでレイアウト処理が実行されます。
1. 親Viewから子Viewのサイズを提案
1. 子Viewは自身のサイズを決定
1. 親Viewが子Viewを配置
1. Viewの端を最も近いピクセルに丸める

![layout_procedure.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3555559/3c6683ff-8c3e-87a2-8792-86a89bf22f4d.png)

https://developer.apple.com/videos/play/wwdc2019/237/

## Layoutの準拠に必要な2つのメソッドについて

structがLayoutに準拠するためには、2つのメソッドが必要です。それぞれの役割について説明します。

ここでは、VStackのように渡されたViewを上から順番に並べて表示するレイアウトを実装します。

### sizeThatFits

sizeThatFitsでは、レイアウトコンテナがViewを表示するために使用する大きさを返します。

VStackを実装する場合、必要な大きさは以下の通りです。
- 幅は、最大の幅を持つViewの幅
- 高さは、各Viewの高さの総和+View間のスペース

```swift
    /// レイアウトコンテナが子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の配置位置を計算する処理を実装しています。

```swift
    /// 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`というカスタムレイアウトが定義されていました。

https://github.com/apple/sample-food-truck/blob/main/App/General/FlowLayout.swift

これを使うことで、以下のようなViewを簡単に実装できます。

| 左端揃え | 中央揃え | 右端揃え |
| --- | --- | --- |
| ![leading.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3555559/dff9f1fb-cf32-4fed-50b0-2f9a94377574.png) | ![center.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3555559/4b259cc5-16a4-0d79-44f0-bbc7f3912941.png) | ![trailing.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3555559/69401981-9377-5631-ab9d-1949cfa759c4.png) |

```swift
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)
    }
}
```

## 参考資料

https://developer.apple.com/videos/play/wwdc2022/10056/

https://developer.apple.com/documentation/swiftui/layout

https://developer.apple.com/documentation/swiftui/food_truck_building_a_swiftui_multiplatform_app

https://software.small-desk.com/development/2022/07/29/swiftui-howto-layout/

https://shtnkgm.com/blog/2023-04-05-swiftui-layout.html

https://note.com/taatn0te/n/nd55c8a99c742

https://qiita.com/SNQ-2001/items/d2e752bfd5f25cedfce7