How to fix UINavigationController extension which breaks SwiftUI Navigation in iOS 17
Asked Answered
C

3

10

I have been using the following extension throughout iOS 15 and iOS 16 seemingly with no issues. For context, I implemented this in my project to offer the ability to retain the swipe-back gesture for navigating backwards in a view hierarchy (child to parent). This is because hiding the toolbar or navbar in SwiftUI with a custom implementation of a back button (for UI styling purposes) causes the swipe-back gesture to be lost.

That's where this extension comes into play. Which I found here, thanks to Nick Bellucci's top rated answer. Hide navigation bar without losing swipe back gesture in SwiftUI

The concern now, is that when returning to the root view after swiping back from a child view, the root view freezes and breaks in iOS 17.0 on both device and simulator. Has anyone reproduced this issue or encountered it? To fully recreate the scenario, simply use .toolbar(.hidden) on a child view, or .navigationBarBackButtonHidden() instead.

It's also relevant to note that I am using dismiss() to tap into the view popping. Snippet below... you simply have to place the custom back button in your child view to your liking to recreate this example.

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

    public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        return viewControllers.count > 1
    }
}
struct CustomBackButton: View {
    @Environment(\.dismiss) var dismiss
    var body: some View {
        Button(action: {
            dismiss()
        }) {
            // your pretty swiftui code here
        }
    }
}

Edit: it's possible I've got myself into a 'square-peg in a round hole' situation. It's likely the solution is to leverage the existing navigation bar and overwrite the back button design while coming up with a way to hide the navigation bar. The caveat here, is that it can pad your content in an unexpected way at the top edge, so there may need to be some customized padding or margin on content that conflicts with the navigation bar's padding when it is hidden from view.

Catnip answered 4/10, 2023 at 0:56 Comment(3)
An option, to avoid this issue, is to simply hide the navigation bar but utilize its native back bar. Perhaps even customize its back button with the use of .toolbar. The compromise would be that the back button would be overlaid in a sticky fashion on top of your other content - which is a minor compromise. I wish creating custom navigation elements was more flexible.Catnip
I'm using this "solution" in the meantime: https://mcmap.net/q/153406/-hide-navigation-bar-without-losing-swipe-back-gesture-in-swiftui. I've found no fix for the native gesture myself.Creath
This solution https://mcmap.net/q/153406/-hide-navigation-bar-without-losing-swipe-back-gesture-in-swiftui is a nice, if hacky, way to hide .toolbar but keep the back navigation gesture. Works on iOS 17.2Taddeusz
S
2

I'm on iOS 17 and I haven't had any issues with

root view freezes and breaks

Here is some of my code below.

struct ContentView: View {
    @StateObject private var navigationStack = CustomNavigationStack()

    var body: some View {
        NavigationStack(path: navigationStack.binding) {
            MainScrollView(
                viewModel: .init()
            )
        }
        .toolbarBackground(.automatic, for: .navigationBar)
        .toolbar {
            ToolbarBuilder.toolBars()
        }
        .navigationDestination(for: NavigationLocation.self) { location in
            destinationView(location: location)
        }
        .navigationTitle("Group Words")
    }

    @ViewBuilder
    private func destinationView(location: NavigationLocation) -> some View {
        switch location {
            case let .cellDetailView(cell):
                CellDetailView(cell: cell)
            ....
        } 
    }
}

Then inside MainScrollView I have

struct MainScrollView: View {
    let viewModel: MainScrollViewModel

    // MARK: Body

    var body: some View {
        ScrollView {
            VStack(alignment: .leading) {
                ForEach(viewModel.cells, id: \.self) { cell in
                    NavigationLink(value: NavigationLocation.cellDetailView(cell)) {
                        CellView(cell: cell)
                    }
                    .buttonStyle(CellButtonStyle())
                }
            }
        }
    }
}



struct CellDetailView: View {
    var cell: ChildWordDetailViewModel

    var body: some View {
        Text(cell.name)
            .navigationTitle(cell.title)
            .navigationBarBackButtonHidden()
            .toolbar {
                ToolbarBuilder.toolBars()
            }
    }

}

Finally since I have a TabView inside some of my detail views, I used this to allow for swiping back to the previous page.

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

    public func gestureRecognizer(
        _ gestureRecognizer: UIGestureRecognizer,
        shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer
    ) -> Bool {
        return gestureRecognizer.isEqual(self.interactivePopGestureRecognizer)
    }
}

I guess here is my CustomNavigationStack

final class CustomNavigationStack: Navigator, ObservableObject {
    @Published private(set) var stack: [NavigationLocation] = []

    var binding: Binding<[NavigationLocation]> {
        Binding(
            get: { self.stack },
            set: { newValue in
                self.stack = newValue
            }
        )
    }

    func push(_ item: NavigationLocation) {
        stack.append(item)
    }

    func pop() {
        _ = stack.popLast()
    }
}

Hope this helps

Statvolt answered 15/10, 2023 at 4:49 Comment(0)
F
2

iOS 17 Fix:

I was having the same freezing issue with the UINavigationController extension on iOS 17 in my app. Instead, I created a custom modifier to re-implement the gesture.

While you do lose the built-in animation mid-gesture, the gesture is so small/fast that, in practice, I didn't notice. The view still animates away once the gesture is detected. You can change the values to make the gesture easier/harder to detect.

Add the following to any views inside a NavigationView or NavigationStack that needs a back gesture:

.swipeToGoBack()

Then add the extension and custom modifier anywhere in your code:

extension View {
    func swipeToGoBack() -> some View {
        self.modifier(SwipeToGoBackModifier())
    }
}

struct SwipeToGoBackModifier: ViewModifier {
    @Environment(\.presentationMode) var presentationMode
    
    func body(content: Content) -> some View {
        content
            .gesture(
                DragGesture(minimumDistance: 20, coordinateSpace: .global)
                    .onChanged { value in
                        print(value)
                        guard value.startLocation.x < 20, value.translation.width > 45 else { return }
                        self.presentationMode.wrappedValue.dismiss()
                        
                    }
            )
    }
}
Forcefeed answered 28/10, 2023 at 19:11 Comment(1)
The benefit of back swipe gesture is that it's possible to 'peek' back without fully navigating by sliding halfway and then returning. The approach with gesture doesn't allow that.Taddeusz
O
0

For iOS 17 and may be some versions below.

You also wrote:

Leverage the existing navigation bar and overwrite the back button design while coming up with a way to hide the navigation bar.

You can make default back button in navigation bar invisible. And then "replace" it on your custom button.

  1. You need to set .tint(.clear) for NavigationStack for make back button "< Back" with no color. Code example:
    NavigationStack {
        ProfileView()
    }
    .tint(.clear)
    .tabItem {
        Image("profileTabImage")
            .frame(width: 24, height: 24)
    }
    .tag(0)
  1. Then it needed to set .navigationTitle("") for remove word "Back" in back button. Now you get only invisible "<" in back button. It also need to set in previous view.

  2. Finaly you need to set custom back button image in ToolbarItem with offset, so it looks like replace default back button. Code example:

.toolbar {
    ToolbarItem(placement: .topBarLeading) {
        Button {
            presentationMode.wrappedValue.dismiss()
        } label: {
            ZStack {
                Image("backProfile").offset(x: -35)
            }
        }
    }
}
  1. (Optional) You also can make navigation bar invisible exept your custom back button. Just use .toolbarBackground(.hidden, for: .navigationBar)

So you can get a navigation bar like this. And save default swipe-back gesture for navigating backwards in a view hierarchy.

enter image description here

Oddson answered 19/3 at 14:42 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.