45

In SwiftUI, whenever the navigation bar is hidden, the swipe to go back gesture is disabled as well.

Is there any way to hide the navigation bar while preserving the swipe back gesture in SwiftUI? I've already had a custom "Back" button, but still need the gesture.

I've seen some solutions for UIKit, but still don't know how to do it in SwiftUI

Here is the code to try yourself:

import SwiftUI

struct RootView: View {
    var body: some View {
        NavigationView {
            NavigationLink(destination: SecondView()) {
                Text("Go to second view")
            }
        }
    }
}

struct SecondView: View {
    var body: some View{
        Text("As you can see, swipe to go back will not work")
        .navigationBarTitle("")
        .navigationBarHidden(true)
    }
}

Any suggestions or solutions are greatly appreciated

Nguyễn Khắc Hào
  • 1,980
  • 2
  • 15
  • 25

7 Answers7

112

This should work by just extending UINavigationController.

extension UINavigationController: UIGestureRecognizerDelegate {
    override open func viewDidLoad() {
        super.viewDidLoad()
        interactivePopGestureRecognizer?.delegate = self
    }

    public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        return viewControllers.count > 1
    }
}
Nick Bellucci
  • 2,296
  • 1
  • 11
  • 8
  • tried your extension in simple example. and it works either with ```.navigationBarItems(leading:...``` or ```.navigationBarBackButtonHidden(true)```. So I think this is more elegant and simple solution – Hrabovskyi Oleksandr Feb 05 '20 at 04:01
  • 1
    @GonzoOin from my experience it should yes. – Nick Bellucci May 05 '20 at 20:09
  • The swipe gesture sometimes workes and sometimes the screen gets stuck when I swipe back and forth. Also I see white area while swiping back. – user832 May 18 '20 at 22:58
  • What should I do when my `SceneDelegate` isn't using `UINavigationController ` but `UIHostingController` as `rootViewController`? – P Kuijpers Jun 17 '20 at 15:37
  • @PKuijpers You can place this extension wherever as it will be overriding the view did load of any navigation controller used in your app. – Nick Bellucci Jun 18 '20 at 15:02
  • it's work @NickBellucci. One issue there when i'm half swipe and appear detail screen again leading navigation baritem not showing – Digvijaysinh Gida Aug 26 '20 at 05:34
  • 3
    THANK YOU. I've looked for hours and your solution is the only one that worked with the exact swiping gesture. Mind explaining what does this mean: `viewControllers.count > 1`? Thank you again – Merunas Grincalaitis Aug 26 '20 at 14:05
  • 3
    @MerunasGrincalaitis that is just checking to make sure the navigation stack has more than one view controller. Essentially the gesture should return false if on the rootviewcontroller. – Nick Bellucci Aug 26 '20 at 19:34
  • It works quite well except for the case when the view contains a drag gesture recognizer. I have a view that contains a Pager to switch between 2 subviews by swiping left/right (the Pager uses `highPriorityGesture`) and it doesn't work together with this code. I tried using `gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer)`, but then it completely messes up the Pager. Surprisingly, the default navigation controller works with the Pager. – koleS Dec 13 '20 at 10:29
  • I had the same issues as @koleS. Any ideas on how to resolve that? – Danilo Campos Apr 20 '21 at 18:32
25

It is even easier than what Nick Bellucci answered. Here is the simplest working solution:

extension UINavigationController {
    override open func viewDidLoad() {
        super.viewDidLoad()
        interactivePopGestureRecognizer?.delegate = nil
    }
}
Fab1n
  • 2,103
  • 18
  • 32
  • This is working great so far for me, and is simpler than Nick Bellucci's solution, so I'll make this the new accepted answer. – Nguyễn Khắc Hào Aug 06 '21 at 02:53
  • this one doesn't work for me in swiftui 3. The one for Nick works – Arturo Oct 06 '21 at 19:37
  • @Arturo I tried this on an iOS 15-only project and it still works. Maybe other reasons? – Nguyễn Khắc Hào Oct 09 '21 at 03:13
  • 2
    The screen freezes for me if I'm on the root view and I swipe back. Anyone has the same issue? – blrsk Oct 21 '21 at 21:37
  • yes @blrsk, I had the same issue when I used this solution. How to reproduce the issue: Navigate to a detail view, then go back to root view, and then swipe back within the root view. Then the screen will freeze. – umayanga Dec 15 '21 at 06:01
  • @NguyễnKhắcHào Nick Bellucci's solution still works with the above scenario. So please mark it as the accepted answer. – umayanga Dec 15 '21 at 06:01
  • @umayanga Thanks for the steps to reproduce the issue. I tried this on iOS 15.2 and the issue still occurs. I'll revert to Nick Bellucci's answer. – Nguyễn Khắc Hào Dec 15 '21 at 20:57
  • Weird, this solution works for me, and I was not able to reproduce the issue following @umayanga's instructions. Perhaps because there is nothing above my root view, so there is nothing to swipe back to? – blu-Fox Feb 07 '22 at 09:39
  • This works great, but I need to disable swiping on some of the pages. Do you know how I can do this? – Otziii Apr 19 '22 at 11:21
  • Actually I need that, too. I will post a solution once I have one. Just googleing 'uiviewcontroller disable swipe back' does deliver some promising results already. – Fab1n Apr 20 '22 at 21:03
  • This solution works but how we be deactivated for certain screens? – lopes710 Jun 23 '22 at 15:27
  • 1
    @lopes710 you can set the pop gesture recognizer delegate on your screen and implement the delegate method that tells the system if the recognizer should act or not. – Fab1n Jun 29 '22 at 05:43
10

When using the UINavigationController extension you might encounter a bug that blocks your navigation after you start swiping the screen and let it go without navigating back. Adding .navigationViewStyle(StackNavigationViewStyle()) to NavigationView does fix this issue.

If you need different view styles based on device, this extension helps:

extension View {
    public func currentDeviceNavigationViewStyle() -> AnyView {
        if UIDevice.current.userInterfaceIdiom == .pad {
            return AnyView(self.navigationViewStyle(DefaultNavigationViewStyle()))
        } else {
            return AnyView(self.navigationViewStyle(StackNavigationViewStyle()))
        }
    }
}
6

adapting @Nick Bellucci solution but not for all screens,

create an AppState class

class AppState {
    static let shared = AppState()

    var swipeEnabled = false
}

add nick's extension (modified)

extension UINavigationController: UIGestureRecognizerDelegate {
    override open func viewDidLoad() {
        super.viewDidLoad()
        interactivePopGestureRecognizer?.delegate = self
    }

    public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        if AppState.shared.swipeEnabled {
            return viewControllers.count > 1
        }
        return false
    }
    
}

and your view

struct YourSwiftUIView: View {
    var body: some View {
        VStack {
            // your code
        }
        .onAppear {
            AppState.shared.swipeEnabled = false
        }
        .onDisappear {
            AppState.shared.swipeEnabled = true
        }
    }
    
}
shanezzar
  • 1,031
  • 13
  • 17
3

I looked around documentation and other sources about this issue and found nothing. There are only a few solutions, based on using UIKit and UIViewControllerRepresentable. I tried to combine solutions from this question and I saved swipe back gesture even while replacing back button with other view. The code is still dirty a little, but I think that is the start point to go further (totally hide navigation bar, for example). So, here is how ContentView looks like:

import SwiftUI

struct ContentView: View {

    var body: some View {

        SwipeBackNavController {

            SwipeBackNavigationLink(destination: DetailViewWithCustomBackButton()) {
                Text("Main view")
            }
            .navigationBarTitle("Standard SwiftUI nav view")


        }
        .edgesIgnoringSafeArea(.top)

    }

}

// MARK: detail view with custom back button
struct DetailViewWithCustomBackButton: View {

    @Environment(\.presentationMode) var presentationMode

    var body: some View {

        Text("detail")
            .navigationBarItems(leading: Button(action: {
                self.dismissView()
            }) {
                HStack {
                    Image(systemName: "return")
                    Text("Back")
                }
            })
        .navigationBarTitle("Detailed view")

    }

    private func dismissView() {
        presentationMode.wrappedValue.dismiss()
    }

}

Here is realization of SwipeBackNavController and SwipeBackNavigationLink which mimic NavigationView and NavigationLink. They are just wrappers for SwipeNavigationController's work. The last one is a subclass of UINavigationController, which can be customized for your needs:

import UIKit
import SwiftUI

struct SwipeBackNavController<Content: View>: UIViewControllerRepresentable {

    let content: Content

    public init(@ViewBuilder content: @escaping () -> Content) {
        self.content = content()
    }

    func makeUIViewController(context: Context) -> SwipeNavigationController {
        let hostingController = UIHostingController(rootView: content)
        let swipeBackNavController = SwipeNavigationController(rootViewController: hostingController)
        return swipeBackNavController
    }

    func updateUIViewController(_ pageViewController: SwipeNavigationController, context: Context) {

    }

}

struct SwipeBackNavigationLink<Destination: View, Label:View>: View {
    var destination: Destination
    var label: () -> Label

    public init(destination: Destination, @ViewBuilder label: @escaping () -> Label) {
        self.destination = destination
        self.label = label
    }

    var body: some View {
        Button(action: {
            guard let window = UIApplication.shared.windows.first else { return }
            guard let swipeBackNavController = window.rootViewController?.children.first as? SwipeNavigationController else { return }
            swipeBackNavController.pushSwipeBackView(DetailViewWithCustomBackButton())
        }, label: label)
    }
}

final class SwipeNavigationController: UINavigationController {

    // MARK: - Lifecycle

    override init(rootViewController: UIViewController) {
        super.init(rootViewController: rootViewController)
    }

    override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
        super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)

        delegate = self
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)

        delegate = self
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        // This needs to be in here, not in init
        interactivePopGestureRecognizer?.delegate = self

    }

    deinit {
        delegate = nil
        interactivePopGestureRecognizer?.delegate = nil
    }

    // MARK: - Overrides

    override func pushViewController(_ viewController: UIViewController, animated: Bool) {
        duringPushAnimation = true
        setNavigationBarHidden(true, animated: false)
        super.pushViewController(viewController, animated: animated)
    }

    var duringPushAnimation = false

    // MARK: - Custom Functions

    func pushSwipeBackView<Content>(_ content: Content) where Content: View {
        let hostingController = SwipeBackHostingController(rootView: content)
        self.delegate = hostingController
        self.pushViewController(hostingController, animated: true)
    }

}

// MARK: - UINavigationControllerDelegate

extension SwipeNavigationController: UINavigationControllerDelegate {

    func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
        guard let swipeNavigationController = navigationController as? SwipeNavigationController else { return }

        swipeNavigationController.duringPushAnimation = false
    }

}

// MARK: - UIGestureRecognizerDelegate

extension SwipeNavigationController: UIGestureRecognizerDelegate {

    func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        guard gestureRecognizer == interactivePopGestureRecognizer else {
            return true // default value
        }

        // Disable pop gesture in two situations:
        // 1) when the pop animation is in progress
        // 2) when user swipes quickly a couple of times and animations don't have time to be performed
        let result = viewControllers.count > 1 && duringPushAnimation == false
        return result
    }
}

// MARK: Hosting controller
class SwipeBackHostingController<Content: View>: UIHostingController<Content>, UINavigationControllerDelegate {
    func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
        guard let swipeNavigationController = navigationController as? SwipeNavigationController else { return }
        swipeNavigationController.duringPushAnimation = false
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)

        guard let swipeNavigationController = navigationController as? SwipeNavigationController else { return }
        swipeNavigationController.delegate = nil
    }
}

This realization provides to save custom back button and swipe back gesture for now. I still don't like some moments, like how SwipeBackNavigationLink pushes view, so later I'll try to continue research.

Hrabovskyi Oleksandr
  • 3,070
  • 2
  • 17
  • 36
1

Hack to hide NavigationBar globally without losing swipe back gesture in SwiftUI. It works on iOS 14 - 17.

import UIKit

extension UINavigationController {
    open override func viewWillLayoutSubviews() {
        super.viewWillLayoutSubviews()
        navigationBar.isHidden = true
    }
}
user2168735
  • 395
  • 4
  • 14
0

Here is a simple SwiftUI solution. FYI, it is not localized for right to left languages and there is no smooth animation as in the native swipe gesture.

struct SecondView: View {
  @Environment(\.dismiss) var dismiss
  
  var body: some View{
    Text("As you can see, swipe to go back will not work")
      .navigationBarTitle("")
      .navigationBarHidden(true)
      .gesture(
        DragGesture(minimumDistance: 20, coordinateSpace: .global)
          .onChanged { value in // onChanged better than onEnded for this case
            guard value.startLocation.x < 20, // starting from left edge
                  value.translation.width > 60 else { // swiping right
            return
          }
        dismiss()
      }
    )
  }
}
imthath
  • 1,353
  • 1
  • 13
  • 35