📱

【SwiftUI】ScrollViewReaderでScrollの移動制御

2022/04/11に公開

はじめに

ScrollViewReaderを用いることで任意のスクロール位置へ移動させる事ができます。

altテキスト

環境

・ macOS: Monterey
・ Xcode: 13.3
・iOS: 15.0

実装方法

ボタンタップで任意の位置にスクロール位置を移動させます。

コードは以下の通りです。

struct ScrollViewReaderSample: View {
    var body: some View {
        ScrollViewReader { proxy in
            List {
                Button {
                    withAnimation {
                        proxy.scrollTo("bottom")
                    }
                } label: {
                    Text("一番下へ")
                }
                .id("top")
                ForEach(0..<50) { index in
                    Text("\(index): hoge")
                }
                Button {
                    withAnimation {
                        proxy.scrollTo("top")
                    }
                } label: {
                    Text("一番下へ")
                }
                .id("bottom")
            }
        }
    }
}

ScrollViewReaderの引数であるScrollViewProxyのscrollToを用いることで任意の位置へスクロールを移動させることができます。任意の位置を定義するためにidを使用します。
idはHashableであれば良いのでenumなどで適当に作っておくと良いかと思います。

また細かく位置を指定したい場合はanchorを定義することで微調整することができます。

proxy.scrollTo(ScrollAnchor.bottom, anchor: .top)

その他実装例1

ScrollView内であればEmptyViewを用いてボタンをタップした時にscrollToメソッドを発火してあげれば例のように一番下にあるitemに追従しているような動きになります。

enum ScrollAnchor: Hashable {
    case top
    case bottom
}

struct ScrollViewReaderSample: View {
    @State private var items: [String] = []

    var body: some View {
        NavigationStack {
            ScrollViewReader { proxy in
                ScrollView {
                    ForEach(0..<items.count, id: \.self) { index in
                        Text(items[index])
                    }
                    EmptyView()
                        .id(ScrollAnchor.bottom)
                }
                .toolbar {
                    ToolbarItem(placement: .topBarTrailing) {
                        Button {
                            items.append("\(items.count): hoge")
                            withAnimation {
                                proxy.scrollTo(ScrollAnchor.bottom,anchor: .bottom)
                            }

                        } label: {
                            Text("追加")
                        }
                    }
                }
            }
        }
    }
}

その他実装例2

しかしScrollViewをList変更すると動作しません。
サンプルのように動きをList内で動作させたい場合は以下のようにonChange内でitems.count - 1のようなidを指定するか、実体のあるものにidを付与するもしくは実体のあるものの間にEmptyViewを入れると動作します。

struct ScrollViewReaderSample: View {
    @State private var items: [String] = []

    var body: some View {
        NavigationStack {
            ScrollViewReader { proxy in
                List {
                    ForEach(0..<items.count, id: \.self) { index in
                        Text(items[index])
                            .id(items.count)
                    }
                    .onChange(of: items) {
                            withAnimation {
                                proxy.scrollTo(items.count - 1)
                            }
                    }
                }
                .toolbar {
                    ToolbarItem(placement: .topBarTrailing) {
                        Button {
                            items.append("\(items.count): hoge")
                        } label: {
                            Text("追加")
                        }
                    }
                }
            }
        }
    }
}

その他実装例3

この例では、これまでに.idを使用してアンカーポイントを指定していましたが、次に示す実装では.idを使用せず、ForEachのid: .selfを利用しています。
この方法では、items配列の各String要素がその内容自体に基づいて一意のIDとして機能します(以下のサンプルコードを参照)。
この一意のID(各Stringの値と同一)を持つ要素に対して、ボタンタップ時にnewItemプロパティを介してscrollToメソッドを呼び出すことで、動的にスクロールするように実装されています。
ただし、この実装方法では、ビューの更新タイミングとボタンのタップ操作が競合することがあり、その結果、Task.sleepを使用して操作に遅延を設けています。
この遅延による実装は少し強引な方法になるため、不安定な挙動を引き起こす可能性があります。実際にこの方法を採用する際は、適切なテストを行い、挙動の安定性を確認してください。

struct ScrollViewReaderSample: View {
    @State private var items = [String]()
    @State private var counter = 1
    
    var body: some View {
        VStack {
            ScrollViewReader { scrollView in
                List {
                    ForEach(items, id: \.self) { item in
                        Text(item)
                            .padding()
                    }
                }
                Button("アイテム追加") {
                    let newItem = "アイテム \(counter)"
                    items.append(newItem)
                    counter += 1
                    Task {
                        try? await Task.sleep(for: .seconds(0.1))
                        withAnimation {
                            scrollView.scrollTo(newItem, anchor: UnitPoint(x: 0, y: 0))
                        }
                    }
                }
            }
        }
    }
}

参考記事

https://developer.apple.com/documentation/swiftui/scrollviewreader

まとめ

ScrollViewReaderを使用すると任意の位置にスクロールできることがわかりました。
ただListやFormの時はScrollと少し挙動が変わるので注意が必要です。
またsectionにfooterを付けると.idがアンカーポイントとして機能しないケースもありますので注意が必要です。

Discussion