WWDC Quick Look 💓 By SwiftGGTeam
Compose custom layouts with SwiftUI

Compose custom layouts with SwiftUI

元の動画を見る

ハイライト

このセッションは、SwiftUI レイアウト機能のための 4 つの新しいツールを中心に展開します。 1 つ目は、遅延読み込みのない 2 次元レイアウトのギャップを埋める、まったく新しい Grid コンテナです。以前は LazyHGrid/LazyVGrid しかありませんでしたが、スクロールのパフォーマンスのために双方向の自動サイズ計算が犠牲になっていました。新しいグリッドはすべてのサブビューを一度にロードし、行の高さと列の幅を自動的に計算できます。これは、ランキングや設定パネルなどの固定コンテンツを含むシーンに特に適しています。


主要内容

SwiftUI にはすでにHStackVStackそして怠惰なグリッド。インターフェイスの配置に関するほとんどの問題を解決できます。

この問題は、より具体的なシナリオで発生します。

投票アプリのランキング リストには、名前、進行状況バー、投票数の 3 つの列が必要です。名前列には最長の名前のみを入力する必要があり、投票列には最大の数値のみを入力する必要があり、残りのスペースは進行状況バーに表示されます。このインターフェースのコンテンツは固定されており、スクロールする必要はありません。表示されているコンテンツのみを読み込むため、遅延グリッドは行と列を同時に自動的に測定できません。

SwiftUI 2022 はこのギャップを埋めます。Gridすべてのサブビューを一度にロードするので、行の高さと列の幅を同時に計算できます。Layoutこのプロトコルを使用すると、独自のコンテナを作成し、SwiftUI の測定および配置プロセスに直接参加できます。ViewThatFits複数の候補ビューから最初に利用可能なバージョンを選択します。AnyLayout異なるレイアウト間を切り替えるときにビューの同一性を維持し、アニメーションを連続的にします。

このスピーチの例は非常に完全です: ランキング リストGrid、投票ボタンに固定幅のカスタム スタックを使用し、大きなフォント サイズに使用します。ViewThatFits縦の列に切り分け、一番上のアバターを放射状にレイアウトしてから、AnyLayout三方引き分けの場合はスイッチバックHStack


詳細

グリッド: 静的な 2D コンテンツは SwiftUI によって自動的に測定されます

(04:28) ランキング リストは典型的な 2 次元レイアウトです。各行はペットで、各列には名前、投票率、投票数が表示されます。

struct Leaderboard: View {
    var pets: [Pet]
    var totalVotes: Int

    var body: some View {
        Grid(alignment: .leading) {
            ForEach(pets) { pet in
                GridRow {
                    Text(pet.type)
                    ProgressView(
                        value: Double(pet.votes),
                        total: Double(totalVotes))
                    Text("\(pet.votes)")
                        .gridColumnAlignment(.trailing)
                }

                Divider()
            }
        }
        .padding()
    }
}

キーポイント:

  • Grid(alignment: .leading)グリッド全体のデフォルトの配置を設定します。 -ForEach(pets)データを使用して複数行のコンテンツを生成します。Pet例で実装したものIdentifiable
  • GridRowそれぞれの 3 つのビューは 3 つの列に分かれています。 -ProgressViewこれは柔軟なビューであり、テキスト列の外側の残りのスペースを取得します。 -Text("\(pet.votes)")投票数を表示します。 -.gridColumnAlignment(.trailing)投票が配置されている列にのみ影響し、数値を右に揃えます。 -Divider()つけるGridRow外側では、グリッドの幅全体に広がります。

Gridリーダーボードは長いリストではないため、ここに収まります。すべてのセルを測定し、各列と行に必要な寸法を自動的に取得できます。

レイアウト: カスタム コンテナをレイアウト エンジンに書き込む

(10:53) 投票ボタンにはもう 1 つの要件があります。各ボタンの幅は同じですが、その幅は最も幅の広いテキストの理想的な幅とのみ同じです。HStackこれにより、ボタンはそれぞれのテキストの幅に応じて配置されます。テキストにフレキシブル フレームを追加すると、ボタンがコンテナ内に収まります。

Layoutこの合意が取り上げているのはこの中間点である。

struct MyEqualWidthHStack: Layout {
    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout Void
    ) -> CGSize {
        // Return a size.
    }

    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout Void
    ) {
        // Place child views.
    }
}

キーポイント:

  • MyEqualWidthHStack自分自身を SwiftUI レイアウト コンテナーとして宣言します。 -sizeThatFitsコンテナがどのくらいの大きさになりたいかを答える責任があります。 -proposalこれは、親コンテナによってこのレイアウトに与えられるサイズの提案です。 -subviewsサブビューにサイズを提案し、結果を読み取るために使用されるプロキシのセットです。 -cache中間計算は 2 つのメソッド間で共有できます。これは、この単純な例では使用されません。 -placeSubviewsサブビューを配置する責任を負いますbounds
  • bounds起源は想定できない(0, 0)、置くときに使用します。bounds.minXbounds.midYそういう属性。

(13:44) モノ幅レイアウトでは、まず各ボタンの理想的なサイズを測定し、次に最大幅と最大高さを見つけます。

private func maxSize(subviews: Subviews) -> CGSize {
    let subviewSizes = subviews.map { $0.sizeThatFits(.unspecified) }
    let maxSize: CGSize = subviewSizes.reduce(.zero) { currentMax, subviewSize in
        CGSize(
            width: max(currentMax.width, subviewSize.width),
            height: max(currentMax.height, subviewSize.height))
    }

    return maxSize
}

キーポイント:

  • subviews.mapすべてのサブビュー デリゲートを反復処理します。 -$0.sizeThatFits(.unspecified)サブビューに理想的なサイズを尋ねます。 -reduce(.zero)サイズゼロから最大値を累積します。 -max(currentMax.width, subviewSize.width)最も幅の広いボタンを見つけます。 -max(currentMax.height, subviewSize.height)一番上のボタンを見つけます。
  • 返されましたmaxSizeコンテナの測定とサブビューの配置の両方に使用されます。

(15:40) 安易に間隔を固定値で書かないでください。 SwiftUI のサブビュー プロキシは、システム推奨の間隔を提供できます。

private func spacing(subviews: Subviews) -> [CGFloat] {
    subviews.indices.map { index in
        guard index < subviews.count - 1 else { return 0 }
        return subviews[index].spacing.distance(
            to: subviews[index + 1].spacing,
            along: .horizontal)
    }
}

キーポイント:

  • subviews.indices.map各サブビューと次のサブビューの間隔を生成します。 -guard index < subviews.count - 1 else { return 0 }最後のビューの後ろに余分なスペースを残さないでください。 -subviews[index].spacing現在のビューの間隔設定を読み取ります。 -distance(to:along:)また、共通エッジ上の 2 つの隣接するビューの優先順位も考慮してください。 -.horizontal水平方向の間隔を計算することを示します。

(16:33) サブビューの最大サイズと間隔を使用すると、sizeThatFitsコンテナのサイズを返します。

func sizeThatFits(
    proposal: ProposedViewSize,
    subviews: Subviews,
    cache: inout Void
) -> CGSize {
    // Return a size.
    guard !subviews.isEmpty else { return .zero }

    let maxSize = maxSize(subviews: subviews)
    let spacing = spacing(subviews: subviews)
    let totalSpacing = spacing.reduce(0) { $0 + $1 }

    return CGSize(
        width: maxSize.width * CGFloat(subviews.count) + totalSpacing,
        height: maxSize.height)
}

キーポイント:

  • 空のレイアウトが直接返されます.zero
  • maxSize(subviews:)すべてのボタンに共有される幅と高さを取得します。 -spacing(subviews:)隣接するボタン間のシステム推奨の間隔を取得します。 -spacing.reduce(0) { $0 + $1 }合計距離を求めます。
  • 幅は、ボタンの最大幅にボタンの数を乗算し、間隔を加えたものと等しくなります。
  • 高さは、最も高いボタンの高さと同じです。

16:51placeSubviews同じ寸法を使用して、各ボタンを独自の中心点に配置します。

func placeSubviews(
    in bounds: CGRect,
    proposal: ProposedViewSize,
    subviews: Subviews,
    cache: inout Void
) {
    // Place child views.
    guard !subviews.isEmpty else { return }
  
    let maxSize = maxSize(subviews: subviews)
    let spacing = spacing(subviews: subviews)

    let placementProposal = ProposedViewSize(width: maxSize.width, height: maxSize.height)
    var x = bounds.minX + maxSize.width / 2
  
    for index in subviews.indices {
        subviews[index].place(
            at: CGPoint(x: x, y: bounds.midY),
            anchor: .center,
            proposal: placementProposal)
        x += maxSize.width + spacing[index]
    }
}

キーポイント:

  • 空のレイアウトでは、サブビューを配置する必要はありません。 -placementProposal各ボタンに同じ幅と高さを推奨します。 -bounds.minX + maxSize.width / 2最初のボタンの中心点の x 座標を取得します。 -bounds.midYボタンを垂直方向の中央に配置します。 -subviews[index].place指定した点にサブビューを配置します。 -anchor: .center急行atサブビューの中心を指します。
  • 各サイクルの後、xボタンの幅と現在の間隔を 1 つ進めます。

(18:07) カスタム レイアウトの使用は、組み込みスタックの使用と同じです。

MyEqualWidthHStack {
    ForEach($pets) { $pet in
        Button {
            pet.votes += 1
        } label: {
            Text(pet.type)
                .frame(maxWidth: .infinity)
        }
        .buttonStyle(.bordered)
    }
}

キーポイント:

  • MyEqualWidthHStack一連の投票ボタンをラップします。 -ForEach($pets)バインディング配列をトラバースして、ボタンを直接変更できるようにしますpet.votes
  • pet.votes += 1それは投票行動です。 -Text(pet.type)候補者名を表示します。 -.frame(maxWidth: .infinity)テキストビューをレイアウトで指定された幅まで拡張できるようにします。 -.buttonStyle(.bordered)システムの枠付きボタン スタイルを使用します。

なぜ推奨されないのかについても説明しますGeometryReaderボタンの幅を反転します。GeometryReader測定されるのはそのコンテナであり、情報はサブビューに渡されます。測定結果を上部フレームに戻すために使用すると、レイアウト エンジンがバイパスされ、測定と再配置が繰り返される可能性があります。

ViewThatFits: ダイナミック タイプの代替レイアウトを準備する

(21:08) フォント サイズが大きいと、水平ボタンが収まらなくなります。現時点では、例には含まれていませんMyEqualWidthHStackもっと複雑に書きますが、ViewThatFitsレイアウト候補は2つ。

struct StackedButtons: View {
    @Binding var pets: [Pet]

    var body: some View {
        ViewThatFits {
            MyEqualWidthHStack {
                Buttons(pets: $pets)
            }
            MyEqualWidthVStack {
                Buttons(pets: $pets)
            }
        }
    }
}

キーポイント:

  • StackedButtons引き継ぐ@Binding var pets、ホールドボタンで投票データを変更できます。 -ViewThatFits内部候補ビューは順番にチェックされます。 ・第一候補は横等幅レイアウトです。 ・第二候補は縦等幅レイアウトです。
  • 横バージョンが収まらない場合、SwiftUIは縦バージョンを選択します。 -Buttons(pets: $pets)2 つの候補レイアウトでボタン コードが重複するのを避けるために、ボタンのコンテンツをサブビューに抽出します。

これは、ダイナミック タイプ、狭い画面、分割画面の処理に適しています。いくつかの明確なレイアウト バージョンを提供し、SwiftUI に現在利用可能なスペースから選択させます。

LayoutValueKey: サブビューがコンテナにレイアウト データを提供できるようにします。

(22:52) 上部のアバターは放射状レイアウトを使用しています。基本バージョンでは、サブビューの数に応じて各アバターをサークル上に配置します。

func placeSubviews(
    in bounds: CGRect,
    proposal: ProposedViewSize,
    subviews: Subviews,
    cache: inout Void
) {
    let radius = min(bounds.size.width, bounds.size.height) / 3.0
    let angle = Angle.degrees(360.0 / Double(subviews.count)).radians
    let offset = 0 // This depends on rank...

    for (index, subview) in subviews.enumerated() {
        var point = CGPoint(x: 0, y: -radius)
            .applying(CGAffineTransform(
                rotationAngle: angle * Double(index) + offset))

        point.x += bounds.midX
        point.y += bounds.midY

        subview.place(at: point, anchor: .center, proposal: .unspecified)
    }
}

キーポイント:

  • radiusレイアウト領域の幅と高さの小さい方の値の1/3をとります。 -angleサブビューの数に応じて 360 度を均等に分割します。
  • 初期点CGPoint(x: 0, y: -radius)円の中心の真上に位置します。 -CGAffineTransform(rotationAngle:)インデックスに基づいてこの点を回転します。 -point.x += bounds.midXそしてpoint.y += bounds.midYローカル座標をレイアウトの中心に移動します。 -subview.place計算された円周点にアバターを配置します。

(23:42) 円形レイアウトをランクごとに回転できるようにするには、レイアウトが各アバターのランクを認識している必要があります。LayoutValueKeyここはコミュニケーションチャネルです。

private struct Rank: LayoutValueKey {
    static let defaultValue: Int = 1
}

extension View {
    func rank(_ value: Int) -> some View {
        layoutValue(key: Rank.self, value: value)
    }
}

キーポイント:

  • Rank従うLayoutValueKey
  • defaultValueランクが設定されていないビューにデフォルト値を提供します。 -defaultValueまた、このレイアウト値の型は次のように決定されます。Int
  • extension Viewより読みやすいものを追加する.rank(_:)修飾子。 -layoutValue(key:value:)レイアウトのサブビュー エージェントが読み取ることができるように、ランクをビューに保存します。

(24:21) ランクを読み取った後、レイアウトはランキングに基づいて全体の回転オフセットを計算できます。

func placeSubviews(
    in bounds: CGRect,
    proposal: ProposedViewSize,
    subviews: Subviews,
    cache: inout Void
) {
    let radius = min(bounds.size.width, bounds.size.height) / 3.0
    let angle = Angle.degrees(360.0 / Double(subviews.count)).radians

    let ranks = subviews.map { subview in
        subview[Rank.self]
    }
    let offset = getOffset(ranks)

    for (index, subview) in subviews.enumerated() {
        var point = CGPoint(x: 0, y: -radius)
            .applying(CGAffineTransform(
                rotationAngle: angle * Double(index) + offset))
        point.x += bounds.midX
        point.y += bounds.midY
        subview.place(at: point, anchor: .center, proposal: .unspecified)
    }
}

キーポイント:

  • subviews.mapサブビューのデリゲートをトラバースします。 -subview[Rank.self]各サブビューのランクを読んでください。 -getOffset(ranks)ランク付けされた配列に基づいて回転角度を計算します。プレゼンテーションでは、この関数の内部ロジックについては詳しく説明されませんでした。
  • 配置ループは基本バージョンと同じですが、回転角度が追加されているだけですoffset
  • この方法では、レイアウト コンテナーはビジネス モデルにアクセスする必要がなく、各サブビューによって明示的に提供されるレイアウト値を読み取るだけです。

AnyLayout: レイアウトを切り替えるときにビューのアイデンティティを維持する

(25:18) 3 方向タイでは、円形レイアウトでは回転によって 3 人のアバターを一列に揃えることができません。例えばAnyLayout放射状レイアウトとHStackLayout間を切り替えます。

struct Profile: View {
    var pets: [Pet]
    var isThreeWayTie: Bool

    var body: some View {
        let layout = isThreeWayTie ? AnyLayout(HStackLayout()) : AnyLayout(MyRadialLayout())

        Podium() // Creates the background that shows ranks.
            .overlay(alignment: .top) {
                layout {
                    ForEach(pets) { pet in
                        Avatar(pet: pet)
                            .rank(rank(pet))
                    }
                }
                .animation(.default, value: pets)
            }
    }
}

キーポイント:

  • isThreeWayTie現在使用するレイアウトを決定します。 -AnyLayout(HStackLayout())組み込みの水平レイアウトを同じタイプにラップします。 -AnyLayout(MyRadialLayout())また、カスタム放射状レイアウトを同じタイプにラップします。 -let layout = ...後続のビュー階層の構造の一貫性を維持します。 -layout { ... }同じグループにAvatarさまざまなレイアウトを適用します。 -.rank(rank(pet))引き続き、ランキングを放射状レイアウトに渡します。 -.animation(.default, value: pets)投票データが変更されたときにスムーズな移行を作成します。

焦点はビューのアイデンティティです。 SwiftUI が認識するのは、古いアバターのセットが消えて新しいアバターのセットが表示されるのではなく、同じアバターのセットがレイアウトを変更することです。したがって、アニメーションは連続的に行うことができます。


重要ポイント

  • 何をすべきか: 設定ページの自動測定の 2 次元テーブルを作成します。 実行する価値がある理由:Gridラベル列と値列は内容に応じて自動的に拡張でき、残りのスペースをスイッチやプログレスバーなどの柔軟なビューで使用できます。 開始方法: を使用しますGrid(alignment:)そしてGridRow固定コンテンツテーブルを書き換えて、右揃えにする必要がある列を追加します。.gridColumnAlignment(.trailing)

  • 何をすべきか: ツール ボタンのセットを、同じ幅でウィンドウを覆わないコントロールに作成します。 実行する価値がある理由:Layoutまずすべてのボタンの理想的なサイズを測定し、次に最も幅の広いボタンを均一の幅として使用できます。 開始方法:Layoutと入力しますsizeThatFits中国語での使用.unspecifiedサブビューを測定します。placeSubviews中国語での使用ProposedViewSize(width:height:)サブビューを配置します。

  • 内容: 大きなフォント サイズと狭いウィンドウのボタン配置を自動的に切り替えます。 実行する価値がある理由:ViewThatFits最初は横型レイアウトを試して、収まらない場合は縦型レイアウトに切り替えることができます。すべての判断をカスタム レイアウトに詰め込む必要はありません。 開始方法: 重複したボタンのコンテンツのカットを取得します。Buttonsサブビューを作成し、そこに水平レイアウトと垂直レイアウトを配置します。ViewThatFits

  • やるべきこと: ビジネスステータスに基づいて回転するアバター、バッジ、またはランキング表示を作成します。 実行する価値がある理由:LayoutValueKey各サブビューが、ランク、重み、レイアウトの優先順位などのレイアウト データを提供できるようにします。 開始方法: を定義します。LayoutValueKey、与えるView便利な修飾子を追加しますplaceSubviews中国語での使用subview[Key.self]読む。

  • やるべきこと: コンパクト モード、拡張モード、フラット モードの間で連続したレイアウト アニメーションを作成します。 実行する価値がある理由:AnyLayoutサブビューのアイデンティティは、レイアウト タイプを切り替えても保持されるため、状態駆動型のインターフェイスの再配置に適しています。 開始方法: 候補レイアウトをパッケージ化します。AnyLayout、同じものを使用しますlayout { ForEach(...) }コンテンツを搬送し、ドライバーのステータスを追加する.animation


関連セッション

コメント

GitHub Issues · utterances