SwiftUIで開閉式メニューの作り方

SwiftUIで下からニュッと表示する開閉式メニューの作り方を解説します。

この記事は私が2020年12月22日に発表した資料、
SwiftUIで作る開閉式メニュー」を記事化したものです。

動作環境

この記事は以下の環境で動作を確認しています。

  • Xcode 12.2
  • iOS 14.2

開閉式メニューを作ろう

今回作る開閉式メニューの動きをgifでみてみましょう。

親Viewを作る

まずは親Viewを作りましょう。

struct ContentView: View {
    @State var isShowMenu = false
    var body: some View {
        ZStack {
            Button(action: {
                withAnimation {
                    isShowMenu.toggle()
                }
            }, label: {
                Text("show menu")
            })
            .frame(width: UIScreen.main.bounds.width)
        }
    }
}

ZStackButtonが真ん中に表示されるViewです。
isShowMenuプロパティでメニューを開閉するかどうかを判断します。

ボタンをタップしたらisShowMenutoggleメソッドで反転させています。
withAnimationで囲うことでアニメーションつきで更新させます。

現状のキャプチャです。まだボタンが真ん中にあるだけの状態です。

MenuViewを作る

続いて子ViewとしてMenuViewを作ります。

struct MenuView: View {
    @Binding var isShowMenu: Bool
    var body: some View {
        VStack {
            Spacer()
            HStack {
                Spacer()
                Button(action: {
                    withAnimation {
                        isShowMenu = false
                    }
                }, label: {
                    Image(systemName: "checkmark")
                        .resizable()
                        .aspectRatio(contentMode: .fit)
                        .frame(width: 20, height: 20)
                        .padding()
                })
            }
            .background(Color.black.opacity(0.8))
            .foregroundColor(.white)
            .offset(x: 0, y: isShowMenu ? 0 : 300)
        }
    }
}

VStackSpacerHStackを縦に並べます。
HStackでは右端にチェックマークをつけたボタンを配置しています。
ボタンを押すと親Viewから渡れたisShowMenuをfalseにします。
isShowMenuの値によってoffsetHStackの位置を変えています。この例ですと、falseだとy軸から300下の位置に移動させています。
HStackの高さは300よりも小さいので、300下に移動すると画面から見えなくなり閉じられて見えるという寸法です。
300の数値は今のところマジックナンバーで特に意味はありません。
HStackの高さよりも大きければ何でも良いです。
後で適切なものに変更します。

さて、親ViewにMenuViewを追加してみましょう。

ZStack {
    Button(action: {
        withAnimation {
            isShowMenu.toggle()
        }
    }, label: {
        Text("show menu")
    })
    .frame(width: UIScreen.main.bounds.width)
    // デフォルトではコンテンツはセーフエリア内にとどまる
    MenuView(isShowMenu: $isShowMenu)
}

表示はこのようになります。

下の隙間が気になりますね。
SwiftUIはデフォルトではSafe Area内でコンテンツが収まります。
これをなんとかしましょう。

Safe Areaを無視してみる

単純な解決方法としてSafe Areaを無視してみましょう。

ZStack {
    Button(action: {
        withAnimation {
            isShowMenu.toggle()
        }
    }, label: {
        Text("show menu")
    })
    .frame(width: UIScreen.main.bounds.width)
    MenuView(isShowMenu: $isShowMenu)
        .ignoresSafeArea(edges: .bottom) // Safe Areaを無視する
}

親ViewからMenuViewignoresSafeAreaをつけてSafe Areaを無視してみます。

表示はどうなるでしょうか?

指定どおりにSafe Areaが無視されました。
しかしユーザーがタップできる領域がSafe Area外に表示されてしまっています。
操作性が悪くなるのでなんとか避けたいです。

これを解決するため、Safe Area外には背景コンテンツを配置しましょう。

Safe Area外には背景コンテンツを配置

ではその方法です。
子Viewとして新たにMenuViewWithinSafeAreaを作ります。

struct MenuViewWithinSafeArea: View {
    @Binding var isShowMenu: Bool
    let bottomSafeAreaInsets: CGFloat
    var body: some View {
        GeometryReader { geometry in // Viewの高さを取得するため
            VStack(spacing: 0) {
                Spacer()
                HStack {...}
                ...
                .offset(x: 0, y: isShowMenu ? 0 : geometry.size.height) // Viewの高さ分移動させる

                // safe area外の背景コンテンツ
                Rectangle()
                    .foregroundColor(Color.red.opacity(0.8)) // 分かりやすいように赤色背景にする
                    .frame(height: bottomSafeAreaInsets) // 高さをbottom Safe Area Insetsに合わせる
                    .edgesIgnoringSafeArea(.bottom) // Safe Areaを無視する
                    .offset(x: 0, y: isShowMenu ? 0 : geometry.size.height) // Viewの高さ分移動させる。
            }
        }
    }
}

MenuViewWithinSafeAreaMenuViewと異なりbottomSafeAreaInsetsプロパティがあります。これは親Viewから渡されるデータです。
またGeometryReaderを追加しています。
これはViewの高さを取得するためです。
HStackoffsetgeometry.size.heightを指定して、Viewの高さ分移動するようにしています。先程のMenuViewは300というマジックナンバーを使っていましたが、この例では意味ある値になっています。

そしてSafe Area外の背景コンテンツとしてRectangleを配置しています。
高さが親Viewから渡されたBottom Safe Area Insetsと同じ値を指定し、edgesIgnoringSafeAreaでSafe Areaを無視しています。そしてHStackと同じように.offsetisShowMenuの値で表示位置を変更しています。

親ViewにMenuViewWithinSafeAreaを適応してみましょう。

GeometryReader { geometry in
    ZStack {
        Button(action: {
            withAnimation {
                isShowMenu.toggle()
            }
        }, label: {
            Text("show menu")
        })
        .frame(width: UIScreen.main.bounds.width)
        MenuViewWithinSafeArea(isShowMenu: $isShowMenu,
                                bottomSafeAreaInsets: geometry.safeAreaInsets.bottom)
            .ignoresSafeArea(edges: .bottom)
    }
}

親ViewにもGeometryReaderを追加します。
これはMenuViewWithinSafeAreageometry.safeAreaInsets.bottomを渡したいからです。親Viewからでないとgeometry.safeAreaInsets.bottomが適切に取得できません。
そしてMenuViewWithinSafeArea自体にもignoresSafeAreaをしてSafe Areaを無視しています。

表示をみてみましょう。

無事にSafe Area外に背景コンテンツを配置できました。

GeometryReaderを使って親ViewからsafeAreaInsets.bottomを渡し、その分の高さのViewを作るのがミソです。

これで開閉式メニューの解説を終わりたいと思います。

サンプルコード

https://gist.github.com/SatoTakeshiX/4c0aed5430f2a272d33ebcfd8192b5d6

宣伝

インプレスR&D社より、「1人でアプリを作る人を支えるSwiftUI開発レシピ」発売中です。
「SwiftUIでアプリを作る!」をコンセプトにSwiftUI自体の解説とそれを組み合わせた豊富なサンプルアプリでどんな風にアプリ実装すればいいかが理解できる本となっています。
iOS 14対応、Widgetの作成も一章まるまるハンズオンで解説しています。
SwiftUIを学びたい方、ぜひこちらのリンクをチェックしてください!




https://nextpublishing.jp/book/12491.html