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.
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.
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?")
}
}
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)
}))
}
}
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.
To be able to observe changes on a value, the value need to be equatable. Using UUID was the simplest way.
Yes I know but that fits my needs.
Naming things is hard.
One day.