【SwiftUI】ScrollViewReaderでScrollの移動制御
はじめに
ScrollViewReaderを用いることで任意のスクロール位置へ移動させる事ができます。
環境
・ 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))
}
}
}
}
}
}
}
参考記事
まとめ
ScrollViewReaderを使用すると任意の位置にスクロールできることがわかりました。
ただListやFormの時はScrollと少し挙動が変わるので注意が必要です。
またsectionにfooterを付けると.idがアンカーポイントとして機能しないケースもありますので注意が必要です。
Discussion