要約
タイトル通りSwiftUIでnavigationBarBackButtonHiddenを使うとスワイプバックが無効になる問題です。スワイプを常に有効にする解決策しかなく、ON/OFF切り替えたいと思い立ちました。
以下をSPMから入れて解決です。
以下の2つがViewのメソッドチェーンで使えるようになります
public func swipeable(_ isEnabled: Bool)-> some View
public func dismissible(backButton: Bool, edgeSwipe: Bool)
前者はスワイプバックのみの制御を行います。
後者はバックボタンとスワイプバックを併せて扱います。
import DestinationSwipeControl
//省略
.navigationDestination(isPresented: $shouldShowChild) {
ChildView()
.dismissible(backButton: false, edgeSwipe: true)
}
このとおりスマートにかけます。(API規則に準拠できてるかは怪しいが)
無知なだけで他に解決策があるかもですが明快に1つのmodifierで制御できるようになったのでこれでよしとします。
解決への道のり
ここからはどうやって解決したかを記します。時間がある人は読んでください。
問題点
SwiftのNavigationの"Dismiss"(戻る)操作には基本的にスワイプバック(左画面端をすワイプ)と左上のバックボタンの2種類があります。これらは.navigationDestination
などを用いるとデフォルトで表示されますが、UXに合わせてカスタマイズしたいという要望も当然出てきます。
スワイプの無効化には
.navigationDestination(isPresented: $shouldShowChild) {
ChildView()
.interactiveDismissDisabled(true)
}
のようにinteractiveDismissDisabled
を使います
一方バックボタンを非表示にするには
.navigationDestination(isPresented: $shouldShowChild) {
ChildView()
.navigationBarBackButtonHidden()
}
のようにnavigationBarBackButtonHidden
を使います。しかし、後者には問題があります。それはスワイプまで無効にしてしまうということです。これは国内外問わず盛り上がっているようで、検索すると(このレベルの話題にしては)たくさんヒットしました。
このstackoverflowではかなり議論がされており、以下の解決策が見つかっています。
extension UINavigationController: UIGestureRecognizerDelegate {
override open func viewDidLoad() {
super.viewDidLoad()
interactivePopGestureRecognizer?.delegate = self
}
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
return viewControllers.count > 1
}
}
大元のUINavigationController(Nav)に手を加えて無理やりスワイプを開始させるというもののようです。しかしこれにも問題があります。それは全ての画面が強制的にスワイプバックするということです。先ほどのinteractiveDismissDisabled
は効かなくなります。
例えばカスタムのバックボタンを使っているアプリで、一般向けの画面ではスワイプバックを有効に、管理者用の編集画面では誤って編集内容を消さないようにスワイプを無効に、ということができないわけです。
方針
先ほど見たNavは子のViewController(VC)を全て取得できます。そしてSwiftUIのカスタムビューはUIViewControllerRepresentable
を通じてカスタムのVCをラップすることができます。そこでNavから該当のVCで表示されているものを探し、カスタムVCを見つけて、それによって先ほどのSwipeを制御しようというものです。
実装
スワイプ用VCに使うprotocolを用意します。後述のキャストの問題でprotocolに切り出しました。View関連なので@MainActor
が必要ですね
@MainActor
protocol SwipeableControllerProtocol {
var isSwipeable: Bool { get }
}
前述のUIViewControllerRepresentable
を用いたSwiftUI -> UIViewControllerにするラッパーです。中に先ほどのprotocolを実装したVCを作ります。
@available(iOS 13.0, *)
struct SwipeableView<Content: View>: UIViewControllerRepresentable {
var enabled: Bool
let content: Content
init(_ enabled: Bool = true, @ViewBuilder content: () -> Content) {
self.enabled = enabled
self.content = content()
}
class SwipeableController: UIHostingController<Content>, SwipeableControllerProtocol {
var isSwipeable = true
}
func makeUIViewController(context: Context) -> SwipeableController {
let controller = SwipeableController(rootView: content)
controller.isSwipeable = enabled
controller.rootView = content
return controller
}
func updateUIViewController(_ uiViewController: SwipeableController, context: Context) {
uiViewController.rootView = content
}
}
最後にNavでさっき作ったSwipeableControllerを探します。単純なフルスクリーンの遷移しかまだ検証できてないので、探し方は改善の余地ありかもです。キャストする際にSwipeableViewのジェネリクスが邪魔だったためprotocolに切り出しました。
extension UINavigationController: @retroactive UIGestureRecognizerDelegate {
override open func viewDidLoad() {
super.viewDidLoad()
interactivePopGestureRecognizer?.delegate = self
}
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
guard let lastVC = viewControllers.last, viewControllers.count > 1 else {
return false
}
guard let swipeableVC = lastVC.children.first as? SwipeableControllerProtocol else {
return true
}
return swipeableVC.isSwipeable
}
}
最後にメソッドチェーンができるようにextensionでView
に実装します。
@available(iOS 13.0, *)
extension View {
@ViewBuilder
public func swipeable(_ enabled: Bool = true)-> some View{
SwipeableView(
enabled
){
self
}
}
}
@available(iOS 15.0, *)
extension View {
@ViewBuilder
public func dismissible(backButton: Bool = true, edgeSwipe: Bool = true) -> some View {
self
.navigationBarBackButtonHidden(!backButton)
.swipeable(edgeSwipe)
}
}
SwiftUI部分とUIKit部分の対応を見ると次のようになっています
最後に
編集内容が誤スワイプで吹っ飛ぶゴミUXの撲滅を願って乾杯🥂
追記
2025/7/21
ContentをラップするとNavigationBarがおかしくなるのでバックグラウンドにVCを表示する形に変更しました。