[SwiftUI] タブがタップされた時に上までスクロールする。あるいはそれを共通化する話。

[SwiftUI Advent Calendar 2022](https://qiita.com/advent-calendar/2022/swiftui) の12日目の記事です。

iOSアプリによく見られる、選択中のタブがもう一度タップされたら上までスクロール、というのをSwiftUIでやってみようと思います。

## Tl;Dr

![Simulator Screen Recording - iPhone 14 - 2022-12-11 at 19.26.35.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29653/b9967243-aa4b-1ac8-9fcb-2dc592817cdb.gif)

## タブタップの検出

まずは「選択中のタブがもう一度タップされた」というイベントを検出することが必要です。

動作検証のため、まずは`TabView`を使ったシンプルな画面を用意してみます。

```swift
enum Tab {
    case first
    case second
}

struct ContentView: View {
    @State private var selectedTab: Tab = .first

    var body: some View {
        TabView(selection: $selectedTab) {
            Text("Tab1")
                .tabItem {
                    Label("First", systemImage: "1.circle")
                }
                .tag(Tab.first)

            Text("Tab2")
                .tabItem {
                    Label("Second", systemImage: "2.circle")
                }
                .tag(Tab.second)
        }
    }
}
```

タブの選択状態を保持しているのは`selectedTab`なので、一見すると[onChange(of:)](https://developer.apple.com/documentation/swiftui/list/onchange(of:perform:))モディファイアが活用できそうです。

しかし、`onChange(of:)`は値が変化した時にしか呼ばれないため、今回のように同じ値が連続で設定された場合を検出することはできません。（これは引数に渡せる値が`Equatable`に制限されていることからも納得できます）

2つの検出方法が考えられます。

### 中間的な`Binding`でインターセプト

一般的に使えるテクニックとして、中間的な`Binding`を作成し、その中で変化を監視するという方法があります。

```swift
struct ContentView: View {
    @State private var selectedTab: Tab = .first

    var body: some View {
        let interceptor = Binding<Tab>(
            get: { selectedTab },
            set: {
                if selectedTab == $0 {
                    // 🚀 前回と同じタブが選択された場合
                }
                selectedTab = $0
            }
        )

        TabView(selection: interceptor) {
            ...
        }
    }
}
```

ここでは`selectedTab`をそのまま受け渡す`interceptor`を作成し、値が設定される`set`のタイミングで、現在の値（`selectedTab`）と新しく設定された値（`$0`）を比較しています。

中間のパイプを作成し、そこに流れる値を監視しているイメージでしょうか。

やや余談になりますが、こうしたインターセプトを利用する箇所が多いのであれば、`Binding`の`extension`を用意するのも手かもしれません。

```swift
extension Binding {
    func willSet(_ handler: @escaping (Value) -> ()) -> Binding<Value> {
        .init(
            get: { wrappedValue },
            set: { newValue in
                handler(newValue)
                wrappedValue = newValue
            }
        )
    }
}

struct ContentView: View {
    @StateObject private var viewModel: ContentViewModel = .init()
    @State private var selectedTab: Tab = .first

    var body: some View {
        TabView(selection: $selectedTab.willSet {
            if selectedTab == $0 {
                // 🚀 前回と同じタブが選択された場合
            }
        }) {
            ...
        }
    }
}
```

今回のケースに限らず、`Binding`は専用の`extension`に定義しておくと何かと便利なケースが多いように思います。

### `@Published`の`didSet`で検出

あるいは、`selectedTab`が`ObservableObject`上の`@Published`プロパティとして定義されているのであれば、`didSet`（あるいは`willSet`）による監視も可能です。

```swift
final class ContentViewModel: ObservableObject {
    @Published var selectedTab: Tab = .first {
        didSet {
            if oldValue == selectedTab {
                // 🚀 前回と同じタブが選択された場合
            }
        }
    }
}

struct ContentView: View {
    @StateObject private var viewModel: ContentViewModel = .init()

    var body: some View {
        TabView(selection: $viewModel.selectedTab) {
            ...
        }
    }
}
```

## イベントを伝達しスクロールする

さて、イベントが検出できたので、あとはこれを契機にしてスクロールするだけです。

`ScrollView`や`List`において任意の位置にスクロールさせたい場合には、`ScrollViewReader`を利用することができます。

```swift
ScrollViewReader { proxy in
    List {
        Text("TOP").id("top") // 🔗 スクロールターゲット

        ForEach(items) { item in
            Text(item.title)
        }
    }
    .onAppear {
        withAnimation {
            proxy.scrollTo("top") // 🚀 指定された`id`の位置にスクロール
        }
    }
}
```

ただ、ここで問題になるのが先述したイベント検出コードは`ScrollViewReader`の外に定義されているため`proxy`変数にアクセスできないという点です。

```swift
struct ContentView: View {
    @State private var selectedTab: Tab = .first

    var body: some View {
        TabView(selection: $selectedTab.willSet {
            if selectedTab == $0 {
                // 🚫 ここでは `proxy` にアクセスできない
            }
        }) {
            ScrollViewReader { proxy in
                ...
            }
        }
    }
}
```

Combineの`Publisher`などの仕組みが必要に感じるかもしれませんが、上位Viewから下位Viewに値を伝達すればよいことを考えると、通常の引数として変化を伝えられればよいことになります。

### `UUID`を利用する

分かりやすいのは`UUID`型の`@State`プロパティを用意し、それを`onChange(of:)`で監視する方法でしょう。

```swift
struct ContentView: View {
    @State private var selectedTab: Tab = .first
    @State private var tabTappedTwice: UUID = .init() // 🔗 トリガー用の変数

    var body: some View {
        TabView(selection: $selectedTab.willSet {
            if selectedTab == $0 {
                tabTappedTwice = UUID() // 🔥 イベント発火
            }
        }) {
            ScrollViewReader { proxy in
                List {
                    ...
                }
                .onChange(of: tabTappedTwice) { _ in // 🔎 変化を検出
                    withAnimation {
                        proxy.scrollTo("top") // 🚀 スクロールを発火
                    }
                }
            }
            ...
        }
    }
}
```

一般的に`UUID`は衝突を考えなくてよいため、新しい値を設定したらそれは異なる値になり、それが`onChange(of:)`によって検出することができます。

### `Bool`を利用する

一方で、今回のようなケースでは`Bool`型でも問題ないことが分かります。

```swift
struct ContentView: View {
    @State private var tabTappedTwice: Bool = false

    var body: some View {
        TabView(selection: $selectedTab.willSet {
            if selectedTab == $0 {
                tabTappedTwice.toggle()
            }
        }) {
            ...
        }
    }
}
```

要は前回の値と必ず異なればよいので、`Bool.toggle()`のような方法も利用できるのです。

### 独自の型`Trigger`を作成する

`Bool`型と`toggle()`を利用した方法はシンプルではありますが、この手の処理は SwiftUI でよく書かれるもので、`toggle()`がイベントの発火であると初見ですぐに読み取れる人は少ないでしょう。

このようなケースでは独自型を作成することもできます。

```swift
struct Trigger {
    private var key: Bool = false

    mutating func fire() {
        key.toggle()
    }
}

struct ContentView: View {
    @State private var tabTappedTwice: Trigger = .init() // ✅ 初期値を考えなくてもよい

    var body: some View {
        TabView(selection: $selectedTab.willSet {
            if selectedTab == $0 {
                tabTappedTwice.fire() // 🔥 イベントを発火
            }
        }) {
            ...
        }
    }
}
```

ここでは`Trigger`という型を作成し、`toggle()`の代わりに`fire()`というメソッドを利用できるようにしました。

`Bool`の場合には初期値を`true`ないし`false`でセットする必要がありましたが、それも不要になっており、APIとして使用する際も迷いが少なくなりますし、そもそも内部表現が`Bool`であることを隠居できたので、`Bool`でマズかった場合に`UUID`に切り替えるなどの変更も容易になります。

また、`onChange(of:)`の代わりに使用する以下のモディファイアも定義したいと思います。

```swift
extension View {
    func onTrigger(of trigger: Trigger?, perform: @escaping () -> Void) -> some View {
        onChange(of: trigger?.key) { _ in
            perform()
        }
    }
}
```

これによりイベントを監視する側のコードも意図が分かりやすくなります。

```swift
.onTrigger(of: tabTappedTwice) { _ in // 🔎 変化を検出
    withAnimation {
        proxy.scrollTo("top") // 🚀 スクロールを発火
    }
}
```

もし、過剰に感じるようであれば`Trigger`を`Equatable`に準拠させて、これまでどおり`onChange(of:)`を利用してもよいでしょう。

## 選択中のタブに限定してスクロールする

さて、これでタブタップ時のスクロールが実現できたわけですが、現状ではタブの選択状態に関わらずにイベントを発火（`tabTappedTwice`プロパティを更新）しているため、選択中のタブだけをスクロールすることはできません。

### タブごとに変数を用意する

ぱっと思いつく方法として、各タブごとにイベント発火用の`tabTappedTwice`プロパティを用意し、タブの選択状態を見てイベントを発火するという方法が考えられます。

```swift
struct ContentView: View {
    @State private var selectedTab: Tab = .first

    @State private var tabTappedTwiceFirst:  Trigger = .init() // ✅ タブ1用
    @State private var tabTappedTwiceSecond: Trigger = .init() // ✅ タブ2用

    var body: some View {
        TabView(selection: $selectedTab.willSet {
            if selectedTab == $0 {
                switch selectedTab {
                case .first:
                    tabTappedTwiceFirst.fire()  // 🔥 タブ1のイベントを発火
                case .second:
                    tabTappedTwiceSecond.fire() // 🔥 タブ2のイベントを発火
                }
            }
        }) {
            ...
        }
    }
}
```

これは機能しますが、タブ毎にプロパティが増えるというのは煩雑です。こういった関連する値は1つにまとめられると見通しがよいでしょう。

### `[Tab: Trigger]` を利用する

例えば、今回の場合はディクショナリ `[Tab: Trigger]` をデータ構造として利用できます。

```swift
struct ContentView: View {
    @State private var selectedTab: Tab = .first

    @State private var tabTappedTwices: [Tab: Trigger] = [ // ✅ タブをキーにしてイベント発火変数を管理
        .first: .init(), 
        .second: .init()
    ]

    var body: some View {
        TabView(selection: $selectedTab.willSet {
            if selectedTab == $0 {
                tabTappedTwices[selectedTab]?.fire() // 🔥 イベント発火
            }
        }) {
            ScrollViewReader { proxy in
                List {
                    ...
                }
                .onTrigger(of: tabTappedTwices[.first]!) { _ in // 🔎 監視
                    withAnimation {
                        proxy.scrollTo("top")
                    }
                }
            }
            ...
        }
    }
}
```

enum を`CaseIterable`に準拠させれば、リテラルで初期値を記述する必要もありません。

```swift
enum Tab: CaseIterable {
    case first
    case second
}

@State private var tabTappedTwices: [Tab: Trigger] = .init(
    uniqueKeysWithValues: Tab.allCases.map { ($0, .init()) }
)
```

SwiftUI は`Int`や`Bool`、`String`といった基本的なデータ型でもかなり強力ですが、必要な時は自分に都合の良い型をいつでも使えることをたまに思い出すのもよいかもしれません。

## リストの先頭にスクロールする

これでほとんどの作業は終わりましたが、最後に細かいところを片付けてしまいましょう。

リスト部分のコードを再掲します。

```swift
ScrollViewReader { proxy in
    List {
        Text("TOP").id("top")

        ForEach(items) { item in
            Text(item.title)
        }
    }
    .onTrigger(of: tabTappedTwices[.first]!) { _ in
        withAnimation {
            proxy.scrollTo("top")
        }
    }
}
```

現状ではスクロール用のターゲットとしてダミーの`Text("TOP")`を配置していますが、実際のプロダクトでは何も表示させたくありません。

### `EmptyView` を利用する

もっとも簡単な解決策は`EmptyView`を利用することです。

```swift
List {
    EmptyView().id("top")

    ForEach(items) { item in
        Text(item.title)
    }
}
```

これは簡潔で分かりやすいですが、`List`まわりの実装はOSバージョンによって差異があることが多く、こうしたダミーの`EmptyView`を使用すると、不都合が発生するケースもあるかもしれません。

### 先頭要素に`id`を付与する

そうした際は、以下のように先頭要素にスクロールターゲット用の`id`を付与する方法もあります。

```swift
List {
    ForEach(items) { item in
        Text(item.title)
            .id(items.first?.id == item.id ? "top" : nil)
    }
}
```

`List`や`ForEach`では要素を区別するために、暗黙的・明示的のどちらにせよ`id`に相当する値が必要になるため、この方法はいつでも利用できるはずです。（他の方法として`index == 0`で判定する方法もあるでしょう）

最後に`proxy.scrollTo`でアンカーも与えておきます。

```swift
proxy.scrollTo("top", anchor: .top)
```

## ここまでのコード全体

ここまでのコード全体は以下のようになります。（タブのコンテンツは`SampleView`として抽出しています）

```swift
extension Binding {
    func willSet(_ handler: @escaping (Value) -> ()) -> Binding<Value> {
        .init(
            get: { wrappedValue },
            set: { newValue in
                handler(newValue)
                wrappedValue = newValue
            }
        )
    }
}

/// タブの種類
enum Tab: String, Identifiable, CaseIterable {
    case first
    case second

    var id: String { rawValue }

    var title: String {
        switch self {
        case .first:  return "First"
        case .second: return "Second"
        }
    }

    @ViewBuilder
    func tabItem() -> some View {
        switch self {
        case .first:  Label(title, systemImage: "1.circle")
        case .second: Label(title, systemImage: "2.circle")
        }
    }
}

/// イベントのトリガー
struct Trigger {
    private(set) var key: Bool = false

    mutating func fire() {
        key.toggle()
    }
}

extension View {
    func onTrigger(of trigger: Trigger?, perform: @escaping () -> Void) -> some View {
        onChange(of: trigger?.key) { _ in
            perform()
        }
    }
}

/// ルートの View
struct ContentView: View {
    @State private var selectedTab: Tab = .first

    @State private var tabTappedTwices: [SelectionValue: Trigger] = .init(
        uniqueKeysWithValues: SelectionValue.allCases.map { ($0, .init()) }
    )

    var body: some View {
        TabView(selection: $selectedTab.willSet {
            if selectedTab == $0 {
                tabTappedTwice[selectedTab]?.fire()
            }
        }) {
            ForEach(Tab.allCases) { tab in
                SampleView(
                    title: tab.title,
                    tabTappedTwice: tabTappedTwices[tab]!
                )
                .tabItem(tab.tabItem)
                .tag(tab)
            }
        }
    }
}

/// タブに表示する View
struct SampleView: View {
    var title: String
    var tabTappedTwice: Trigger

    var body: some View {
        NavigationView {
            ScrollViewReader { proxy in
                List {
                    ForEach(items) { item in
                        Text(item.title)
                            .id(items.first?.id == item.id ? "top" : nil)
                    }
                }
                .onTrigger(of: tabTappedTwice) { _ in
                    withAnimation {
                        proxy.scrollTo("top", anchor: .top)
                    }
                }
                .listStyle(.plain)
            }
            .navigationTitle(title)
            .navigationBarTitleDisplayMode(.inline)
        }
    }
}

/// 要素
struct Item: Identifiable {
    var id: Int
    var title: String { "Item \(id)" }

    init(_ number: Int) {
        id = number
    }
}

/// ダミーデータ
private let items: [Item] = (1..<100).map(Item.init)
```

## 共通コンポーネントとして作成する

せっかくなので、最後に共通コンポーネントとして再利用可能な設計にしたいと思います。

### `TabContainer`

まず、今回の`TabView`の処理を内包する`TabContainer`を作成してみます。

```swift
struct TabContainer<
    SelectionValue: Hashable & CaseIterable, 
    Content: View
>: View {
    @Binding var selection: SelectionValue
    @ViewBuilder var content: ([SelectionValue: Bool]) -> Content

    @State private var tabTappedTwices: [SelectionValue: Trigger] = .init(
        uniqueKeysWithValues: SelectionValue.allCases.map { ($0, .init()) }
    )

    var body: some View {
        TabView(selection: $selection.willSet {
            if selection == $0 {
                tabTappedTwices[selection]?.fire()
            }
        }) {
            content(tabTappedTwices)
        }
    }
}
```

型変数や制約が多いですが、やっている処理は全く変わりません。

ここでは`SelectionValue`の型制約として`CaseIterable`への準拠を必須にし、それによって`allCases`を呼び出して`tabTappedTwices`の初期値を作成していますが、もし`enum`以外の値を扱いたい場合はインターフェースを見直す必要があるでしょう。

これを利用するとコードは以下のようになります。

```swift
struct ContentView: View {
    @State private var selectedTab: Tab = .first

    var body: some View {
        TabContainer(selection: $selectedTab) { tabTappedTwices in
            ForEach(Tab.allCases) { tab in
                SampleView(
                    title: tab.title,
                    tabTappedTwice: tabTappedTwices[tab]!
                )
                .tabItem(tab.tabItem)
                .tag(tab)
            }
        }
    }
}
```

`TabContainer`という見慣れない名前や、`tabTappedTwices`という引数をクロージャに受け取る点以外は、ほとんど標準の`TabView`と同じ見た目かと思います。

### `scrollToTop`モディファイア

もう1つ細かい部分ですが、スクロールターゲットようの`id`を`"top"`（あるいは別の何か）に決定してしまえば、上部までスクロールする処理はモディファイアとして抽出できます。

```swift
extension View {
    func scrollToTop(on trigger: Trigger?, proxy: ScrollViewProxy) -> some View {
        onChange(of: trigger) { _ in
            withAnimation {
                proxy.scrollTo("top", anchor: .top)
            }
        }
    }
}
```

これを利用すると以下のようになります。

```swift
struct SampleView: View {
    var title: String
    var tabTappedTwice: Trigger

    var body: some View {
        NavigationView {
            ScrollViewReader { proxy in
                List {
                    ForEach(items) { item in
                        Text(item.title)
                            .id(items.first?.id == item.id ? "top" : nil)
                    }
                }
                .scrollToTop(on: tabTappedTwice, proxy: proxy) // ✅
                .listStyle(.plain)
            }
            .navigationTitle(title)
            .navigationBarTitleDisplayMode(.inline)
        }
    }
}
```

このように Building-block として、自分たちが欲しいようにいくらでも拡張できるのは SwiftUI の強みだと感じます。

### `TabList`

さらに筆を進めて、タブ内に表示する専用の`List`を抽出することもできます。

```swift
struct TabList<
    Data: RandomAccessCollection,
    Content: View
>: View where Data.Element: Identifiable {
    var items: Data
    var scrollToTop: Trigger
    var content: (Data.Element) -> Content

    init(
        _ items: Data,
        scrollToTop: Trigger,
        content: @escaping (Data.Element) -> Content
    ) {
        self.items = items
        self.scrollToTop = scrollToTop
        self.content = content
    }

    var body: some View {
        ScrollViewReader { proxy in
            List(items) { item in
                content(item)
                    .id(items.first?.id == item.id ? "top" : nil)
            }
            .scrollToTop(on: scrollToTop, proxy: proxy)
            .listStyle(.plain)
        }
    }
}
```

これを利用した場合は以下のようになります。

```swift
struct SampleView: View {
    var title: String
    var tabTappedTwices: Trigger

    var body: some View {
        NavigationView {
            TabList(items, scrollToTop: tabTappedTwices) { item in // ✅
                Text(item.title)
            }
            .navigationTitle(title)
            .navigationBarTitleDisplayMode(.inline)
        }
    }
}
```

これは **過度な抽象化** にあたるケースが多いでしょうし、エッジケースを考え出すと追加の実装が必要になることもあるので、「共通化できるから」という理由でやるべきではないでしょう。

しかし、必要ならばいつでもこうした共通化によって、自分たちに必要な Building-block を用意できる点は抑えておくとよいのではないでしょうか。

## おわりに

そんなわけで、タブがタップされた時に上までスクロールする処理を SwiftUI でどのように実現するか、そしてどのように共通化できるかのアイディアを書かせていただきました。

SwiftUI には多くのやり方があるので、これが Best-way ではないかもしれませんが、何かしらの参考になれば幸いです。

なお、明後日の12/14（水）にも [カウシェさんのアドベントカレンダー](https://adventar.org/calendars/7660) にて SwiftUI の記事を出させていただきますので、よろしければそちらもお楽しみくださいませ 🎁

*- Merry Christmas, SwiftUI Developers -*

## ソースコード

https://gist.github.com/YusukeHosonuma/7bcb6afa796e076164569e22fa809e68

https://gist.github.com/YusukeHosonuma/3ad13e270d3cc7b6c1d7709c4ebe1ae8

## 参考

- https://designcode.io/swiftui-handbook-tabbar-scroll-to-top
- https://medium.com/geekculture/move-to-top-of-tab-on-selecting-same-tab-from-tab-bar-in-swiftui-a2b2cfd33872

## おすすめ

https://qiita.com/YusukeHosonuma/items/9889eee79d7c760f4d63