iOS14のSwiftUIではリストのスクロール処理をコードで制御できるようになった

# はじめに

iOS13のSwiftUIではできなかったコードによるリストのスクロール処理が、iOS14ではできるようになりました。
本記事でその実装方法をまとめています。

## iOS13でのスクロール処理

iOSアプリを開発しているとよくある「○番目のセルに自動でスクロールする」という処理ですが、
これをiOS13のSwiftUIでは実現する方法がありませんでした。

```swift
struct ContentView: View {
    var body: some View {
        List(0..<100) {
            Text("\($0)")
        }
    }
}
```

UIKitでは以下のような形で、`scrollToRow`, `scrollToItem`などのメソッドを呼び出す形で該当の要件を簡単に実装できるので、
この機能のためだけに`UIViewRepresentable`, `UIViewControllerRepresentable`などを利用することもしばしばありました。

```swift
// UITableView
tableView.scrollToRow(at: IndexPath(row: 10, section: 0),
                      at: .top,
                      animated: true)

// UICollectionView
collectionView.scrollToItem(at: .init(item: 10, section: 0), 
                            at: .top,
                            animated: true)
```

## iOS14でのスクロール処理

iOS14では `ScrollViewReader` というスクロール状態を制御できる新しいViewが追加されており、
これを利用すると任意の地点に自動でスクロールさせる処理の実装が可能になります。

以下にサンプルコードを示します。

#### 準備

まずは、準備としてスクロール地点を指定するためのTextFieldを設置します。

```swift
struct ContentView: View {
    @State private var text: String = ""

    var body: some View {
        VStack {
            HStack {
                TextField("input row number", text: $text)
                Button("Scroll") {
                    guard let row = Int(text) else { return }
                    print(row)
                }
            }.padding()
            
            List(0..<100) {
                Text("\($0)")
            }
        }
    }
}
```

これにより、ボタンをタップするとユーザーがTextFieldに入力した値を取得できるようになります。

#### スクロール制御

それでは実際にスクロールさせる処理の実装部分です。

まずはスクロール制御をしたい箇所を `ScrollViewReader` で囲います。
これによりスクロール制御が可能な `ScrollViewProxy` インスタンスの取得ができるようになります。

```swift
struct ContentView: View {
    @State private var text: String = ""

    var body: some View {
        VStack {
+           ScrollViewReader { (proxy: ScrollViewProxy) in
                HStack {
                    TextField("input row number", text: $text)
                    Button("Scroll") {
                        guard let row = Int(text) else { return }
                    }
                }.padding()
                List(0..<100) {
                    Text("\($0)")
                }
+           }
        }
    }
}
```

さらに、スクロール位置を特定するためのIDを対象のViewに対して付与しておき、
制御を開始したい部分で `ScrollViewProxy` の `scrollTo` メソッドにそのIDを指定するだけでスクロール処理を実現することができます。

```swift
struct ContentView: View {
    @State private var text: String = ""

    var body: some View {
        VStack {
            ScrollViewReader { (proxy: ScrollViewProxy) in
                HStack {
                    TextField("input row number", text: $text)
                    Button("Scroll") {
                        guard let row = Int(text) else { return }
+                       withAnimation {
+                           proxy.scrollTo(row, anchor: .top)
+                       }
                    }
                }.padding()

                List(0..<100) {
                    Text("\($0)")
+                       .id($0)
                }
            }
        }
    }
}
```

これでやりたいことが実現できるようになりました。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/41486/20af5acd-f801-93ac-4557-835496b5a7bd.gif" width="300"/>

ちなみに `withAnimation` を付与しなければ、スクロールのアニメーションは行われなくなります。
また、第二引数の `anchor` でスクロール後の位置を細かく指定できます。

# おまけ

Gridに対しても問題なく動作しました。

```swift
struct ContentView: View {
    @State private var text: String = ""

    var body: some View {
        VStack {
            ScrollViewReader { (proxy: ScrollViewProxy) in
                HStack {
                    TextField("input row number", text: $text)
                    Button("Scroll") {
                        guard let row = Int(text) else { return }
                        withAnimation {
                            proxy.scrollTo(row, anchor: .top)
                        }
                    }
                }.padding()
                
                ScrollView {
                    LazyVGrid(
                        columns: [
                            GridItem(.flexible(minimum: 0, maximum: .infinity)),
                            GridItem(.flexible(minimum: 0, maximum: .infinity)),
                        ],
                        alignment: .center,
                        spacing: nil
                    ) {
                        ForEach(0..<100) {
                            Text("\($0)")
                                .frame(height: 100)
                        }
                    }
                }
            }
        }
    }
}
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/41486/dcccd42e-4ecd-d882-3465-f147054532ba.gif" width="300"/>