Nested NavigationLinks with isActive bindings do not work as expected

Recently I was experimenting with SwiftUI navigation and I thought I found a way to make it flexible and loosely coupled, yet still state-based and somewhat free of imperative-navigation bugs (double push, etc).

Basic idea is to have a linked list of Views (erased to AnyView) and a recursive view with NavigationLink in it, which is active when corresponding view is present in the list

But it does not work and I don't understand why. On iOS device it only pushes one level deep, even though the list is multiple levels deep and the isActive bindings return true

struct ContentView: View {
    @State
    var navigationList: NavigationList?

    var body: some View {
        NavigationView {
            Navigatable(list: $navigationList) {
                Button("Push test", action: {
                    navigationList = .init(next: nil, screen: Screen {
                        TestView()
                    })
                })
            }
        }
    }
}

struct TestView: View {
    @Environment(\.navigationList)
    @Binding
    var list
    
    var body: some View {
        Button("Push me", action: {
            list = .init(next: nil, screen: Screen {
                TestView()
            })
        })
    }
}

struct Navigatable<Content: View>: View {
    @Binding
    var list: NavigationList?
    let content: () -> Content

    init(list: Binding<NavigationList?>, @ViewBuilder content: @escaping () -> Content) {
        self._list = list
        self.content = content
    }

    var body: some View {
        ZStack {
            NavigationLink(
                isActive: isActive,
                destination: {
                    Navigatable<Screen?>(list: childBinding) {
                        list?.screen
                    }
                }, 
                label: EmptyView.init
            ).hidden()
            LazyView {
                content()
            }.environment(\.navigationList, $list)
        }
    }
    
    var isActive: Binding<Bool> {
        .init(
            get: { list != nil },
            set: {
                if !$0 {
                    list = nil
                }
            }
        )
    }
    
    var childBinding: Binding<NavigationList?> {
        .init(
            get: { list?.next },
            set: { list?.next = $0 }
        )
    }
}

struct Screen: View {
    let content: () -> AnyView
    
    init<C: View>(@ViewBuilder content: @escaping () -> C) {
        self.content = {
            .init(content())
        }
    }
    
    var body: some View {
        content()
    }
}

struct NavigationList {
    @Indirect
    var next: NavigationList?
    
    let screen: Screen
}


enum NavigationListKey: EnvironmentKey {
    static var defaultValue: Binding<NavigationList?> {
        .constant(nil)
    }
}

extension EnvironmentValues {
    var navigationList: Binding<NavigationList?> {
        get { self[NavigationListKey.self] }
        set { self[NavigationListKey.self] = newValue }
    }
}

struct LazyView<Content: View>: View {
    @ViewBuilder var content: () -> Content
    
    var body: some View {
        content()
    }
}

@propertyWrapper
struct Indirect<Wrapped> {
    private final class Storage: CustomReflectable {
        var wrapped: Wrapped
        
        init(_ wrapped: Wrapped) {
            self.wrapped = wrapped
        }
        
        var customMirror: Mirror {
            .init(self, children: [(label: "wrapped", value: wrapped)])
        }
    }
    
    private let storage: Storage
    
    var wrappedValue: Wrapped {
        get { storage.wrapped }
        mutating set { storage.wrapped = newValue }
    }
    
    init(wrappedValue: Wrapped) {
        self.storage = .init(wrappedValue)
    }
}

After struggling with it I tried simple example of nested navigation with bindings

struct ContentView: View {
    @State var isActive = true

    var body: some View {
        NavigationView {
            NavigationLink(
                isActive: $isActive,
                destination: {
                    Test1()
                },
                label: {
                    Text("Push Test1")
                }
            ).isDetailLink(false)
        }
    }
}

struct Test1: View {
    @State var isActive = true

    var body: some View {
        NavigationLink(
            isActive: $isActive,
            destination: {
                Test2()
            },
            label: {
                Text("Push Test2")
            }
        )
    }
}

struct Test2: View {
    var body: some View {
        Text("Test2")
    }
}

And it does not work either. It only pushes one screen and that's it

I managed to make it work somehow.
Here is the updated code

final class Navigator: ObservableObject {
    @Published var navigationList: NavigationList? = .init(
        next: .init(next: .init(
            next: nil,
            screen: Screen {
                TestView()
            }
        ), screen: Screen {
            TestView()
        }),
        screen: .init {
            TestView()
        }
    )
}

struct ContentView: View {
    @StateObject var nav = Navigator()

    var body: some View {
        NavigationView {
            Navigatable(list: $nav.navigationList) {
                Button("Push test", action: {
                    nav.navigationList = .init(next: nil, screen: Screen {
                        TestView()
                    })
                })
            }
        }
    }
}

struct TestView: View {
    @Environment(\.navigationList)
    @Binding
    var list
    
    var body: some View {
        Button("Push me", action: {
            list = .init(next: nil, screen: Screen {
                TestView()
            })
        })
    }
}

struct Navigatable<Content: View>: View {
    @Binding
    var list: NavigationList?
    let content: () -> Content
    @State var hasAppeared = false

    init(
        list: Binding<NavigationList?>,
        @ViewBuilder content: @escaping () -> Content
    ) {
        self._list = list
        self.content = content
    }

    var body: some View {
        ZStack {
            NavigationLink(isActive: isActive, destination: {
                Navigatable<Screen?>(list: childBinding) {
                    list?.screen
                }
            }, label: EmptyView.init).isDetailLink(false).hidden()
            LazyView {
                content()
            }.environment(\.navigationList, $list)
        }.onAppear {
            hasAppeared = true
        }
    }
    
    var isActive: Binding<Bool> {
        .init(
            get: {
                if hasAppeared {
                    return list != nil
                }
                return false
            },
            set: {
                if !$0 {
                    list = nil
                }
            }
        )
    }
    
    var childBinding: Binding<NavigationList?> {
        .init(
            get: { list?.next },
            set: { list?.next = $0 }
        )
    }
}

struct Screen: View {
    let id: AnyHashable
    let content: () -> AnyView
    
    init<C: View>(
        id: AnyHashable = ObjectIdentifier(C.self),
        @ViewBuilder content: @escaping () -> C
    ) {
        self.id = id
        self.content = {
            .init(content())
        }
    }
    
    var body: some View {
        content()
    }
}

struct NavigationList {
    @Indirect
    var next: NavigationList?
    
    let screen: Screen
}


enum NavigationListKey: EnvironmentKey {
    static var defaultValue: Binding<NavigationList?> {
        .constant(nil)
    }
}

extension EnvironmentValues {
    var navigationList: Binding<NavigationList?> {
        get { self[NavigationListKey.self] }
        set { self[NavigationListKey.self] = newValue }
    }
}

struct LazyView<Content: View>: View {
    @ViewBuilder var content: () -> Content
    
    var body: some View {
        content()
    }
}

@propertyWrapper
struct Indirect<Wrapped> {
    private final class Storage: CustomReflectable {
        var wrapped: Wrapped
        
        init(_ wrapped: Wrapped) {
            self.wrapped = wrapped
        }
        
        var customMirror: Mirror {
            .init(self, children: [(label: "wrapped", value: wrapped)])
        }
    }
    
    private let storage: Storage
    
    var wrappedValue: Wrapped {
        get { storage.wrapped }
        mutating set { storage.wrapped = newValue }
    }
    
    init(wrappedValue: Wrapped) {
        self.storage = .init(wrappedValue)
    }
}

I don't know why it does not work with @State though

State should only be used for simple value types. Although NavigationList is a value type, it uses the @Indirect property wrapper, which has reference semantics. NavigationList should be a class that conforms to ObservableObject and you should use StateObject to create instances of it in your view hierarchy. See https://www.hackingwithswift.com/books/ios-swiftui/why-state-only-works-with-structs

1 Like

Yeah, but it doesn't have reference semantics. It has semantics of indirect enum case, which is something in between, but it has a mutating setter and the updates do propagate all the way up, and trigger @State update. It seems like there is some difference in how @State and @ObservedObject are handled internally in SwiftUI, and that's why it works only with @Published. But thank you for the answer!

The Indirect property wrapper stores its wrapped value in a Storage class. Classes have reference semantics. You shouldn't be using code like this for a value stored in a State variable.

1 Like