SwiftUIでサイドメニューを実装してみた

趣味プログラミングでSwiftUIでサイドメニューを作ってみたので、その実装紹介記事です。
2020.09.08

概要

大阪オフィスの山田です。趣味プログラミングで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フラグで表現します。 GeometryReaderEmptyViewを組み合わせて背景部分を作ります。透過ありのグレー色を指定しています。この背景部分をタップした時に、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)
        }
    }
}

メイン画面側では、以下の実装を入れます。

  1. サイドメニューを開いているかどうかのフラグを用意する
  2. ボタンタップで、フラグをtoggleする
  3. SideMenuViewにフラグをバインドして渡す

サイドメニューは、SafeAreaまで表示させたいので、.edgesIgnoringSafeArea(.all)を指定します。 これで、左上のボタンをタップしたらSideMenuViewが表示されます。SideMenuView側でisOpenフラグをバインドしているので、SideMenuViewで値が変更されると、ContentViewisOpenSideMenuフラグに反映されます。

メニュー部分

HStackVStackを使って実装していきます。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と、表示するテキストを受け取ることができるようになっています。

選択した文字をメイン画面に反映

いくつか方法はあると思いますが、ここではContentViewStateを作成し、それを愚直に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)
        }
    }
}

Statetextという変数を宣言し、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
        }
    }
}

bindTextisOpen@Bindingで宣言して、イニシャライザで値をセットしています。そして、タップされた時(onTapGesture)でisOpenフラグとbindTextを更新しています。更新された値は、ContentViewにも伝わることで、メニューが閉じられ、選択された文字列が表示されます。

ここまでの実装をGistに載せておきます。

終わりに

サイドメニューって無くなりそうでなかなか無くならないニクイやつ

参考