45
24

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

SwiftUIAdvent Calendar 2022

Day 12

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

Last updated at Posted at 2022-12-12

SwiftUI Advent Calendar 2022 の12日目の記事です。

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

Tl;Dr

Simulator Screen Recording - iPhone 14 - 2022-12-11 at 19.26.35.gif

タブタップの検出

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

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

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:)モディファイアが活用できそうです。

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

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

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

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

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)を比較しています。

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

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

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に定義しておくと何かと便利なケースが多いように思います。

@PublisheddidSetで検出

あるいは、selectedTabObservableObject上の@Publishedプロパティとして定義されているのであれば、didSet(あるいはwillSet)による監視も可能です。

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) {
            ...
        }
    }
}

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

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

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

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

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

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

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:)で監視する方法でしょう。

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型でも問題ないことが分かります。

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()がイベントの発火であると初見ですぐに読み取れる人は少ないでしょう。

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

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:)の代わりに使用する以下のモディファイアも定義したいと思います。

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

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

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

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

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

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

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

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

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] をデータ構造として利用できます。

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に準拠させれば、リテラルで初期値を記述する必要もありません。

enum Tab: CaseIterable {
    case first
    case second
}

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

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

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

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

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

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を利用することです。

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

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

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

先頭要素にidを付与する

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

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

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

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

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

ここまでのコード全体

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

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を作成してみます。

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以外の値を扱いたい場合はインターフェースを見直す必要があるでしょう。

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

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"(あるいは別の何か)に決定してしまえば、上部までスクロールする処理はモディファイアとして抽出できます。

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

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

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を抽出することもできます。

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)
        }
    }
}

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

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(水)にも カウシェさんのアドベントカレンダー にて SwiftUI の記事を出させていただきますので、よろしければそちらもお楽しみくださいませ 🎁

- Merry Christmas, SwiftUI Developers -

ソースコード

参考

おすすめ

45
24
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
45
24

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?