能登 要

札幌在住のiOSアプリ開発者。SwiftUI により分割されたデバイス間を縦横にやりとりできる考え方に転換しています。

iOSアプリ開発者。2009年のiPhoneアプリ開発開始時期から活動。開発言語のアップデートの中でSwiftUIおよび周辺技術に着目中。

SwiftUI を使用したMotion Tab Barの再現 - SwiftUI100行チャレンジ③

SwiftUI100行チャレンジシリーズ-エピソード3。

公開されているFlutterのウィジット(UIパーツ)の挙動をSwiftUIで再現していく。SwiftUIの構成手順についての理解を深める。最初に再現するUIパーツの紹介をした後、サンプルコードを示した後、UIパーツ再現の基本要素、コードの注目箇所について解説する。

SwiftUI100行チャレンジとは? SwiftUIで簡潔にUIコードが記述できるメリットを生かし100行でApple プログラミングのサンプルコードを公開するチャレンジ。すぐに試すことができるサンプルを通してSwiftUIに関心がある人と情報を共有する方法として取り組んでいる。

1) 内容の対象者

SwiftUIに興味を持っているアプリ開発者でSwiftUIで何をどの程度できるか不安な方の参考になると思う。SwiftUIは使わないと忘れてしまうので語彙や手順を思い出す際にも便利かと思う。

アプリ開発にFlutterを採用している開発者にとってはTabbar Widgetの再現度をSwiftUIでどの程度再現できているかだけでも興味対象となるかもしれない。

2) 再現するUIパーツ

カスタムTabBarを再現していく。オリジナルのFlutterコードは参考とせず、公開されているgifファイルのアニメーションを参考にする。

Tabアイテムを選択するたびにアニメーションするTabBarとなっている。選択状態を示すサークルの動きがアクセントとなったデザインとなっている。

GitHub - therezacuet/Motion-Tab-Bar

ウィジットの使い方についてはFlutterのパッケージを紹介サイトを参考にしてほしい。

Flutter Package: A Beautiful Animated Motion Tab Bar Widget - Flutter Resource

3) サンプルコード

以下がMotiontTabbarを再現したサンプルコードとなる。Xcode12で新プロジェクト - iOS App - Language Swift, Interface SwiftU でプロジェクトを作成後、ContentViewの内容を差し替えるだけで動作を確認できる。

motiontabbar with swiftui

import SwiftUI
struct TopFrameView: Shape {
func path(in rect: CGRect) -> Path {
let bezierPath = UIBezierPath()
bezierPath.move(to: CGPoint(x: 53.22, y: 4.36))
for (to, controlPoint1, controlPoint2) in [(CGPoint(x: 60.83, y: 13.06), CGPoint(x: 57.76, y: 7.77), CGPoint(x: 60.14, y: 11.68)), (CGPoint(x: 68.43, y: 22.84), CGPoint(x: 63, y: 17.4), CGPoint(x: 65.05, y: 20.96)), (CGPoint(x: 75.16, y: 23.98), CGPoint(x: 70.49, y: 23.98), CGPoint(x: 75.16, y: 23.98))] {
bezierPath.addCurve(to: to, controlPoint1: controlPoint1, controlPoint2: controlPoint2)
}
bezierPath.addLine(to: CGPoint(x: 0.84, y: 23.98))
for (to, controlPoint1, controlPoint2) in [(CGPoint(x: 7.57, y: 22.84), CGPoint(x: 0.84, y: 23.98), CGPoint(x: 5.51, y: 23.98)), (CGPoint(x: 15.17, y: 13.06), CGPoint(x: 10.95, y: 20.96), CGPoint(x: 13, y: 17.4)), (CGPoint(x: 22.78, y: 4.36), CGPoint(x: 15.86, y: 11.68), CGPoint(x: 18.24, y: 7.77)), (CGPoint(x: 36.38, y: -0), CGPoint(x: 27.58, y: 0.77), CGPoint(x: 33.55, y: 0.1)), (CGPoint(x: 38, y: 0), CGPoint(x: 37.39, y: -0.04), CGPoint(x: 38, y: 0)), (CGPoint(x: 53.22, y: 4.36), CGPoint(x: 38, y: 0), CGPoint(x: 46.7, y: -0.53))] {
bezierPath.addCurve(to: to, controlPoint1: controlPoint1, controlPoint2: controlPoint2)
}
bezierPath.close()
return Path(bezierPath.cgPath)
}
}
struct TabItemDescription {
var imageName: String
var title: String
func iconView(_ foregroundColor: Color) -> some View { Image(systemName: imageName).font(.system(size: 24)).foregroundColor(foregroundColor) }
func labelView(_ foregroundColor: Color) -> some View { Text(title).font(.system(size: 9, weight: .bold)).foregroundColor(foregroundColor) }
}
enum Defs {
static let tabItems: [TabItemDescription] = [.init(imageName: "house.fill", title: "HOME"), .init(imageName: "magnifyingglass", title: "SEARCH"), .init(imageName: "person.fill", title: "PROFILE")]
static let accentColor = Color(UIColor(red: 0.553, green: 0.455, blue: 0.929, alpha: 1.000)); static let backgroundColor = Color(UIColor(red: 0.945, green: 0.969, blue: 0.984, alpha: 1.000))
static let topFrameSize = CGSize(width: 75, height: 24)
static let tabbarHeight = CGFloat(49)
static let bottomSafeArea = CGFloat(40)
static let iconCircleEdge = CGFloat(40)
static let labelOffset = CGSize(width: 0, height: 32)
static let bottomSafeAreaOffset = CGSize(width: 0, height: Defs.bottomSafeArea * 0.5)
}
struct ContentView: View {
@State var selectedIndex = 0
var body: some View {
ZStack {
Text("Hello, World!") // main contents example
GeometryReader { proxy in
VStack {
Spacer()
HStack(alignment: .bottom, spacing: 0){
ForEach(0..<3) { (index) in
VStack(spacing: 0) {
Spacer()
Rectangle()
.foregroundColor( .clear )
.frame(width: Defs.iconCircleEdge, height: Defs.iconCircleEdge)
.overlay(
ZStack {
Defs.tabItems[index].iconView(.white).opacity(self.selectedIndex == index ? 1.0 : 0.0)
Defs.tabItems[index].iconView(Defs.accentColor).opacity(self.selectedIndex != index ? 1.0 : 0.0)
}
)
.background(
ZStack {
Defs.tabItems[index].labelView(.black).opacity(self.selectedIndex == index ? 1.0 : 0.0)
Defs.tabItems[index].labelView(.clear).opacity(self.selectedIndex != index ? 1.0 : 0.0)
}.offset(Defs.labelOffset)
)
Rectangle()
.foregroundColor(.clear)
.frame(height: self.selectedIndex == index ? 26 : 5)
}
.frame(width: proxy.size.width * 0.333)
.contentShape( Rectangle() )
.onTapGesture {
withAnimation(.interactiveSpring(response: 0.4, dampingFraction: 0.7) ) { self.selectedIndex = index }
}
}
}
.background(
Rectangle()
.frame(height: Defs.topFrameSize.height + Defs.tabbarHeight + Defs.bottomSafeArea)
.overlay(
Circle()
.foregroundColor(Defs.accentColor)
.frame(width: Defs.iconCircleEdge, height: Defs.iconCircleEdge)
.offset(CGSize(width: CGFloat(self.selectedIndex - 1) * (proxy.size.width * 0.333), height: -29))
)
.foregroundColor(.white)
.offset(Defs.bottomSafeAreaOffset)
.mask(
VStack(spacing: 0) {
TopFrameView()
.frame(width: Defs.topFrameSize.width, height: Defs.topFrameSize.height)
.offset(CGSize(width: CGFloat(self.selectedIndex - 1) * (proxy.size.width * 0.333), height: 0))
Rectangle()
.frame(height: Defs.tabbarHeight + Defs.bottomSafeArea)
}.offset(Defs.bottomSafeAreaOffset)
)
.shadow(color: Color.black.opacity(0.3) , radius: 15, x: 0, y: 0)
)
.frame(height: Defs.topFrameSize.height + Defs.tabbarHeight)
}
}.ignoresSafeArea(edges: [.trailing, .leading])
}.background(Defs.backgroundColor)
}
}

コードを100行に収める都合で制約が発生している。具体的にはTabBarのアイテム数の変更に対応できていない。横方向のTabBar表示への対応、DynamicTypeへの対応、Tabアイテムのラベルののベースラインが揃えられていない、アクセシビリティへの対応などが省略されている。

4)UIパーツ再現の基本要素

今回再現するTabbarに限らずSwiftUIでの画面を構築するための基本手順は、アニメーションや画像などの装飾的要素を作成する前にView、GeometoryReader、各種スタックを使って画面のレイアウトを決める。

レイアウトを決める段階では仮のViewとして色付きのRectangleを使用してレイアウトを決めていく。

Rectangle()
    .foregroundColor(.red)
    .opacity(0.5)

4-1) Viewのサイズ指定

Viewのframeモデファイヤーを使ってサイズを変更できる。

Rectangle()
    .foregroundColor(.red)
    .frame(width: 100)

サイズの他、各種サイズ指定が用意されている。

通常のViewの他、次に説明するStack、GeometryReader にも適用できる。

4-2)スタック(ZStack, VStack, HStack)

Tabを実現するために各種スタックViewを使用している。スタックはViewを積み上げる(Stack)する機能でZStack, VStack, HStackがそれぞれ、Z軸方向(z-order)、垂直方向(vertical)、水平方向(horizontal)を指定できる。

4-2-1)スペース(VStack, HStack用)

垂直および水平方向には空白に関する指定が付与されている。パラメーターとしてspacing(デフォルト値が0以外)、要素としてSpacerが用意されているので目的によって使い分ける。UIパーツ作成についていえばspacing=0の方が位置決め時に混乱することが少ない。

4-2-2)揃え

全てのStackでalignmentを指定できる。各スタックには積み上げ方向が用意されているが担当していない方向の揃えを指定できる。Z軸を指定できるZStackは水平垂直方向それぞれの揃えを指定できる。

4-4)GeometryReader

Tabbarの選択位置のアニメーション位置を決定するために画面の幅を必要とする。SiwftUIではView内のサイズを得る手段としてGeometryReader が用意されている。

 GeometryReader { proxy in
   // proxy.size.width, proxy.size.height
 }

iOS13とiOS14ではGeometryReaderの挙動が異なる(iOS13ではでデフォルト揃え有、iOS14ではデフォルトの揃え無)ので注意してほしい。

Multiplatform(マルチプラットフォーム)での画面サイズ情報入手手段3種 @ScaledMetric とEnvironment 、GeometryProxy を使い分ける

4-5)レイアウトをひとまず完成させる

Rectangle()、各種スタック(ZStack,VStack,HStack)、GeometryReader の基本レイアウトを作っていく。SwiftUIのプレビュー上でViewを配置する、もしくはXcode上のエディッターでコードを記述するなどでレイアウトを作成していく。

Tabbarのような画面下部に配置されるレイアウトであれば、

  1. VStackとSpacerを使用して画面下部に配置
  2. 矩形上にTabアイテムが配置されるのでZStackを使用
  3. Tabアイテムを横方向に複数配置するためにHStackを使用
  4. Tabアイテム内の縦方向に要素を配置するためにVStackとSpacerを使用

の順序で配置する。再現するMotion Tabbar では選択した位置に上部に飛び出た画面パーツを移動させる必要があるためGeometoryReader をレイアウトに追加して画面位置に基づいて位置計算に使用する。

GeometryReaderはUI生成タイミングで、GeometryProxy から実際の画面サイズを取得できる。offsetモデファイヤーで位置を調整できる。

// Tabアイテム3つの2番目に配置
GeometryReader { proxy in
    TopFrameView()
        .frame(width: 75, height: 24)
        .offset(CGSize(width: CGFloat(1) * (proxy.size.width * 0.333), height: 0))
}

ここまでで以下のようなレイアウトを実現できる。 StackLayers

5) レイアウトを整理する

レイアウトは完成できたが、TabのパーツはRectangle()を使った仮のものとなっている。Stackの数が多いため視認性も良くない。そもそも追加したStackが機能しているかも含めてレイアウトの最適化に取り組む。

レイアウトの最適化に際して、View内の合成、Viewのオフセット指定を導入する。ZStackと同じようなViewの重なりを実現するが、ZStackとは違いレイアウトへの影響を与えないView内の重なりを指定できる。

5-1) View内の合成(overlay, background)

SwiftUIを構築するための基本要素となるViewには合成レイヤーが用意されている。合成レイヤーは前面と背面が用意されておりそれぞれoverlay、backgroundモデファイヤー(modifier)が存在している。

SwiftUI-Overlay-Background

Rectangle()
    .overlay(
        // 上に合成するViewを指定
    )
    .background(
        // 下に合成するViewを指定
    )

合成レイヤーのViewは縦横共に中央揃え。大きさは特に指定しない場合はoverlay、backgroundを指定したViewのサイズとなる。

サンプルコードでは不可視View(.clear指定)にoverlay、backgroundに表示用Viewを追加している。不可視Viewにoverlayとbackgroundに可視化要素を持たせることでレイアウトと可視化要素を分けて考えることができる。

5-2) Viewのオフセット指定

Viewのoffsetモデファイヤーを使って配置位置を調整できる。サンプルではView内のレイヤー合成の際の位置調整に使用している。

Rectangle()
    .foregroundColor(.red)
    .offset(CGSize(width: 0, height: 100))

5-1のViewの合成にも適用される。

5-3) レイアウトの整理を完成する

View内の合成および、Stackの揃え(alignmentモデファイヤー)を使用してStackを減らすことに成功している。Tabbar背面をbackgroundに移動することでレイアウトへの影響を与えずに表示を調整可能となった。

OptimazedLayers

レイアウト整理完了後は、仮置きしたRectangle()をImageやText置き換えていく。置き換えの際にレイヤー合成を使用するようにする。

Tabbar再現している途中、アニメーションを付加する際にアイコンが振動する挙動は発生していた。原因を確認してみるとImageやTextはサイズを指定しても実際のサイズが異なっていたのが原因だった。

SFSymbolsSize

ImageやTextなど指定したサイズとはならないViewを配置する際はレイヤー合成(overlay ,background)に配置するとレイアウト崩れを防ぐことができる。

5)コードの注目箇所

UIパーツ再現の基本要素に含まれないが注目箇所について記述する。

5-1)カスタムShape(line:3-17)

画像リソースを100行内に収めるためUIBezierPath を使ってTab上部の曲線フレームをShapeで実現している。透過画像(png, svg)が用意できるのであればImageで置き換えることができる。

パスデータはPaintCode3で作成したパスを使用している。ベクター描画ソフトの要領で図形を作成するとSwiftのソースコードとして生成されるアプリとなっている。

行数を節約するためにfor〜in 構文を使用しているが以下のコードをPaintCode3で生成している。

//// Bezier Drawing
let bezierPath = UIBezierPath()
bezierPath.move(to: CGPoint(x: 53.22, y: 4.36))
bezierPath.addCurve(to: CGPoint(x: 60.83, y: 13.06), controlPoint1: CGPoint(x: 57.76, y: 7.77), controlPoint2: CGPoint(x: 60.14, y: 11.68))
bezierPath.addCurve(to: CGPoint(x: 68.43, y: 22.84), controlPoint1: CGPoint(x: 63, y: 17.4), controlPoint2: CGPoint(x: 65.05, y: 20.96))
bezierPath.addCurve(to: CGPoint(x: 75.16, y: 23.98), controlPoint1: CGPoint(x: 70.49, y: 23.98), controlPoint2: CGPoint(x: 75.16, y: 23.98))
bezierPath.addLine(to: CGPoint(x: 0.84, y: 23.98))
bezierPath.addCurve(to: CGPoint(x: 7.57, y: 22.84), controlPoint1: CGPoint(x: 0.84, y: 23.98), controlPoint2: CGPoint(x: 5.51, y: 23.98))
bezierPath.addCurve(to: CGPoint(x: 15.17, y: 13.06), controlPoint1: CGPoint(x: 10.95, y: 20.96), controlPoint2: CGPoint(x: 13, y: 17.4))
bezierPath.addCurve(to: CGPoint(x: 22.78, y: 4.36), controlPoint1: CGPoint(x: 15.86, y: 11.68), controlPoint2: CGPoint(x: 18.24, y: 7.77))
bezierPath.addCurve(to: CGPoint(x: 36.38, y: -0), controlPoint1: CGPoint(x: 27.58, y: 0.77), controlPoint2: CGPoint(x: 33.55, y: 0.1))
bezierPath.addCurve(to: CGPoint(x: 38, y: 0), controlPoint1: CGPoint(x: 37.39, y: -0.04), controlPoint2: CGPoint(x: 38, y: 0))
bezierPath.addCurve(to: CGPoint(x: 53.22, y: 4.36), controlPoint1: CGPoint(x: 38, y: 0), controlPoint2: CGPoint(x: 46.7, y: -0.53))
bezierPath.close()

5-2)リソース(line:18-33)

Tabアイテムの定義と定数を記述している。enum Def は定数をまとめるnamespace扱いとして使用した。

以下の記事を参考にしている。

Swiftではnamespaceとしてcaseなしenumが使える - Qiita

Tabアイテムを定義しているTabItemDescription ではTabで使用するアイコンとタイトル用Viewを作成する関数iconView、labelView を定義している。

アイコンはSFSymbolsのリソースを使用している。SFSymbolはImage扱いだがfont属性でサイズを調整できる。

struct TabItemDescription {
    var imageName: String
    var title: String
    func iconView(_ foregroundColor: Color) -> some View {
        Image(systemName: imageName)
            .font(.system(size: 24))
            .foregroundColor(foregroundColor)
    }
    func labelView(_ foregroundColor: Color) -> some View {
        Text(title)
            .font(.system(size: 9, weight: .bold))
            .foregroundColor(foregroundColor)
    }
}

5-3)ヒット領域の指定(line:67)

.contentShape()モデファイヤーを指定するとヒット領域を指定できる。ヒット領域はスタックなどヒット領域領域を持たない部品にヒット領域を指定することができる。指定はShapeを指定できる。

.contentShape( Rectangle() )

以下のBlogを参考にしている。

【SwiftUI】contentShapeでTap領域を広げる - Qiita

5-4)タップイベント(line:68-70)

.contentShape()モデファイヤーイベントでタップイベントを記述することができる。

.onTapGesture {
withAnimation(.interactiveSpring(response: 0.4, dampingFraction: 0.7) ) { self.selectedIndex = index }
}

5-5)アニメーション(line:69)

withAnimation{}を指定するとアニメーションを指定できる。

withAnimation(.interactiveSpring(response: 0.4, dampingFraction: 0.7) ) { self.selectedIndex = index }

SwiftUIでは値を変更するとUIに反映する仕組みが用意されている。サンプルでは選択位置を指定する変数selectedIndex を定義している。

    @State var selectedIndex = 0

アニメーションはselectedIndexを変更箇所をwithAnimation{}で囲うとアニメーションとして認識される。

withAnimation{}はオプションでアニメーションのスタイルを指定できる。

withAnimation(.interactiveSpring(response: 0.4, dampingFraction: 0.7) ) { 
    // 変更する変数を指定
}

5-6)色アニメーション(line:51-54,57-60)

ZStackを使ってViewを配置してopacityモデファイヤーを使用して透過アニメーションを実現している。

Defs.tabItems[index].iconView(.white).opacity(self.selectedIndex == index ? 1.0 : 0.0)
Defs.tabItems[index].iconView(Defs.accentColor).opacity(self.selectedIndex != index ? 1.0 : 0.0)

Defs.tabItems[index].labelView(.black).opacity(self.selectedIndex == index ? 1.0 : 0.0)
Defs.tabItems[index].labelView(.clear).opacity(self.selectedIndex != index ? 1.0 : 0.0)

以下のStack Overflow投稿への回答を参考にしている。

ios - SwiftUI: Animate Text color - foregroundColor() - Stack Overflow

5-7)安全領域を超えては配置する(line:69)

ignoresSafeAreaモデファイヤーで画面に配置する際に安全領域(SafeArea)を無視することができる。

}.ignoresSafeArea(edges: [.trailing, .leading])

6) 時間を割いた箇所

Tabbarのレイアウトはすぐに実現できたがZStackを多用していた。100行におさめるためにはStackのネストによる行数使用を節約するためStackの揃え属性で置き換えている。

HStack(alignment: .bottom, spacing: 0){

再現中にSFSymboldやテキストのサイズがTabbar全体のレイアウトに影響する箇所があった。放置しておくと数ピクセル配置がずれるような問題を誘発するため対処している。対処としてはSFSymboldやテキストをレイヤー合成用のoverlayやbackgroundモデファイヤーに移動している(レイヤー合成はレイアウトに影響を得た得ないため)。

余談

すぐに試すことができるサンプルコードとして100行でまとめたが、100行以上であれば汎用的なUIパーツが実現できる。

UIの再現を試すことでSwiftUIでのUI構築の手順についての理解を深めることは可能だが、SwiftUIでのUI構築を実際にアプリに適用する際はアセットの準備が重要になる。

ここでいうアセットとはUIの挙動、見た目、操作性などUIを構築する上での資料のことで、アセットがしっかりしているとパーツを積み上げてUIを構築していくSwiftUIでは作業が捗る。

UIの再現は再現対象のUIが資料として成立していたので作業上の迷いがなかった。実際にSwiftUIでアプリUIを構築する際も十分なアセットが準備されているのが望ましいだろう。