![見出し画像](https://assets.st-note.com/production/uploads/images/103693444/rectangle_large_type_2_c5afc07ae1558adb81502368f62a6edd.png?width=1200)
SwiftUIのTabViewでインタラクティブなタブインジケーターを作る
![](https://assets.st-note.com/production/uploads/images/103693591/picture_pc_e004a09a85dab233da207b5004174346.gif)
今回はこちらの記事で紹介したTabViewでのスワイプページングの改善として、スワイプ操作に追従して上タブのインジケーターがインタラクティブに動くようにしてみた。このようなUIはTwitterやTikTokなどでもよく見かける。
前回の記事では、TabViewと上タブに決めうちでページやタブを並べたが、今回はタブのデータモデルを定義して、データ数に応じてタブ表示が増減できるようにした。
/// タブコンテンツのデータモデル
struct SlideTabContent<Content: View>: Identifiable {
/// タブID (ForEachで必要)
var id: Int
/// タブタイトル
var title: String
/// タブコンテンツ (任意のView)
var content: Content
}
上記のデータモデルから表示するタブのコンテンツを作成して、SlideTabViewに渡すだけで簡単にインタラクティブなタブインジケーターを表示できるようにしている。
struct CustomSampleView: View {
@State var tabContents = [
SlideTabContent(id: 0, title: "Page1", content: PageView(color: .red)),
SlideTabContent(id: 1, title: "Page2", content: PageView(color: .green)),
SlideTabContent(id: 2, title: "Page3", content: PageView(color: .blue))
]
var body: some View {
SlideTabView(tabContents: tabContents)
.ignoresSafeArea(edges: .bottom)
}
}
SlideTabViewの構成
TabView部分
タブコンテンツのデータをForEachで表示して、PageTabViewStyleを指定すればスワイプページングはできるが、今回の肝となるインタラクティブなタブインジケーターは次のように実装する。
まずそれぞれのタブコンテンツにおいて、GeometryReaderでproxyを取得し、proxy.frame(in: .global)を使ってグローバル座標に変換する。さらにこれに対してonChangeで値の変化を監視し、変化があった時にスワイプの操作量をもとにインジケーター位置を計算して更新すればよい。
struct SlideTabView<Content: View>: View {
@State var tabContents: [SlideTabContent<Content>]
@State var selection: Int = 0
@State private var indicatorPosition: CGFloat = 0
var body: some View {
GeometryReader { geometry in
let screenWidth = geometry.size.width
let safeAreaInsetsLeading = geometry.safeAreaInsets.leading
VStack(spacing: 0) {
SlideTabBarView(tabBars: tabContents.map{( $0.id, $0.title )},
color: .black,
selection: $selection,
indicatorPosition: $indicatorPosition)
.frame(height: 48)
TabView(selection: $selection) {
ForEach(tabContents) { tabContent in
tabContent.content
.tag(tabContent.id)
.overlay {
GeometryReader{ proxy in
Color.clear
.onChange(of: proxy.frame(in: .global), perform: { value in
// 表示中のタブをスワイプした時のみ処理する
guard selection == tabContent.id else { return }
// 対象タブのスワイプ量をTabBarの比率に変換して、インジケーターのoffsetを計算する
let offset = -(value.minX - safeAreaInsetsLeading - (screenWidth * CGFloat(selection))) / tabCount
if selection == tabContents.first?.id {
// 最初のタブの場合、offsetが0以上の時のみ位置を更新する
if offset >= 0 {
indicatorPosition = offset
} else {
return
}
}
if selection == tabContents.last?.id {
// 最後のタブの場合、offsetがscreenWidth以下の時のみ位置を更新する
if offset + screenWidth/tabCount <= screenWidth {
indicatorPosition = offset
} else {
return
}
}
indicatorPosition = offset
})
}
}
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
.animation(.easeInOut, value: selection)
}
}
}
private var tabCount: CGFloat {
CGFloat(tabContents.count)
}
}
上タブ
SlideTabBarViewでは、タブコンテンツのデータからIDとタイトルを受け取ってタブを表示し、上で計算されたインジケーター位置を受け取って、インジケーターの表示位置を更新している。
なお、Buttonにしているのはタップ時にselectionを更新して、タブ切り替えができるようにしているため。
struct SlideTabBarView: View {
let tabBars: [(id: Int, title: String)]
let color: Color
@Binding var selection: Int
@Binding var indicatorPosition: CGFloat
var body: some View {
GeometryReader { geometry in
HStack(spacing: 0) {
ForEach(tabBars, id: \\.id) { tabBar in
Button {
selection = tabBar.id
} label: {
Text(tabBar.title)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.foregroundColor(color)
.padding(8)
}
}
}
.overlay(alignment: .bottomLeading) {
Rectangle()
.foregroundColor(color)
.frame(width: geometry.size.width / tabBarCount, height: 4)
.offset(x: indicatorPosition, y: 0)
}
}
}
private var tabBarCount: CGFloat {
CGFloat(tabBars.count)
}
}
リポジトリ
参考
この記事が気に入ったらサポートをしてみませんか?