モバイルアプリのインターフェースをトレース実装する会の成果報告

こんにちは、モバイルチームの中村(@Kyomesuke)です。

モバイルチームでは、現在トレンドになっているユーザーインターフェースの実装方法を探求することを目的として、他社アプリのインターフェースやアニメーションを SwiftUI でトレース実装する会を開催しています。 本記事ではその成果の一つをご紹介いたします。

この勉強会について

「モバイルアプリのインターフェースをトレース実装する会(自称モイト会)」は1回あたり1時間不定期で開催している勉強会です。 モバイルチームはいくつかのプロダクトチームに分かれているのですが、その垣根を超えて有志のメンバー4,5人が参加しています。

トレース実装をするインターフェースについては、サイボウズの製品に活かせそうなものとしてカレンダーやカンバンのような複数データの制御を扱っていそうなアプリに着目して選定しています。 特にユーザーの操作に伴う動的な構造変化やアニメーションのあるインターフェースに注目してトレース実装対象を探しています。 また、このモイト会とは別に、世のトレンドを知り、注目したモバイルアプリのコンセプトを調査して深堀りすることを目的とした「モバイルアプリを触りまくる会(通称モ触会)」という勉強会があり、そこで取り上げられたインターフェースからモイト会のトレース実装対象を選ぶことがあります。

トレース実装をする際は 3rd Party のライブラリを用いずに SwiftUI と Apple 公式 の Framework だけで再現するようにしています。 また、学習機会を均等にするためにドライバーをローテーションしたモブプログラミングで実装を進めています。 すでに SwiftUI に慣れている人もそうでない人も意見を出し合ってわいわいとやっています!

今回のトレース対象

さて、トレース実装した対象についてですが、今回は Google Fit のマテリアルデザインのフローティングアクションボタンに注目してみました。

Google Fit: アクティビティ トラッカー

Google Fit: アクティビティ トラッカー

  • Google LLC
  • ヘルスケア/フィットネス
  • 無料
apps.apple.com

トレース対象を観察

Google Fitのフローティングアクションボタンのアニメーション
Google Fitのフローティングアクションボタンのアニメーション

では、Google Fit のフローティングアクションボタンについて観察してみましょう。 初期状態では +(プラス) のアイコンが描かれたボタンが画面右下に浮いた状態になっており、このボタンを押すと + が回転しながら × になると同時に複数の子アイテムが下から上へと順に展開されます。 そして、もう一度ボタンを押すと展開した時とは逆再生のように子アイテムが格納されてゆき ×+ に戻ります。

トレース実装

まずは出来上がったもののデモをご覧ください。

今回SwiftUIでトレース実装したデモ
今回SwiftUIでトレース実装したデモ

+ が描かれたフローティングアクションボタンを押すと3種類の子アイテムが下から順に展開され、もう一度押すと子アイテムが上から順に格納されます。 背景の変化や子アイテムの左側のラベルや実装していませんが、アニメーションの基本的な要素は抑えられていると思います。

成果物のプロジェクトのソースコードは GitHub に公開してあります。
cybozu/ios-interface-trace

では、段階を追って実装のポイントについて追っていきましょう。

1.フローティングアクションボタンの設置

フローティングアクションボタンの設置
フローティングアクションボタンの設置

struct FloatingActionButton: View {
    var body: some View {
        Button(action: {}, label: {
            // SF Symbolsで用意されているプラスマークを使う
            Image(systemName: "plus")
                .resizable()
                .frame(width: 40, height: 40, alignment: .center)
        })
            .foregroundColor(.white)
            .frame(width: 100, height: 100, alignment: .center)
            .background(Color.gray)
            .cornerRadius(50)
            // シャドウは最後にかける
            .shadow(color: .gray, radius: 6, x: 0.0, y: 5)
    }
}

ポイント

  • FloatingActionButton という View を用意し、Button を設置します。
  • + のマークは SF Symbols で用意されているプラスマークを使用しました。
  • 丸いボタンにするために cornerRadius() で半径として幅高さの半分の長さを指定します。
  • フローティングさせるために shadow() を使います。
  • cornerRadius()shadow() は処理する順序が見た目に影響するので注意します。

2.フローティングアクションボタンのアイコンの回転アニメーション

フローティングアクションボタンのアイコンの回転アニメーション
フローティングアクションボタンのアイコンの回転アニメーション

struct FloatingActionButton: View {
    // ボタンの押下状態を把握する
    @State private var tapped = false

    var body: some View {
        Button(action: {
            // 0.3秒かけてアイコンが回転するようにアニメーションさせる
            withAnimation(Animation.linear(duration: 0.3)) {
                tapped.toggle()
            }
        }, label: {
            Image(systemName: "plus")
                .resizable()
                .frame(width: 40, height: 40, alignment: .center)
        })
            .foregroundColor(.white)
            .frame(width: 100, height: 100, alignment: .center)
            .background(Color.gray)
            .cornerRadius(50)
            // tappedの状態に応じて回転させる
            .rotationEffect(tapped ? Angle(degrees: 135) : .zero)
            .shadow(color: .gray, radius: 6, x: 0.0, y: 5)
    }
}

ポイント

  • +× に回転させるために rotationEffect() を使い 135 度傾けます。
  • tapped プロパティの状態に応じて +× が切り替わるように Buttonaction にアニメーションの処理を書きます。
    • withAnimation(Animation.linear(duration: 0.3)) {} のスコープ内で tapped.toggle() することで duration の時間をかけて回転アニメーションが実行されるようになります。

3.子アイテムの設置(展開された状態)

子アイテムの設置(展開された状態)
子アイテムの設置(展開された状態)

struct ChildItemButton: View {
    let offsetY: CGFloat
    let color: Color
    let sfName: String

    var body: some View {
        Button(action: {}, label: {
            Image(systemName: sfName)
                .resizable()
                .frame(width: 40, height: 40, alignment: .center)
        })
            .foregroundColor(.white)
            .frame(width: 100, height: 100, alignment: .center)
            .background(color)
            .cornerRadius(50)
            // 展開時の高さ方向のオフセットを指定
            .offset(x: 0, y: offsetY)
    }
}

ポイント

  • 子アイテムの View として ChildItemButton を定義します。
  • ボタンとしての基本的な構成は FloatingActionButton と変わりません。
  • color プロパティと sfName プロパティでボタンの色とアイコンを指定できるようにしています。
  • offset プロパティで子アイテムが展開している時の高さ方向のオフセットを指定できるようにしています。
struct FloatingActionButton: View {
    @State private var tapped = false

    var body: some View {
        ZStack {
            // 高さ方向のoffsetを指定しながら子アイテムを設置
            ChildItemButton(offsetY: -150, color: Color.green, sfName: "paperplane")
            ChildItemButton(offsetY: -300, color: Color.yellow, sfName: "lasso")
            ChildItemButton(offsetY: -450, color: Color.red, sfName: "trash")
            Button(action: {
                withAnimation(Animation.linear(duration: 0.3)) {
                    tapped.toggle()
                }
            }, label: {
                Image(systemName: "plus")
                    .resizable()
                    .frame(width: 40, height: 40, alignment: .center)
            })
            .foregroundColor(.white)
            .frame(width: 100, height: 100, alignment: .center)
            .background(Color.gray)
            .cornerRadius(50)
            .rotationEffect(tapped ? Angle(degrees: 135) : .zero)
            .shadow(color: .gray, radius: 6, x: 0.0, y: 5)
        }
        // 全体を右下寄せに表示する
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)
        .padding(16)
    }
}

ポイント

  • ZStack を使って、ChildItemButtonButton を並べます。
    • VStack でないのは後で要素を重ねるために Z 軸方向の指定が必要となるからです。
  • フローティングアクションボタンを右下に配置したいため ZStack に対して frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) で右下にずらします。

4.子アイテムの展開アニメーション

フローティングアクションボタンのアイコンの回転アニメーション
フローティングアクションボタンのアイコンの回転アニメーション

struct ChildItemButton: View {
    let offsetY: CGFloat
    let color: Color
    let sfName: String

    var body: some View {
        Button(action: {}, label: {
            Image(systemName: sfName)
                .resizable()
                .frame(width: 40, height: 40, alignment: .center)
        })
            .foregroundColor(.white)
            .frame(width: 100, height: 100, alignment: .center)
            .background(color)
            .cornerRadius(50)
            .offset(x: 0, y: offsetY)
            // 表示/非表示が切り替わる時のアニメーションを指定する
            // offsetYの分だけズレを戻す
            .transition(.offset(x: 0, y: -offsetY))
    }
}

ポイント

  • 子アイテムが非表示から表示に切り替わる時、フローティングアクションボタンの下から出現するように見せるため、transition(.offset(x:y:)) を使ってオフセットが ±0 になるようにします。
struct FloatingActionButton: View {
    // 子アイテムの展開アニメーションを制御するために配列にする
    @State private var tapped = [false, false, false]

    var body: some View {
        // .zIndex() でViewの重なり順を指定する
        ZStack {
            // tappedの状態切り替えで子アイテムの表示/非表示を制御する
            if tapped[0] {
                ChildItemButton(offsetY: -150, color: Color.green, sfName: "paperplane")
                    .zIndex(2)
            }
            if tapped[1] {
                ChildItemButton(offsetY: -300, color: Color.yellow, sfName: "lasso")
                    .zIndex(1)
            }
            if tapped[2] {
                ChildItemButton(offsetY: -450, color: Color.red, sfName: "trash")
                    .zIndex(0)
            }
            Button(action: {
                // 展開のアニメーションは全体で0.9秒
                // それぞれの子アイテムの移動時間を計算して、移動開始時間のズレを調整する
                // 展開する時(tappedがfalse→trueになるとき)はdelayは不要
                // 格納する時は合計で0.9秒になるようにdurationとdelayを設定
                withAnimation(Animation.linear(duration: 0.3).delay(tapped[0] ? 0.6 : 0)) {
                    tapped[0].toggle()
                }
                withAnimation(Animation.linear(duration: 0.6).delay(tapped[1] ? 0.3 : 0)) {
                    tapped[1].toggle()
                }
                withAnimation(Animation.linear(duration: 0.9)) {
                    tapped[2].toggle()
                }
            }, label: {
                Image(systemName: "plus")
                    .resizable()
                    .frame(width: 40, height: 40, alignment: .center)
            })
            .foregroundColor(.white)
            .frame(width: 100, height: 100, alignment: .center)
            .background(Color.gray)
            .cornerRadius(50)
            .rotationEffect(tapped[0] ? Angle(degrees: 135) : .zero)
            .shadow(color: .gray, radius: 6, x: 0.0, y: 5)
            .zIndex(3)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)
        .padding(16)
    }
}

ポイント

  • 子アイテムの展開アニメーションを制御するために tapped を配列にします。
    • 子アイテムの移動距離は展開時の高さ方向のオフセットによって異なり、それに伴い移動にかかる時間も異なります。そのため、子アイテムの表示/非表示の切り替えを司るフラグはアイテムそれぞれに必要になります。
    • tapped[0],tapped[1],tapped[2] のそれぞれが true のときに各子アイテムが表示されるようにします。同様に tappedtoggle() も別々に実行されるようにします。
  • 今回は展開のアニメーションを全体で 0.9 秒としていますが、子アイテムの移動時間を計算して各アニメーションの durationdelay を調整します。
    • 展開する時(tapped が false→true になるとき)は delay が不要です。
    • 格納する時(tapped が true→false になるとき)は durationdelay の合計が 0.9 秒になるようにします。
  • zIndex() で奥行き方向の指定をして要素の重なりを制御します。

まとめ

今回は Google Fit のフローティングアクションボタンに注目して SwiftUI でトレース実装してみた成果物についてご紹介しました。 要素を配置したり角丸にしたり影をつけたりといった単純な実装でしたら SwiftUI は簡単ですが、複数の要素をアニメーションさせるためには durationdelay など時間の制御を要素ごとに行うなど少々テクニックが必要でしたね。SwiftUI の transition() の仕様について深く理解することでアニメーションの制御が自在にできるようになりそうです。今後もインターフェースのトレース実装成果物を紹介していく予定ですのでお楽しみに!