SwiftUI で、要素数が少ない場合も考慮したチャットライクな画面を実装する
「最新が下に積まれる」「要素が少ないときは上に詰まる」という、チャット画面に期待される 2 つの要件を SwiftUI でどう両立させるかです。
- 公開日
この記事は別サイトにも掲載しています。 https://iganin.hatenablog.com/entry/2022/12/21/231718
環境
以下の環境で実施しています。
- Xcode 14.0
- iOS 16.0.2
要件定義
いわゆるチャットの 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 を反転させ要件①を満たす方法のみでは要件②が満たせないことをお伝えしました。 ここで少々トリッキーではありますが、最近見つけた方法を共有します。
考え方は以下です。
- Scroll 領域の下部を Spacer などで埋める
- 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 にアップロードしていますので、ご興味ある方はお手元で動かしてみてください。
SwiftUI のチャットライクなレイアウトのサンプル - Gist
@tobi462 さんと@_natpenguin さんのお力添えのおかげで今回の実装にたどり着くことができました。 この場を借りまして御礼申し上げます。