2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【SwiftUI】navigationBarBackButtonHiddenでswipe backが無効になる問題

Last updated at Posted at 2025-07-20

要約

タイトル通り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部分の対応を見ると次のようになっています
image.png

最後に

編集内容が誤スワイプで吹っ飛ぶゴミUXの撲滅を願って乾杯🥂

追記

2025/7/21
ContentをラップするとNavigationBarがおかしくなるのでバックグラウンドにVCを表示する形に変更しました。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?