SH Lab の アプリ開発部屋

リリースしたアプリの告知とかサポートとか、技術的なお話とか、そんな感じでつぶやきます。

SwiftUI のPathとカスタムシェイプで独自のタブバーを作ってみる

カスタムシェイプとアニメーション

SwiftUIでは、Circle()Capsule()のように初めから用意されているシェイプの他にも、Shapeプロトコルを実装して独自にシェイプを作成することができます。

さらには、そのシェイプにアニメーションを与えることもできるので、早速試してみたいと思います。

とりあえずの完成形

今回はこんな感じのものを作ってみたいと思います。 f:id:hoshi0523:20201103121707g:plain

GitHubはこちらです github.com

開発環境

参考書

SwiftUIの勉強には、この辺りの本が特におすすめです!

基礎から学ぶ SwiftUI

基礎から学ぶ SwiftUI

CoreGraphicsの勉強は、この辺りがよろしいかと思います。

タブ部分の作成

まずはタブの要素を表すenumを定義しています。

enum TabItem: String, CaseIterable {
    
    case home
    case favorite
    case settings
    
    var imageName: String {
        switch self {
        case .home: return "house"
        case .favorite: return "suit.heart"
        case .settings: return "gearshape"
        }
    }
}

このenumを使って、タブ部分の見た目を作成します。

VStack {
    // tab view.
    HStack {
        
        Spacer()
        
        ForEach(TabItem.allCases, id: \.self) { tabItem in
            Button(action: {}, label: {
                Text(tabItem.rawValue.uppercased())
                    .fontWeight(.heavy)
                    .foregroundColor(.white)
            })
            .frame(width: 100, height: 100)
        }
        
        Spacer()
    }
    .frame(height: 70)
    .background(
        Color.accentColor
            .ignoresSafeArea()
    )
    
    // TODO: content body.
    Spacer()
}

出来上がりはこんな感じです。タブをタップしても何も起きませんね。

f:id:hoshi0523:20201103123241p:plain

タブの位置を取得する

タップされたタブの位置情報を取得するための処理を追加します。まずはその位置情報を保持するためのプロパティを@Stateで定義します。

// 選択中のタブのx軸中央.
@State private var tabMidX: CGFloat = 0

次に、ボタンそれぞれをGeometryRenderで囲い、位置情報などを取得可能にします。

ForEach(TabItem.allCases, id: \.self) { tabItem in
    // 1. GeometryReaderでボタンを囲う.
    GeometryReader { geometry in

        Button(action: {
            // 2. タップされたボタンのx軸中央を保持.
            tabMidX = geometry.frame(in: .global).midX
        }, label: {
            Text(tabItem.rawValue.uppercased())
                .fontWeight(.heavy)
                .foregroundColor(.white)
        })
        // 3. frameはGeometryReaderに従う.
        .frame(
            width: geometry.size.width,
            height: geometry.size.height
        )
        .onAppear {
            // 4. 初期表示時のみ、一番左のタブのポジションを保持.
            if tabItem == TabItem.allCases.first {
                tabMidX = geometry.frame(in: .global).midX
            }
        }
    }
    // 5. frameはGeometryReaderで定義.
    .frame(width: 100, height: 100)
}
  1. GeometryRenderを利用して、ボタンを囲っています
  2. ボタンがタップされたら、そのボタンのX軸方向の中心座標を保持します
  3. frameには直接数値を与えるのではなく、親ビューの値を利用するようにしています
  4. 画面表示のタイミングで、一番左のタブの座標情報を初期値として保持しています
  5. GeometryRenderframe情報を与えています

保持している値を確認できるように、デバッグ用のTextを追加しています

    // ~ 省略 ~

    // TODO: content body.
    Spacer()

    // debug.
    Text("\(tabMidX)")
}

できあがりはこんな感じです。
選択されたタブのポジションが取得できているようですね!

f:id:hoshi0523:20201103125252g:plain

簡単なカスタムシェイプを作ってみる

ではカスタムシェイプを作ってタブ部分に適用してみます。

まずは既存のシェイプを返すだけのカスタムシェイプを作ってみます。カスタムシェイプは、Shapeを実装した構造体を作成し、pathというメソッドを実装します。

struct TabShape: Shape {
    func path(in rect: CGRect) -> Path {
        // とりあえずCapsuleを返すだけ.
        Capsule().path(in: rect)
    }
}
.background(
    Color.accentColor
        // 作成したシェイプでclipShapeを行う.
        .clipShape(TabShape())
        .ignoresSafeArea()
)

出来上がりはこんな感じです。見事にCapsuleでクリップされましたね。

f:id:hoshi0523:20201103125849p:plain

独自のカスタムシェイプを作ってみる

では改めてカスタムシェイプを作成していきます。まずは基本となる矩形を描画します。

func path(in rect: CGRect) -> Path {
    Path { path in
        // 基本となる矩形.
        path.move(to: .zero)
        path.addLine(to: CGPoint(x: 0, y: rect.height))
        path.addLine(to: CGPoint(x: rect.width, y: rect.height))
        path.addLine(to: CGPoint(x: rect.width, y: 0))
        path.closeSubpath()
    }
}

次に、カスタムシェイプにタブの位置情報のためのプロパティを定義します。

struct TabShape: Shape {
    // タブのX軸方向の中心座標.
    var tabMidX: CGFloat

    // ~ 省略 ~
}

呼び出し側にも修正を加えます。

.background(
    Color.accentColor
        // TabShapeの引数にtabMidXを指定.
        .clipShape(TabShape(tabMidX: tabMidX))
        .ignoresSafeArea()
)

曲線を描画する

曲線のサイズやそれぞれのコントロールポイントのポジションがどのように算出されているかは、下図を参考にしてください。

f:id:hoshi0523:20201103133241p:plain

では実際に曲線を描画します。

Path { path in
    
    // ~省略~
    
    let curveWidth = rect.width / 10
    let curveHeight = rect.height / 5
    
    // 始点.
    path.move(to: CGPoint(x: tabMidX - curveWidth, y: rect.height))
    
    // 左半分.
    let to1 = CGPoint(x: tabMidX, y: rect.height - curveHeight)
    let ctrl1a = CGPoint(x: tabMidX - (curveWidth / 2), y: rect.height)
    let ctrl1b = CGPoint(x: tabMidX - (curveWidth / 2), y: rect.height - curveHeight)
    path.addCurve(to: to1, control1: ctrl1a, control2: ctrl1b)
    
    // 右半分.
    let to2 = CGPoint(x: tabMidX + curveWidth, y: rect.height)
    let ctrl2a = CGPoint(x: tabMidX + (curveWidth / 2), y: rect.height - curveHeight)
    let ctrl2b = CGPoint(x: tabMidX + (curveWidth / 2), y: rect.height)
    path.addCurve(to: to2, control1: ctrl2a, control2: ctrl2b)
}

出来上がりはこんな感じです。選択されたボタンに合わせて、カスタムシェイプが描画されていますね。

f:id:hoshi0523:20201103133918g:plain

カスタムシェイプにアニメーションを与える

カスタムシェイプにアニメーションを与えるには、カスタムシェイプ内にanimatableDataを定義する必要があります。アニメーションを伴って変化させたいプロパティを、以下のように記述します。

struct TabShape: Shape {
    
    var tabMidX: CGFloat
    
    // アニメーションを伴って変化させたい値を、getter / setter で定義する
    var animatableData: CGFloat {
        get { tabMidX }
        set { tabMidX = newValue }
    }

    // ~省略~

ボタンタップ時の処理は、withAnimationを使って囲います。今回はボヨヨン感を出すため、interactiveSpringを利用しています。

Button(action: {
    // アニメーションを伴って変化させる.
    withAnimation(
        .interactiveSpring(
            response: 0.5, dampingFraction: 0.5, blendDuration: 0.5
        )
    ) {
        // タップされたボタンのx軸中央を保持.
        tabMidX = geometry.frame(in: .global).midX
    }
}, label: {
    // ~省略~

出来上がりはこんな感じです。アニメーションを伴って変化するようになりましたね!

f:id:hoshi0523:20201103135012g:plain

それぞれのタブにアイコンを加える

では、選択中のタブにアイコンを表示してみます。まずは選択中のタブを保持するためのプロパティを@Stateで定義します。

// 選択中のタブ.
@State private var selected: TabItem = .home

それぞれのタブボタンに、アイコン画像を与えます。ZStateを利用して重ねるようなイメージで配置します。

ForEach(TabItem.allCases, id: \.self) { tabItem in
    
    ZStack {
        // アイコン画像を追加.
        Image(systemName: tabItem.imageName)
            .foregroundColor(.accentColor)
            // 選択状態で、Y軸方向のポジションを切り替える.
            .offset(y: tabItem == selected ? 26 : -100)
        
        GeometryReader { geometry in
            // ~省略~
    }
}

出来上がりはこんな感じです。選択されたタブに合わせて、アイコン画像がアニメーションを伴って現れるようになりました!

f:id:hoshi0523:20201103140044g:plain

コンテンツ部分のViewを切り替える

では、タブビューらしくメイン領域を切り替えられるように実装します。メイン領域にはダミーのビューを配置します。

// コンテンツ領域のダミービューを定義する.
struct ContentBodyView: View {
    
    let tabItem: TabItem
    
    var body: some View {
        Text(tabItem.rawValue.uppercased())
            .font(.largeTitle)
            .fontWeight(.heavy)
            .foregroundColor(.accentColor)
    }
}

TabViewを利用してメイン領域にビューを配置します。アイコンが裏に回ってしまわないよう、zIndexを利用してZ軸方向の順番を調整しています。

// ~省略~
}
.frame(height: 70)
.background(
    Color.accentColor
        .clipShape(TabShape(tabMidX: tabMidX))
        .ignoresSafeArea()
)
.zIndex(1) // Z軸的に手前に描画する.

// TabViewを利用して、メイン領域にビューを配置する.
TabView(selection: $selected) {
    ForEach(TabItem.allCases, id: \.self) { tabItem in
        ContentBodyView(tabItem: tabItem)
            .tag(tabItem)
    }
}
.zIndex(0) // Z軸的に奥に描画する.

タブビューは不要なので、イニシャライザを利用して非表示にしています。

// ~省略~

// 選択中のタブ.
@State private var selected: TabItem = .home

init() {
    UITabBar.appearance().isHidden = true
}

// ~省略~

ステータスバーやドロップシャドウの調整

Info.plistView controller-based status bar appearanceを調整して、ステータスバーの色をlightにしています。

また、タブビューにドロップシャドウを追加しています。

// ~省略~
.background(
    Color.accentColor
        .clipShape(TabShape(tabMidX: tabMidX))
        .ignoresSafeArea()
        // ドロップシャドウを追加.
        .shadow(
            color: Color.black.opacity(0.1),
            radius: 1, x: 0, y: 5
        )
)
.zIndex(1)

これで完成です!出来上がりはこんな感じです!

f:id:hoshi0523:20201103121707g:plain

まとめ

カスタムシェイプを使えば、独自の計上の背景などをたやすく作ることができます!アニメーションを与えることもできますので、色々と試してみてください。

今回作ったアプリのGitHubはこちらです。よかったら参考にしてみてください! github.com