Iganinのブログ

日頃の開発で学んだ知見を中心に記事を書いています。

【SwiftUI】要素数の少ない場合の表示も考慮したチャットライクな画面の実装

環境

以下の環境で実施しています。

要件定義

いわゆるチャットのUIにおいては、最新の要素が画面下部に追加されていき、画面の初期表示時は画面最下部を基準に表示されているという要件が主流かと思います。

また、その一方で要素数が少なく画面全体を占めないような場合は要素が上の方に詰まっている以下のような画面を表示するのが要件として一般的ではないでしょうか。

つまり、以下の要件が一般的なチャット画面に期待されるものかと思います。

  • 要件① 最新のデータは画面下部に追加されていき、画面上部が過去のデータとなる。
  • 要件② 要素数が少ない時は画面上部に要素が詰まっている。

要件①を満たす実装

要件①を満たすための実装を考えます。 ScrollViewやListをそのまま使用すると、scrollや画面表示の都合上実現が難しいため工夫が必要です。 さまざまな実現方法があるかと思いますが、ScrollView、Listを反転し、中身の要素を再度反転させ整合性をとる実装が多いのではないでしょうか。

struct SampleScreen: View {
    @State private var list: [Sample] = []
    
    var body: some View {
        List {
            Group {
                ForEach(list) { sample in
                    cell(sample: sample)
                }
            }
            .rotation3DEffect(.degrees(180), axis: (x: 1, y: 0, z: 0))
        }
        .rotation3DEffect(.degrees(180), axis: (x: 1, y: 0, z: 0))
    }
}

問題

先ほどの実装で要件①は満たすことができますが、実は要件②は満たせていません。 Listを180度回転させている影響で、要素が画面下部によってしまい、要素数が少ない場合に要素が下に寄ってしまいます。

解消方法

Viewを反転させ要件①を満たす方法のみでは要件②が満たせないことをお伝えしました。 ここで少々トリッキーではありますが、最近見つけた方法を共有します。

考え方は以下です。

  1. Scroll領域の下部をSpacerなどで埋める
  2. 1を実現するために、Scroll領域のContentsの高さを測り反映させる

具体的な実装を見ていきましょう。

解消方法の実装

具体的な実装は下記のようになります。 ScrollViewの内部に内部要素を上に詰めるためのSpacerを含めています。 Spacerの高さとScrollViewに含まれる他の要素の高さの合計が画面全体の高さを越えるようにする必要があります。

ここで他の要素の高さを取得するのがそのままでは難しいため、PreferenceKeyと@Stateで定義したheightを使用しSpacerに伝えるようにしています。 こちらの実装で先ほどの要件②も満たすことができ、要件①、要件②を満たすチャットライクな画面を実現できます。

struct HeightKey: PreferenceKey {
    typealias Value = CGFloat
    static var defaultValue: CGFloat = 0

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

struct SampleView: View {
    @State private var list: [Sample] = []
    @State var height: CGFloat = 0

    var body: some View {
        GeometryReader { scrollViewProxy in
            ScrollView {
                // 要素数が少ない場合に要素を上詰めするために画面下部にスペースを確保する
                // 他の要素で画面全体のスペースが埋まる場合は不要なので、高さを0とする
                Spacer()
                    .frame(height: max(scrollViewProxy.size.height - height, 0))
                LazyVStack(alignment: .leading, spacing: 0) {
                    ForEach(viewModel.list) { sample in
                        cell(sample: sample)
                            .rotation3DEffect(.degrees(180), axis: (x: 1, y: 0, z: 0))
                    }
                }
                .overlay {
                    GeometryReader { contentsProxy in
                        // PreferenceKeyを使用しコンテンツの高さを伝える
                        Color.clear.preference(
                            key: HeightKey.self,
                            value: contentsProxy.size.height
                        )
                    }
                }
            }
            .rotation3DEffect(.degrees(180), axis: (x: 1, y: 0, z: 0))
            .onPreferenceChange(HeightKey.self) { newHeight in
                // コンテンツの高さの変化を検知し、\@Stateで保持している
                // heightに伝えViewに反映させる
                height = newHeight
            }
        }
    }
}

終わりに

今回ご紹介した実装を書きGistにアップロードしていますので、ご興味ある方はお手元で動かしてみてください。

https://gist.github.com/HironobuIga/1abd6e38fddc5d06f1e7be5a49208704

@tobi462さん@_natpenguinさんのお力添えのおかげで今回の実装にたどり着くことができました。 この場を借りまして御礼申し上げます。