FlowStacks allows you to hoist SwiftUI navigation and presentation state into a Coordinator

Overview

FlowStacks

Coordinator pattern in SwiftUI

FlowStacks allow you to manage complex SwiftUI navigation and presentation flows with a single piece of state. This makes it easy to hoist that state into a high-level coordinator view. Using this pattern, you can write isolated views that have zero knowledge of their context within the navigation flow of an app.

Usage

To begin, create an enum encompassing each of the screens your navigation stack might contain, e.g.:

enum Screen {
    case home
    case numberList
    case numberDetail(Int)
}

You can then add a flow representing a stack of these screens (NFlow for navigation, or PFlow for presentation) as a single property in a coordinator view. In the body of the coordinator view, initialize an NStack (or PStack for presentation) with a binding to the flow, and a ViewBuilder closure. The closure builds a view for a given screen, e.g.:

struct AppCoordinator: View {
    @State var flow = NFlow<Screen>(root: .home)
    
    var body: some View {
        NavigationView {
            NStack($flow) { screen in
                switch screen {
                case .home:
                    HomeView(onGoTapped: showNumberList)
                case .numberList:
                    NumberListView(onNumberSelected: showNumber, cancel: pop)
                case .numberDetail(let number):
                    NumberDetailView(number: number, cancel: popToRoot)
                }
            }
        }
    }
    
    private func showNumberList() {
        flow.push(.numberList)
    }
    
    private func showNumber(_ number: Int) {
        flow.push(.number(number))
    }
    
    private func pop() {
        flow.pop()
    }
    
    private func popToRoot() {
        flow.popToRoot()
    }
}

As you can see, pushing a new view is as easy as flow.push(...) and popping can be achieved with flow.pop(). There are convenience methods for popping to the root (flow.popToRoot()) and popping to a specific screen in the flow (flow.popTo(.home)).

If the user taps the back button, the flow will be automatically updated to reflect its new state. Navigating back with an edge swipe gesture or long-press gesture on the back button will also update the flow.

Coordinators are just views, so they can be presented, added to a TabView or a WindowGroup, and can be configured in all the normal ways views can.

Child coordinators

As coordinator views are just views, they can even be pushed onto a parent coordinator's navigation stack. When doing so, it is best that the child coordinator is always at the top of the parent's flow, as it will take over responsibility for pushing new views.

In order to allow coordinators to be nested in this way, the child coordinator should not include its own NavigationView. In fact, it's a good idea to add the NavigationView as high in the view hierarchy as you can - e.g. at the top-level of the app, when presenting a new coordinator, or when adding one to a TabView.

Using View Models

Using NStacks in the coordinator pattern also works well when using View Models. In these cases, the navigation state can live in the coordinator's own view model, and the Screen enum can include each screen's view model. With view models, the example above can be re-written:

enum Screen {
    case home(HomeViewModel)
    case numberList(NumberListViewModel)
    case numberDetail(NumberDetailViewModel)
}

class AppCoordinatorViewModel: ObservableObject {
    @Published var flow = NFlow<Screen>()
    
    init() {
        flow.push(.home(.init(onGoTapped: showNumberList)))
    }
    
    func showNumberList() {
        flow.push(.numberList(.init(onNumberSelected: showNumber, cancel: pop)))
    }
    
    func showNumber(_ number: Int) {
        flow.push(.numberDetail(.init(number: number, cancel: popToRoot)))
    }
    
    func pop() {
        flow.pop()
    }
    
    func popToRoot() {
        flow.popToRoot()
    }
}

struct AppCoordinator: View {
    @ObservedObject var viewModel: AppCoordinatorViewModel
    
    var body: some View {
        NavigationView {
            NStack($viewModel.flow) { screen in
                switch screen {
                case .home(let viewModel):
                    HomeView(viewModel: viewModel)
                case .numberList(let viewModel):
                    NumberListView(viewModel: viewModel)
                case .number(let viewModel):
                    NumberView(viewModel: viewModel)
                }
            }
        }
    }
}

Presentation

In order to use presentation instead of navigation for showing and unshowing screens, the examples above can be re-written using a PStack instead of an NStack, and a PFlow instead of an NFlow. The push methods become present and the pop methods become dismiss. The present method allows you to customize the presentation style and add a callback on dismissal:

flow.present(detailView, style: .fullScreenCover) {
    print("Detail dismissed")
}

How does it work?

This blog post outlines how NStack translates the stack of screens into a hierarchy of views and NavigationLinks. PStack uses a similar approach.

Limitations

SwiftUI does not allow more than one screen to be pushed, presented or dismissed in one update, though it is possible to pop any number of views in one update. NFlow and PFlow only expose methods to make updates that are supported in SwiftUI.

Comments
  • Desynchronization of NFlow stack [iOS 15]

    Desynchronization of NFlow stack [iOS 15]

    I've noticed that occasionally that the NStack stack array gets desynchronized from the application's actual navigation state. This manifests in behavior like: requiring two taps to navigate, navigating to the wrong screen, or double-navigating to a screen. Have you noticed this at all?

    It's not consistently reproducible, but it seems to happen most frequently when navigating quickly back and forth between screens. Trying to dig in a bit deeper to figure out if there's any workarounds.

    I've mostly been testing using Xcode 13.0 and the iOS 15.0 simulator. I haven't been able to reproduce so far in Xcode 12.5.1 or Xcode 13.0 when using the iOS 14.5 simulator, which is puzzling. Might be a SwiftUI NavigationLink state bug on iOS 15.

    opened by chrisballinger 21
  • `Route.root` should probably use `.push` instead of `.sheet`

    `Route.root` should probably use `.push` instead of `.sheet`

    I haven't explored/experimented thoroughly the new 0.1.x release, but I believe I've found an issue.

    Click to see reproducible example
    enum Screen {
      case firstScreen
      case secondScreen
    }
    
    struct ContentView: View {
      var body: some View {
        NavigationView {
          FlowCoordinator(onCompletion: {
            print("end")
          })
        }
      }
    }
    
    struct FlowCoordinator: View {
      @State private var routes: Routes<Screen> = [.root(.firstScreen)]
    
      var onCompletion: () -> Void
    
      var body: some View {
        Router($routes) { screen, _  in
          switch screen {
            case .firstScreen:
              FirstScreen(onCompletion: { routes.push(.secondScreen) })
            case .secondScreen:
              SecondScreen(onCompletion: onCompletion)
          }
        }
      }
    }
    
    struct FirstScreen: View {
      var onCompletion: () -> Void
    
      var body: some View {
        Button("go to second", action: onCompletion)
      }
    }
    
    struct SecondScreen: View {
      var onCompletion: () -> Void
    
      var body: some View {
        Button("complete", action: onCompletion)
      }
    }
    

    The example above will crash as soon as we try to push to the second screen.

    Looking at the FlowStacks codebase, I believe the following definition should use/return push instead of sheet:

    https://github.com/johnpatrickmorgan/FlowStacks/blob/6d0431834e8ebedbdfc8cf4e86fedb22c44b7717/Sources/FlowStacks/Combined/Route.swift#L21-L25

    Otherwise the canPush will return false in the example above, and trigger an assertion.

    https://github.com/johnpatrickmorgan/FlowStacks/blob/6d0431834e8ebedbdfc8cf4e86fedb22c44b7717/Sources/FlowStacks/Combined/Array%2BRouteProtocol.swift#L7-L18

    Workarounds for the example above:

    • replace the routes definition with: @State private var routes: Routes<Screen> = [.push(.firstScreen)] (where I replaced .root(.firstScreen) with .push(.firstScreen)).
    • like above, but replace .root(.firstScreen) with .root(.firstScreen, embedInNavigationView: true), however that would not work for child coordinators (and would embed another NavigationStack to the current one).

    I'm curious to know if there are reasons that I didn't think of behind using .sheet for the .root definition. If there are none and this is indeed a bug, I'm happy to create a PR with the change if needed.

    Thank you in advance!

    opened by zntfdr 8
  • Cover and sheet not working on iOS 14.2 (Sim)

    Cover and sheet not working on iOS 14.2 (Sim)

    Hi, let me first say, I love your approach. ViewModifier stack plus array of routes representing it. That is the best and most solid approach I have seen. Really nice when refactoring code.

    Problem found: I cannot get the sheets to work in iOS 14.2. It applies to the fullscreen cover, too – however, not in 100% of cases. Problem applies to my own code, but also when I build your example code. Tested only in 14.2, not 14.x so far.

    Regards

    opened by tscdl 6
  • Manage navigation manually

    Manage navigation manually

    This PR:

    • Add manualNavigation to Route that allows user to create their own navigation flow without using Apple's native NavigationView
    • Add new example screen Manual to demonstrate using manualNavigation in both iOS and macOS

    This addresses issue #22

    iOS:

    https://user-images.githubusercontent.com/4270232/165051259-680d3c3e-26fb-4ba4-9641-5acd3487295e.mp4

    macOS:

    https://user-images.githubusercontent.com/4270232/165051229-a5679de8-723c-473e-86a1-b8ef3caa9207.mov

    opened by josephktcheung 5
  • Show root, go back to root only if needed

    Show root, go back to root only if needed

    It doesn't make sense IMO to get an assert when going back to root if the root is already presented. Or is there any other way to show the initial screen (for e.g. deeplink handling):

    $routes.withDelaysIfUnsupported {
        $0.dismiss()
        $0.goBackToRoot()
    }
    
    opened by Kondamon 4
  • Background thread publishing changes when using `RouteSteps`

    Background thread publishing changes when using `RouteSteps`

    This issue doesn't appear to happen when using the binding modifiers. I'm not sure why. But when using:

            RouteSteps.withDelaysIfUnsupported(self, \.routes) {
                $0.goBackToRoot()
            }
    

    if there are more than one steps it will print out the following:

    FlowStacksApp[55359:4275326] [SwiftUI] Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.
    

    It appears that inside scheduleRemainingSteps when it does a Task.sleep when it comes back it will no longer be on the main thread and all remaining operations will execute on a random background thread pool thread.

    Marking scheduleRemainingSteps as @MainActor solves this issue, though may have other implications.

    This was tested on iOS 15 and 15.5 on simulators. Same thread changing behavior can be seen with a playground as well,

    opened by utahwithak 4
  • Not updating the view after e.g. coverScreen or push

    Not updating the view after e.g. coverScreen or push

    We have found out a strange behaviour that have occurred a few times now during implementation. We don't know if it's related to using multiple ObservableObjecs within the views and passing them around from the parent to a few childs or is it something else.

    After a button click the routes are updated as expected but after the routes update e.g. via coverScreen, no rendering update happens in SwiftUI. So it looks like the if the button click doesn't have any effect on the UI. I have played around with using a custom .id(increasingNumber) on the related view and modified the id with the button click. After the 2nd click all buttons responds as expected again. However, do you have any idea why does this happen when using FlowStacks?

    opened by Kondamon 3
  • Semantic versioning issue - 0.1.7 has breaking changes, consider releasing as 0.2.0 instead

    Semantic versioning issue - 0.1.7 has breaking changes, consider releasing as 0.2.0 instead

    Hey! I just wanted to start by saying that this library is great and I have pointed a lot of folks in your direction who are looking for a better approach to navigation in SwiftUI.

    We currently pinned to "upToNextMinor" and had some code that was still using the deprecated NStack/PStack, which looks to be removed in the 0.1.6->0.1.7 release: https://github.com/johnpatrickmorgan/FlowStacks/compare/0.1.6...0.1.7

    It looks like some CI system of ours automatically bumped the patch version assuming it was "safe" to do so, and it broke the build.

    I know that the 0.1.x implies that there will be some API instability, but I'd suggest at least bumping the minor version when making breaking changes that might require folks to undertake a bigger refactor. Normally I'd suggest pulling the 0.1.7 release and re-tagging it as 0.2.0 - but it's been out for 2 days now and that might cause even further issues if people's Package.resolved are pointing to that version now.

    Just wanted to flag this here for other folks who might be encountering this as well.

    Cheers!

    opened by chrisballinger 3
  • withDelaysIfUnsupported from viewModel

    withDelaysIfUnsupported from viewModel

    Is it possible to use withDelaysIfUnsupported within a view model? I have "@Published var routes: Routes = []" defined in my view model. $routes gives me back a publisher and so withDelaysIfUnsupported is not found.

    opened by kgrigsby-codesmith 3
  • Expose the `onDismiss` closure when presenting a sheet.

    Expose the `onDismiss` closure when presenting a sheet.

    opened by lionel-alves 3
  • No way to know when a sheet is dismissed

    No way to know when a sheet is dismissed

    Hi, first of all thanks for making SwiftUI navigation easier with you library, it is the best approach I came across by far. As a big fan of coordinators, I think your approach is even better that with UIKit since it easy to have one coordinator for a small flow that can both push and present, very convenient!

    My issue: Presenting sheets, there is no way to know when it is dismissed (the onDismiss on the native call is passed to nil in you library). Exposing that would be very convenient as the onAppear is not called for sheets (well known problem).

    Best regards

    opened by lionel-alves 3
  • Toolbar button presenting sheet only works once on iOS...

    Toolbar button presenting sheet only works once on iOS...

    this is a wierd one. My guess is that it is a swiftUI bug. I'm mostly posting here just so future folks can find my workaround. (though if a fix is possible - that would be great!)

    Very simple setup;

    Home screen embedded in navigation view

    Home screen has a navbar button which presents a sheet

    Click on the button - the sheet presents. Swipe it down, it dismisses (the dismiss callback is called correctly)

    Now you can't click on the toolbar button (though it doesn't show as disabled)

    My fix is simply to change the id on the home view. That re-draws it and re-enables the toolbar. Bug doesn't happen on iPad - go figure...

    Am I missing something, or a better fix???

    enum Screen {
      case home
      case sheet
    }
    
    struct ContentView: View {
        @State var routes: Routes<Screen> = [.root(.home,embedInNavigationView: true)] {
            didSet {
                print("routes: \(routes)")
            }
        }
        
        @State var homeId:UUID = UUID()
        
        var body: some View {
            Router($routes) { screen, _ in
                switch screen {
                case .home:
                    Home(showSheet: showSheet)
                    //hacky fix
                    //uncomment the .id to fix the issue
                    //change the id here to re-enable the toolbar after a the sheet is dismissed...
                    //    .id(homeId)
                case .sheet:
                    Sheet()
                }
            }
        }
        
        func showSheet(){
            routes.presentSheet(.sheet) {
                print("dismiss")
                //hacky fix
                homeId = UUID()
            }
        }
    }
    
    
    struct Home: View {
        var showSheet:()->Void
        
        var body: some View {
            VStack
            {
                Text("Home")
                
                //this button always works
                Button {
                    showSheet()
                } label: {
                    Text("Show Sheet")
                }
    
            }
                .toolbar {
                    ToolbarItem(placement: .navigationBarTrailing) {
                        //this button only works once (without the id fix)
                        Button {
                            showSheet()
                        } label: {
                            Text("Sheet")
                        }
    
                    }
                }
        }
    }
    
    struct Sheet: View {
        var body: some View {
            Text("Sheet")
        }
    }
    
    opened by ConfusedVorlon 4
  • [iOS 14] View pops back if Router is inside of other

    [iOS 14] View pops back if Router is inside of other

    Hi everybody and thanks a lot @johnpatrickmorgan for this job! I was looking chance to organise my app code with MVVMc + SwiftUI and draw a conclusion, that FlowStacks is a best solution!

    Here is a code example with weird behaviour when put one Router inside other leads to popping instead of pushing and only on iOS <= 14.5. (routes.push(.second) -> pops back to .root(.main) in CoordinatingViewB)

    import SwiftUI
    import FlowStacks
    
    enum ScreensA {
    
        case main, bFlow
    
    }
    
    struct CoordinatingViewA: View {
        
        @State private var routes: Routes<ScreensA> = [.root(.main)]
    
        var body: some View {
            NavigationView {
                Router($routes) { screen, _ in
                    switch screen {
                    case .main:
                        Button {
                            routes.push(.bFlow)
                        } label: {
                            Text("push bFlow")
                        }
                        .navigationTitle("Main A")
                    case .bFlow:
                        CoordinatingViewB()
                    }
                }
                .navigationBarTitleDisplayMode(.inline)
            }
            .navigationViewStyle(.stack)
        }
        
    }
    
    enum ScreensB {
    
        case main, first, second
    
    }
    
    struct CoordinatingViewB: View {
        
        @State private var routes: Routes<ScreensB> = [.root(.main)]
    
        var body: some View {
            Router($routes) { screen, _ in
                switch screen {
                case .main:
                    Button {
                        routes.push(.first)
                    } label: {
                        Text("push First B")
                    }
                    .navigationTitle("Main B")
                case .first:
                    Button {
                        routes.push(.second)
                    } label: {
                        Text("push second B")
                    }
                    .navigationTitle("First B")
                case .second:
                    Text("Finish")
                        .navigationTitle("Second B")
                }
            }
        }
    }
    
    struct TestFlowStack_Previews: PreviewProvider {
        static var previews: some View {
            CoordinatingViewA()
        }
    }
    

    Of course, I may use it incorrectly, that's why need your experience and waiting for it)

    Thanks!

    opened by rigamikhail27 6
  • Xcode 14 beta 5, iOS 16, various warnings about publishing updates within view updates

    Xcode 14 beta 5, iOS 16, various warnings about publishing updates within view updates

    If I switch to the XCode beta, I see frequent warnings like this

    Screenshot 2022-08-14 at 11 22 53

    I'm not familiar enough with the logic to know whether you can just dispatch to main for writes here...

    I love the project - thank you :)

    opened by ConfusedVorlon 3
  • Fix sheet presentation in < iOS 14.5

    Fix sheet presentation in < iOS 14.5

    Fix proposed to fix issues when presenting manners in versions prior to iOS 14.5.

    Also added dismiss closures that were set to nil in the present function.

    opened by alejandroruizponce 3
  • [XCode 14 beta] NavigationLink presenting a value must appear inside a NavigationContent-based NavigationView. Link will be disabled.

    [XCode 14 beta] NavigationLink presenting a value must appear inside a NavigationContent-based NavigationView. Link will be disabled.

    Hi,

    I ran the example app in XCode 14 beta and get the below warning in console:

    2022-06-13 11:52:11.725672+0800 FlowStacksApp[77257:2136526] [SwiftUI] NavigationLink presenting a value must appear inside a NavigationContent-based NavigationView. Link will be disabled.
    

    Seems like FlowStacks needs to refactor quite a bit to support the new Navigation API.

    Best, Joseph

    opened by josephktcheung 3
Releases(0.2.4)
  • 0.2.4(Aug 18, 2022)

  • 0.2.2(May 27, 2022)

  • 0.2.1(May 23, 2022)

    • Checks count in goBack. (Thanks @DavidKmn)
    • Ensures RouteSteps. withDelaysIfUnsupported schedules all steps on main actor. (Thanks @DavidKmn)
    Source code(tar.gz)
    Source code(zip)
  • 0.1.0(Dec 30, 2021)

    • Presentation and navigation can now be combined into a single routes array, removing the need for separate PStack and NStack. The new Router can handle both.
    • Convenience methods are now extensions on Array, with no more need for NFlow or PFlow, simplifying state management.
    • Large-scale navigation updates can now be made within a withDelaysIfUnsupported call, and will be broken down into smaller updates that SwiftUI supports. This works around a limitation in SwiftUI that only allows one screen to be pushed, presented or dismissed at a time.
    • The view builder closure for creating screens can now provide a binding to the screen so that screens can mutate their state within the routes array.
    • A new showing function makes it easy to have a fixed root screen which shows zero or more routes.
    Source code(tar.gz)
    Source code(zip)
Owner
John Patrick Morgan
Swift Developer in London
John Patrick Morgan
A drop-in universal library helps you to manage the navigation bar styles and makes transition animations smooth between different navigation bar styles

A drop-in universal library helps you to manage the navigation bar styles and makes transition animations smooth between different navigation bar styles while pushing or popping a view controller for all orientations. And you don't need to write any line of code for it, it all happens automatically.

Zhouqi Mo 3.3k Dec 21, 2022
An alternative SwiftUI NavigationView implementing classic stack-based navigation giving also some more control on animations and programmatic navigation.

swiftui-navigation-stack An alternative SwiftUI NavigationView implementing classic stack-based navigation giving also some more control on animations

Matteo 753 Jan 2, 2023
Simple example for the coordinator design pattern and using th Xcoordinator pod

Cordinator-Pattern-Sample This an Example and base for the coordinator design pattern using the XCoordinator pod ?? XCoordinator is a navigation frame

Ali Fayed 3 Sep 13, 2022
Tools for making SwiftUI navigation simpler, more ergonomic and more precise.

SwiftUI Navigation Tools for making SwiftUI navigation simpler, more ergonomic and more precise. Motivation Tools Navigation overloads Navigation view

Point-Free 1.1k Jan 1, 2023
SwiftUINavigator: a lightweight, flexible, and super easy library which makes SwiftUI navigation a trivial task

The logo is contributed with ❤️ by Mahmoud Hussein SwiftUINavigator is a lightwe

OpenBytes 22 Dec 21, 2022
sRouting - The lightweight navigation framework for SwiftUI.

sRouting The lightweight navigation framework for SwiftUI. Overview sRouting using the native navigation mechanism in SwiftUI. It's easy to handle nav

Shiro 8 Aug 15, 2022
Change SwiftUI Navigation Bar Color for different View

SwiftUINavigationBarColor Change SwiftUI NavigationBar background color per screen. Usage For NavigationBarColor to work, you have to set the Navigati

Hai Feng Kao 18 Jul 15, 2022
🧭 SwiftUI navigation done right

?? NavigationKit NavigationKit is a lightweight library which makes SwiftUI navigation super easy to use. ?? Installation ?? Swift Package Manager Usi

Alex Nagy 152 Dec 27, 2022
Navigation helpers for SwiftUI applications build with ComposableArchitecture

Swift Composable Presentation ?? Description Navigation helpers for SwiftUI applications build with ComposableArchitecture. More info about the concep

Dariusz Rybicki 52 Dec 14, 2022
Make SwiftUI Navigation be easy

VNavigator VNavigator is a clean and easy-to-use navigation in SwiftUI base on UINavigationController in UIKit Installation From CocoaPods CocoaPods i

Vu Vuong 10 Dec 6, 2022
SwiftUINavigation provides UIKit-like navigation in SwiftUI

SwiftUINavigation About SwiftUINavigation provides UIKit-like navigation in Swif

Bhimsen Padalkar 1 Mar 28, 2022
A lightweight iOS mini framework that enables programmatic navigation with SwiftUI, by using UIKit under the hood.

RouteLinkKit A lightweight iOS mini framework that enables programmatic navigation with SwiftUI. RouteLinkKit is fully compatible with native Navigati

Αθανάσιος Κεφαλάς 4 Feb 8, 2022
Simple iOS app to showcase navigation with coordinators in SwiftUI + MVVM.

SimpleNavigation Simple iOS app to showcase the use of the Coordinator pattern using SwiftUI and MVVM. The implementation is as easy as calling a push

Erik Lopez 7 Dec 6, 2022
Custom navigation swiftui NavigationLink NavigationView

Custom navigation swiftui Experimenting with navigation link. if you find this idea interesting you can take and expend it into a more powerful soluti

Igor 5 Dec 2, 2022
Backported SwiftUI navigation APIs introduced in WWDC22

Navigation Backport This package uses the navigation APIs available in older SwiftUI versions (such as NavigationView and NavigationLink) to recreate

John Patrick Morgan 532 Dec 29, 2022
Easily hide and show a view controller's navigation bar (and tab bar) as a user scrolls

HidingNavigationBar An easy to use library (written in Swift) that manages hiding and showing a navigation bar as a user scrolls. Features Usage Custo

Tristan Himmelman 1k Dec 21, 2022
UINavigationBar Category which allows you to change its appearance dynamically

Deprecated This lib uses a hacky way to achieve the result, in the new iOS version, the structure of UINavigation is changed and this lib no longer wo

Leo 4.5k Dec 3, 2022
A wrapper for NavigationView and NavigationLink that makes programmatic navigation a little friendlier.

NavigatorKit A wrapper for NavigationView and NavigationLink that makes programmatic navigation a little friendlier. NavigatorKit is an opinionated wr

Gyuri Grell 2 Jun 16, 2022
Simple and integrated way to customize navigation bar experience on iOS app.

NavKit Simple and integrated way to customize navigation bar experience on iOS app. It should save our time that we usually use to make abstraction of

Wilbert Liu 37 Dec 7, 2022