😬

[Swift]TCAで 下線付き上タブを実装する

2024/08/25に公開

要約

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する実装は、いくつか考えた。

  • (今回選択した)onChangeでselectedTabを監視する方法。
  • tapTab actionで、NSNotificationを送信し、それを監視する方法。
  • scrollProxytapTab actionに渡して、Reducerの中でscrollToを呼ぶ方法。

どの方法でも要件を満たす実装ができるけど、最もシンプルなものを採用した。

References

Discussion