johnpatrickmorgan / FlowStacks

FlowStacks allows you to hoist SwiftUI navigation and presentation state into a Coordinator
MIT License
783 stars 56 forks source link

Nested coordinator can't open more than one screen #76

Open lisindima opened 3 weeks ago

lisindima commented 3 weeks ago

Hi, I found another bug with nested coordinators. A nested coordinator can't open more than one screen, when trying to call a transition - nothing happens, and if you close the open screen after that - navigation will be broken.

Example code that reproduces the behavior:

import SwiftUI
import FlowStacks

enum Path: Hashable {
    case sport(Int, SecondCoordinator)
}

enum NewPath: Hashable {
    case challenge(String, SecondCoordinator)
}

class FirstCoordinator: ObservableObject {
    @Published var path: Routes<Path> = []

    func push() {
        path.push(.sport(1, .init(self)))
    }
}

struct ContentView: View {
    @ObservedObject var coordinator: FirstCoordinator

    var body: some View {
        FlowStack($coordinator.path, withNavigation: true) {
            VStack {
                Button {
                    coordinator.push()
                } label: {
                    Text("Push")
                }
            }
            .navigationTitle("ROOT")
            .flowDestination(for: Path.self) { screen in
                switch screen {
                case let .sport(value, coordinator):
                    SportView(coordinator: coordinator, value: value)
                }
            }

        }
    }
}

class SecondCoordinator: ObservableObject {
    private let parent: FirstCoordinator
    private let id = UUID()

    @Published var path: Routes<NewPath> = []

    init(_ parent: FirstCoordinator) {
        self.parent = parent
    }

    func push() {
        path.push(.challenge("2", self))
    }
}

extension SecondCoordinator: Hashable, Equatable {
    static func == (lhs: SecondCoordinator, rhs: SecondCoordinator) -> Bool {
        lhs.id == rhs.id
    }

    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }
}

struct SportView: View {
    @ObservedObject var coordinator: SecondCoordinator

    let value: Int

    var body: some View {
        FlowStack($coordinator.path) {
            VStack {
                Button {
                    coordinator.push()
                } label: {
                    Text("push")
                }
                Text(value.description)
            }
            .navigationTitle("Sport")
            .flowDestination(for: NewPath.self) { screen in
                switch screen {
                case let .challenge(value, coordinator):
                    ChallengeView(coordinator: coordinator, value: value)
                }
            }
        }
    }
}

struct ChallengeView: View {
    @ObservedObject var coordinator: SecondCoordinator

    let value: String
    var body: some View {
        VStack {
            Text(value)
            Button {
                coordinator.push()
            } label: {
                Text("challenge")
            }
        }
        .navigationTitle("Challenge")
    }
}

I also attach a recording of the behavior https://github.com/johnpatrickmorgan/FlowStacks/assets/43087859/557012be-0619-4838-af8f-a4bf5d63121b

lisindima commented 2 weeks ago

@johnpatrickmorgan hi, do you have any ideas or suggestions?)

johnpatrickmorgan commented 2 weeks ago

Hi @lisindima, thanks for raising this. Sorry for the delay - I've been looking into it, and I'm a bit puzzled so far. I'll give more info about my findings so far on Monday. Thanks!

johnpatrickmorgan commented 2 weeks ago

Hi, here's a quick update:

FlowStack holds onto the binding passed in to its initialiser (in its externalTypedPath property) and ensures any changes made to it get propagated to its own internal source of truth - its path property. It does so via a call to onChange(of: ). The issue seems to arise because changes to the externalTypedPath are triggering the FlowStack to recompute its body, but the onChange closure is for some reason not getting called. Adding _printChanges shows that the mutation causes the FlowStack's body to be recomputed (@self, _externalTypedPath changed is printed), but the onChange(of: _externalTypedPath) closure is not called. I've been trying to figure out why but I haven't found anything concrete so far.

Along the way I did notice a simpler issue that was causing FlowLinks not to work within a nested FlowStack, so I put in a fix for that in v0.6.3.

lisindima commented 1 week ago

Thanks for your investigation, I also found one rather dirty hack, but it works well so far.

Use withNavigation: true in the nested coordinator. This will result in duplication of the NavigationBar, but this can be fixed with navigationViewModifier and we get this code:

struct SportView: View {
    @ObservedObject var coordinator: SecondCoordinator

    let value: Int

    var body: some View {
        FlowStack($coordinator.path, withNavigation: true, navigationViewModifier: NavigationBarHidden()) {
            VStack {
                Button {
                    coordinator.push()
                } label: {
                    Text("push")
                }
                Text(value.description)
            }
            .navigationTitle("Sport")
            .flowDestination(for: NewPath.self) { screen in
                switch screen {
                case let .challenge(value, coordinator):
                    ChallengeView(coordinator: coordinator, value: value)
                }
            }
        }
    }
}

struct NavigationBarHidden: ViewModifier {
   func body(content: Content) -> some View {
        content
            .navigationBarHidden(true)
    }
}

Maybe this will help you)

lisindima commented 1 day ago

with this hack there was a problem, on iOS 15 onAppear is called twice, without this hack the problem is similar - the nested coordinater is not able to open more than one screen.

also this hack doesn't work with FlowPath()

@johnpatrickmorgan hi, is there any progress on this issue?