SwiftUI ScrollView の bounces を無効化する (したかった)

## はじめに

iOS の ScrollView には、コンテンツの端までスクロールするとバウンドをするようなアニメーションがデフォルトで実装されています。

<details><summary>参考画像</summary>

![ScrollViewDemo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3555559/01aff3c4-9217-6a20-6887-3a22c2cdb290.gif)

</details>

しかし、アプリによってはコンテンツの端までスクロールしてもバウンドしないように設定したい場合もあると思います。
その場合、どのように実装すればいいのでしょうか？

### UIKit の場合 (UIScrollView)

`scrollView.bounces = false` を設定することで対応できます。

https://developer.apple.com/documentation/uikit/uiscrollview/1619420-bounces

```swift
let scrollView = UIScrollView()
scrollView.bounces = false
```

### SwiftUI の場合 (ScrollView)

2023年11月時点では、**ScrollView の bounces が無効となるように設定する modifier が (おそらく) 存在しません**。

そのため、bounces を無効化したい場合は少し工夫をする必要があります。

:::note info
iOS 16.4 以降では [scrollBounceBehavior(_:axes:)](https://developer.apple.com/documentation/swiftui/view/scrollbouncebehavior(_:axes:)) という modifier が使えますが、これを使っても `scrollView.bounces = false` と全く同じ設定にすることはできません
:::


## 対応策

SwiftUI で ScrollView の bounces を無効化するためには、以下のような方法があります。

1. ScrollView の部分を UIScrollView で実装する
1. `UIScrollView.appearance().bounces = false` を設定する
1. SwiftUIIntrospect を利用する

### 1. ScrollView の部分を UIScrollView で実装する

SwiftUI の ScrollView では実装ができないので、ScrollView の部分を UIScrollView で実装して UIViewRepresentable を使って表示する方法です。

#### メリット

- 一番安全で確実な方法
    - Apple が提供している API だけで実装ができる
    - 影響範囲が 1つの ScrollView に限られる
- bounces 以外のプロパティを使った細かい調整もやりやすい

#### デメリット

- ScrollView で表示するコンテンツを UIView で実装する必要がある
    - 中身のコンテンツは SwiftUI で作ることも可能だが、UIHostingController を使って UIView に変換する手間がかかる

### 2. `UIScrollView.appearance().bounces = false` を設定する

SwiftUI の ScrollView は内部で UIScrollView を生成しており、それを wrap して SwiftUI の API として提供しています。
そのため、`UIScrollView.appearance()` を使ってアプリ全体の UIScrollView の設定を変更すると SwiftUI ScrollView も変更の影響を受けます。

これを利用して、bounces を無効化します。

```swift
struct ContentView: View {

    @Environment(\.dismiss) var dismiss
    func customDismiss() {
        // `dismiss()` を実行すると .onDisappear が呼び出されない
        // そのため、カスタムのメソッドを作成し dismiss 実行前に bounces の設定をもとに戻す
        UIScrollView.appearance().bounces = true
        dismiss()
    }

    var body: some View {
        ScrollView {
            ...
        }
        .onAppear {
            UIScrollView.appearance().bounces = false
        }
        .onDisappear {
            // UIScrollView.appearance はアプリ全体に影響するので、設定をもとに戻す
            UIScrollView.appearance().bounces = true
        }
    }
}
```

#### メリット

- `UIScrollView.appearance().bounces = false` を呼び出すだけで bounces の制御ができるので、実装が簡単

#### デメリット

- `scrollView.bounces = false` の設定がアプリ内全ての ScrollView に影響してしまう
    - デグレが発生していないかを確認するのがめちゃくちゃ大変
    - `UIScrollView.appearance().bounces = true` を呼び出すタイミングがずれたりするだけで、アプリ全体の動作が大きく変わってしまう

### 3. SwiftUIIntrospect を利用する

上で説明した通り、SwiftUI ScrollView は UIScrollView を wrap しています。
そのため、ScrollView 内部の UIScrollView を取得することができれば、UIScrollView の API を使って動作を制御することができます。

SwiftUIIntrospect というライブラリを使うことで、SwiftUI の内部で呼び出されている UIKit のクラスに簡単にアクセスすることができるようになります。

https://github.com/siteline/swiftui-introspect

```swift
import SwiftUI
import SwiftUIIntrospect

...

var body: some View {
    ScrollView {
        ...
    }
    .introspect(.scrollView, on: .iOS(.v13, .v14, .v15, .v16, .v17)) { scrollView in
        scrollView.bounces = false
    }
}
```

#### メリット

- `.introspect()` modifier を呼び出すだけで UIScrollView のインスタンスを取得できるので、実装が簡単
- プロパティの変更が 1つの ScrollView に限定される

#### デメリット

- UI の実装がサードパーティーのライブラリに大きく依存する
    - ライブラリのアップデートによって UI の動作が大きく変わってしまう可能性がある
    - アプリを実行する端末の iOS バージョンによって動作差異が発生することも
- SwiftUI 内部の UIKit を無理やり掘り出して利用しているので、Apple の思想上あまり良くない感じはする
    - 多分大丈夫だけど、将来的に reject 対象になる可能性もゼロではないかも


## まとめ

ScrollView の bounces を無効化することは、UIKit では簡単に対応できますが SwiftUI ではかなりめんどくさい対応になることがわかりました。
今後は SwiftUI を使って iOS アプリの開発を行うことが一般的になると思いますが、SwiftUI はかゆいところに手が届かないことが多いのでまだまだ UIKit の知識は必要不可欠だということを改めて痛感しました。

個人的には、デザインや機能が複雑な UI については潔く UIKit で実装してしまい、Apple から対応された API が提供されたタイミングで SwiftUI に移行するのが一番いいんじゃないのかなと思います。


## 参考資料

https://stackoverflow.com/questions/58799474/how-to-disable-scrollview-bounce-in-swiftui

https://iosexample.com/bounced-scrollview-in-swiftui/

https://yanamura.hatenablog.com/entry/swiftui-introspect

https://qiita.com/maiyama18/items/712cb389a91caae16735