SwiftUI のPathとカスタムシェイプで独自のタブバーを作ってみる
カスタムシェイプとアニメーション
SwiftUIでは、Circle()やCapsule()のように初めから用意されているシェイプの他にも、Shapeプロトコルを実装して独自にシェイプを作成することができます。
さらには、そのシェイプにアニメーションを与えることもできるので、早速試してみたいと思います。
とりあえずの完成形
今回はこんな感じのものを作ってみたいと思います。

GitHubはこちらです github.com
開発環境
参考書
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()
}
出来上がりはこんな感じです。タブをタップしても何も起きませんね。

タブの位置を取得する
タップされたタブの位置情報を取得するための処理を追加します。まずはその位置情報を保持するためのプロパティを@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) }
GeometryRenderを利用して、ボタンを囲っています- ボタンがタップされたら、そのボタンのX軸方向の中心座標を保持します
frameには直接数値を与えるのではなく、親ビューの値を利用するようにしています- 画面表示のタイミングで、一番左のタブの座標情報を初期値として保持しています
GeometryRenderにframe情報を与えています
保持している値を確認できるように、デバッグ用のTextを追加しています
// ~ 省略 ~ // TODO: content body. Spacer() // debug. Text("\(tabMidX)") }
できあがりはこんな感じです。
選択されたタブのポジションが取得できているようですね!

簡単なカスタムシェイプを作ってみる
ではカスタムシェイプを作ってタブ部分に適用してみます。
まずは既存のシェイプを返すだけのカスタムシェイプを作ってみます。カスタムシェイプは、Shapeを実装した構造体を作成し、pathというメソッドを実装します。
struct TabShape: Shape { func path(in rect: CGRect) -> Path { // とりあえずCapsuleを返すだけ. Capsule().path(in: rect) } }
.background(
Color.accentColor
// 作成したシェイプでclipShapeを行う.
.clipShape(TabShape())
.ignoresSafeArea()
)
出来上がりはこんな感じです。見事にCapsuleでクリップされましたね。

独自のカスタムシェイプを作ってみる
では改めてカスタムシェイプを作成していきます。まずは基本となる矩形を描画します。
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()
)
曲線を描画する
曲線のサイズやそれぞれのコントロールポイントのポジションがどのように算出されているかは、下図を参考にしてください。

では実際に曲線を描画します。
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)
}
出来上がりはこんな感じです。選択されたボタンに合わせて、カスタムシェイプが描画されていますね。

カスタムシェイプにアニメーションを与える
カスタムシェイプにアニメーションを与えるには、カスタムシェイプ内に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: { // ~省略~
出来上がりはこんな感じです。アニメーションを伴って変化するようになりましたね!

それぞれのタブにアイコンを加える
では、選択中のタブにアイコンを表示してみます。まずは選択中のタブを保持するためのプロパティを@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 // ~省略~ } }
出来上がりはこんな感じです。選択されたタブに合わせて、アイコン画像がアニメーションを伴って現れるようになりました!

コンテンツ部分の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.plistのView 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)
これで完成です!出来上がりはこんな感じです!

まとめ
カスタムシェイプを使えば、独自の計上の背景などをたやすく作ることができます!アニメーションを与えることもできますので、色々と試してみてください。
今回作ったアプリのGitHubはこちらです。よかったら参考にしてみてください! github.com

