[SwfitUI] Layout を理解する

SwiftUI2021

     
iOS16, macOS13 で新しく導入される Layout を使ってみます。

環境&対象

以下の環境で動作確認を行なっています。

  • macOS Monterey 12.5 Beta
  • Xcode 14.0 Beta3
  • iOS 16.0 beta

Layout

これまでは、SwiftUI でのビューの位置を調整しようとするときは、以下のようなことをしていました。
・GeometryReader を使用して、親ビューから提案されているサイズを取得して調整する。
・Preference を使用して、子ビューのサイズ調整を親ビューに伝える。

Apple の WWDC ビデオでも指摘されていますが、処理がループしてしまうこともあり得るため、工夫が必要でした。
(Apple のビデオでも 上記の調整方法は推奨しないと言っています。)

これまでは、SwiftUI でレイアウトを行うには レイアウト用コンテナや .frame 等の View Modifier を使う以外にはできませんでした。

新しく Layout が導入されたことで、レイアウト処理の一部としてレイアウトを調整できるようになります。

Layout を使って、VStack 相当のレイアウトを自分で作ってみます。
作ってみることで、より SwiftUI でのレイアウトの仕組みが理解できるようになることが期待です。

Layout は、実際には Protocol であり、準拠した struct を作ることで 独自 Layout を作成することができます。
Layout に準拠するためには、2つのメソッドを実装することが必要です。


func sizeThatFits(
    proposal: ProposedViewSize,
    subviews: Self.Subviews,
    cache: inout Self.Cache
) -> CGSize

func placeSubviews(
    in bounds: CGRect,
    proposal: ProposedViewSize,
    subviews: Self.Subviews,
    cache: inout Self.Cache
)

順番に説明していきます。

sizeThatFits

この関数は、自分が必要とするサイズを返すようにします。

親ビューは、この関数が返すサイズを使用して、レイアウトすることになります。

この関数は、以下の3つの引数を持ちます。
・proposal: ProposedViewSize
・subviews: Self.Subviews
・cache: inout Self.Cache

proposal

親ビューから渡される 提案サイズです。親ビューは、複数のサイズでのレイアウトを検討するために、異なるサイズを引数に複数回 sizeThatFits を呼ぶことがあり得ますので、注意が必要です。
また、この proposal は、CGSize での値を持たないこともあるため さらに注意が必要です。具体的には、以下の値を持つことがあります。
・zero: できるだけ小さい領域にレイアウトすることをリクエスト
・infinity: できるだけ大きい領域にレイアウトすることをリクエスト
・unspecified: 理想的な大きさの領域にレイアウトすることをリクエスト

subviews

subviews は、自 Layout の子ビューです。つまり レイアウト対象の 子ビューということです。
ただし、子 ビュー自体ではなく Proxy となる Subview の配列になっています。(自 Layout から、子ビューを変更することはできません。)
具体的な型は、[LayoutSubview] です。

cache

cache は、計算コストが高いケースで、計算した情報をキャッシュしておくための領域です。使わずにおいても問題ありません。

placeSubviews

この関数で、子ビューのレイアウトを実際に行います。

この関数は、以下の4つの引数を持ちます。
・bounds: CGRect
・proposal: ProposedViewSize
・subviews: Self.Subviews
・cache: inout Self.Cache

bounds

bounds は、親ビューから提案されたサイズです。
型は、CGRect です。このとき、CGRect の原点が (0,0) であることは保証されていないので、.minX, .minY 等を使用して計算することが必要となります。

proposal

proposal は、親ビューから提案されたサイズです。
sizeThatFits は、レイアウトを決定するために異なる proposal 値で呼ばれることがあるので、placeSubviews で与えられる proposal が最終的に レイアウトで使用されるサイズです。

subviews/cache

subviews、cache は、sizeThatFits が受け取るものと同じです。

MyVStack

自分で VStack 相当の Layout を作成するために2つのメソッドを実装していきます。

VStack 相当から少しづつ拡張もしてみます。

GuideBoardStack という名称で、以下のような表示になるようなレイアウトを作ってみます。

MyVStack

将来的(?)には 以下のようなレイアウトに拡張(?)していく予定です。

GuideBoardStack

仕様

この GuideBoardStack レイアウトでは、以下のようなレイアウトにします。
・与えられたビューを上から順番に、配置する

つまり、まずは、VStack がやっているレイアウトを自分で作ってみるということです。

sizeThatFits

sizeThatFits では、使用する大きさを返します。

VStack と同様の振る舞いとするので、以下のような大きさになります。
・幅は、最大の幅を持つビューの幅
・高さは、各ビューの高さの総和 + ビュー間のスペース


    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        guard !subviews.isEmpty else { return .zero }
		// 1)
        let subviewSizes = subviews.map({$0.sizeThatFits(.unspecified)})
		// 2)
        let maxWidth: CGFloat = subviewSizes.reduce(0.0, {partialResult, subviewSize in
            max(partialResult, subviewSize.width)
        })
		// 3)
        var totalHeight = subviewSizes.reduce(0.0, { partialResult, subviewSize in
            partialResult + subviewSize.height
        })
		// 4)
        _ = subviews.indices.dropLast().map({
            totalHeight += subviews[$0].spacing.distance(to: subviews[$0 + 1].spacing, along: .vertical)
        })
		// 5)
        return CGSize(width: maxWidth, height: totalHeight)
    }

以下のような処理で、使用する大きさを算出し CGSize として返しています。
1) sizeThatFits を子ビューの proxy に対して使用して、子ビューそれぞれのサイズを確認。
2) 子ビューの最大幅を見つける
3) 子ビューの高さの和を算出
4) 子ビュー間の縦方向のスペーシング分を加算
5) 子ビューの最大幅を持ち、スペーシングを含めた子ビューの高さの和を CGSize として返す

palceSubviews

palceSubviews で 子ビューを配置していきます。

subview のメソッドである place(at: ,anchor:, proposal:) を使用して配置します。
at は、配置座標、anchor は、配置座標に合わせる箇所、proposal は、子ビューに提案するサイズです。


    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        guard !subviews.isEmpty else { return }
        // 1)
        var y = bounds.minY
        for index in subviews.indices {
            // 2)
            subviews[index].place(at: CGPoint(x: bounds.midX, y: y),
                                  anchor: .top,
                                  proposal: .unspecified)
            // 3)
            y += subviews[index].sizeThatFits(.unspecified).height

            // 4)
            let nextIndex = subviews.index(after: index)
            if nextIndex < subviews.endIndex {
                y += subviews[index].spacing.distance(to: subviews[nextIndex].spacing, along: .vertical)
            }
        }
    }

上から 順番に配置していき、配置後に、次のビュー配置位置を計算する という処理にしています。
なお、placeSubviews に与えられる bounds は、(0,0) を原点に持つことは保証されていません。

1) 上から順番に配置していくので、最初に配置する y 座標は、bounds として与えられる矩形の minY です。
2) placeで配置するときに、x 座標は、bounds の中心を与えています。合わせる位置指定の anchor に .top を指定しますので、ちょうど .topCenter 相当 を指定していることになります。proposal には、.unspecified を指定しています。
3) 配置後、次のビューを配置する y 座標向けに、配置したビューの高さ分ずらします。
4) 次のビューまでの spacing を計算し、y に追加します。

最後のビューは、次のビューまでの spacing を計算できないため、4) が少し複雑な条件式になっています。

完成図

このようにして作った Layout で配置すると以下のようになります。

GuideBoardStackStep1

作成した Layout が配置対象としている領域に緑の枠をつけています。

長くなってしまったので、少し凝ったレイアウトへの変更は次回以降で行っていきます。

まとめ

Layout を使って、独自レイアウトを作る

Layout を使って、独自レイアウトを作る
  • sizeThatFits は、レイアウトで使用する領域を返す
  • sizeThatFits は、子ビューに対して呼び出すことで、子ビューの希望するサイズを確認することができる
  • subview.spacing を使用することで、要素間のスペーシング値を取得できる
  • placeSubviews では、実際に、子ビューをレイアウトしていく
  • placeSubviews に渡される bounds は、(0,0) スタートが保証されないので、minX/minY/maxX/maxY 等を使用して配置位置を計算する
  • 子ビューを配置する place では、anchor で指定位置に子ビューのどの位置を合わせるかを指定できる

説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。

SwiftUI おすすめ本

SwiftUI を理解するには、以下の本がおすすめです。

# SwiftUI2.0 が登場したことで少し古くなってしまいましたが、いまでも 定番本です。

コード全体

以下は、使用したコードの全体です。


//
//  ContentView.swift
//
//  Created by : Tomoaki Yagishita on 2022/07/13
//  © 2022  SmallDeskSoftware
//

import SwiftUI

struct ContentView: View {
    var body: some View {
        ZStack(alignment: .top) {
            RoundedRectangle(cornerRadius: 2)
                .fill(.gray).frame(width: 30, height: 300)
            GuideBoardStack()
                .callAsFunction({
                GuideArrow(text: "London", direction: .up)
                GuideArrow(text: "Tokyo", direction: .upright).font(.title)
                GuideArrow(text: "Nagoya", direction: .left)
                GuideArrow(text: "Okinawa", direction: .downleft)
                GuideArrow(text: "Australia", direction: .down)
                GuideArrow(text: "Yokohama Station", direction: .right)
            })
            //.border(.green)
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

struct GuideBoardStack: Layout {
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        guard !subviews.isEmpty else { return .zero }
        let subviewSizes = subviews.map({$0.sizeThatFits(.unspecified)})
        let maxWidth: CGFloat = subviewSizes.reduce(0.0, {partialResult, subviewSize in
            max(partialResult, subviewSize.width)
        })
        var totalHeight = subviewSizes.reduce(0.0, { partialResult, subviewSize in
            partialResult + subviewSize.height
        })
        _ = subviews.indices.dropLast().map({
            totalHeight += subviews[$0].spacing.distance(to: subviews[$0 + 1].spacing, along: .vertical)
        })
        return CGSize(width: maxWidth, height: totalHeight)
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        guard !subviews.isEmpty else { return }

        var y = bounds.minY
        for index in subviews.indices {
            subviews[index].place(at: CGPoint(x: bounds.midX, y: y),
                                  anchor: .top,
                                  proposal: .unspecified)
            y += subviews[index].sizeThatFits(.unspecified).height
            let nextIndex = subviews.index(after: index)
            if nextIndex  subviews.endIndex {
                y += subviews[index].spacing.distance(to: subviews[nextIndex].spacing, along: .vertical)
            }
        }
    }
}

struct GuideArrow: View {
    let text: String
    let direction: GuideDirection
    var body: some View {
        HStack(spacing: 0) {
            switch direction {
            case .left, .up, .upleft, .downleft:
                Image(systemName: direction.symbolName)
                Text(text)
            case .right, .down, .downright, .upright:
                Text(text)
                Image(systemName: direction.symbolName)
            }
        }
        .foregroundColor(.white)
        .padding(2)
        .background{
            RoundedRectangle(cornerRadius: 5).fill(Color.blue)
        }
        .padding(1)
        .background{
            RoundedRectangle(cornerRadius: 6).fill(Color.white)
        }
        .padding(1)
        .background{
            RoundedRectangle(cornerRadius: 7).fill(Color.blue)
        }
    }
}

enum GuideDirection: String, RawRepresentable {
    case up, down, left, right, upleft, upright, downleft, downright
    var symbolName:String {
        switch self {
        case .up:
            return "arrow.up"
        case .down:
            return "arrow.down"
        case .left:
            return "arrow.left"
        case .right:
            return "arrow.right"
        case .upleft:
            return "arrow.up.left"
        case .upright:
            return "arrow.up.right"
        case .downleft:
            return "arrow.down.left"
        case .downright:
            return "arrow.down.right"
        }
    }
}

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です