以下の内容はhttps://fxwx23.hatenablog.com/entry/swiftui-list-footer-spacer-behaviorより取得しました。


SwiftUI.SectionのFooterの末端要素を画面下端にalignさせるテク

これは はてなエンジニア Advent Calendar 202520日目の記事です。


SwiftUI での ListForm で使われる Section の Footer の中で、ある要素を下端に寄せたくなったことはありませんか?要素をVStackに入れてSpacerを挟めばできるでしょうと思ってやってみると、予想以上に複雑になります。

まず問題となるSectionの画面はこちらです。

よくあるフォーム画面ですね

お題はこの Bottom Aligned Footer というテキストを画面下端に寄せることです。

ただし、この末端の要素はあくまでスクロールビューの中の要素の一つとしたいので、ListForm のスクロールと連動し、表示領域が足りなければ画面から見えなくなるものとします。そのため、画面下端に固定するために overlay(alignment: .bottom) { ... } を使うことは適していません。overlayの場合、キーボードを表示した時などにテキストフィールドに要素が重なってしまうからです。

そのためFooter要素は VStack で構成し Text の間に Spacer を挟みます。先ほどのスクリーンショットは以下のようなコードになっています。

struct ContentView: View {
    var body: some View {
        Form {
            Section {
                TextField("TextField 1", text: Binding.constant(""))
                TextField("TextField 2", text: Binding.constant(""))
                TextField("TextField 3", text: Binding.constant(""))
                TextField("TextField 4", text: Binding.constant(""))
            } header: {
                Text("Header")
            } footer: {
                VStack(alignment: .leading) {
                    Text("Footer")

                    Spacer(minLength: 16)

                    Text("Bottom Aligned Footer")
                }
            }
        }
    }
}

ただ、これだけでは Spacer は広がりません。テキストを画面下端(SafeArea)に寄せるには Spacer に必要な距離を設定するか、 VStack 自体に高さを設定する必要があります。

Footerの1番上から画面下端までの高さを算出できれば良さそうですが、 Spacer には minLength が設定されていたことを考慮すると、 VStack 自体に必要な高さを求めることは、テキストを含めた子Viewそれぞれの高さも求める必要がでてきてしまい、やりたいことに対してやや複雑すぎる印象です。

そのため、末端の要素が画面下端に寄るように Spacer に必要な距離を設定するアプローチを紹介します。必要な距離は、FooterのbottomのY座標から、ルートのViewのbottomのY座標までの距離の差分です。

Viewの高さを算出する

まず Form 自体の高さを算出できるようにします。 View のサイズを取得できるモディファイアを作ります。これは SwiftUI ではよく知られた手法です。サブビューから親コンテナビューへの情報の送信には Preferences を使います。

extension View {
    func viewSize(onChange: @escaping (CGSize) -> Void) -> some View {
        overlay {
            GeometryReader { geometry in
                Color.clear.preference(key: ViewSizePreferenceKey.self, value: geometry.size)
            }
        }
        .onPreferenceChange(ViewSizePreferenceKey.self, perform: onChange)
    }
}

private struct ViewSizePreferenceKey: PreferenceKey {
    static var defaultValue: CGSize = .zero

    static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
        value = nextValue()
    }
}

これで View の高さを取得することができます。今回のFormのケースの場合、高さをbottomのY座標として扱うことができます。

struct ContentView: View {
    @State var formBottomY: CGFloat = 0

    var body: some View {
        Form {

        }
        .viewSize {
            formBottomY = $0.height
        }
    }
}

要素の座標の取得する

サブビューの座標の取得にはまずサブビューの Anchor(レイアウト参照)を取得するので、 そのための PreferenceKey を用意します。

struct AnchorPointKey: PreferenceKey {
    static var defaultValue: Anchor<CGPoint>?

    static func reduce(value: inout Anchor<CGPoint>?, nextValue: () -> Anchor<CGPoint>?) {
        value = nextValue()
    }
}

GeometryReader から得られるジオメトリ情報と、 anchorPreference(key:value:transform:)) から得られる Anchor を使って、Footer の bottom のY座標を取得することができます。

GeometryReader { geometry in
    Form {
        Section {
            ...
        } header: {
            ...
        } footer: {
            VStack(alignment: .leading) {
                ...
            }
            .anchorPreference(key: AnchorPointKey.self, value: .bottom) { $0 }
        }
        .onPreferenceChange(AnchorPointKey.self) { anchor in
            print(geometry[anchor].y)) // FooterのbottomのY座標が取得できる
        }
    }
}

差分を算出して Spacer に設定する

ここまで取得した Form のbottomのY座標とFooterのbottomのY座標を使うことで、 Spacer に必要な距離を算出できます。Anchorはスクロール毎に更新されるので、一度 Spacer の距離を設定したらそれを維持するようにしましょう。そして、 Form のサイズが切り替わったタイミングで Spacer の距離が再計算された値に更新されるように State は Optional で定義します。

struct ContentView: View {
    @State var spacerLength: CGFloat?
    @State var formBottomY: CGFloat = 0 {
        didSet {
            // 画面回転やキーボード表示でFormの高さが変わるのでSpacerを再計算する
            spacerLength = nil
        }
    }

    var body: some View {
        GeometryReader { geometry in
            Form {
                Section {
                    TextField("TextField 1", text: Binding.constant(""))
                    TextField("TextField 2", text: Binding.constant(""))
                    TextField("TextField 3", text: Binding.constant(""))
                    TextField("TextField 4", text: Binding.constant(""))
                } header: {
                    Text("Header")
                } footer: {
                    VStack(alignment: .leading) {
                        Text("Footer")

                        Spacer(minLength: spacerLength ?? 16)

                        Text("Bottom Aligned Footer")
                    }
                    .anchorPreference(key: AnchorPointKey.self, value: .bottom) { $0 }
                }
                .onPreferenceChange(AnchorPointKey.self) { anchor in
                    // FooterのbottomYからFormのbottomYまでの長さを計算して必要なSpacerの長さを算出する
                    if let anchor, spacerLength == nil {
                        let length = abs(formBottomY - geometry[anchor].y)
                        spacerLength = max(16, length)
                    }
                }
            }
            .viewSize {
                formBottomY = $0.height
            }
        }
    }
}

これで Bottom Aligned Footer というテキストを画面下端に寄せることができました 🎉

キーボード表示や画面回転にも対応できています!

ListForm は制約が多いので、今回のケースのように一見シンプルでも複雑なアプローチを強いられることがあります。実装したいUIが素朴に実装できるのかどうかまず試してみることが重要ですね...。

みなさんもこのお題にチャレンジしてみてください!もっとスマートな方法がきっとあるはずです。


明日は id:yujiorama さんです!




以上の内容はhttps://fxwx23.hatenablog.com/entry/swiftui-list-footer-spacer-behaviorより取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14