SwiftUI Advent Calendar 2022 の12日目の記事です。
iOSアプリによく見られる、選択中のタブがもう一度タップされたら上までスクロール、というのをSwiftUIでやってみようと思います。
Tl;Dr
タブタップの検出
まずは「選択中のタブがもう一度タップされた」というイベントを検出することが必要です。
動作検証のため、まずは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
)を比較しています。
中間のパイプを作成し、そこに流れる値を監視しているイメージでしょうか。
やや余談になりますが、こうしたインターセプトを利用する箇所が多いのであれば、Binding
のextension
を用意するのも手かもしれません。
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
)による監視も可能です。
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
を利用することができます。
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") // 🚀 スクロールを発火
}
}
もし、過剰に感じるようであればTrigger
をEquatable
に準拠させて、これまでどおり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 はInt
やBool
、String
といった基本的なデータ型でもかなり強力ですが、必要な時は自分に都合の良い型をいつでも使えることをたまに思い出すのもよいかもしれません。
リストの先頭にスクロールする
これでほとんどの作業は終わりましたが、最後に細かいところを片付けてしまいましょう。
リスト部分のコードを再掲します。
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)
}
}
List
やForEach
では要素を区別するために、暗黙的・明示的のどちらにせよ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 -
ソースコード
参考
- 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
おすすめ