[Swift] SwiftUIで作るハンバーガーメニュー

# 作ったもの

ボタンをポチと押すと画面左からメニューがニュッと出てくるハンバーガーメニューを作りました。
![hamburger_menu.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264906/4904d802-579c-014f-c00d-81e41702e2c8.gif)

# どうやって実装するの？

## 最初は画面外に居るハンバーガーメニュー

まずハンバーガーメニューがどういう描画をされ、どういう動きをしているのかを理解するところから始めましょう。

ハンバーガーメニューはアプリがロードされたタイミングで既に画面上に描画をされています。
しかし、画面の外にいるのでユーザは見えていないだけです。
そして、何かイベントが発火したタイミングでx座標を変化させながらユーザが見える範囲の画面に登場、という流れになっています。

図にすると以下のようになります。
![スクリーンショット 2020-04-15 21.43.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264906/8b189441-f8ac-3e06-2ae2-404f40c3553c.png)



## ハンバーガーメニューの位置を計算

ハンバーガーメニューをうまくコントロールするに当たって、ハンバーガーメニューViewの初期位置と終着位置のそれぞれ２つのX座標を定める必要があります。
これは当然デバイスによって画面の幅というのは異なるので、[GeometryReader](https://developer.apple.com/documentation/swiftui/geometryreader)などを用いて都度変化させる必要があります。

#### 初期のX座標を計算

理想としては、ハンバーガーメニューの右端が親Viewの左端と重なるくらいが良いですね。

![スクリーンショット 2020-04-15 21.50.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264906/5ed68ec9-d170-08b5-1119-a7eb769ff6ea.png)

私がいつもシミュレータで使っているiPhone11 ProMaxの画面幅**414.0**を例に計算します。
また、ハンバーガーメニューの大きさは好みですが、私は今回親Viewのちょうど半分**(親Viewの幅 * 0.5)**にしました。

言葉で説明すると大変なので図にしましたw
親Viewの左端にあたるX座標から更にハンバーガーメニューのViewの幅の半分をマイナス方向に引きずり込んであげるイメージです。
![スクリーンショット 2020-04-15 22.44.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264906/e1701be5-44ae-39f6-356f-d09c524b0774.png)

コードに起こすとこんな感じです

```swift
@State public var currentOffset = CGFloat //動的に変化するハンバーガーメニューのX座標
@State public var closeOffset = CGFloat   //閉じた状態のX座標

/**
 * MenuViewの初期位置・出現時のx軸を定める
 * @param CGFloat viewWidth 親Viewの幅
 */ 
public func setInitPosition(viewWidth: CGFloat) {
    self.currentOffset = (viewWidth/2) * -1 + ((viewWidth * 0.5) / 2) * -1
    self.closeOffset = self.currentOffset
}
```

### 終着のX座標を計算

次にハンバーガーメニューがどこまで出しゃばるかを決めます。
例えば画面の半分までハンバーガーメニューが出現するようにします。
初期位置の場合は親Viewの左端よりも更にマイナス方向に押し込みましたが、今度はプラスの方向に押し出すイメージです。
![スクリーンショット 2020-04-15 22.44.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264906/6d38dee0-6e2f-cba3-e7c7-685f57200c81.png)

先ほどのコードに開いた状態のStateを追加して数値を入れてみましょう。

```swift
@State public var currentOffset = CGFloat //動的に変化するハンバーガーメニューのX座標
@State public var closeOffset = CGFloat   //閉じた状態のX座標
@State public var openOffset = CGFloat    //開いた状態のX座標

/**
 * MenuViewの初期位置・出現時のx軸を定める
 * @param CGFloat viewWidth 親Viewの幅
 */ 
public func setInitPosition(viewWidth: CGFloat) {
    self.currentOffset = (viewWidth/2) * -1 + ((viewWidth * 0.5) / 2) * -1
    self.closeOffset = self.currentOffset
     self.openOffset = ((viewWidth / 2) * -1)+((viewWidth * 0.5) / 2)
}
```

## ボタンによる出現

座標が計算できたので、実際にメニューViewを作りボタンから発火するようにしましょう。

メニューはとりあえず適当に(ぁ

```swift:HamburgerMenu.swift
import SwiftUI

struct HamburgerMenu: View {
    var body: some View {
        GeometryReader { geometry in
            VStack(alignment: .leading) {
                Text("ハンバーガーメニュー")
                    .font(.system(size: 24))
                Divider()
                ScrollView(.vertical, showsIndicators: true) {
                    Text("あ")
                        .font(.system(size: 18))
                    Text("い")
                        .font(.system(size: 18))
                    Text("う")
                        .font(.system(size: 18))
                }
            }
            .padding(.horizontal, 20)
        }
    }
}
```

イベントを発火させるボタンを配置したNavigationBarも作ります。

```swift:NavigationBar.swift
import SwiftUI

struct NavigationBar: View {
    
    @Binding public var currentOffset: CGFloat
    @Binding public var openOffset: CGFloat
    @Binding public var closeOffset: CGFloat
    
    var body: some View {
        GeometryReader { geometry in
            ZStack {
                Color(red: 107/255, green: 142/255, blue: 35/255).edgesIgnoringSafeArea(.all)
                VStack {
                    Button(action: {
                        self.toggleHamburgerMenu()
                    }) {
                        Image("Hamburger") //予め用意したハンバーガーメニュー用のImage
                            .renderingMode(.original)
                            .resizable()
                            .frame(width: 50.0, height: 50.0, alignment: .leading)
                    }
                }.padding()
            }
        }
    }
    
    // ハンバーガーメニューのX座標を変化させる処理は今回メソッドに切り出しました
    public func toggleHamburgerMenu() {
        if (self.currentOffset == self.openOffset) {
            self.currentOffset = self.closeOffset
        } else {
            self.currentOffset = self.openOffset
        }
    }
}
```

そして、ハンバーガーメニューを重ねて表示させるメインのViewを作ります。
今回私が作ったアプリはこのViewもContentViewからコンポーネントとして切り出してあるので、ちょっと特殊です。

```swift:MainContent.swift
import UIKit
import SwiftUI
import QGrid
import Combine

struct DelayList: View {
    
    // HamburgerMenuのx座標をこのViewとNavigationBarで共有
    @Binding public var currentOffset: CGFloat
    @Binding public var openOffset: CGFloat
    @Binding public var closeOffset: CGFloat
    
    var body: some View {
        GeometryReader { geometry in
            ZStack {
                VStack {
                    Text("さんぷる")
                }
                HamburgerMenu()
                    .background(Color.gray)
                    .frame(width: geometry.size.width * 0.5)
                    .onAppear(perform: {
                        self.setInitPosition(viewWidth: geometry.size.width)
                    })
                    .offset(x: self.currentOffset)
                    .animation(.default)
            }
        }
    }
    
    // MenuViewの初期位置・出現時のx軸を定める
    public func setInitPosition(viewWidth: CGFloat) {
        self.currentOffset = (viewWidth/2) * -1 + (viewWidth*0.5 / 2) * -1
        //self.currentOffset = viewWidth * -1
        self.closeOffset = self.currentOffset
        self.openOffset = ((viewWidth / 2) * -1)+((viewWidth * 0.5) / 2)
    }
}
```

そしてNavigationBarとMainContentの２つのViewを包括しているContentViewです。

```swift:ContentView.swift
import SwiftUI

struct ContentView: View {
    
    // ハンバーガーメニューの表示/非表示を管理するための変数
    @State public var currentOffset = CGFloat.zero
    @State public var openOffset = CGFloat.zero
    @State public var closeOffset = CGFloat.zero
    
    var body: some View {
        GeometryReader { geometry in
            VStack {
                NavigationBar(currentOffset: self.$currentOffset, openOffset: self.$openOffset, closeOffset: self.$closeOffset)
                    .frame(width: geometry.size.width, height: geometry.size.height/10)
                MainContent(currentOffset: self.$currentOffset, openOffset: self.$openOffset, closeOffset: self.$closeOffset)
            }
        }
    }
}

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

今挙げた３つのViewの関係は以下のようになっています。

![スクリーンショット 2020-04-15 23.26.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264906/3165b310-5858-aa25-51b2-a323e6cca344.png)

## 実際に動かしてみる

サイドメニュー適当でごめんなさいw
![demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264906/5434be33-5faf-5a0c-bd70-410b4cb5ed5c.gif)



# 参考にさせて頂いたWebサイト

[SwiftUIへの道](https://d1v1b.com/swiftui/various_slidemenu)
[SwiftUIの肝となるGeometryReaderについて理解を深める](https://qiita.com/masa7351/items/0567969f93cc88d714ac)
[【SwiftUI】SwiftUIで@Bindingを触ってみました。](https://ameblo.jp/kamekame0912/entry-12572784982.html)