SwiftUI カスタマイズTabBar

リサーチ・アンド・イノベーションのhongです。iOSエンジニアをやっています。最近SwiftUIを触り始めました。メモを残したいので、Blogの初投稿をしようと思います。今回はSwiftUIでカスタマイズのTabBar作成について解説いたします。SwiftUIの自由度はとっても高いと思いますので、他の作り方もあると思います。

この記事の構成

この記事は3セッションに分けようと思います

セッション1はSwiftUIが提供するTabBarを紹介します。

セッション2は提供されたTabBarを使わずにViewでTabBarを作ります。

セッション3はちょっとカスタマイズの要素を入れます。

セッション1 SwiftUIが提供するTabBar

とりあえず公式のTabBarをみてみましょう。

タブを二つ追加して、テキストとイメージを設置します。それぞれタップ可能です。

コードは以下となります。

TabBar.swift

import SwiftUI

struct TabBar: View {
    var body: some View {
        TabView {
            HomeView()
                .tabItem {
                    Image(systemName: "house")
                    Text("ホーム")
                }
            CalendarView()
                .tabItem {
                    Image(systemName: "calendar")
                    Text("家計簿")
                }
        }
    }
}

HomeView.swift

import SwiftUI

struct HomeView: View {
    var body: some View {
        Text("ホーム")
    }

CalendarView.swift

import SwiftUI

struct CalendarView: View {
    var body: some View {
        Text("家計簿")
    }
}

ただの数行でこの様な効果が出ます。タップイベントは自動で設定されますし、UIKitと比べるととてもシンプルですね。 ちなみに使ったアイコンはSF Symbolsから探しました。

Appleが提供したTabBarはとても使いやすいですが、場合によって自分達でカスタマイズする必要あります。例えば真ん中のTabを大きくして、ユーザーにアピールしたい場合など。

例えば、弊社サービスCODEのアプリのTabBarはそうなっていたりします。

セッション2 Tab Barを作る

先ずは一個のタブのデータモデルを設計しましょう、タブが含む要素は多いですが、とりあえず重要な物から書きます:

  • タイトル
  • アイコン
  • 識別子(タイトルを使ってもいいですが、あった方が見やすいかと)

最低限の要素を書いたので、モデルを書きましょう。以下となります:

Tab.swift

import SwiftUI

enum Tab {
    case home
    case explore
    case review
    case calendar
}

struct TabItem: Identifiable {
    let id = UUID()
    let text: String
    let icon: String
    let tab: Tab
}

// sample data
var tabItems = [
    TabItem(text: "ホーム", icon: "house", tab: .home),
    TabItem(text: "検索", icon: "magnifyingglass", tab: .explore),
    TabItem(text: "評価", icon: "square.and.pencil.circle", tab: .review),
    TabItem(text: "家計簿", icon: "calendar", tab: .calendar)
]

モデルを作りました。Tabのenumを追加していて、これを識別子として使う予定です。一応後で使うサンプルデータも作りました。

モデル作成した後はいよいよSwift UIの出番ですね。とりあえずタップなしUIだけを作成します、いい感じに出来ています:

早速コードをみてみましょう:

TabBar.swift

struct TabBar: View {
    var body: some View {
        ZStack(alignment: .bottom) {
            ContentView()
                .frame(maxWidth: .infinity, maxHeight: .infinity)
            HStack {
                ForEach(tabItems) { item in
                    VStack(spacing: 0) {
                        Image(systemName: item.icon)
                            .symbolVariant(.fill)
                            .font(.body.bold())
                            .frame(width: 44, height: 29)
                        Text(item.text)
                            .font(.caption2)
                            .lineLimit(1)
                    }
                    .frame(maxWidth: .infinity)
                }
            }
            .padding(.horizontal, 8)
            .padding(.top, 14)
            .frame(height: 88, alignment: .top)
            .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 34, style: .continuous))
            .frame(maxHeight: .infinity, alignment: .bottom)
            .ignoresSafeArea()
        }
    }
}

ZStackの中にHStackを入れてます。HStackはよく使うかもしれませんが、ZStackというものがあります。これはZ軸の概念があって、後から入れたビューが前に入れていたビューをブロックします。なので、最後に入れたHStackContentViewの上にいます、TabBarぽいですね。 最後にMaterial指定のバックグラウンドを入れました。一行で綺麗なガラス効果を入れられるのは楽です。(ちなみにContentViewはただのオレンジビューでした)

UIは大体完成しました、あとはタップイベントを追加ですね。ボタンを入れて簡単にできると思います。

まず同じように完成した効果を見ましょう:

タップした際に画面が変更されてます。そして選択されたタブにはハイライトが付き、他のタブがグレーアウトします。

コードは以下となります

TabBar.swift

struct TabBar: View {

    // 選択されたタブのプロパティを追加
    @State var selectedTab: Tab = .home

    var body: some View {
        ZStack(alignment: .bottom) {

            Group {
                // 選択したタブごとContentViewを切り替える
                switch selectedTab {
                case .home:
                    ContentView()
                case .explore:
                    ContentView2()
                case .review:
                    ContentView3()
                case .calendar:
                    ContentView4()
                }
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)

            HStack {
                ForEach(tabItems) { item in
                    // VStackの外にButtonを追加して、ボタンのアクションにはitem.tabへの変更を設定します。そしてImageとTextをlabel:内に移動します
                    Button {
                        selectedTab = item.tab
                    } label: {
                        VStack(spacing: 0) {
                            Image(systemName: item.icon)
                                .symbolVariant(.fill)
                                .font(.body.bold())
                                .frame(width: 44, height: 29)
                            Text(item.text)
                                .font(.caption2)
                                .lineLimit(1)
                        }
                        .frame(maxWidth: .infinity)
                    }
                    .foregroundStyle(selectedTab == item.tab ? .primary : .secondary)
                }
            }
            .padding(.horizontal, 8)
            .padding(.top, 14)
            .frame(height: 88, alignment: .top)
            .background(.ultraThinMaterial)
            .frame(maxHeight: .infinity, alignment: .bottom)
            .ignoresSafeArea()
        }
    }
}

コード内書いたコメント以外にもいくつか説明したい点があって: - Groupについて

- `selectedTab`の`switch`は`Group`内にいれています。`Group`内部全てのViewに同じmodifierを適用します。
  • ButtonforegroundStyleについて

    • secondaryとprimaryを使っています。secondaryの方はprimaryに比べて、わずかに透明で背後の色が少し透けて見えます。
  • item.tabについて、

    • 忘れたかもしれませんが、Tabのモデル作った時に入れてます^_^

自作のTabBarが大体完成しました。あとはカスタマイズの要素を入れましょう

セッション3 カスタマイズの要素を入れる

カスタマイズの要素を入れる点についてですが、そこまで珍しいものを入れるのではなく、とりあえずタブ上部に細いバーを追加しようと思います。

完成の効果をみてみましょう

タブの上に細いバーを入れました、アイコンだけの時より見やすいと思います。

では、コード側をみてみましょう

TabBar.swift

struct TabBar: View {

    @State var selectedTab: Tab = .home

    // 新しく追加されたプロパティです
    // バーのカラーとバーの長さです
    @State var color: Color = .black
    @State var tabItemWidth: CGFloat = 0

    var body: some View {
        ZStack(alignment: .bottom) {

            // 前と同じなので省略します...

            HStack {
                ForEach(tabItems) { item in
                    Button {
                        selectedTab = item.tab
                    } label: {
                        VStack(spacing: 0) {
                            Image(systemName: item.icon)
                                .symbolVariant(.fill)
                                .font(.body.bold())
                                .frame(width: 44, height: 29)
                            Text(item.text)
                                .font(.caption2)
                                .lineLimit(1)
                        }
                        .frame(maxWidth: .infinity)
                    }
                    .foregroundStyle(selectedTab == item.tab ? .primary : .secondary)
                    // overlayを置くと、buttonと同じframeの層を作ります
                    .overlay(
                        // GeometryReaderを使って buttonの長さを取得します
                        GeometryReader { proxy in
                            // ----- 参照1 -----
                            Color.clear.preference(key: TabWidthPreferenceKey.self, value: proxy.size.width)
                        }
                    )
                    .onPreferenceChange(TabWidthPreferenceKey.self) { value in
                        tabItemWidth = value
                    }
                }
            }
            .padding(.horizontal, 8)
            .padding(.top, 14)
            .frame(height: 88, alignment: .top)
            .background(.ultraThinMaterial)
            // overlayを使ってバーを一番上に置きます
            .overlay(
                overlay
            )
            .frame(maxHeight: .infinity, alignment: .bottom)
            .ignoresSafeArea()
        }
    }

    var overlay: some View {
        HStack {
            // ----- 参照2 -----
            if selectedTab == .calendar { Spacer() }
            if selectedTab == .explore { Spacer() }
            if selectedTab == .review {
                Spacer()
                Spacer()
            }
            Rectangle()
                .fill(color)
                .frame(width: 28, height: 5)
                .cornerRadius(3)
                .frame(width: tabItemWidth)
                .frame(maxHeight: .infinity, alignment: .top)
            if selectedTab == .home { Spacer() }
            if selectedTab == .explore {
                Spacer()
                Spacer()
            }
            if selectedTab == .review { Spacer() }
        }
        .padding(.horizontal, 8)
    }
}

Tab.swift

import SwiftUI

//...
struct TabWidthPreferenceKey: PreferenceKey {
    static var defaultValue: CGFloat = 0
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }
}

コード内に書いた 参照 コメント箇所について解説します: - 参照1について - まずGeometryReaderを使ってますが、シンプルにproxy取得場所内にtabItemWidth = proxy.size.widthを書くとエラーが発生します。ここの値を取りたい場合、PreferenceKeyを使わないとダメです。Tab.swift内でTabWidthPreferenceKeyを追加しています。そうすると.preferenceproxy内の値取り出すことが可能です。 - 取ったあと.onPreferenceChangeで値を保存できます。

  • 参照2について
    • .overlayで一番上にバーを置きます。バーの出る場所ですが、選択されたタブの上にしたいので、ここはSpacerを使いました。ホームの場合右に一個Spacerを入れます。検索の場合右二個左一個入れます。評価の場合は右一個左二個入れます。家計簿は左一個入れます。
    • Rectangleのframeを3回設定してます、一回目はバーのframeを設定(width: 28, height: 5)、二番目はバーを真ん中に表示するため先ほど取得したtabItemWidthをwitdhに設定します、そして三つ目はmaxHeight: .infinityにして高さを最大にします、そうするとバーが一番上に表示されます。

切替が若干味気ないのでアニメーションをいれましょう。SwiftUIのアニメーションはとてもシンプルでわかりやすいです、selectedTab = item.tabwithAnimationに入れるだけです。

TabBar.swift

withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
    selectedTab = item.tab
}

完成しました、最後の効果をみてみましょう。ついでにLandscapeでも確認します。

問題なさそうですね。

まとめ

SwiftUIを実際にやるとやはりUIKitと大きい違いがありますね、でも慣れるとスピードも上がると思います。最も重要な点はxibとさよならできることだと思います。今後もSwiftUI移行を前向きに取り組んでいきます。

参考

SwiftUI Handbook


リサーチ・アンド・イノベーションではエンジニアを募集中です。

興味を持っていただけた方はお気軽にご連絡ください!

採用情報