SwiftUIでサイドメニューを実装してみた
概要
大阪オフィスの山田です。趣味プログラミングでSwiftUIでサイドメニューを作ってみたので、その実装紹介記事です。
開発環境
- Xcode 11.6
- macOS 10.15.4
作るもの
画像のようなサイドメニューを作ってみたいと思います。横にスライドするのではなく、上に被せる形のメニューです。 選択したメニューのタイトルがメイン画面に表示されます。完成形はこちら。
メイン画面
まずメインの画面の左上に、サイドメニューを開くボタンを配置します。
navigationBarItem
として配置します。
struct ContentView: View { var body: some View { NavigationView { Text("Hello, World!") .navigationBarTitle("メイン画面") .navigationBarItems(leading: ( Button(action: { // Todo: サイドメニューを開く }) { Image(systemName: "line.horizontal.3") .imageScale(.large) })) } } }
サイドメニュー
背景部分
サイドメニューの背景部分の実装です。
struct SideMenuView: View { @Binding var isOpen: Bool var body: some View { ZStack { // 背景部分 GeometryReader { geometry in EmptyView() } .background(Color.gray.opacity(0.3)) .opacity(self.isOpen ? 1.0 : 0.0) .opacity(1.0) .animation(.easeIn(duration: 0.25)) .onTapGesture { self.isOpen = false } // Todo: ここにリスト部分を実装する } } }
サイドメニューが開いているかどうかをisOpen
フラグで表現します。
GeometryReader
とEmptyView
を組み合わせて背景部分を作ります。透過ありのグレー色を指定しています。この背景部分をタップした時に、isOpen
フラグをfalseにして、opacity
が0になり、見えない状態になります。
ここまで出来たら一度、メイン画面と繋ぎこみます。
struct ContentView: View { @State var isOpenSideMenu: Bool = false var body: some View { ZStack{ NavigationView { Text("Hello, World!") .navigationBarTitle("メイン画面") .navigationBarItems(leading: ( Button(action: { self.isOpenSideMenu.toggle() }) { Image(systemName: "line.horizontal.3") .imageScale(.large) })) } SideMenuView(isOpen: $isOpenSideMenu) .edgesIgnoringSafeArea(.all) } } }
メイン画面側では、以下の実装を入れます。
- サイドメニューを開いているかどうかのフラグを用意する
- ボタンタップで、フラグをtoggleする
- SideMenuViewにフラグをバインドして渡す
サイドメニューは、SafeAreaまで表示させたいので、.edgesIgnoringSafeArea(.all)
を指定します。
これで、左上のボタンをタップしたらSideMenuView
が表示されます。SideMenuView
側でisOpen
フラグをバインドしているので、SideMenuView
で値が変更されると、ContentView
のisOpenSideMenu
フラグに反映されます。
メニュー部分
HStack
とVStack
を使って実装していきます。List
を使っても良いかと思いましたが、今回はStack
を使いました。
struct SideMenuView: View { @Binding var isOpen: Bool let width: CGFloat = 270 var body: some View { ZStack { // ...背景部分... // リスト部分 HStack { VStack() { SideMenuContentView(topPadding: 100, systemName: "person", text: "Profile") SideMenuContentView(systemName: "bookmark", text: "Bookmark") SideMenuContentView(systemName: "gear", text: "Setting") Spacer() } .frame(width: width) .background(Color(UIColor.systemGray6)) .offset(x: self.isOpen ? 0 : -self.width) .animation(.easeIn(duration: 0.25)) Spacer() } } } } // セルのビュー struct SideMenuContentView: View { let topPadding: CGFloat let systemName: String let text: String init(topPadding: CGFloat = 30, systemName: String, text: String) { self.topPadding = topPadding self.systemName = systemName self.text = text } var body: some View { HStack { Image(systemName: systemName) .foregroundColor(.gray) .imageScale(.large) .frame(width: 32.0) Text(text) .foregroundColor(.gray) .font(.headline) Spacer() } .padding(.top, topPadding) .padding(.leading, 32) } }
リスト部分はHStack
を使って、リスト部分(VStack
)とスペース部分(Spacer
)を並べます。リスト部分は、指定した幅で表示されますが、isOpen
フラグがfalse
の時は、offsetを幅の分だけマイナスし、隠れるようにします。また表示非表示のアニメーションを指定しています。
メニューの各セルはSideMenuContentView
として切り出しています。上のPaddingは、外部から指定できるようにしています。一番上のセルだけ100を指定して、多めに余白を取れるようにしています。
各セルは画像のsystemName
と、表示するテキストを受け取ることができるようになっています。
選択した文字をメイン画面に反映
いくつか方法はあると思いますが、ここではContentView
でState
を作成し、それを愚直にBinding
で渡していきます。
まずContentView
です。
struct ContentView: View { @State var isOpenSideMenu: Bool = false @State var text = "Hello, World!" var body: some View { ZStack{ NavigationView { Text(text) .navigationBarTitle("メイン画面") .navigationBarItems(leading: ( Button(action: { self.isOpenSideMenu.toggle() }) { Image(systemName: "line.horizontal.3") .imageScale(.large) })) } SideMenuView(isOpen: $isOpenSideMenu, text: $text) .edgesIgnoringSafeArea(.all) } } }
State
でtext
という変数を宣言し、SideMenuView
に渡しています。
次は、SideMenuView
です。
struct SideMenuView: View { @Binding var isOpen: Bool @Binding var text: String let width: CGFloat = 270 var body: some View { ZStack { GeometryReader { geometry in EmptyView() } .background(Color.gray.opacity(0.3)) .opacity(self.isOpen ? 1.0 : 0.0) .opacity(1.0) .animation(.easeIn(duration: 0.25)) .onTapGesture { self.isOpen = false } HStack { VStack() { SideMenuContentView(topPadding: 100, systemName: "person", text: "Profile", bindText: $text, isOpen: $isOpen) SideMenuContentView(systemName: "bookmark", text: "Bookmark", bindText: $text, isOpen: $isOpen) SideMenuContentView(systemName: "gear", text: "Setting", bindText: $text, isOpen: $isOpen) Spacer() } .frame(width: width) .background(Color(UIColor.systemGray6)) .offset(x: self.isOpen ? 0 : -self.width) .animation(.easeIn(duration: 0.25)) Spacer() } } } }
@Binding
で変数を宣言し、それをSideMenuContentView
に渡しています。選択と同時にサイドメニューを閉じたいので、isOpen
フラグも一緒に渡しています。
最後にSideMenuContentView
です。
struct SideMenuContentView: View { let topPadding: CGFloat let systemName: String let text: String @Binding var bindText: String @Binding var isOpen: Bool init(topPadding: CGFloat = 30, systemName: String, text: String, bindText: Binding<String>, isOpen: Binding<Bool>) { self.topPadding = topPadding self.systemName = systemName self._bindText = bindText self._isOpen = isOpen self.text = text } var body: some View { HStack { Image(systemName: systemName) .foregroundColor(.gray) .imageScale(.large) .frame(width: 32.0) Text(text) .foregroundColor(.gray) .font(.headline) Spacer() } .padding(.top, topPadding) .padding(.leading, 32) .onTapGesture { self.bindText = self.text self.isOpen = false } } }
bindText
とisOpen
を@Binding
で宣言して、イニシャライザで値をセットしています。そして、タップされた時(onTapGesture
)でisOpen
フラグとbindText
を更新しています。更新された値は、ContentView
にも伝わることで、メニューが閉じられ、選択された文字列が表示されます。
ここまでの実装をGistに載せておきます。
終わりに
サイドメニューって無くなりそうでなかなか無くならないニクイやつ