NStack is a SwiftUI view that allows you to hoist navigation state into a Coordinator

Related tags

Layout NStack
Overview

NStack

An NStack allows you to manage SwiftUI navigation state with a single stack property. This makes it easy to hoist that state into a high-level view, such as a coordinator. The coordinator pattern allows you to 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 the navigation stack might contain, e.g.:

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

You can then add a stack of these screens as a single property in a coordinator view. In the body of the coordinator view, return a NavigationView containing an NStack. The NStack should be initialized with a binding to the stack, and a ViewBuilder closure. The closure builds a view from a given screen, e.g.:

struct AppCoordinator: View {
    @State var stack = Stack<Screen>(root: .home)
    
    var body: some View {
        NavigationView {
            NStack($stack) { 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() {
        stack.push(.numberList)
    }
    
    private func showNumber(_ number: Int) {
        stack.push(.number(number))
    }
    
    private func pop() {
        stack.pop()
    }
    
    private func popToRoot() {
        stack.popToRoot()
    }
}

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

If the user taps the back button, the stack 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 stack.

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 stack, 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 stack = Stack<Screen>()
    
    init() {
        stack.push(.home(.init(onGoTapped: showNumberList)))
    }
    
    func showNumberList() {
        stack.push(.numberList(.init(onNumberSelected: showNumber, cancel: pop)))
    }
    
    func showNumber(_ number: Int) {
        stack.push(.numberDetail(.init(number: number, cancel: popToRoot)))
    }
    
    func pop() {
        stack.pop()
    }
    
    func popToRoot() {
        stack.popToRoot()
    }
}

struct AppCoordinator: View {
    @ObservedObject var viewModel = AppCoordinatorViewModel()
    
    var body: some View {
        NavigationView {
            NStack($viewModel.stack) { 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)
                }
            }
        }
    }
}

Limitations

Currently, SwiftUI does not support increasing the navigation stack by more than one in a single update. The Stack object will throw an assertion failure if you try to do so.

How does it work?

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

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
WHAT WILL YOU LEARN? Onboarding Screen with Page Tab View, state of the app with the new App Storage

WHAT WILL YOU LEARN? Onboarding Screen with Page Tab View, state of the app with the new App Storage Onboarding or a Home screen Understand how the new App Life Cycle works Link View 
 Group Box View Disclosure View Dynamically List View with a loop

Ghullam Abbas 5 Oct 17, 2022
Easiest way to load view classes into another XIB or storyboard.

LoadableViews Easiest way to load view classes into another XIB or storyboard. Basic setup Subclass your view from LoadableView Create a xib file, set

MLSDev 43 Nov 4, 2022
This library allows you to make any UIView tap-able like a UIButton.

UIView-TapListnerCallback Example To run the example project, clone the repo, and run pod install from the Example directory first. Installation UIVie

wajeehulhassan 8 May 13, 2022
Flixtor-iOS - iOS streaming app inspired by Netflix that allows you to watch any film and series

Flixtor-iOS iOS streaming app inspired by Netflix that allows you to watch any f

Kevin Liu 0 Jan 14, 2022
A tiny category on UIView that allows you to set one property: "parallaxIntensity" to achieve a parallax effect with UIMotionEffect

NGAParallaxMotion A tiny category on UIView that allows you to set one property: parallaxIntensity to achieve a parallax effect with UIMotionEffect. S

Michael Bishop 650 Sep 26, 2022
A simple integrated version of iOS 13 Compositional Layout, modified into a way similar to Functional Programming to generate UICollectionViewCompositionalLayout.

WWCompositionalLayout A simple integrated version of iOS 13 Compositional Layout, modified into a way similar to Functional Programming to generate UI

William-Weng 1 Jul 4, 2022
SwiggyClone - A Swiggy clone will dive deep into UICompositional Layouts and alot of new concepts

SwiggyClone This app is a clone of Swiggy (in progress). In this project we will

Dheeraj Kumar Sharma 81 Dec 25, 2022
Modern-collection-view - Modern collection view for swift

Modern collection view Sample application demonstrating the use of collection vi

Nitanta Adhikari 1 Jan 24, 2022
Allows users to pull in new song releases from their favorite artists and provides users with important metrics like their top tracks, top artists, and recently played tracks, queryable by time range.

Spotify Radar Spotify Radar is an iOS application that allows users to pull in new song releases from their favorite artists and provides users with i

Kevin Li 630 Dec 13, 2022
An open source package for as-you-type formatting in SwiftUI.

DiffableTextViews An open source package for as-you-type formatting in SwiftUI. Features Feature Description ⌨️ Responsive Formats text as you type ??

Oscar Byström Ericsson 46 Dec 5, 2022
Flow layout / tag cloud / collection view in SwiftUI.

SwiftUIFlowLayout A Flow Layout is a container that orders its views sequentially, breaking into a new "line" according to the available width of the

Gordan Glavaš 115 Dec 28, 2022
Half modal view for SwiftUI

ResizableSheet ResizableSheeet is a half modal view library for SwiftUI. You can easily implement a half modal view. Target Swift5.5 iOS14+ Installati

matsuji 76 Dec 16, 2022
✨ Super sweet syntactic sugar for SwiftUI.View initializers.

ViewCondition ✨ Super sweet syntactic sugar for SwiftUI.View initializers. At a Glance struct BorderTextView: View { var color: Color? @ViewBuild

Yoon Joonghyun 76 Dec 17, 2022
SwiftUI package to present a Bottom Sheet interactable view with the desired Detents. Also known as Half sheet.

BottomSheetSUI BottomSheetSUI is a package that gives you the ability to show a Bottom sheet intractable, where you can add your own SwiftUI view. You

Aitor Pagán 8 Nov 28, 2022
A grid layout view for SwiftUI

Update July 2020 - latest SwiftUI now has built-in components to do this, which should be used instead. FlowStack FlowStack is a SwiftUI component for

John Susek 147 Nov 10, 2022
Horizontal and Vertical collection view for infinite scrolling that was designed to be used in SwiftUI

InfiniteScroller Example struct ContentView: View { @State var selected: Int = 1 var body: some View { InfiniteScroller(direction: .ve

Serhii Reznichenko 5 Apr 17, 2022
ReadabilityModifier - UIKits readableContentGuide for every SwiftUI View, in the form of a ViewModifier

ReadabilityModifier UIKits readableContentGuide for every SwiftUI View, in the form of a ViewModifier What it is Displaying multiple lines of text in

YAZIO 15 Dec 23, 2022
A SwiftUI proof-of-concept, and some sleight-of-hand, which adds rain to a view's background

Atmos A SwiftUI proof-of-concept, and some sleight-of-hand, which adds rain to a view's background. "Ima use this in my app..." Introducing Metal to S

Nate de Jager 208 Jan 2, 2023
A SwiftUI ScrollView that runs a callback when subviews are scrolled in and out of view.

VisibilityTrackingScrollView This package provides a variant of ScrollView that you can use to track whether views inside it are actually visible. Usa

Elegant Chaos 3 Oct 10, 2022