SwiftUI List との闘い

サムネイル

TUNAGのプロダクト開発チームでiOSアプリを開発しているおしんです。

SwiftUIの標準コンポーネントであるListを使う機会があったのですが、List を使ってデザイン通りに画面を実装することは予想以上に困難でした。

このブログでは、Listのデフォルトの挙動と適切な対処法について紹介します。

List とは

UIKitのUITableViewに相当するSwiftUIのコンポーネントで、縦方向のスクロール可能なリストを作成できます。

Listを使用することで、データ配列を自動的にレイアウトし、パフォーマンスが最適化されたスクロール可能なUIを構築できます。

以下のコードでは、items の配列をリストに変換し、各要素を Text で表示しています。

struct ContentView: View {
    let items = ["Pacific", "Atlantic", "Indian", "Southern", "Arctic"]

    var body: some View {
        List(items, id: \.self) { item in
            Text(item)
        }
    }
}

Listの使い所

無限スクロールの実装が必要な画面では、配列の要素の(個数 - 1)番目が画面に表示されたら、次の要素を配列の末尾に足すという方法を取ることが多いと思います。

その際、(個数 - 1)番目の要素のViewの .onAppear を適切に制御する必要がありますが、 ScrollView + LazyVStack を用いた実装では、要素追加時の .onAppear の発火タイミングの調整が難く、実用的ではありません。

そのような場合においてはScrollView + LazyVStack ではなく、子要素の.onAppearを適切にハンドリングできるList を使うことが推奨されます。

List の何が大変か

高いパフォーマンスを発揮する List は便利なコンポーネントですが、Listのデフォルトの挙動を調整する際に、以下のような難しさがありました。

  • 行・セクションの余白やセパレータの扱い
  • List 内に埋め込んだボタンが意図しない挙動をする

それぞれの課題と対処法を紹介します。

行・セクションの余白調整

デフォルトでは以下のように余白やセパレータが適用されているため、デザインに合わせて調整が必要です。

List標準コンポーネントを使ったサンプル画像

出典:https://developer.apple.com/documentation/swiftui/list

List {
    Text("Pacific")
        .listRowSeparator(.hidden) // セパレータを非表示にする
        .listRowInsets(EdgeInsets()) // 行の余白をなくす(四隅のpaddingを0にする)
}
.listStyle(.plain) // Listのデフォルトのスタイルを変更

セクションの余白については、iOS15 以降では行の余白を消す API が提供されていますが、セクションの余白を消す API は iOS17 以降でしか利用できません。

そのため、以下のように inset を余白分だけマイナス値で相殺することで回避しました。

if #available(iOS 17.0, *) {
        List {
            Text("Pacific")
        }
        .listSectionSpacing(.leastNonzeroMagnitude) // iOS17以降でのみ使用可
} else {
        List {
            Text("Pacific")
                .listRowInsets(EdgeInsets(top: -20, leading: 0, bottom: 0, trailing: 0)) // セクションの余白を相殺
        }
}

ボタンが意図しない挙動をする問題

List ではデフォルトでスワイプアクションやコンテキストメニューのようなボタンが用意されています。

そのため、行に対して追加でボタンを配置すると List 側のボタンと競合してしまうことがありました。

List のデフォルトのアクションを無効化するか、明示的にカスタムボタンの動作を定義することで意図しない動作を防ぎました。

List {
    Button(action: {}) {
        Text("Pacific")
    }
}
.buttonStyle(.plain) // rowのデフォルトのタップアクションを無効にする

ボタンが反応しなくなる問題

1行に複数のボタンを埋め込むと、特定のボタンがタップに反応しなくなることがありました。

Rectangleshape に設定することで回避しました。

List {
    HStack {
        Button(action: {}) {
            Text("button1")
        }
        .contentShape(Rectangle()) // タップ領域を明示的に確保
        Button(action: {}) {
            Text("button2")
        }
        .contentShape(Rectangle()) // タップ領域を明示的に確保
    }
}
.buttonStyle(.plain)

全体像

以上を踏まえ、Listのデフォルトの見た目を最大限抑えた場合の全体像がこちらになります。

// セクション間の隙間の調整が必要な場合は
// iOS 17以上 → `listSectionSpacing`
// iOS 17未満 → `listRowInsets`の当て方を調整
List {
    Group {
        HStack(spacing: 0) {
            Button(action: {}) {
                Text("button1")
            }
            .contentShape(Rectangle()) // タップ領域を明示的に確保
            Button(action: {}) {
                Text("button2")
            }
            .contentShape(Rectangle()) // タップ領域を明示的に確保
        }
        let items = ["Pacific", "Atlantic", "Indian", "Southern", "Arctic"]
        ForEach(items, id: \.self) { item in
            Text(item)
        }
  }
  .listRowBackground(Color.clear) // 背景透過
  .listRowSeparator(.hidden) // セパレータを非表示にする
  .listRowInsets(EdgeInsets()) // 行の余白をなくす(四隅のpaddingを0にする)
}
.listStyle(.plain) // Listのデフォルトのスタイルを変更
.buttonStyle(.plain) // rowのデフォルトのタップアクションを無効にする
.environment(\.defaultMinListRowHeight, .leastNonzeroMagnitude) // rowのデフォルトの高さを0にする

プレビュー

このブログで紹介した、Listのデフォルトの挙動を最小限にしたサンプル画像

まとめ

無限スクロールを実現するために List を採用した結果、List のデフォルトの挙動について深く理解する機会となりました。

SwiftUI の List は便利ですが、デフォルトの挙動が多く、その挙動を最小限にするには多くのモディファイアが必要であるとともに、そのデフォルトの挙動を十分に理解する必要があると感じました。

本ブログが List を使う際の参考になれば幸いです。

採用情報

スタメンでは、iOSエンジニアに限らず、全ての領域でエンジニアを募集しています。もし興味を持っていただけたら、下記からご応募ください!

herp.careers