能登 要

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

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

SwiftUI を使用したCurved Navigation Barの再現 - SwiftUI100行チャレンジ④

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

公開されているFlutterのウィジット(UIパーツ)の挙動をSwiftUIで再現していく。SwiftUIの構成手順についての理解を深める。最初に再現するUIパーツの紹介をした後、サンプルコードを示した後、アニメーションの再現方法、Viewのマスクについて注意点について解説する。

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

1) 内容の対象者

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

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

2) 再現するUIパーツ

エピソード3に続きカスタムTabBarを再現していく。オリジナルのFlutterコードは参考とせず、公開されているgifファイルのアニメーションを参考にする。選択済みTabアイテム位置に応じてTabbarが湾曲しているのが特徴のカスタムTabbarとなっている。

GitHub - rafalbednarczuk/curved_navigation_bar: Animated Curved Navigation Bar in Flutter

前もって再現上レギュレーションがエピソード3と異なることを伝えておく。具体的にはTabBraの高さをiOS標準に合わせている。高さを変更しているためオリジナルのレイアウトとは相違がある。

3) サンプルコード

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

CurvedNavigationBarAnimation

import SwiftUI

struct TopFrameView: Shape {
    func path(in rect: CGRect) -> Path {
        let bezierPath = UIBezierPath()
        bezierPath.move(to: CGPoint(x: 9.16, y: 0.04))
        let curvePoints = [(CGPoint(x: 22.13, y: 1.8), CGPoint(x: 13.75, y: 0.17), CGPoint(x: 18.98, y: 0.6)), (CGPoint(x: 36.89, y: 10.14), CGPoint(x: 28.69, y: 4.3),  CGPoint(x: 31.15, y: 4.93)), (CGPoint(x: 46.73, y: 22.65), CGPoint(x: 42.63, y: 15.35), CGPoint(x: 44.27, y: 18.48)), (CGPoint(x: 58.2, y: 34.32), CGPoint(x: 49.19, y: 26.81), CGPoint(x: 52.47, y: 30.78)), (CGPoint(x: 70.5, y: 37.65), CGPoint(x: 63.94, y: 37.86), CGPoint(x: 70.5, y: 37.65)), (CGPoint(x: 82.8, y: 34.32), CGPoint(x: 70.5, y: 37.65), CGPoint(x: 77.06, y: 37.86)), (CGPoint(x: 94.27, y: 22.65), CGPoint(x: 88.53, y: 30.78), CGPoint(x: 91.81, y: 26.81)), (CGPoint(x: 104.11, y: 10.14), CGPoint(x: 96.73, y: 18.48), CGPoint(x: 98.37, y: 15.35)), (CGPoint(x: 118.87, y: 1.8), CGPoint(x: 109.85, y: 4.93), CGPoint(x: 112.31, y: 4.3)), (CGPoint(x: 140.79, y: 0.12), CGPoint(x: 124.98, y: -0.53), CGPoint(x: 138.91, y: 0.03)), (CGPoint(x: 140.97, y: 0.13), CGPoint(x: 140.88, y: 0.13), CGPoint(x: 140.94, y: 0.13)), (CGPoint(x: 141, y: 49), CGPoint(x: 140.99, y: 0.13), CGPoint(x: 141, y: 31.27))]
        for (to, controlPoint1, controlPoint2) in curvePoints {
            bezierPath.addCurve(to: to, controlPoint1: controlPoint1, controlPoint2: controlPoint2)
        }
        bezierPath.addLine(to: CGPoint(x: 0, y: 49))
        bezierPath.addCurve(to: CGPoint(x: 0, y: 0.13), controlPoint1: CGPoint(x: 0, y: 31.27), controlPoint2: CGPoint(x: 0, y: 0.13))
        bezierPath.addCurve(to: CGPoint(x: 9.16, y: 0.04), controlPoint1: CGPoint(x: 0, y: 0.13), controlPoint2: CGPoint(x: 4.2, y: -0.09))
        bezierPath.close()
        return Path(bezierPath.cgPath)
    }
}
enum ItemInformation: String {
    case plus = "plus"; case list = "list.bullet"; case arrowLeftArrowRight = "arrowLeftArrowRight"; case branch = "arrow.triangle.branch"; case person = "person"
    func iconView() -> some View {
        self != .arrowLeftArrowRight ? ViewBuilder.buildEither(first: Image(systemName: self.rawValue).font(.system(size: 21)).foregroundColor(Items.captionColor))
                                        : ViewBuilder.buildEither(second:
                                            HStack(spacing: 0) {
                                                    Image(systemName: "arrow.forward") .font(.system(size: 14, weight: .semibold)).foregroundColor(Items.captionColor).offset(CGSize(width: 3, height: 4))
                                                    Image(systemName: "arrow.backward").font(.system(size: 14, weight: .semibold)).foregroundColor(Items.captionColor).offset(CGSize(width: -3, height: -4))
                                                })
    }
}
enum Items {
    static let items: [ItemInformation] = [.plus, .list, .arrowLeftArrowRight, .branch, .person]
    static let maskMargin = CGFloat(1); static let topFrameHeight = CGFloat(28); static let tabBarHeight = CGFloat(49); static let bottomSafeAreaHeight = CGFloat(40); static let tabAreaHeight = Self.topFrameHeight + Self.tabBarHeight; static let itemEdge = CGFloat(42)
    static let backgroundColor = Color(UIColor(red: 0.271, green: 0.534, blue: 0.991, alpha: 1.000));
    static let captionColor = Color.black
}

struct ContentView: View {
    @State var selectedIndex = 2
    var body: some View {
        ZStack {
            Text("Hellow world.")
            GeometryReader { proxy in
                VStack {
                    Spacer()
                    ZStack {
                        ForEach(0..<5) { index in
                            Rectangle()
                                .frame(width: Items.itemEdge, height: Items.itemEdge)
                                .foregroundColor(.clear)
                                .overlay(Items.items[index].iconView())
                                .background(
                                    Circle()
                                        .frame(width: Items.itemEdge, height: Items.itemEdge)
                                        .foregroundColor(.white)
                                )
                                .offset(CGSize(width: CGFloat(self.selectedIndex - 2) * (proxy.size.width * 0.2), height: self.selectedIndex == index ? -5 : 51))
                        }
                    }
                    .frame(width: proxy.size.width, height: Items.tabAreaHeight)
                    .overlay(
                        VStack(spacing: 0) {
                            Spacer()
                            HStack(alignment: .center, spacing: 0){
                                ForEach(0..<5) { index in
                                    Rectangle()
                                        .foregroundColor(.clear)
                                        .frame(width: proxy.size.width / 5, height: Items.tabBarHeight)
                                        .overlay( Items.items[index].iconView().opacity(selectedIndex == index ? 0 : 1.0))
                                        .offset(CGSize(width: 0.0, height: selectedIndex == index ? 30 : 0))
                                        .contentShape( Rectangle() )
                                        .onTapGesture { withAnimation(.interactiveSpring(response: 0.4, dampingFraction: 0.7) )  { selectedIndex = index}}
                                }
                            }
                        }.frame(height: Items.tabAreaHeight)
                        .background(
                            Rectangle()
                                .foregroundColor(.white)
                                .frame(height: Items.tabAreaHeight + Items.bottomSafeAreaHeight)
                                .mask(
                                    VStack(spacing: 0){
                                        Spacer()
                                        HStack(alignment: .bottom, spacing: 0){
                                            Rectangle().frame(width: (proxy.size.width / 5) * 4).offset(CGSize(width: Items.maskMargin, height: 0))
                                            TopFrameView()
                                                .frame(width: 141)
                                            Rectangle().frame(width: (proxy.size.width / 5) * 4).offset(CGSize(width: -Items.maskMargin, height: 0))
                                        }.frame(height: Items.tabBarHeight)
                                        .offset(CGSize(width: (proxy.size.width / 5) * CGFloat(selectedIndex - 2) , height: 0))
                                        Rectangle().frame(height: Items.bottomSafeAreaHeight).offset(CGSize(width: 0, height: -Items.maskMargin))
                                    }.frame(height: Items.tabAreaHeight + Items.bottomSafeAreaHeight)
                                )
                                .offset(CGSize(width: 0, height: Items.bottomSafeAreaHeight * 0.5))
                        )
                    )
                }
            }
        }.edgesIgnoringSafeArea([.leading, .trailing]).background(Items.backgroundColor)
    }
}

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

4) アニメーションの基本事項

SwiftUIでのアニメーションでは基本事項を押さえておく。

4-1) アニメーションの開始タイミング

SwiftUIでは@Stateで定義した変数や、Observabledの派生クラスの@Published 変数の値変更が監視され、値に変化があった場合にUIがレンダリングされる。

SwiftUIではレンダリング処理前、レンダリング処理後のUIレンダリング結果を記憶し、アニメーション化するタイミングを指定する機能が組み込まれている。例えば、@Stateで定義した変数の値を変化するだけで変化させたいUIパーツが意図して動いていればあとはアニメーション化する指示をSwiftUIで記述すれば良いことになる。

SwiftUIでのアニメーション化指示の方法はいくつかあるが、ボタンのタップアクションで使い勝手の良いのはwithAnimationである。

withAnimation {
    // ここに監視対象の変数を変更する
}

SwiftUIの動きとしては、withAnimation記述前のUI状態をアニメーションの開始状態として記憶、withAnimationで囲まれた中での変化がアニメーションの終了状態として記憶されたのちSwiftUIによりアニメーションが開始される。

4-2) 連続した値を表現できる型、値を表現できる型を含むモデファイヤーはアニメーション化される

CGFloat型、CGFloat型を含む構造体を引数としたモデファイヤーではスムーズなアニメーションが提供される。

文字列は連続した値を表現できないのでスムーズなアニメーション対象となっていない。意外だがColorもアニメーション対象にはならない。

Colorは、UIColorなどの引数を持る、色がダークモード対応するなど利用する側が思うよりも複雑な構造となっている。色変化アニメーションをColorで実現できないわけではなく、色違いのUIパーツ2つの透過率を切り替えて実現するなど代替方法が考えられている。

4-3) UIパーツの変化量が同じ場合はアニメーションは同期する

遅延および順序指定をしない限り変化量が同じUIパーツのアニメーションは同期する。例えば2つ以上のUIパーツにoffsetモデファイヤーを使ってUIパーツの横方向の位置を変更する際に同じ移動量を指定した場合は同期したアニメーションは同タイミング、同移動量のアニメーションとなる。UIパーツの重ね合わせの都合でUIパーツ同士の配置をそろえなくても良い。

4-4) Viewの生成コストが低い

UIKitに対してViewを生成コストが低いので1つにViewに複数のアニメーションを付加するよりも複数のViewにアニメーションを分担させるとアニメーション処理が整理される。具体的な例としては反復アニメーションは、2つ以上のUIパーツを切り替えるだけで済むなどである。

ViewGenerateCosts

5) 複雑なアニメーション構築のアプローチ

4の基本事項に加え、複雑なアニメーションも水平方向、垂直方向で分解できるか検討してみると良い。

Animations decomposition

ここではアニメーションに再現について説明する。UIパーツ再現の基本要素については前エピソードを参考にして欲しい。

Curved Navigation BarではTabアイテムの選択状態が変化するたびに各箇所のアニメーションが一体となって動いている。一見再現は難しいように思えるアニメーションでも落ち着いてアニメーションを観察する。

gifアニメーションファイルをmacOSにダウンロードするとプレビューアプリから各コマを観察できる。サンプルでは100コマのアニメーションとなっていた。

TabBar上で動く選択サークルの動きは水平垂直方向のアニメーションで分けて考える。選択サークルのアニメーションは縦方向は選択したTabアイテムの位置に移動し、縦方向はTabへ沈む/浮上する動きにみえる。サークルのアニメーションは無理に反復アニメーションを適用するのではなく、Viewを増やしアニメーション処理を分担させてみることで解決を試みる。

TabItemAnimations

gifアニメーションから残りのアニメーションについても検討する。

未選択状態のアイコンと選択サークル上のアイコンはアニメーション中同時に存在している。こちらは別のアニメーションとして切り出す。

カスタムBarの特徴的な切り抜きはSwiftUIのmaskを機能を使って切り抜いたRectangle()で実現している。アニメーションは切り抜き対象のRectAngleではなくmaskに指定した複合Viewのoffsetモデファイヤーを使用して変更した。

6) アニメーションの指定と開始タイミング

カスタムTabbarではTabアイテムをタップするタイミングでselectedIndex の値を変更をwithAnimation で囲むことでSwiftUIにアニメーションタイミングを指定している。

import SwiftUI

struct TopFrameView: Shape {
    func path(in rect: CGRect) -> Path {
        let bezierPath = UIBezierPath()
        bezierPath.move(to: CGPoint(x: 9.16, y: 0.04))
        let curvePoints = [(CGPoint(x: 22.13, y: 1.8), CGPoint(x: 13.75, y: 0.17), CGPoint(x: 18.98, y: 0.6)), (CGPoint(x: 36.89, y: 10.14), CGPoint(x: 28.69, y: 4.3),  CGPoint(x: 31.15, y: 4.93)), (CGPoint(x: 46.73, y: 22.65), CGPoint(x: 42.63, y: 15.35), CGPoint(x: 44.27, y: 18.48)), (CGPoint(x: 58.2, y: 34.32), CGPoint(x: 49.19, y: 26.81), CGPoint(x: 52.47, y: 30.78)), (CGPoint(x: 70.5, y: 37.65), CGPoint(x: 63.94, y: 37.86), CGPoint(x: 70.5, y: 37.65)), (CGPoint(x: 82.8, y: 34.32), CGPoint(x: 70.5, y: 37.65), CGPoint(x: 77.06, y: 37.86)), (CGPoint(x: 94.27, y: 22.65), CGPoint(x: 88.53, y: 30.78), CGPoint(x: 91.81, y: 26.81)), (CGPoint(x: 104.11, y: 10.14), CGPoint(x: 96.73, y: 18.48), CGPoint(x: 98.37, y: 15.35)), (CGPoint(x: 118.87, y: 1.8), CGPoint(x: 109.85, y: 4.93), CGPoint(x: 112.31, y: 4.3)), (CGPoint(x: 140.79, y: 0.12), CGPoint(x: 124.98, y: -0.53), CGPoint(x: 138.91, y: 0.03)), (CGPoint(x: 140.97, y: 0.13), CGPoint(x: 140.88, y: 0.13), CGPoint(x: 140.94, y: 0.13)), (CGPoint(x: 141, y: 49), CGPoint(x: 140.99, y: 0.13), CGPoint(x: 141, y: 31.27))]
        for (to, controlPoint1, controlPoint2) in curvePoints {
            bezierPath.addCurve(to: to, controlPoint1: controlPoint1, controlPoint2: controlPoint2)
        }
        bezierPath.addLine(to: CGPoint(x: 0, y: 49))
        bezierPath.addCurve(to: CGPoint(x: 0, y: 0.13), controlPoint1: CGPoint(x: 0, y: 31.27), controlPoint2: CGPoint(x: 0, y: 0.13))
        bezierPath.addCurve(to: CGPoint(x: 9.16, y: 0.04), controlPoint1: CGPoint(x: 0, y: 0.13), controlPoint2: CGPoint(x: 4.2, y: -0.09))
        bezierPath.close()
        return Path(bezierPath.cgPath)
    }
}
enum ItemInformation: String {
    case plus = "plus"; case list = "list.bullet"; case arrowLeftArrowRight = "arrowLeftArrowRight"; case branch = "arrow.triangle.branch"; case person = "person"
    func iconView() -> some View {
        self != .arrowLeftArrowRight ? ViewBuilder.buildEither(first: Image(systemName: self.rawValue).font(.system(size: 21)).foregroundColor(Items.captionColor))
                                        : ViewBuilder.buildEither(second:
                                            HStack(spacing: 0) {
                                                    Image(systemName: "arrow.forward") .font(.system(size: 14, weight: .semibold)).foregroundColor(Items.captionColor).offset(CGSize(width: 3, height: 4))
                                                    Image(systemName: "arrow.backward").font(.system(size: 14, weight: .semibold)).foregroundColor(Items.captionColor).offset(CGSize(width: -3, height: -4))
                                                })
    }
}
enum Items {
    static let items: [ItemInformation] = [.plus, .list, .arrowLeftArrowRight, .branch, .person]
    static let maskMargin = CGFloat(1); static let topFrameHeight = CGFloat(28); static let tabBarHeight = CGFloat(49); static let bottomSafeAreaHeight = CGFloat(40); static let tabAreaHeight = Self.topFrameHeight + Self.tabBarHeight; static let itemEdge = CGFloat(42)
    static let backgroundColor = Color(UIColor(red: 0.271, green: 0.534, blue: 0.991, alpha: 1.000));
    static let captionColor = Color.black
}

struct ContentView: View {
    @State var selectedIndex = 2
    var body: some View {
        ZStack {
            Text("Hellow world.")
            GeometryReader { proxy in
                VStack {
                    Spacer()
                    ZStack {
                        ForEach(0..<5) { index in
                            Rectangle()
                                .frame(width: Items.itemEdge, height: Items.itemEdge)
                                .foregroundColor(.clear)
                                .overlay(Items.items[index].iconView())
                                .background(
                                    Circle()
                                        .frame(width: Items.itemEdge, height: Items.itemEdge)
                                        .foregroundColor(.white)
                                )
                                .offset(CGSize(width: CGFloat(self.selectedIndex - 2) * (proxy.size.width * 0.2), height: self.selectedIndex == index ? -5 : 51))
                        }
                    }
                    .frame(width: proxy.size.width, height: Items.tabAreaHeight)
                    .overlay(
                        VStack(spacing: 0) {
                            Spacer()
                            HStack(alignment: .center, spacing: 0){
                                ForEach(0..<5) { index in
                                    Rectangle()
                                        .foregroundColor(.clear)
                                        .frame(width: proxy.size.width / 5, height: Items.tabBarHeight)
                                        .overlay( Items.items[index].iconView().opacity(selectedIndex == index ? 0 : 1.0))
                                        .offset(CGSize(width: 0.0, height: selectedIndex == index ? 30 : 0))
                                        .contentShape( Rectangle() )
                                        .onTapGesture { withAnimation(.interactiveSpring(response: 0.4, dampingFraction: 0.7) )  { selectedIndex = index}}
                                }
                            }
                        }.frame(height: Items.tabAreaHeight)
                        .background(
                            Rectangle()
                                .foregroundColor(.white)
                                .frame(height: Items.tabAreaHeight + Items.bottomSafeAreaHeight)
                                .mask(
                                    VStack(spacing: 0){
                                        Spacer()
                                        HStack(alignment: .bottom, spacing: 0){
                                            Rectangle().frame(width: (proxy.size.width / 5) * 4).offset(CGSize(width: Items.maskMargin, height: 0))
                                            TopFrameView()
                                                .frame(width: 141)
                                            Rectangle().frame(width: (proxy.size.width / 5) * 4).offset(CGSize(width: -Items.maskMargin, height: 0))
                                        }.frame(height: Items.tabBarHeight)
                                        .offset(CGSize(width: (proxy.size.width / 5) * CGFloat(selectedIndex - 2) , height: 0))
                                        Rectangle().frame(height: Items.bottomSafeAreaHeight).offset(CGSize(width: 0, height: -Items.maskMargin))
                                    }.frame(height: Items.tabAreaHeight + Items.bottomSafeAreaHeight)
                                )
                                .offset(CGSize(width: 0, height: Items.bottomSafeAreaHeight * 0.5))
                        )
                    )
                }
            }
        }.edgesIgnoringSafeArea([.leading, .trailing]).background(Items.backgroundColor)
    }
}

7) 複合Viewによるマスク処理での注意点

Tabbarの湾曲箇所を再現するために複数のViewを使った(Rectangle, Shape)を使ったマスクを使用している。複数のVieを組み合わせることで複雑なマスクを切り抜くことができる。

注意すべきは、Viewのタイル張りは禁物で数pointでも重なりを付加しないとマスクにビームが走る場合がある。この現象はホーム画面でアプリをタスク一覧から復帰させる際に確認できる。

複合Viewを使ってアニメーションを実現する際はoffsetモデファイヤーを使用して重なりが生じるようにすると良い。

まとめ

カスタムTabbarを再現するため、SiwftUIでのアニメーションの基本事項を示したのち、複雑なアニメーションを分解し再現する際のアプローチについて記述している。カスタムTabbarでの特殊なテクニック"複合Viewを使った複雑なマスク"の注意点についてもふれている。

SwiftUIを使用したアニメーションの基本事項は少なく簡潔に記述できる。より複雑なアニメーションを実現しようとするとSwiftUIのUI構築についての知識(レイヤーについての知識、マスクの使い方)が必要になってくるだろう。SwiftUIを取り組み始めたアプリ開発者にとって既存UIの再現はSwiftUIを使いこなすための演習題材としては手頃だと思う。