Khắc Hào
Khắc Hào

Reputation: 2688

Hide navigation bar without losing swipe back gesture in SwiftUI

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:

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)
    }
}
 

Upvotes: 81

Views: 26810

Answers (11)

shiwei.sun
shiwei.sun

Reputation: 11

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

    public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        guard !self.isNavigationBarHidden else { return false }
        return viewControllers.count > 1
    }
}

Using .toolbar(.hidden, for: .navigationBar) to control swipe back gesture useable.

BTW, Custom Back Button Code

struct CNavigationBarStyle: ViewModifier {
    @Environment(\.presentationMode) var presentationMode
    
    var title: String
    
    func body(content: Content) -> some View {
        content
            .navigationBarBackButtonHidden(true)
            .navigationBarTitle(title, displayMode: .inline)
            .toolbar {
                ToolbarItem(placement: .topBarLeading) {
                    Button {
                        presentationMode.wrappedValue.dismiss()
                    } label: {
                        Image("NavigationBack")
                            .resizable()
                            .scaledToFit()
                    }
                }
            }
            .toolbarBackground(Color.white, for: .navigationBar)
    }
}

extension View {
    func customNavigationBarStyle(_ title: String = "") -> some View {
        modifier(CNavigationBarStyle(title: title))
    }
}

Upvotes: 1

iCediCe
iCediCe

Reputation: 1723

I ended up with this solution. It works pretty well.

class NavigationController: UINavigationController {
    
    var interactivePopGestureRecognizerDeleate: InteractivePopGestureRecognizerDeleate?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        interactivePopGestureRecognizerDeleate = InteractivePopGestureRecognizerDeleate(navigationController: self)
        interactivePopGestureRecognizer?.delegate = interactivePopGestureRecognizerDeleate
    }
}

class InteractivePopGestureRecognizerDeleate: NSObject, UIGestureRecognizerDelegate {
    weak var navigationController: UINavigationController?
    
    init(navigationController: UINavigationController) {
        self.navigationController = navigationController
    }
    
    func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        navigationController?.viewControllers.count ?? 0 > 1
    }

     func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
         true
     }

    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        // Cancel other gesture recognizer since we want the dismiss gesture to take precedence
        // Not doing this would make us able to scroll the view while dismissing the view controller
        otherGestureRecognizer.state = .cancelled
        return false
    }
}

Upvotes: 0

SwiftDevStudent
SwiftDevStudent

Reputation: 49

I had this issue when I was using a NavigationLink to navigate to a contact and since I wanted the CNContactViewController to look like the system I had to add a UINavigationController, so none of the other solutions worked for me. What did work for me was adding a basically invisible overlay on the leading edge of the NavigationLink destinationView.

ContactViewController(contact: contact, isPresented: $isPresented)
    .navigationBarHidden(true)
    .navigationBarBackButtonHidden(true)
    .navigationBarTitleDisplayMode(.inline)
    .ignoresSafeArea(.container, edges: .top)
    .overlay(alignment: .leading){
        Color.white.opacity(0.01)
           .edgesIgnoringSafeArea(.vertical)
           .frame(maxWidth:12, maxHeight: .infinity)
     }

Upvotes: 0

Egor Hmara
Egor Hmara

Reputation: 83

You can make navigation bar invisible. But in this case you can't handle events for part of view under navigation bar.

I use this way:

  1. Set .tint(.clear) for NavigationStack for make back button "< Back" with no color. Example in my code:
    NavigationStack {
        ProfileView()
    }
    .tint(.clear)
    .tabItem {
        Image("profileTabImage")
            .frame(width: 24, height: 24)
        Text("")
    }
    .tag(Tab.profile)
  1. Set .navigationTitle("") for remove "Back" in back button. Now you get only "<" in back button. It also need to set in previous view.

  2. Set .toolbarBackground(.hidden, for: .navigationBar)

Optional: 4. I use my own back button. I set image in ToolbarItem with offset, so it looks like replace default back button. Example in my code:

.toolbar {
    ToolbarItem(placement: .topBarLeading) {
        Button {
            presentationMode.wrappedValue.dismiss()
        } label: {
            ZStack {
                Image("backProfile").offset(x: -35)
            }
        }
    }
}

Upvotes: -1

imthath
imthath

Reputation: 1708

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 now work without animation because of the drag gesture")
      .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()
      }
    )
  }
}

Upvotes: 7

Fab1n
Fab1n

Reputation: 2304

You need to set the interactivePopGestureRecognizer on UINavigationController.

See this for a complete answer: https://stackoverflow.com/a/60067869/1340499

My original one is shorter but introduces a bug when executing the gesture on the root.

Original answer

Here is the simplest working solution:

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

Upvotes: 38

user2168735
user2168735

Reputation: 489

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
    }
}

Upvotes: 6

shanezzar
shanezzar

Reputation: 1170

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
        }
    }
    
}

Upvotes: 13

Mantas Skeiverys
Mantas Skeiverys

Reputation: 141

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()))
        }
    }
}

Upvotes: 12

Nick Bellucci
Nick Bellucci

Reputation: 3036

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
    }
}

Upvotes: 165

Hrabovskyi Oleksandr
Hrabovskyi Oleksandr

Reputation: 3275

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.

Upvotes: 3

Related Questions