上部左右から出現するメニューを作る

Published by @SoNiceInfo at 6/24/2020


様々なスライドメニューの画像

SwiftUIで上部左右から出現するスライドメニューを作る方法を紹介します。
スワイプで上部左右から出てくるメニュー、ハンバーガーメニューを同時に実装します。
スワイプはgesture、ハンバーガーメニューはButton(action:)で実装します。
ここで紹介するコードを組み合わせれば、スライドメニューをNavigationViewの内外で表示することもできます。

スワイプによるメニューを実装するポイント

スワイプによりメニューを表示させるには以下のようなコードが重要となります。
onChanged, onEndedで画面操作の動作中と終了を検知してそれぞれの動きを実装します。

.gesture(
    // スワイプに関するイベントに関して検知します
    // minimumDistanceを指定すると、その距離分のスワイプを検知した後に発火します
    DragGesture(minimumDistance: 5)
        // スワイプが検知されたときの動きを実装します
        .onChanged{ value in
            self.offset = v.translation.width
        }
        // スワイプが終了したときの動きを実装します
        .onEnded { value in
            if (value.translation.width > 0) {
                self.offset = self.openOffset
            } else {
                self.offset = self.closeOffset
            }
        }
)

ハンバーガーメニューを実装するポイント

ハンバーガーメニューを実装するのはとても簡単です。
Button(action:)でメニューを開くための動きを実装します。

Button(action: {
    self.offset = .zero
}){
    Image(systemName: "list.bullet")
}

メニューを用意する

まずはメニューを用意します。以下のコードをコピペしてMenuView.swiftを作成してください。
作業簡略化のため「Twitterのホーム画面とスライドメニューを作る」のメニュー画面を使います。
スワイプに関する部分はContentView.swiftで実装します。

//
//  MenuView.swift
//

import SwiftUI

struct MenuView: View {

    var body: some View {
        VStack(alignment: .leading) {
            Image("animal_kuma")
                .resizable()
                .overlay(
                    Circle().stroke(Color.gray, lineWidth: 1))
                .frame(width: 60, height: 60)
                .clipShape(Circle())
            Text("SwiftUIへの道")
                .font(.largeTitle)
            Text("@road2swiftui")
                .font(.caption)
            Divider()
            ScrollView (.vertical, showsIndicators: true) {
                HStack {
                    Image(systemName: "person")
                    Text("Profile")
                }
                HStack {
                    Image(systemName: "list.dash")
                    Text("Lists")
                }
                HStack {
                    Image(systemName: "text.bubble")
                    Text("Topics")
                }
            }
            Divider()
            Text("Settings and privacy")
        }
        .padding(.horizontal, 20)
    }
}


struct MenuView_Previews: PreviewProvider {
    static var previews: some View {
        MenuView()
    }
}
MenuViewを作成した画面

上部から出現

DragGesture内のvalue.translation.heightが正の値だと上から下への移動となります。
value.startLocationを使用するとスワイプを始めた位置が取得できるので、画面上部からスワイプした場合のみメニューを出します。
navigationBarItemsにハンバーガーメニューを設置します。

//
//  ContentView.swift
//

import SwiftUI

struct ContentView: View {
    // offset変数でメニューを表示・非表示するためのオフセットを保持します
    @State private var offset = CGFloat.zero
    @State private var closeOffset = CGFloat.zero
    @State private var openOffset = CGFloat.zero
    
    var body: some View {
        // 画面サイズの取得にGeometoryReaderを利用します
        GeometryReader { geometry in
            NavigationView {
                ZStack(alignment: .topLeading) {
                    // メインコンテンツ
                    VStack () {
                        Spacer()
                        Text("This is main contents")
                            .font(.largeTitle)
                        Spacer()
                    }
                    .background(Color.white)
                    .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .center)
                    // スライドメニューがでてきたらメインコンテンツをグレイアウトします
                    Color.gray.opacity(
                        Double((self.closeOffset - self.offset)/self.closeOffset) - 0.4
                    )
                    // スライドメニュー
                    MenuView()
                        .background(Color.white)
                        .frame(width: geometry.size.width, height: geometry.size.height * 0.7)
                        // 最初に画面のオフセットの値をスライドメニュー分マイナスします。
                        .onAppear(perform: {
                            self.offset = (geometry.frame(in: .global).origin.y + geometry.size.height) * -1
                            self.closeOffset = self.offset
                            self.openOffset = .zero
                        })
                        .offset(y: self.offset)
                        // スライドのアニメーションを設定します
                        .animation(.default)
                }
                // ジェスチャーに関する実装をします
                // スワイプのしきい値を設定してユーザの思わぬメニューの出現を防ぎます
                .gesture(DragGesture(minimumDistance: 5)
                    .onChanged{ value in
                        // メインコンテンツの縦方向のスクロールを妨げないためにstartLocationを使用します
                        // オフセットの値(メニューの位置)をスワイプした距離に応じて狭めていきます
                        if (self.offset != self.openOffset && value.startLocation.y < 30) {
                            self.offset = self.closeOffset + value.translation.height
                        }
                    }
                    .onEnded { value in
                        // スワイプ開始位置よりも終了位置が下だったらメニューを開く
                        if (value.startLocation.y < value.location.y) {
                            if (value.startLocation.y < 30) {
                                self.offset = self.openOffset
                            }
                        } else {
                            self.offset = self.closeOffset
                        }
                    }
                )
                // 上部のNavigationBarに表示する項目を設定
                .navigationBarTitle("This is bar", displayMode: .inline)
                .navigationBarItems(trailing: Button(action: {
                        if (self.offset == self.openOffset) {
                            self.offset = self.closeOffset
                        } else {
                            self.offset = self.openOffset
                        }
                    }){
                        Image(systemName: "list.bullet")
                    })
                    .edgesIgnoringSafeArea(.bottom)
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
上部からのスライドメニュー上部からのスライドメニュー

左から出現

DragGesture内のvalue.translation.widthが正の値だと左から右への移動となります。

//
//  ContentView.swift
//

import SwiftUI

struct ContentView: View {
    // offset変数でメニューを表示・非表示するためのオフセットを保持します
    @State private var offset = CGFloat.zero
    @State private var closeOffset = CGFloat.zero
    @State private var openOffset = CGFloat.zero
    
    var body: some View {
        // 画面サイズの取得にGeometoryReaderを利用します
        GeometryReader { geometry in
            ZStack(alignment: .leading) {
                // メインコンテンツ
                VStack () {
                    HStack () {
                        Button(action: {
                            self.offset = self.openOffset
                        }){
                            Image(systemName: "list.bullet")
                        }
                        Spacer()
                        Text("This Is Bar")
                        Spacer()
                    }
                    .padding(.horizontal)
                    Divider()
                    Spacer()
                    Text("This is main contents")
                        .font(.largeTitle)
                    Spacer()
                }
                .background(Color.white)
                .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .center)
                // スライドメニューがでてきたらメインコンテンツをグレイアウトします
                Color.gray.opacity(
                    Double((self.closeOffset - self.offset)/self.closeOffset) - 0.4
                )
                // スライドメニュー
                MenuView()
                    .background(Color.white)
                    .frame(width: geometry.size.width * 0.7)
                    // 最初に画面のオフセットの値をスライドメニュー分マイナスします。
                    .onAppear(perform: {
                        self.offset = geometry.size.width * -1
                        self.closeOffset = self.offset
                        self.openOffset = .zero
                    })
                    .offset(x: self.offset)
                    // スライドのアニメーションを設定します
                    .animation(.default)
            }
            // ジェスチャーに関する実装をします
            // スワイプのしきい値を設定してユーザの思わぬメニューの出現を防ぎます
            .gesture(DragGesture(minimumDistance: 5)
                .onChanged{ value in
                    // オフセットの値(メニューの位置)をスワイプした距離に応じて狭めていきます
                    if (self.offset < self.openOffset) {
                        self.offset = self.closeOffset + value.translation.width
                    }
                }
                .onEnded { value in
                    // スワイプ終了位置が開始位置よりも右にあればメニューを開く
                    if (value.location.x > value.startLocation.x) {
                        self.offset = self.openOffset
                    } else {
                        self.offset = self.closeOffset
                    }
                }
            )
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

NavigationViewを使う場合はこちら。

//
//  ContentView.swift
//

import SwiftUI

struct ContentView: View {
    // offset変数でメニューを表示・非表示するためのオフセットを保持します
    @State private var offset = CGFloat.zero
    @State private var closeOffset = CGFloat.zero
    @State private var openOffset = CGFloat.zero
    
    var body: some View {
        // 画面サイズの取得にGeometoryReaderを利用します
        GeometryReader { geometry in
            ZStack(alignment: .leading) {
                // メインコンテンツ
                NavigationView {
                    ZStack {
                        VStack () {
                            Spacer()
                            Text("This is main contents")
                                .font(.largeTitle)
                            Spacer()
                        }
                        // スライドメニューがでてきたらメインコンテンツをグレイアウトします
                        Color.gray.opacity(
                            Double((self.closeOffset - self.offset)/self.closeOffset) - 0.4
                        )
                    }
                    .navigationBarTitle("This is bar", displayMode: .inline)
                    .navigationBarItems(leading: Button(action: {
                            self.offset = self.openOffset
                        }){
                            Image(systemName: "list.bullet")
                        })
                    .edgesIgnoringSafeArea(.vertical)
                }
                // スライドメニュー
                MenuView()
                    .background(Color.white)
                    .frame(width: geometry.size.width * 0.7)
                    .edgesIgnoringSafeArea(.bottom)
                    // 最初に画面のオフセットの値をスライドメニュー分マイナスします。
                    .onAppear(perform: {
                        self.offset = geometry.size.width * -1
                        self.closeOffset = self.offset
                        self.openOffset = .zero
                    })
                    .offset(x: self.offset)
                    // スライドのアニメーションを設定します
                    .animation(.default)
            }
            // ジェスチャーに関する実装をします
            // スワイプのしきい値を設定してユーザの思わぬメニューの出現を防ぎます
            .gesture(DragGesture(minimumDistance: 5)
                    .onChanged{ value in
                        // オフセットの値(メニューの位置)をスワイプした距離に応じて狭めていきます
                        if (self.offset < self.openOffset) {
                            self.offset = self.closeOffset + value.translation.width
                        }
                    }
                    .onEnded { value in
                        // スワイプの終了位置が開始位置よりも右だったらメニューを開く
                        if (value.location.x > value.startLocation.x) {
                            self.offset = self.openOffset
                        } else {
                            self.offset = self.closeOffset
                        }
                    }
                )
        }
    }
}


struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
左からのスライドメニュー左からのスライドメニュー

右から出現

DragGesture内のvalue.translation.widthが負の値だと右から左への移動となります。
NavigationViewを使うパターンは割愛。

//
//  ContentView.swift
//

import SwiftUI

struct ContentView: View {
    // offset変数でメニューを表示・非表示するためのオフセットを保持します
    @State private var offset = CGFloat.zero
    @State private var closeOffset = CGFloat.zero
    @State private var openOffset = CGFloat.zero
    
    var body: some View {
        // 画面サイズの取得にGeometoryReaderを利用します
        GeometryReader { geometry in
            ZStack(alignment: .leading) {
                // メインコンテンツ
                VStack () {
                    HStack () {
                        Spacer()
                        Text("This Is Bar")
                        Spacer()
                        Button(action: {
                            self.offset = self.openOffset
                        }){
                            Image(systemName: "list.bullet")
                        }
                    }
                    .padding(.horizontal)
                    Divider()
                    Spacer()
                    Text("This is main contents")
                        .font(.largeTitle)
                    Spacer()
                }
                .background(Color.white)
                .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .center)
                // スライドメニューがでてきたらメインコンテンツをグレイアウトします
                Color.gray.opacity(
                    Double((self.closeOffset - self.offset)/self.closeOffset) - 0.4
                )
                // スライドメニュー
                MenuView()
                    .background(Color.white)
                    .frame(width: geometry.size.width * 0.7)
                    // 最初に画面のオフセットの値をスライドメニュー分マイナスします。
                    .onAppear(perform: {
                        self.offset = geometry.size.width
                        self.closeOffset = self.offset
                        self.openOffset = geometry.size.width - geometry.size.width * 0.7
                    })
                    .offset(x: self.offset)
                    // スライドのアニメーションを設定します
                    .animation(.default)
            }
            // ジェスチャーに関する実装をします
            // スワイプのしきい値を設定してユーザの思わぬメニューの出現を防ぎます
                .gesture(DragGesture(minimumDistance: 5)
                    .onChanged{ value in
                        // オフセットの値(メニューの位置)をスワイプした距離に応じて狭めていきます
                        if (self.offset > self.openOffset) {
                            self.offset = self.closeOffset + value.translation.width
                        }
                    }
                    .onEnded { value in
                        // value.translation.widthを使っても書けます
                        // 移動した距離がマイナス方向だったらメニューを開く
                        if (value.translation.width < 0) {
                            self.offset = self.openOffset
                        } else {
                            self.offset = self.closeOffset
                        }
                    }
                )
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
左からのスライドメニュー左からのスライドメニュー

ダウンロード

紹介するコードはGitHubで公開しています。
https://github.com/d1v1b/Various-Slide-Menus

その他

マップアプリやGoogleアプリで採用されている、画面下部にいるカード式スライドメニューの作り方はこちら。
下部から出現するカード風スライドメニューを作る



    アプリをリリースしました!

    ToDoアプリ

    iCloudを利用したデバイス間データ共有、ダークモードに対応しています。

    リリースしたToDoアプリのスクリーンショット

    IPアドレス履歴保存アプリ

    取得したIPアドレスを確認、位置情報とともに保存できます。

    リリースしたIPアドレス保存アプリのスクリーンショット