inSmartBank

B/43を運営する株式会社スマートバンクのメンバーによるブログです

SwiftUIで作るタブ風UI

こんにちは!スマートバンクでアプリエンジニアをしているkanekoです。

先日リリースされたB/43の最新バージョンでは、お金の使いすぎを防ぐ家計管理サポート機能を拡充しました!

prtimes.jp

リニューアルにあたりUIKitで実装されていた画面をSwiftUIで新規作成したので実装の詳細とその際に調べたアニメーションの挙動について共有します。

支出画面について

支出画面はニュースアプリなどで見られる選択可能なタブ+スワイプでコンテンツを切り替え可能な画面で、以下のような要件があります。

  • 選択可能な月が一覧で表示される(この月選択可能な画面をタブと呼ぶ)
  • 表示されるタブは選択中の月を中央にして3つ
  • それぞれの月はクリックで切り替えが可能
  • タブの下に現在選択中の月の支出情報が表示される
  • 支出情報部分はSwipeで前後の月に切り替え可能。その際に上側のタブも連動する

支出画面

SwiftUIでは標準でTabViewが用意されており、 .tabViewStyle を指定することでStyleを適用できます。今回支出画面を実装する上で、Swipeでページング可能な支出情報の領域をPageTabViewStyleを指定したTabView、連動する上部のタブ部分を選択中のIndexの変更を検知してOffsetを変更するカスタムViewで実装することにしました。

以下が各Viewの構成です。

画面構成

  • Tab: 上側のタブ情報を表示するためのView
  • TabItem: Tab内に表示される各月情報を表すView
  • TabView: 各月の支出情報を表示するSwipe可能なView

個別の実装を見ていきましょう。

タブ内で表示するTabItemを作成する

まずはタブに表示する選択可能な個別のUIの作成です。

TabItem

月の情報とタブの選択状態を表すインジケーターを表示し、自身が選択された際にコールバックを返すViewを作成します。

// 個別のタブに表示する情報を表す
struct TabPageEntry {
    // ◯月などの月情報を表す文字列
    let title: String
}

// 個別の月を表示するためのView
struct TabItem: View {
    let pageEntry: TabPageEntry
    let isSelected: Bool
    let onSelected: () -> Void

    var body: some View {
        Button(
            action: onSelected,
            label: {
                HStack(spacing: 2) {
                    Text(pageEntry.title)
                            .foregroundStyle(
                            isSelected ? Color.green : Color.gray
                        )
                }
            }
        )
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        // タブが選択されている際に選択状態を表すインジケータを表示する
        .overlay(alignment: .bottom) {
            if isSelected {
                Rectangle()
                    .frame(width: 24, height: 2)
            }
        }
    }
}

タブを実装する

次に先ほど実装したTabItemを一覧で表示するTabの実装を行います。

TabはTabItemの一覧を表示し、選択中のTabItemを中央に表示させるための処理をOffsetを変更することで行います。Indexの更新はTabItemの選択時以外に支出情報を表示するTabViewのSwipeの際にも発生するため selectedIndex は外部から渡せるようにする必要があります。

struct Tab: View {
    @Binding private(set) var selectedIndex: Int
        let pageEntries: [TabPageEntry]

        var body: some View {
        GeometryReader { geometry in
            let displayTabCount = 3
            let itemWidth = geometry.size.width / CGFloat(displayTabCount)
            // 選択中のタブが一覧の先頭/末尾でも中央に表示するために左右にSpacerで余白を追加する
            let spacerWidth = itemWidth * CGFloat((displayTabCount - 1) / 2)
            LazyHStack(alignment: .center, spacing: 0) {
                Spacer()
                    .frame(width: spacerWidth)
                ForEach(pageEntries.indices, id: \.self) { index in
                    TabItem(
                        tabPageEntry: pageEntries[index],
                        isSelected: selectedIndex == index,
                        onSelected: { selectedIndex = index }
                    )
                    .frame(width: itemWidth)
                }
                Spacer()
                    .frame(width: spacerWidth)
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            // 選択中のTabItemが中央に表示されるようOffsetを調整する
            .offset(x: -CGFloat(self.selectedIndex) * itemWidth)
            .animation(.default, value: selectedIndex)
        }
        .frame(height: 48)
    }
}

支出画面

後は出来上がったTabとStyleにPageTabViewStyleを指定したTabViewを表示させてあげれば完成です。

struct SpendingScreen: View {
        @State var currentIndex: Int = 0
        let pageEntries: [TabPageEntry]
        
        var body: some View {
                VStack(spacing: 0) {
                        Tab(
                                selectedIndex: $currentIndex,
                                pageEntries: pageEntries
                        )
            TabView(selection: $currentIndex) {
                    ForEach(Array(pageEntries.enumerated()), id: \.offset) { offset, pageEntry in
                      // 支出情報画面の作成
                      }
            }
            .tabViewStyle(.page(indexDisplayMode: .never))
                }
        }
}

このように、TabとTabView間で $currentIndex を共有することで、月のクリックとページのSwipeどちらの操作でも支出情報画面を切り替えることが可能となります。

TabとTabViewの連動

まとめ

このエントリーでは、SwiftUIでのタブを用いた画面実装について紹介しました。

同じような画面を実装する際に参考になると幸いです。

スマートバンクでは一緒に B/43 を作り上げていくメンバーを募集しています!カジュアル面談も受け付けていますので、お気軽にご応募ください !!

smartbank.co.jp

We create the new normal of easy budgeting, easy banking, and easy living.
In this blog, engineers, product managers, designers, business development, legal, CS, and other members will share their insights.