メニューを表示するフローティングアクションボタンを SwiftUI で実装してみた
見出し画像

メニューを表示するフローティングアクションボタンを SwiftUI で実装してみた

こんにちは、Zaim で iOS 開発を担当している @ugoです。
WWDC 2019 で SwiftUI が発表されてから 2 年以上が経ち、今年の iOSDC 2021 でも SwiftUI をテーマにしたセッションが複数ありましたね。
そろそろ本格的に SwiftUI を導入しているプロダクトも増えてきているのではないでしょうか。

Zaim では OS ウィジェットはもちろん、NFC カードリーダーを SwiftUI を使用して実装しています。


私は趣味でも SwiftUI を使用してアプリを開発しています。
Storyboard/Xib ⇔ Swiftファイルを交互に開いたりする必要がなかったり、シミュレータではなく Preview でレイアウトの確認がすぐにできたりするのが非常に楽で、効率が良くなりました。
今回、個人開発しているアプリでメニューを表示するフローティングアクションボタンを実装してみたので、紹介します。

実装するもの

今回実装するのは以下のようなフローティングアクションボタンです。

画像1

ボタン本体

struct FloatingMenuActionButton: View {
   @Binding var isSelected: Bool
   var body: some View {
       Button {
           withAnimation(.easeIn(duration: 0.2)) {
               isSelected.toggle()
           }
       } label: {
           Image(systemName: "plus")
               .resizable()
               .frame(width: 25, height: 25)
       }
       .frame(width: 60, height: 60)
       .background(Color.green)
       .foregroundColor(.white)
       .clipShape(Circle())
       .rotationEffect(.init(degrees: isSelected ? 45 : 0))
   }
}

ボタンが選択状態かどうかを isSelected フラグで管理しています。
ボタンの回転アニメーションを適用 するため、タップ時に withAnimation の中で isSelected を toggle させました。
こうすることで isSelected の状態によって角度が変わる rotationEffect モディファイアにアニメーションが適用になり、45 度の回転アニメーションが表現できます。

画像2

ここまできたら親の ContentView にボタンを表示してみます。

struct ContentView: View {
   @State var isSelected = false
   var body: some View {
       ZStack {
           if isSelected {
               Color.gray.edgesIgnoringSafeArea(.all)
           }
           VStack {
               Spacer()
               HStack {
                   Spacer()
                   FloatingMenuActionButton(isSelected: $isSelected)
               }
               .padding()
           }
       }
   }
}

親画面では以下の実装を入れています。
・ボタンを押しているかどうかのフラグを用意してボタンにバインドする
・ボタンの選択状態によって背景をグレーアウトする
今回はボタンの選択状態を ContentView に反映するためにボタンの isSelected を @Binding で管理しました。親側で選択状態を検知する必要がない場合は、@State で管理したほうが呼び出し側の負担も減りそうです。

メニュー部分

struct FloatingMenuActionButton: View {
   @Binding var isSelected: Bool
   var body: some View {
       VStack {
           if isSelected {
               Button {
                   withAnimation(.easeIn(duration: 0.2)) {
                       isSelected.toggle()
                   }
                   print("Menu button Tapped")
               } label: {
                   Image(systemName: "paperplane.fill")
                       .resizable()
                       .frame(width: 25, height: 25)
               }
               .frame(width: 60, height: 60)
               .background(Color.white)
               .foregroundColor(.black)
               .clipShape(Circle())
               .transition(.move(edge: .bottom))
           }
           Button {
               withAnimation(.easeIn(duration: 0.2)) {
                   isSelected.toggle()
               }
           } label: {
               Image(systemName: "plus")
                   .resizable()
                   .frame(width: 25, height: 25)
           }
           .frame(width: 60, height: 60)
           .background(Color.green)
           .foregroundColor(.white)
           .clipShape(Circle())
           .rotationEffect(.init(degrees: isSelected ? 45 : 0))
       }
   }
}

VStack を使用して、isSelected の状態によってメニューボタンの表示を分岐しています。
メニューボタンを下から表示するアニメーションも transition モディファイアを使うことで表現できました。
たったこれだけの記述で表示アニメーションを表現できるので、とても便利ですよね。
これでメニューを表示するフローティングアクションボタンの見た目が実装できました。

完成形

このままだと他の View から使いたい場合にメニューボタンの数やアイコン、タップ時の挙動が指定できず、汎用性が高くありません。
そこで、FloatingMenuActionButton に配列でパラメータを渡してメニューボタンのアイコンやタップ時のアクションを親から指定できるようにします。

struct FloatingMenuActionButton: View {
   @Binding var isSelected: Bool
   let floatingMenuItems: [FloatingMenuItem]
   var body: some View {
       VStack {
           if isSelected {
               ForEach(floatingMenuItems) { item in
                   Button {
                       withAnimation(.easeIn(duration: 0.2)) {
                           isSelected.toggle()
                       }
                       item.buttonAction()
                   } label: {
                       Image(systemName: item.iconName)
                           .resizable()
                           .frame(width: 25, height: 25)
                   }
                   .frame(width: 60, height: 60)
                   .background(Color.white)
                   .foregroundColor(.black)
                   .clipShape(Circle())
                   .transition(.move(edge: .bottom))
               }
           }
           Button {
               withAnimation(.easeIn(duration: 0.2)) {
                   isSelected.toggle()
               }
           } label: {
               Image(systemName: "plus")
                   .resizable()
                   .frame(width: 25, height: 25)
           }
           .frame(width: 60, height: 60)
           .background(Color.green)
           .foregroundColor(.white)
           .clipShape(Circle())
           .rotationEffect(.init(degrees: isSelected ? 45 : 0))
       }
   }
}
struct FloatingMenuItem: Identifiable {
   let id = UUID()
   let iconName: String
   let buttonAction: (() -> Void)
   init(iconName: String, buttonAction: @escaping (() -> Void)) {
       self.iconName = iconName
       self.buttonAction = buttonAction
   }
}
struct ContentView: View {
   @State var isSelected = false
   @State var showOtherView = false
   var body: some View {
       ZStack {
           if isSelected {
               Color.gray.edgesIgnoringSafeArea(.all)
           }
           VStack {
               Spacer()
               HStack {
                   Spacer()
                   FloatingMenuActionButton(isSelected: $isSelected, floatingMenuItems: generateFloatingMenuItems())
               }
               .padding()
           }
       }
       .sheet(isPresented: $showOtherView) {
           Text("Other View")
       }
   }
   private func generateFloatingMenuItems() -> [FloatingMenuItem] {
       return  [ .init(iconName: "pencil", buttonAction: { showOtherView.toggle() }),
                 .init(iconName: "camera.fill", buttonAction: { showOtherView.toggle() }),
                 .init(iconName: "paperplane.fill", buttonAction: { showOtherView.toggle() }),
       ]
   }
}

これでメニューボタンタップ時に他の画面へ遷移できるようになりました!

画像3

最後に

今回、実装したフローティングアクションボタンを Swift Package Manager に対応させ GitHub に公開してみました。もしよければ触ってみてもらえると幸いです。
https://github.com/YugoMatsuda/FloatingMenuActionButton

Zaim ではエンジニアを募集しています。ぜひお話を聞きにきてください!












この記事が気に入ったら、サポートをしてみませんか?
気軽にクリエイターの支援と、記事のオススメができます!