SwiftUI ScrollView でスナップ動作を実装

## 背景と宣伝

諸般の事情でカルーセルやスライドショー的なパーツを SwiftUI で実装する必要があったのですが、思ったより手軽に実装できなかったので、対応内容をまとめてみました。

今回の対応をベースに ScrollView + スナップ動作を簡単に実現するためのライブラリも作成しているので、とりあえずお試しといった場合は、こちらをどうぞ。

https://github.com/hugehoge/Snappable

## ゴール

![goal.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/178830/37883ca3-1b0c-c22a-3132-9abdb0419abf.gif)

のようなスナップ動作を SwiftUI の ScrollView で実現します。

動作環境については、 iOS 15.0 以上の想定で進めます。

## 前準備

スナップ動作を実装する前に、横方向にスクロール可能な View 実装を用意します。

```swift:前準備
struct CarouselContentView: View {
  var body: some View {
    LazyHStack {
      ForEach(Array(1...100), id: \.self) { index in
        ZStack {
          Color(hue: Double(index * 11 % 100) / 100, saturation: 0.5, brightness: 1.0)
          Text("\(index)")
        }
        .frame(width: 280, height: 200)
      }
    }
  }
}

struct ContentView: View {
  var body: some View {
    ScrollView(.horizontal, showsIndicators: false) {
      CarouselContentView()
    }
  }
}
```

- 表示するアイテムは固定サイズで １００ 個
- 並び順に依存する背景色とテキスト表示
  - HSL で背景色の色味だけ揃える

といった実装で、動きとしては以下のような状態です。

<details>
<summary><b>前準備段階での動作</b></summary>
<div>
![base.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/178830/04adb55c-e1bd-9868-2cc1-c763948c2e7c.gif)
</div>
</details>

これをベースとしてスナップ動作の実装を進めます。

## 対応方針

スナップ動作を実現するにあたって、必要な要素を以下のように分割、それぞれ対応していくことにしました。

1. スナップ開始タイミングの検知
1. スナップ先座標の取得
1. スナップ先座標へのスクロール (スナップ動作)

要求されるスナップ動作によって、それぞれの指定が微妙に異なるのですが、今回は以下の条件で対応します。 (冒頭の gif 画像通りの動作です)

- 指を離した瞬間 (ドラッグ終了) のタイミングでスナップ開始
- Center-to-Center で ScrollView の中央位置に最も近いアイテムをスナップ先に指定

## 対応内容
### 1. スナップ開始タイミングの検知

いきなり大問題なのですが、 現在 (Xcode 13 / iOS 15) の SwiftUI では

- [UIScrollViewDelegate](https://developer.apple.com/documentation/uikit/uiscrollviewdelegate) 相当の処理が I/F として提供されていない
- ScrollView + [DragGesture](https://developer.apple.com/documentation/swiftui/draggesture) の併用ができない

といった状況のため、

> 指を離した瞬間 (ドラッグ終了) のタイミングでスナップ開始

の実現にハードルがあります。

非常に悩んだのですが、 [Introspect for SwiftUI](https://github.com/siteline/SwiftUI-Introspect) を導入し、 ScrollView が背後で利用している UIScrollView のインスタンスを強引に取得して、 UIScrollViewDelegate を利用してドラッグ終了を検知することとしました。

具体的な対応手順としては、

1. UIScrollViewDelegate に適合した NSObject な class を用意
1. SwiftUI View 側で 1. の class のインスタンスを保持
1. UIScrollViewDelegate を利用してドラッグ終了を検知した場合、クロージャ経由で View 側に伝達

といった流れで、対応コードと動作確認は以下のような感じとなります。

<details>
<summary><b>対応コード</b></summary>
<div>

```swift:UIScrollViewDelegate&#x20;の実装
class DragDetector: NSObject, UIScrollViewDelegate {
  var didEndScroll: (() -> Void)?

  func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
    if !decelerate {  // decelerate の場合は、 `scrollViewDidEndDecelerating(_:)` に処理を任せる
      didEndScroll?()
    }
  }

  func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
    didEndScroll?()
  }
}
```

```swift:動作確認
struct ContentView: View {
  @State private var didEndScroll: Bool = false // 動作確認用
  private let dragDetector = DragDetector()

  var body: some View {
    VStack {
      ScrollView(.horizontal, showsIndicators: false) {
        CarouselContentView()
      }
      // `Introspect for SwiftUI` で UIScrollView のインスタンスを取得
      .introspectScrollView { scrollView in
        scrollView.delegate = dragDetector
      }

      // 動作確認用
      if didEndScroll {
        Text("ScrollView didEndScroll")
      } else {
        Text(" ")
      }
    }
    .onAppear {
      dragDetector.didEndScroll = {
        self.didEndScroll = true
      }
    }
  }
}
```
</div>
</details>

<details>
<summary><b>動作確認</b></summary>
<div>
![detect_dragging.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/178830/c30740f5-8a93-04df-25c5-552735f57b0f.gif)
</div>
</details>

### 2. スナップ先座標の取得

> Center-to-Center で ScrollView の中央位置に最も近いアイテムをスナップ先に指定

を実現するために、 ScrollView 自体の x 軸中央座標と、 ScrollView (+ LazyHStack) 中で管理している子 View の x 軸中央座標を算出。

各子 View と ScrollView の間で距離を求めて、最も距離が短くなる子 View を取得します。

ScrollView の座標については、 GeometryReader を利用してそのまま算出できるのですが、子 View については、 LazyHStack で管理しており、 100 個すべてが必ず存在するわけでもないため、 [PreferenceKey](https://developer.apple.com/documentation/swiftui/preferencekey) + [anchorPreference](https://developer.apple.com/documentation/swiftui/form/anchorpreference(key:value:transform:)) の仕組みを利用して、座標を取得更新していきます。

**参考:**

https://jierong.dev/ja/2020/08/15/getting-subview-frame-in-swiftui.html

https://swiftui-lab.com/communicating-with-the-view-tree-part-2/

対応手順としては、

1. 子 View 識別用のインデックス値 (id) と座標を格納する PreferenceKey を定義
1. 子 View に anchorPreference の ViewModifier を追加して、 親 View に PreferenceKey を伝達
1. 親 View 側のプロパティで ScrollView と PreferenceKey 経由で取得した子 View の座標を保持
1. 特定タイミングでプロパティで保持している座標を元に、スナップ先の子 View を算出

といった流れで、とりあえずは

> 4.特定タイミングでプロパティで保持している座標を元に、スナップ先の子 View を算出

を暫定的なボタンタップとすることで、動作確認を実施しました。

対応コードと動作確認結果は次のようになります。

<details>
<summary><b>対応コード</b></summary>
<div>

```swift:PreferenceKey&#x20;の用意
class SnapAnchorPreferenceKey: PreferenceKey {
  // Dictionary で子 View の インデックス値 (id) と 座標のペアを保持
  static var defaultValue: [Int: Anchor<CGPoint>] = [:]
  static func reduce(
    value: inout [Int: Anchor<CGPoint>],
    nextValue: () -> [Int: Anchor<CGPoint>]
  ) {
    for (index, anchor) in nextValue() {
      value[index] = anchor
    }
  }
}
```

```swift:子&#x20;View&#x20;側の対応
struct CarouselContentView: View {
  var body: some View {
    LazyHStack {
      ForEach(Array(1...100), id: \.self) { index in
        ZStack {
          Color(hue: Double(index * 11 % 100) / 100, saturation: 0.5, brightness: 1.0)
          Text("\(index)")
        }
        .frame(width: 280, height: 200)
        // 各表示アイテムの中心位置を PreferenceKey で親 View に伝達
        .anchorPreference(
          key: SnapAnchorPreferenceKey.self,
          value: .center
        ) { [index: $0] }
      }
    }
  }
}
```

```swift:動作確認
struct ContentView: View {
  @State private var scrollViewAnchor: CGFloat?
  @State private var anchors: [Int: CGFloat] = [:]
  @State private var snappingIndex: Int?

  var body: some View {
    VStack {
      GeometryReader { proxy in
        ScrollView(.horizontal, showsIndicators: false) {
          CarouselContentView()
        }
        // 子 View の座標変化に合わせてプロパティに座標を保存
        .onPreferenceChange(SnapAnchorPreferenceKey.self) { preference in
          anchors = preference.mapValues { proxy[$0].x }
        }
        .onAppear {
          scrollViewAnchor = proxy.size.width / 2
        }
      }
      // 動作確認用
      HStack {
        Spacer()
        Button("Check") {
          guard let scrollViewAnchor = scrollViewAnchor else { return }

          // 最短距離の子 View を探索
          snappingIndex = anchors.min { leftAnchor, rightAnchor in
            let leftDistance = abs(scrollViewAnchor - leftAnchor.value)
            let rightDistance = abs(scrollViewAnchor - rightAnchor.value)
            return leftDistance < rightDistance
          }?.key
        }
        if let snappingIndex = snappingIndex {
          Text("\(snappingIndex)")
        } else {
          Text(" ")
        }
        Spacer()
      }
      Spacer()
    }
  }
}
```
</div>
</details>

<details>
<summary><b>動作確認</b></summary>
<div>
![calc_snap_point.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/178830/7b83b285-175f-589c-e95a-e1b6fc4aa8ba.gif)
</div>
</details>

この対応において、 [iOS 15.0 未満では Anchor\<Value> が Equatable に準拠していない](https://developer.apple.com/documentation/swiftui/anchor/==(_:_:)?changes=_2_3) 問題から、 iOS 15.0 以上の Deployment Target が要求されてしまいます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/178830/3b713a83-cf9d-a79b-b125-bc765f129eb8.png)

iOS 15.0 未満での動作も考慮する場合は、 `Anchor<Value>`　ではなく直接 CGPoint を保持するような実装が必要となります。

### 3. スナップ先座標へのスクロール (スナップ動作)

スクロール動作については、 iOS 14 から実装された [ScrollViewReader](https://developer.apple.com/documentation/swiftui/scrollviewreader) と [ScrollViewProxy](https://developer.apple.com/documentation/swiftui/scrollviewproxy) によって、シンプルに実装可能です。

対応としては、

1. 子 View ごとにユニークとなるような [id(_:)](https://developer.apple.com/documentation/swiftui/view/id(_:)) を設定
1. ScrollViewReader + ScrollViewProxy で対象の子 View までスクロール

という形となります。

<details>
<summary><b>対応コード</b></summary>
<div>

```swift:動作確認
struct CarouselContentView: View {
  var body: some View {
    LazyHStack {
      ForEach(Array(1...100), id: \.self) { index in
        ZStack {
          Color(hue: Double(index * 11 % 100) / 100, saturation: 0.5, brightness: 1.0)
          Text("\(index)")
        }
        .frame(width: 280, height: 200)
        // スナップ動作のスクロールで利用するためにユニークな id を付与
        .id(index)
      }
    }
  }
}

struct ContentView: View {
  @State private var scrollTo: String = ""

  var body: some View {
    ScrollViewReader { proxy in
      VStack {
        ScrollView(.horizontal, showsIndicators: false) {
          CarouselContentView()
        }
        // 動作確認用
        HStack {
          Spacer()

          TextField("Scroll to", text: $scrollTo)
            .frame(width: 100)

          Button("Scroll") {
            guard let index = Int(scrollTo) else { return }

            // アニメーション付きでスクロール
            withAnimation {
              proxy.scrollTo(index, anchor: .center)
            }
          }

          Spacer()
        }
        Spacer()
      }
    }
  }
}
```
</div>
</details>

<details>
<summary><b>動作確認</b></summary>
<div>
![scroll_to.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/178830/528dbe12-216c-1ca6-f92d-e2088221fbce.gif)

</div>
</details>

### まとめ

これまでの　3 つの対応要素をまとめることによって、本記事冒頭の gif 画像動作が実装できます。

まとめた際の最終形のコードは以下の通りです。

```swift:最終形
// ドラッグ終了検知用の UIScrollViewDelegate に適合した NSObject
class DragDetector: NSObject, UIScrollViewDelegate {
  var didEndScroll: (() -> Void)?

  func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
    if !decelerate {  // decelerate の場合は、 `scrollViewDidEndDecelerating(_:)` に処理を任せる
      didEndScroll?()
    }
  }

  func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
    didEndScroll?()
  }
}

// 子 View の座標伝達で利用する PreferenceKey
class SnapAnchorPreferenceKey: PreferenceKey {
  // Dictionary で子 View の インデックス値 (id) と 座標のペアを保持
  static var defaultValue: [Int: Anchor<CGPoint>] = [:]
  static func reduce(
    value: inout [Int: Anchor<CGPoint>],
    nextValue: () -> [Int: Anchor<CGPoint>]
  ) {
    for (index, anchor) in nextValue() {
      value[index] = anchor
    }
  }
}

struct CarouselContentView: View {
  var body: some View {
    LazyHStack {
      ForEach(Array(1...100), id: \.self) { index in
        ZStack {
          Color(hue: Double(index * 11 % 100) / 100, saturation: 0.5, brightness: 1.0)
          Text("\(index)")
        }
        .frame(width: 280, height: 200)
        // スナップ動作のスクロールで利用するためにユニークな id を付与
        .id(index)
        // 各表示アイテムの中心位置を PreferenceKey で親 View に伝達
        .anchorPreference(
          key: SnapAnchorPreferenceKey.self,
          value: .center
        ) { [index: $0] }
      }
    }
  }
}

struct ContentView: View {
  private let dragDetector = DragDetector()

  @State private var anchors: [Int: CGFloat] = [:]

  var body: some View {
    ScrollViewReader { scrollViewProxy in
      GeometryReader { geometryProxy in
        ScrollView(.horizontal, showsIndicators: false) {
          CarouselContentView()
        }
        // `Introspect for SwiftUI` で UIScrollView のインスタンスを取得
        .introspectScrollView { scrollView in
          scrollView.delegate = dragDetector
        }
        // 子 View の座標変化に合わせてプロパティに座標を保存
        .onPreferenceChange(SnapAnchorPreferenceKey.self) { preference in
          anchors = preference.mapValues { geometryProxy[$0].x }
        }
        .onAppear {
          dragDetector.didEndScroll = {
            let scrollViewAnchor = geometryProxy.size.width / 2
            // 最短距離の子 View を探索
            let snappingIndex = anchors.min { leftAnchor, rightAnchor in
              let leftDistance = abs(scrollViewAnchor - leftAnchor.value)
              let rightDistance = abs(scrollViewAnchor - rightAnchor.value)
              return leftDistance < rightDistance
            }?.key
            // アニメーション付きでスクロール
            withAnimation {
              scrollViewProxy.scrollTo(snappingIndex, anchor: .center)
            }
          }
        }
      }
    }
  }
}
```

## その他対応方法との比較、参考記事

今回は ScrollView を生かしたままスナップ動作を実現しましたが、類似の実装を検索すると

- ScrollView を利用せずに、独自でスクロール + スナップ動作を実装
- スナップ開始タイミングを ScrollView の offset 変化で検知

といったパターンでの実装もありました。

それぞれの対応でのメリット / デメリットは以下のようになると思われます。

|対応方法|メリット|デメリット|
|:-|:-|:-|
|今回の対応<br>(ドラッグ終了検知に UIScrollView を利用)|ScrollView の動作を生かせる|背後に UIScrollView があるという暗黙の実装に依存|
|独自でスクロール + スナップ動作実装|DragGesture によって、 SwiftUI ベースでのドラッグ終了検知が可能|ScrollView と独自スクロールの間で動作差異が生まれる|
|ScrollView の offset 変化検知|ScrollView の動作を生かしつつ、SwiftUI ベースでのドラッグ終了検知が可能|ドラッグしたまま静止された場合に留まれない|

### 独自でスクロール + スナップ動作を実装

https://medium.com/@xtabbas/implementing-snap-carousel-in-swiftui-3ae084504670

https://levelup.gitconnected.com/snap-to-item-scrolling-debccdcbb22f

### スナップ開始タイミングを ScrollView の offset 変化で検知

https://itnext.io/swiftui-two-directional-snaplist-95cb852957be