柔軟にコンポーネントを配置する自作Layoutの解説(SwiftUI)

# 柔軟にコンポーネントを配置する自作Layoutの解説(SwiftUI)
SwiftUIでコンポーネントを動的かつ綺麗に設置するためのLayout実装のソースコードをコメント付きで掲載します

**注意：コメントの内容は筆者がAppleのドキュメントを読んだ上での独自の解釈を前提としています**

## 背景
以下の記事で動的にsubview(Viewのコンポーネント)を配置するためのLayoutが示されていましたが、アルゴリズムやデータのフローの読解が難しく感じたためコメントをソースコードに加えながら理解に努めました

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

実装例を確認したい場合は引用記事を参照してください

## ソースコード
```
struct FlowLayout: Layout {
    var alignment: Alignment = .center
    var spacing: CGFloat?

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize {
        
        // FlowResultのイニシャライザ呼び出しの時点で動的にsubviewを挿入するのに必要な情報が作成済み
        let result = FlowResult(
            in: proposal.replacingUnspecifiedDimensions().width,
            subviews: subviews,
            alignment: alignment,
            spacing: spacing
        )
        return result.bounds // layoutのCGSizeをreturn
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) {
        let result = FlowResult(
            in: proposal.replacingUnspecifiedDimensions().width,
            subviews: subviews,
            alignment: alignment,
            spacing: spacing
        )
        for row in result.rows {
            let rowXOffset = (bounds.width - row.frame.width) * alignment.horizontal.percent // alignmentの値に応じてLayoutの左端からの幅を導出
            for index in row.range {
                
                // rowの左端からのオフセット、Row内でのsubviewのRow左端からのオフセット、Layout左端のキャンパスの左端からのオフセットを足す
                let xPos = rowXOffset + row.frame.minX + row.xOffsets[index - row.range.lowerBound] + bounds.minX // XOffsetの導出ではRangeとした場合のrowの下限を現在のsubviewのindexから引く
                
                // 行内でのsubviewのalignmentを設定
                let rowYAlignment = (row.frame.height - subviews[index].sizeThatFits(.unspecified).height) *
                alignment.vertical.percent
                let yPos = row.frame.minY + rowYAlignment + bounds.minY // 行の上部のheight(bounds.minYが基準)、行内でのsubviewのalignment、layout全体のheightの和
                subviews[index].place(at: CGPoint(x: xPos, y: yPos), anchor: .topLeading, proposal: .unspecified) // (width: x, height: y)を左上に基準としてsubviewをplaceする
            }
        }
    }

    struct FlowResult {
        var bounds = CGSize.zero // Layout全体のサイズ
        var rows = [Row]() // 各行の情報を格納するCollection

        struct Row {
            var range: Range<Int> // subviewのインデックス範囲
            var xOffsets: [Double] // x方向の左端からの距離
            var frame: CGRect // 行全体のwidthとheightの情報
        }

        init(in maxPossibleWidth: Double, subviews: Subviews, alignment: Alignment, spacing: CGFloat?) {
            var itemsInRow = 0 // 最初のviewはインデックスが0のRowにある
            var remainingWidth = maxPossibleWidth.isFinite ? maxPossibleWidth : .greatestFiniteMagnitude // Row内の残りの幅を初期化。通常は横幅が無限でない場合は横の最大幅で初期化
            var rowMinY = 0.0
            var rowHeight = 0.0 // rowの高さ
            var xOffsets: [Double] = [] // row内のsubviewのxoffsetを入れる配列
            
            // subviewをそれぞれrowに格納する処理をする
            for (index, subview) in zip(subviews.indices, subviews) {
                let idealSize = subview.sizeThatFits(.unspecified)
                
                // 最初のsubviewでない、かつRowの残り幅をsubviewが超過する場合に次のRowに移動する
                if index != 0 && widthInRow(index: index, idealWidth: idealSize.width) > remainingWidth {
                    finalizeRow(index: max(index - 1, 0), idealSize: idealSize)
                }
                addToRow(index: index, idealSize: idealSize) // Rowに挿入、subviewのindexとそのサイズを情報として追加

                // 最後のsubviewの挿入後にfinalizeRow
                if index == subviews.count - 1 {
                    finalizeRow(index: index, idealSize: idealSize)
                }
            }

            // 一つ前のsubviewとのspacingを測る
            func spacingBefore(index: Int) -> Double {
                guard itemsInRow > 0 else { return 0 } // Rowの先頭であれば早期return
                
                // nil結合演算子で、spacingか、spacingがnilの場合に一つ前のsubviewとの間のspacingを返す
                return spacing ?? subviews[index - 1].spacing.distance(to: subviews[index].spacing, along: .horizontal)
            }

            // subviewを挿入した後の左端からのspacingをreturn
            func widthInRow(index: Int, idealWidth: Double) -> Double {
                idealWidth + spacingBefore(index: index)
            }

            func addToRow(index: Int, idealSize: CGSize) {
                let width = widthInRow(index: index, idealWidth: idealSize.width)

                xOffsets.append(maxPossibleWidth - remainingWidth + spacingBefore(index: index)) // rowに挿入するsubviewのxoffsetを配列に追加
                remainingWidth -= width // rowの残り幅を更新
                rowHeight = max(rowHeight, idealSize.height) // rowのCGSizeの高さを更新
                itemsInRow += 1 // row内のsubviewのカウントを追加
            }

            
            func finalizeRow(index: Int, idealSize: CGSize) {
                let rowWidth = maxPossibleWidth - remainingWidth // 挿入していったsubviewの最後尾のspacing
                
                // Rowの情報をrowに格納
                rows.append(
                    Row(
                        range: index - max(itemsInRow - 1, 0) ..< index + 1, // 挿入したsubviewのsubviews内でのindex
                        xOffsets: xOffsets,
                        frame: CGRect(x: 0, y: rowMinY, width: rowWidth, height: rowHeight) // Rowのサイズ情報
                    )
                )
                bounds.width = max(bounds.width, rowWidth) // Layoutのwidthを更新
                let ySpacing = spacing ?? ViewSpacing().distance(to: ViewSpacing(), along: .vertical)
                bounds.height += rowHeight + (rows.count > 1 ? ySpacing : 0) // Layoutのheightを更新
                rowMinY += rowHeight + ySpacing
                
                // 以下で動的にsubviewを挿入するのに必要な変数を初期化s
                itemsInRow = 0
                rowHeight = 0
                xOffsets.removeAll()
                remainingWidth = maxPossibleWidth
            }
        }
    }
}

private extension HorizontalAlignment {
    var percent: Double {
        switch self {
        case .leading: return 0
        case .trailing: return 1
        default: return 0.5
        }
    }
}

private extension VerticalAlignment {
    var percent: Double {
        switch self {
        case .top: return 0
        case .bottom: return 1
        default: return 0.5
        }
    }
}
```

### 終わりに
とても使い勝手の良い実装について学ぶことができました。
またApple Documentを読む良い訓練になったので、引き続き学習に努めていきます。