【SwiftUI】エッジスワイプを実装してみた

NavigationStackの画面遷移では、画面左端を左から右にスワイプすると遷移元に戻ることができます。しかし`.navigationBarBackButtonHidden(true)`　を記述してしまうと戻るボタンが非表示になるだけでなく、スワイプ機能も失われてしまいます。これの対策として今回はエッジスワイプをして遷移元に戻るという機能を実装したいと思います。

|標準のエッジスワイプ|今回実装するエッジスワイプ|
|:-:|:-:|
|![ezgif.com-video-to-gif (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3111542/a253a67d-3338-477a-2999-6eaea1610f2a.gif)|![ezgif.com-video-to-gif (2).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3111542/57bdc122-74da-c69c-8588-11c0ccb22c88.gif)|

:::note 
NavigationStack標準のエッジスワイプでは、画面自体を指で左右に動的に動かすことができますが、今回実装するのは画面上でスワイプをしたら自動で画面遷移するというものになります。そのため、標準のものとはUXが異なります。
:::

NavigationStackの戻るボタンのカスタマイズをした上で追加の機能を実装するということでエッジスワイプについて説明するので、全体のソースコードが知りたいという方はこちらの記事をご覧ください。

https://qiita.com/kaito-seita/items/5c847be63fd4748b58e3

# DragGestureを使って実装してみる
#### DragGestureとは
画面に触れている位置（座標）を取得して、その値をもとにアクションを実行するためのコンポーネントです。

https://developer.apple.com/documentation/swiftui/draggesture

#### onChanged
たった今画面に触れている位置（座標）を取得して、`value`として渡してもらえます。`.updating`との大きな違いは、画面から指を離した場所を`value`が保持し続けるという点です。
```Swift
DragGesture().onChanged { value in

}
```
#### updating
`onChanged`と同様に触れている位置を取得しますが、`@GestureState`というプロパティラッパーを使用して値を常時更新します。`value`で値を取得し、`state`で管理し、`transaction`で値をリセットするといったイメージです。
```Swift
@GestureState private var dragOffset: CGSize = .zero
...
.updating($dragOffset, body: { (value, state, transaction) in
    state = value.translation
})
```

今回はシンプルに書ける`onChanged`を使用して実装したいと思います。
実際に`DragGesture`の部分だけ書いてみると次のようになります。
```Swift
.gesture (
    DragGesture().onChanged { value in
        if value.startLocation.x < edgeWidth && value.translation.width > dragWidth {
            dismiss()
        }
    }
)
```
先ほどの`DragGesture().onChanged`は`.gesture`の`Modifier`内で記述することができます。また、valueに対してはさまざま用意されていて、今回は初期位置が取得できる`.startLocation`と移動量が取得できる`.translation`を使用しています。ほかに何があるか気になる方はAppleの公式ドキュメントを参考にしてみてください。

https://developer.apple.com/documentation/swiftui/draggesture/value

取得した数値の比較に必要な値として`edgeWidth`と`dragWidth`を用意しています。
```Swift
private let edgeWidth: CGFloat = 30
private let dragWidth: CGFloat = 30
```
まず、`edgeWidth`は左端からどのくらいの横幅をエッジとして定義しているかを示し、スワイプ開始位置である`startLocation`がそのエッジ内にあるかを判定します。
次に、`dragWidth`はその名の通り、どのくらい画面を横にドラッグしたらスワイプしたという判定になるかを定義していて、実際のスワイプ量である`translation`と比較して、スワイプしたかどうかを判定しています。
```Swift
if value.startLocation.x < edgeWidth && value.translation.width > dragWidth
```
# Extensionに追加する
```Swift: View+DragGesture.swift
struct EdgeSwipe: ViewModifier {
    @Environment(\.dismiss) var dismiss

    private let edgeWidth: Double = 30
    private let baseDragWidth: Double = 30
    
    func body(content: Content) -> some View {
        content
            .gesture (
                DragGesture().onChanged { value in
                    if value.startLocation.x < edgeWidth && value.translation.width > baseDragWidth {
                        dismiss()
                    }
                }
            )
    }
}

extension View {
    
    func edgeSwipe() -> some View {
        self.modifier(EdgeSwipe())
    }
}
```
`ViewModifier`を用意して、それを`View`の`extension`として追加しています。
# 実装する際のポイント
`extension`で記述した`.edgeSwipe`を呼び出す際には必ず画面全体で`DragGesture`を検知できるようにします。

#### 透明なViewを背景にした場合
```Swift: SecondView.swift
struct SecondView: View {
    
    var body: some View {
        ZStack {
            Color.clear
                .contentShape(Rectangle())
            Text("SecondView")
        }
        .navigationBackButton()
        .edgeSwipe()
    }
}
```
`Color.clear`に対しては`.contentShape`を付与することで`Gesture`が反応するようになります。透明以外では必要ありません。
#### VStackを画面全体に広げた場合
```Swift: SecondView.swift
struct SecondView: View {
    
    var body: some View {
        VStack {
            Text("SecondView")
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .contentShape(Rectangle())
        .navigationBackButton()
        .edgeSwipe()
    }
}
```
先ほどに比べて`frame`の指定が増えていますが、　これの方が背景の設定が不要なので余計な記述がなくなります。

ここで取り上げたことをまとめている記事があるのでよかったら参考にしてみてください。

https://qiita.com/kaito-seita/items/73e65882ac8070b526e7

# 終わりに
githubにサンプルのプロジェクトを上げてるのでよかったらどうぞ

https://github.com/KaitoSeita/NavigationBarBackButtonDemo

# 参考リンク

https://blog.daichidaiji.com/swiftui-gesture-cancel/

https://developer.apple.com/documentation/swiftui/gesture/updating(_:body:)