😬
[Swift]TCAで 下線付き上タブを実装する
要約
TCAを使用した下線付き上タブの実装を共有する。
完成したのは、こんな感じのもの。
- タブの選択に応じて、Screenを切り替る。
- 選択中のタブは赤色にする。
- 選択中のタブは下線を表示する。
環境
- TCA v1.13.1
- Xcode: v15.3
実装
上タブの各タブ(TopTabButton)
struct TopTabButton: View {
var title: String
var isSelected: Bool
var namespace: Namespace.ID
var action: (() -> Void)
var body: some View {
Button {
action()
} label: {
VStack {
Text(title)
.foregroundStyle(isSelected ? .red : .black)
.padding(.horizontal, 20)
if isSelected {
Color.red.frame(height: 3)
.matchedGeometryEffect(id: "underline", in: namespace)
} else {
Color.clear.frame(height: 3)
}
}.padding(.top, 20)
}
}
}
上タブ全体
struct TabItem: Hashable, Identifiable {
var id: UUID = UUID()
var title: String
}
Reducer
@Reducer
struct TopTab {
@ObservableState
struct State: Equatable {
var tabs: [TabItem]
var selectedTab: TabItem
}
enum Action: Sendable {
case changeTab(tab: TabItem)
}
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .changeTab(let tab):
withAnimation(.bouncy) {
state.selectedTab = tab
}
return .none
}
}
}
}
View
struct TopTabView: View {
// MatchedGeometryEffectのためにNamespaceを定義
@Namespace private var tabNamespace
var store: StoreOf<TopTab>
var body: some View {
VStack(spacing: 0) {
ScrollViewReader { scrollProxy in
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 0) {
ForEach(store.tabs) { tab in
let isSelected = tab == store.selectedTab
TopTabButton(title: tab.title, isSelected: isSelected, namespace: tabNamespace) {
store.send(.changeTab(tab: tab))
}
}
}
.onChange(of: store.selectedTab) { _, _ in
// 選んだタブが真ん中にくるようにScrollする。
scrollProxy.scrollTo(
store.selectedTab.id,
anchor: .center
)
}
}
}
}
}
}
上タブの使用例
@Reducer
struct Screen {
static let tabs: [TabItem] = [
.init(title: "Tab1"),
.init(title: "Tab2"),
.init(title: "Tab3"),
.init(title: "Tab4"),
.init(title: "Tab5")
]
@ObservableState
struct State: Equatable {
var topTab = TopTab.State(tabs: tabs, selectedTab: tabs[0])
}
enum Action: Sendable, BindableAction {
case binding(BindingAction<State>)
case topTab(TopTab.Action)
}
var body: some Reducer<State, Action> {
BindingReducer()
Scope(state: \.topTab, action: \.topTab) {
TopTab()
}
Reduce { _, action in
switch action {
case .binding:
return .none
case .topTab:
return .none
}
}
}
}
struct ScreenView: View {
// TabView.selectionが、`Binding`なので、@Bindableにする。
@Bindable var store: StoreOf<Screen>
var body: some View {
VStack {
TopTabView(store: store.scope(state: \.topTab, action: \.topTab))
TabView(selection: $store.topTab.selectedTab) {
ForEach(Screen.tabs) { tab in
Text("Screen for \(tab.title)")
.tag(tab)
}
}
}
}
}
ポイント
- 下線のAnimationはMatchedGeometryEffectで実現する。
- TopTabViewの選択と、TopTabの選択を連動させる。
他の方法として考えたもの
TopTabViewにおいて、タブを選択したときに、選んだタブが中央にくるようにScrollする実装は、いくつか考えた。
- (今回選択した)
onChang
eでselectedTab
を監視する方法。 -
tapTab
actionで、NSNotificationを送信し、それを監視する方法。 -
scrollProxy
をtapTab
actionに渡して、Reducerの中でscrollTo
を呼ぶ方法。
どの方法でも要件を満たす実装ができるけど、最もシンプルなものを採用した。
Discussion