Skip to content

Instantly share code, notes, and snippets.

@Que20
Last active July 2, 2023 12:39
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Que20/acc48b684cbe4519f506712f968bd9d4 to your computer and use it in GitHub Desktop.
Save Que20/acc48b684cbe4519f506712f968bd9d4 to your computer and use it in GitHub Desktop.
SwiftUI NavigationWrapper

SwiftUI Navigation Wrapper

Context

I find navigation quite difficult to use in my applied Architecture for SwiftUI project. I made this simpler way to trigger navigation events such as push views and present modals.

Solution

The basic solution I ended up using is this wrapper around NavigationStack. I uses a state variable enum representing the pending navigation operation where it's value changes will trigger the requested operation.

Usage

struct ContentView: View {
    var body: some View {
        NavigationWrapper { navigation in
            VStack {
                Button("Hello, there! 🧔") {
                    navigation.push(Text("General Kenobi"))
                }
                Button("BEEP BLIP 🤖") {
                    navigation.present(Text("Come here, my little friend."))
                }
            }
        }
        .navigationTitle("Who's a bold one?")
    }
}

Code

import Foundation
import SwiftUI

enum NavigationOperation<Destination> where Destination : View {
    case modal(EquatableView<Destination>)
    case push(EquatableView<Destination>)
    case none
}

extension NavigationOperation: Equatable {
    static func == (lhs: NavigationOperation<Destination>, rhs: NavigationOperation<Destination>) -> Bool {
        switch lhs {
            case .modal(let lview):
                if case let .modal(rview) = rhs {
                    return lview == rview
                }
            case .push(let lview):
                if case let .push(rview) = rhs {
                    return lview == rview
                }
            case .none:
                if case .none = rhs {
                    return true
                }
        }
        return false
    }
}

struct EquatableView<Content: View>: View, Equatable {
    @ViewBuilder let content: Content
    let id: String = UUID().uuidString
    var body: some View {
        content
    }

    static func == (lhs: EquatableView, rhs: EquatableView) -> Bool {
        lhs.id == rhs.id
    }
}

protocol NavigationController {
    func present(_ view: some View)
    func push(_ view: some View)
}

struct NavigationWrapper<Content: View>: View, NavigationController {
    @State var navigationOperation: NavigationOperation<AnyView> = .none
    @State var shouldSheet: Bool = false
    @State var shouldPush: Bool = false
    @ViewBuilder var content: ((NavigationController) -> Content)

    var body: some View {
        NavigationStack {
            content(self)
        }
        .sheet(isPresented: $shouldSheet) {
            if case let .modal(destination) = navigationOperation {
                destination
            }
        }
        .navigationDestination(isPresented: self.$shouldPush) {
            if case let .push(destination) = navigationOperation {
                destination
            }
        }
        .onAppear {
            navigationOperation = .none
        }
        .onChange(of: navigationOperation) { newValue in
            switch newValue {
                case .push(_):
                    shouldPush = true
                case .modal(_):
                    shouldSheet = true
                case .none:
                    shouldPush = false
                    shouldSheet = false
            }
        }
        .onChange(of: shouldPush) { newValue in
            if !shouldPush {
                navigationOperation = .none
            }
        }
        .onChange(of: shouldSheet) { newValue in
            if !shouldSheet {
                navigationOperation = .none
            }
        }
    }

    func push(_ view: some View) {
        navigationOperation = .push(EquatableView<AnyView>(content: {
            AnyView(view)
        }))
    }

    func present(_ view: some View) {
        navigationOperation = .modal(EquatableView<AnyView>(content: {
            AnyView(view)
        }))
    }
}

Notes

Why do I need to use AnyView?

I need to copy views as an associated value with the NavigationOperation enum. Views need to be typed ereased. I'm not sure you could do it without it but if you have an idea or a suggestion, please, comment and I'll be happy to get rid of it.

Why Views need to be Equatable? What's with the UUID?

To be able to observe changes on a value, the value need to be equatable. Using UUID was the simplest way.

That whole thing doesn't feel very declarative/SwiftUIy.

Yes I know but that fits my needs.

Why's it called "NavigationController"?

Naming things is hard.

Can you put that in a SwiftPackage?

One day.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment