上タブでインタラクティブなインジケーターを作りたい
iOS アプリを作る際に、Swift はとても便利である。SwiftUI は、UI を組み立てていく上でパズル?的な感覚でホイホイ仕上がっていくのが気持ちいい。何がどっち方向にどう重なっているのかがイメージできれば、とても簡単だ。
今回は、これ使わないアプリあるの?ってくらい使用頻度の高い TabView
を深堀って行きたい。
現在の Swift の stable バージョンは、6.0.1
だ。それで進めることにする。対象は iPhone で iOS 17+。
最終的に作りたいものは、こんな感じ。
極めて普通。なのだが、実装的には若干トリッキー?だ。
いきなりこれを目指すと、分かりづらいので Step by Step で進める。理屈が分かれば、何でも自由自在に組めるようになる。
一番基本の TabView
import SwiftUI
struct BasicTabView: View {
@State var selectedTab: Int = 0
var body: some View {
TabView(selection: $selectedTab) {
PlainPage(text: "Page-0")
.tag(0)
.toolbarBackground(.visible, for: .tabBar)
.tabItem {
Label("Menu-0", systemImage: "figure.walk.circle")
}
PlainPage(color: .blue, text: "Page-1")
.tag(1)
.toolbarBackground(.visible, for: .tabBar)
.tabItem {
Label("Menu-1", systemImage: "figure.walk.diamond")
}
PlainPage(color: .green, text: "Page-2")
.tag(2)
.toolbarBackground(.visible, for: .tabBar)
.tabItem {
Label("Menu-2", systemImage: "figure.run.square.stack")
}
}
}
}
#Preview {
BasicTabView()
}
画面下部に、タブメニューがくっついてるヤツ。これが言ってみれば Root Layout みたいな使われ方をすることが多いものと思われる。うっかり iPad で見ると、いやーんな感じに表示されるので、注意されたい。
TabView
には、くっつく横スクロールのような .page
スタイルがあり、.tabViewStyle(.page)
とすることで、それが実現できる。画面下部に現れるドットが並ぶヤツを消したい場合は、.page(indexDisplayMode: .never))
と指定すれば良い。
TabView(selection: $selectedTab) {
...
}
.tabViewStyle(.page(indexDisplayMode: .never)) // indexDisplayMode: always, automatic, never
他にもあるけど、色々いじってみてね、ということで省略。なお、上記サンプルコード内の PlainPage
は、何でも良かったので、仮で以下のようにして使い回す。実際には、ScrollView の中に入れたりするが、今回説明したいこととは関係ないので、これで。
import SwiftUI
struct PlainPage: View {
var color: Color = .pink
var text: String = "Plain Page View"
var textColor: Color = .white
var body: some View {
ZStack {
color.ignoresSafeArea()
Text(text)
.font(.largeTitle)
.foregroundColor(textColor)
}
}
}
#Preview {
PlainPage(color: .green, text: "Sample Page View")
}
BasicTabView.swift はコードがダサいので、以下のように修正する。
import SwiftUI
// struct MenuItem -> Constant
struct NormarizedBasicTabView: View {
@State var selectedTab: Int = 0
let menuItems: [MenuItem] = [
.init(text: "Menu-0", label: "Label-0", color: .pink, icon: "figure.walk.circle"),
.init(text: "Menu-1", label: "Label-1", color: .blue, icon: "figure.walk.diamond"),
.init(text: "Menu-2", label: "Label-2", color: .green, icon: "figure.walk.triangle")
]
var body: some View {
TabView(selection: $selectedTab) {
ForEach(0..<menuItems.count, id: \.self) { index in
PlainPage(color: menuItems[index].color, text: menuItems[index].text)
.tag(index)
.toolbarBackground(.visible, for: .tabBar)
.tabItem {
Label(menuItems[index].label, systemImage: menuItems[index].icon)
}
}
}
}
}
#Preview {
NormarizedBasicTabView()
}
struct MenuItem {
let text: String
let label: String
let color: Color
let icon: String
}
上タブ
次に、画面上部にタブメニューが表示されるものを作りたい。
構造は以下のように考える。
- 画面を VStack でタブメニュー部分とページ部分に分割する
- タブメニュー部分は、HStack で横並びにする
- Body 部分は
.tabViewStyle(.page(indexDisplayMode: .never))
な TabView にする
var body: some View {
VStack(spacing: 0) {
// MARK: TopMenuBar
HStack(spacing: 0) {
ForEach(0..<menuItems.count, id: \.self) { index in
Button {
// selectedTab = index // not smooth
withAnimation {
selectedTab = index
}
} label: {...}
}
}
.frame(height: 50)
// MARK: TabView
TabView(selection: $selectedTab) {
ForEach(0..<menuItems.count, id: \.self) { index in
PlainPage(color: menuItems[index].color, text: menuItems[index].text)
.tag(index)
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
.ignoresSafeArea()
}
}
ピッタリくっつけたいので、spacing: 0
としている。
タブの中身をボタンにしているのは、押したら selectedTab
を切り替えたいためで、必要なければ Text
などでも良い。Button Action で、withAnimation
にしないと、押した際のヌルヌル感が損なわれる。withAnimation にすることで、押すと Body 部がパッと切り替わるのが、ヌルヌルっとスライドして切り替えるようになる。
次に、どのタブが選択されているのか分かるように、Active なタブには下線(インジケーター)的なものをつけたい。これには色々なやり方があるような気もするが、今回は、以下のように細長い Rectangle
を VStack で文字の下に配置する。Active なものは色が付き、それ以外は、透明(.clear
)。
Button {
withAnimation {
selectedTab = index
}
} label: {
VStack(spacing: 0) {
Spacer()
Text(menuItems[index].label)
.foregroundColor(index == selectedTab ? .black : .gray)
Spacer()
Rectangle()
.fill(index == selectedTab ? .black : .clear)
.frame(height: 3, alignment: .bottom)
}
}
横スクロールする上タブ
タブの数が多い場合、画面分けたら?と思うが、タブ自体を横スクロールするようなことも考えられる。
横スクロールは簡単で、メニュー部分を ScrollView(.horizontal)
で囲ってやれば良い。
ScrollView(.horizontal, showsIndicators: false) {
// MARK: TopMenuBar
HStack(spacing: 0) {...}
}
横スクロールのインジケーターはいらないので、showsIndicators: false
とした。
また、このままだとぎっちり詰まってしまうので、タブ幅を固定した。
Text(menuItems[index].label)
.foregroundColor(index == selectedTab ? .black : .gray)
.frame(width: 120)
これで、一応できたが、例えば Body 部を横に進めていき、Menu 部で初期表示位置が画面外のページにたどり着くと、Menu 部を自分でスクロールさせないといけなくて、しんどい。なので、Focus があたっているタブが勝手に表示されるように ScrollViewReader
を使う。
ScrollViewReader { scrollProxy in
ScrollView(.horizontal, showsIndicators: false) {
...
}
.onChange(of: selectedTab) {
withAnimation {
scrollProxy.scrollTo(selectedTab, anchor: .center)
}
}
}
Button アクションで、scrollTo
すると、Body 部とは連携されないので、ScrollView
に onChange
でつける。
なお、.onChange
は、iOS 17 以降で仕様が代わり、以前の書き方は非推奨になった。のに、非推奨の書き方をサジェストするのは、XCode の一人ボケツッコミなのだろうか。
ついてくるインジケーター
さて、これでだいたい OK な気がしなくもないが、アクティブなタブを表すインジケーター的下線が、これまでの方法だとパチっパチっと切り替わり、Body 部が左右にスワイプしてスクロールできるのに対して、非連続的で少々違和感がある。どう実装しているか分かってるから納得いかないだけかもしれないが、インジケーターもヌルヌル動かしたい。こういうの、大事。
Body 部の横方向の座標に合わせてインジケーターをインタラクティブに動かしたい。動かしたいのだが、横方向の座標が GeometryReader などでは、うまく取れなかった。
色々調べてみると、完全にインタラクティブにインジケーターを操作するために、TabView を諦め、horizontal な ScrollView にして実現するという方法があるようだ。ScrollViewReader で座標を取得する。
ただ、読んでパッと理解できない程度の複雑さになるのと、やっぱり TabView という偉大なアセットを放棄してしまうもったいなさとを踏まえ、あがいてみることにした。
実装方針
横スクロールするタブは忘れ、画面内に収まる固定のタブをいじる。
まずはタブメニュー部の構造を変える。
このように組み直し、Rectangle の開始位置を選択されているタブの位置にフワっと動かしてやれば良い作戦。
TabView の selecton
が切り替わるタイミングは、各タブの表示領域が50%を超えたタイミング、つまり引っ張ってる最中に切り替わるので、インジケーターが動き出すタイミングも、完全に Body 部が切り替わる前になる。あとは、animation の duration を調整してやれば、違和感なくヌルヌル感を演出することができる。
実装
図解しておいてなんだが、今回は VStack ではなく、overlay
で組む。そっちの方が、簡単だから。Rectangle の長さを計算するために、画面の横幅が必要になるので、GeometryReader
を使う。
@State var indicatorOffset: CGFloat = 0
var body: some View {
GeometryReader { geometry in
VStack(spacing: 0) {
// MARK: TopMenuBar
HStack(spacing: 0) {
ForEach(0..<menuItems.count, id: \.self) { index in
Button {
withAnimation {
selectedTab = index
}
} label: {
Spacer()
Text(menuItems[index].label)
.foregroundColor(index == selectedTab ? .black : .gray)
Spacer()
}
}
}
.frame(height: 50)
.overlay(alignment: .bottomLeading) {
Rectangle()
.frame(width: geometry.size.width / CGFloat(menuItems.count), height: 3)
.offset(x: indicatorOffset, y: 0)
}
// MARK: TabView
....
}
}
}
Button の label 内の VStack をやめ、Rectangle を overlay に切り出した。Rectangle の offset で指定している indicatorOffset
の計算は、onChange で行う。
// MARK: TopMenuBar
HStack(spacing: 0) {
...
}
.frame(height: 50)
.overlay(alignment: .bottomLeading) {
...
}
.onChange(of: selectedTab) {
withAnimation(.easeInOut(duration: 0.3)) {
indicatorOffset = geometry.size.width / CGFloat(menuItems.count) * CGFloat(selectedTab)
}
}
好みの問題な気もするが、duration
は、だいたい 0.3 くらいにしておくと、違和感なくヌルヌルになる。
どうだろう?むしろ完全に連動していない方が良い気さえしてきた笑
以下がコード全文だ。
import SwiftUI
struct UpperIntaractiveIndicatorTabView: View {
@State var selectedTab: Int = 0
@State var indicatorOffset: CGFloat = 0
var menuItems: [MenuItem] = []
var body: some View {
GeometryReader { geometry in
VStack(spacing: 0) {
// MARK: TopMenuBar
HStack(spacing: 0) {
ForEach(0..<menuItems.count, id: \.self) { index in
Button {
withAnimation {
selectedTab = index
}
} label: {
Spacer()
Text(menuItems[index].label)
.foregroundColor(index == selectedTab ? .black : .gray)
Spacer()
}
}
}
.frame(height: 50)
.overlay(alignment: .bottomLeading) {
Rectangle()
.frame(width: geometry.size.width / CGFloat(menuItems.count), height: 3)
.offset(x: indicatorOffset, y: 0)
}
.onChange(of: selectedTab) {
withAnimation(.easeInOut(duration: 0.3)) {
indicatorOffset = geometry.size.width / CGFloat(menuItems.count) * CGFloat(selectedTab)
}
}
// MARK: TabView
TabView(selection: $selectedTab) {
ForEach(0..<menuItems.count, id: \.self) { index in
PlainPage(color: menuItems[index].color, text: menuItems[index].text)
.tag(index)
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
.ignoresSafeArea()
}
}
}
}
#Preview {
let menuItems: [MenuItem] = [
.init(text: "Menu-0", label: "Label-0", color: .pink, icon: "figure.walk.circle"),
.init(text: "Menu-1", label: "Label-1", color: .blue, icon: "figure.walk.diamond"),
.init(text: "Menu-2", label: "Label-2", color: .green, icon: "figure.walk.triangle")
]
UpperIntaractiveIndicatorTabView(menuItems: menuItems)
}
課題
これであなたも TabView マスター。どんなものにも対応できるはずだ。本当にできるか、TabView を使って以下のような画面を実装せよください。
ポイントは、上タブのデザインが変更になり、インジケーターが下線ではなく、背景になっている点だ。overlay だと上に重なってしまうので、そうならないようにするにはどうするのか? TabView がネストしているのは、驚くほどどうでも良い。
Good Luck 👍️