johnpatrickmorgan / flowstacks Goto Github PK
View Code? Open in Web Editor NEWFlowStacks allows you to hoist SwiftUI navigation and presentation state into a Coordinator
License: MIT License
FlowStacks allows you to hoist SwiftUI navigation and presentation state into a Coordinator
License: MIT License
Steps to reproduce
Expected behavior
Actual behavior
Instead of directly navigating to the root view controller, the app dismisses views one by one until reaching the root view controller.
iOS version: [iOS 17.2]
Xcode version: [Xcode 15.2]
Relevant code snippets:
struct ContentView: View {
@EnvironmentObject var navigator: FlowNavigator<Screen>
var body: some View {
VStack {
Button("View second") {
navigator.push(.second)
}
Button("Go back to root") {
navigator.goBackToRoot()
}
}
}
}
struct ContentView2: View {
@EnvironmentObject var navigator: FlowNavigator<Screen>
var body: some View {
VStack {
Button("View third") {
navigator.push(.third)
}
Button("Go back to root") {
navigator.popToCurrentNavigationRoot()
}
}
}
}
struct ContentView3: View {
@EnvironmentObject var navigator: FlowNavigator<Screen>
var body: some View {
VStack {
Button("Go back to root") {
navigator.goBackToRoot()
}
}
}
}
import FlowStacks
enum Screen {
case first
case second
case third
}
struct AppCoordinator: View {
@State var routes: Routes<Screen> = [.root(.first, embedInNavigationView: true)]
var body: some View {
Router($routes) { screen, _ in
switch screen {
case .first:
ContentView()
case .second:
ContentView2()
case .third:
ContentView3()
}
}
}
private func showfirst() {
routes.presentSheet(.first)
}
private func showsecond() {
routes.push(.second)
}
private func goBack() {
routes.goBack()
}
private func goBackToRoot() {
routes.goBackToRoot()
}
}
@main
struct flow_stackApp: App {
var body: some Scene {
WindowGroup {
AppCoordinator()
}
}
}
Thank you for this excellent framework!
We have found an issue when using an UIViewControllerRepresentable with a popover. The UIVIewController present a popover when it appears. After clicking on the popover and dismissing it, a new rendering cycle starts and the NavigationView
pops back to its root view.
We have tried to debug the issue and set a breakpoint on the switch
statement. The routes
just hold a single element instead of 2 routes (root and the pushed view). It seems that after the routes object is updated no rendering cycle occurs were there are 2 elements in routes
.
In another debugging attempt we replaced the routes with a ButtonView and everything works fine. However also here we have seen that the routes
object has 1 element after init (when evaluating it at the before mentioned breakpoint), after pushing a 2nd view it still shows only 1 element, after another push there are 3 elements in the routes object.
public struct Content: View {
@State private var routes: Routes<Screen>
let onDismissTapped: (() -> Void)
public var body: some View {
Router($routes) { screen, _ in
switch screen {
case .rootView:
let model = Model()
RootView(model: model)
case .pushedView:
let model = Model2(didSelectNextButton: clickedNext)
ViewRepresentableView(model: model) // this one is presenting a popover when it appeares
case .thirdView:
ThirdView()
}
}
.navigationBarTitle(Text(" "), displayMode: .inline)
}
}
struct ButtonView: View {
var action: () -> Void
let date = Date()
var body: some View {
Button("press me! \(date.asStringISO8601)") {
action()
}
}
}
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,
I haven't explored/experimented thoroughly the new 0.1.x release, but I believe I've found an issue.
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
:
FlowStacks/Sources/FlowStacks/Combined/Route.swift
Lines 21 to 25 in 6d04318
Otherwise the canPush
will return false
in the example above, and trigger an assertion.
FlowStacks/Sources/FlowStacks/Combined/Array+RouteProtocol.swift
Lines 7 to 18 in 6d04318
Workarounds for the example above:
@State private var routes: Routes<Screen> = [.push(.firstScreen)]
(where I replaced .root(.firstScreen)
with .push(.firstScreen)
)..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!
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!
Hi there!
Sorry, don't currently have a shareable reproduction case, but the change made in 0.3.5 seems to have broken the SwiftUI native @Environment(\.dismiss)
action. The view that's presented by a TCARouter
does not actually dismiss correctly. In my TCA state, nothing changes either when I press a button that calls the Environment's dismiss in a view.
I'm using TCACoordinators, but the issue doesn't appear to be specifically with TCACoordinators โ if I pin my app to FlowStacks 0.3.4, everything works great. If I pin to 0.3.5, it breaks and @Environment(\.dismiss)
stops working.
The issue affects both iOS 16 and iOS 17 โ I've tried it on both iOS 16.4 and iOS 17.0.1/17.2 sims, and the issue persists. Given the only other non-iOS 17 change that exists is wrapping a screenView
in a ZStack
, I wonder if that's the particular issue I'm encountering. I'm just wondering if the screenView
needs the whole @Environment
propagating to it perhaps? I dunno, I'll give that a try and PR if it fixes things!
I'll try and get a reproduction case up soon as well, but I thought I'd better report the issue sooner!
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: 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!
I have a use case where routes = [.root(screen1), .push(screen2(X))]
. Then using:
withDelaysIfUnsupported {
$0.popToRoot()
$0.push(.screen2(Y)
}
Notice the screen2's have different associated values. The problem is the screen didn't change. RouteSteps.calculateSteps returned an empty array.
I worked around it by:
routes.popToRoot()
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(650)) {
self.routes.push(.screen2(Y))
}
I suppose if the associated values were Equatable and additional check could be made in calculateSteps but that could be problematic if they're classes/ObservableObjects.
I think if there were another function that just executed the steps with delays that would work for me.
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.
On initial screen, first .goBack() doesn't crash, but messes the navigation stack (won't go back from next pushed view).
Second .goBack() crashes due to invalid index
[2023-10-25T18:28:09.205Z] ....akcsxqnsztlozognwdzheadarajc/SourcePackages/checkouts/FlowStacks/Sources/FlowStacks/Node.swift:22:9: error: missing return in closure expected to return 'Bool'
My team uses TCACoordinators repo (also authored by you, thank you!!) and that pulls in FlowStacks via:
.package(url: "https://github.com/johnpatrickmorgan/FlowStacks", from: "0.3.0"),
so today after 0.3.5 was released we started seeing warnings about the missing return show up.
Is this something that can be resolved? If not, we're looking into hard coding 0.3.4 since that was the last version that was working well for us, but we wanted to check!
Thank you!!
onBack() and .push() on 17os sometimes work weird.
For instance, routes.push(.screen3) from screen2 go back to screen1
Issue: when we call dismiss with only the .root
node we get a black screen.
It can be useful to call dismiss to make sure nothing is presented before presenting something, for example for deep-linking from a push notification.
The issue is the dismiss function test if the node isPresented
if so it removes it, but the .root
node uses a .sheet
so isPreview
is true.
Imo dismissing when there is only the root should do nothing. Wdyt?
Hi!
This lib is still the best way to handle navigation using SwiftUI!
When a sheet is dismiss with a swipe, it takes a really long time for the routes to actually be updated, ~1 sec after the sheet is completely gone, so if you tap quickly on something that push it can do nothing or crash (if the sheet didn't have embedInNavigationView: true
)
Not a lot we can do here since the onDismiss returns pretty late. But this got me thinking, looking at the code I don't understand where the routes are actually updated in that case, no ones is calling dismiss to actually remove this screen from the routes. It does disappear so it is done somewhere.
Thanks for your help @johnpatrickmorgan
PS: I actually re-did a benmark of all the possible solutions to handle navigation using SwiftUI, and this lib is truly the only nice way! ๐
is it possible to dismiss all opened sheets with goBackToRoot after it?
good to have the case for reset app's state to initial.
Hi, thanks for the great library. I'm trying to implement navigation with nested coordinators. My project is divided into modules, each module has its own coordinator, and there is also a root coordinator that creates all the others. There is an example of code that causes an error: Fatal error: No view builder found for type NewPath. I provide you with a minimal example to reproduce the error
enum Path: Hashable {
case one(Int)
}
enum NewPath: Hashable {
case one(String)
}
class FirstCoordinator: ObservableObject {
@Published var path: Routes<Path> = []
func push() {
path.push(.one(1))
}
}
struct ContentView: View {
@ObservedObject var coordinator: FirstCoordinator
var body: some View {
FlowStack($coordinator.path, withNavigation: true) {
VStack {
Button {
coordinator.push()
} label: {
Text("Push")
}
Text("ROOT")
}
.flowDestination(for: Path.self) { screen in
switch screen {
case let .one(value):
TestView(coordinator: .init(), value: value)
}
}
}
}
}
class SecondCoordinator: ObservableObject {
@Published var path: Routes<NewPath> = []
func push() {
path.push(.one("exception"))
}
}
struct TestView: 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)
}
.flowDestination(for: NewPath.self) { screen in
switch screen {
case let .one(value):
Test2View(value: value)
}
}
}
}
}
struct Test2View: View {
let value: String
var body: some View {
Text(value)
}
}
Is this a library bug or a bug in my implementation?
I am trying to reuse a view created in one coordinator and place it in another one.
The problem I encounter is that the called view is shown as it should, but all actions called on the navigator (FlowNavigator<>) inside subordinate views cause the application to crashes. What's interesting is that the "first" call on the navigator seems to work as expected - the same cell that is inside the list I'm trying to display opens the dependent view without a problem - only nested navigators seem to have a problem.
App crashes due to fatal error:
No ObservableObject of type FlowNavigator found. A View.environmentObject(_:) for FlowNavigator may be missing as an ancestor of this view.
I noticed that setting the key embedInNavigationView to true fixes this error a bit, however the subordinate views are pushed directly to where the coordinator view is located and not as initially in "proper" but not quite working navigationView.
There you can find demo project with the issue:
https://github.com/sparafinski/FlowStacksDemo
And there is a short video presentig the issue:
https://github.com/johnpatrickmorgan/FlowStacks/assets/40520109/e9ae83ff-5ccb-40e0-a9c8-8607e17cdf2c
Hello, I examined framework (great job imo looks very good), I had problem to run it on iPad with sidebar, is there going to be version that supports iPad and sidebar? (maybe I'm doing something wrong)
p.s.
on iPad it's just pushing new views on sidebar part of screen instead of making selection.
I have an app coordinator that has several child coordinators. I show each child by replacing the root of the app coordinatorโs routes property. Right now it immediately switch between them. Is it possible to dissolve between them?
on ios 17.0.3 (iphone XS max) when you do backToRoot() or goBackTo(index: 0), then all screens opened via push are closed sequentially one after another.
Expected Behavior: go straight to the root screen.
I'm already sorry if the question is easy to answer, but I'm not yet familiar with iOS and SwiftUI, but I'm stuck since I do not know how to handle deeplinks or push notifications properly.
My current approaches end up either with nothing was happing or with an error due to: ๏ปฟAccessing State's value outside of being installed on a View. This will result in a constant Binding of the init
. I guess that this is because the function call to trigger the navigation was executed inside the AppDelegate.
It would be awesome if a sample could be provided for how deeplinks/push-notifications can be handled.
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
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?
In the below video I am getting grey background color when presenting screen2 but I am expecting translucent color.
Please Help me
You can find my code below.
I want to customize transition between View like UIViewController
Please help me
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")
}
}
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.
Hello, our team faces an issue with FlowStacks that causes the app to keep instances of viewModels around even though they are not needed anymore. After a longer debugging session, I've noticed that commenting out one line in FlowStacks resolves the issue.
Whenever, we 'restart' our process/app (e.g. switching the country), at least the first instance of the viewModel is retained. There are no strong references to it anymore besides from FlowNavigator
. This causes our viewModel's subscriptions to still remain active and trigger when data changes causing an increasing number of requests, etc.
I added a small project showcasing this issue - https://github.com/Brudus/FlowStacksMemoryLeak/tree/main
You can either investigate the logs or use the memory graph debugger. As soon as you pressed the reset button once, there should only be a single instance of TabBarCoordinatorViewModel
but there are two. At least the very first instance is kept around indefinitely.
I assume the issue was introduced with v0.3.2 when the FlowNavigator was added. The issue can be fixed by commenting out line 35 of Router.swift (.environmentObject(FlowNavigator($routes))
), but this is, obviously, no solution for FlowStacks itself.
Let me know, if you need more information. Thanks for the great work with FlowStacks!
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
.xcodeproj
is added to .gitignore
so it's a bit difficult to launch the example app
I was using FlowStacks to create an app that pushes a new view every time I press a button. However, if the view stack is stacked more than three times using push several times, the view is not pushed normally and bounces out.
How do we solve this?
import SwiftUI
import FlowStacks
enum AppScene {
case home
case map
case mypage
case setting
case opensource
case webview
}
struct AppCoordinator: View {
@State var routes: Routes<AppScene>
init(root: AppScene) {
routes = [.root(root)]
}
var body: some View {
Router($routes) { screen, _ in
switch screen {
case .home:
HomeScreen()
case .map:
MapScreen()
case .mypage:
MyPageScreen(goSetting: {
routes.push(.setting)
})
case .setting:
SettingScreen(goOpenSource: {
routes.push(.opensource)
})
case .opensource:
OpenSourceScreen(goWebView: {
routes.push(.webview)
})
case .webview:
WebViewScreen()
}
}
}
}
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
Hi there! I am admittedly pretty green with iOS development and the framework, but learning lots and having fun. I'm running into some interesting behavior when playing with mixing navigation types. Basically, if I push
a screen onto the stack, then presentSheet
another screen, then dismiss the sheet with a swipe down, I get a double dismiss animation:
Manually dismissing the sheet works just fine. I built a small repro app to see the behavior:
https://github.com/rdonnelly/FlowStacksDoubleDismiss
Definitely willing to consider other approaches if something seems fishy in the code! Thanks!
Hi,
We tried using TCACoordinators in our iOS and macOS app. Since macOS doesn't support StackNavigationViewStyle
, I wonder if the library can be extensible so that it can wrap a Node
inside a custom view that can provide alternative navigation in macOS.
I looked into some other SwiftUI navigation libraries and here's one that also uses coordinator pattern that allows user to define a custom coordinator https://github.com/rundfunk47/stinsen#defining-the-coordinator
In addition, Stinsen also has two Coordinators you can use,
ViewWrapperCoordinator
andNavigationViewCoordinator
.ViewWrapperCoordinator
is a coordinator you can either subclass or use right away to wrap your coordinator in a view, andNavigationViewCoordinator
is aViewWrapperCoordinator
subclass that wraps your coordinator in aNavigationView
.
Perhaps we can do something similar in this library as well?
Best,
Joseph
I did not find a way to have both a push and present navigation type on the same view. Is this possible? Thank you
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
I've created a small demo app that demonstrates issues with initiating a subflow (child coordinator) from a flow. You can find the git repo for this demo app here, for the sake of convenience I will also provide a "snapshot" of the entire code below (but might get stale if I update the code, so git repo is source of truth.)
This is the flow of the app:
AppCoordinator
Main <---------------*
/ | \
Splash ->{ {Sign Out} \
\ / \
OnboardingCoordinator \
- Welcome \
- TermsOfService \
- SignUpCoordinator |
- Credentials |
- PersonalInfo |
- SetupPINCoordinator |
- InputPIN /
- ConfirmPIN ->---*
The relevant part is the onboarding flow, which starts with OnboardingView (coordinator) as root, and then pushes the screens and subflows.
//
// DemoFlowStacksApp.swift
// DemoFlowStacks
//
// Created by Alexander Cyon on 2022-04-21.
//
import SwiftUI
import FlowStacks
// MARK: - APP
@main
struct DemoFlowStacksApp: App {
var body: some Scene {
WindowGroup {
VStack {
Text("`push`: `SetPIN` backs to `Credentials`")
AppCoordinator()
.navigationViewStyle(.stack)
.environmentObject(AuthState())
}
}
}
}
struct User {
struct Credentials {
let email: String
let password: String
}
struct PersonalInfo {
let firstname: String
let lastname: String
}
let credentials: Credentials
let personalInfo: PersonalInfo
}
typealias PIN = String
final class AuthState: ObservableObject {
@Published var user: User? = nil
@Published var pin: PIN? = nil
var isAuthenticated: Bool { user != nil }
func signOut() { user = nil }
public init() {}
}
// MARK: - App Coord.
struct AppCoordinator: View {
enum Screen {
case splash
case main(user: User, pin: PIN?)
case onboarding
}
@EnvironmentObject var auth: AuthState
@State var routes: Routes<Screen> = [.root(.splash)]
var body: some View {
Router($routes) { screen, _ in
switch screen {
case .splash:
SplashView(
toOnboarding: toOnboarding,
toMain: toMain
)
case let .main(user, pin):
MainView(user: user, pin: pin, signOut: signOut)
case .onboarding:
OnboardingCoordinator(done: onboardingDone)
}
}
}
private func signOut() {
auth.signOut()
toOnboarding()
}
private func onboardingDone(user: User, pin: PIN?) {
toMain(user: user, pin: pin)
}
private func toOnboarding() {
routes = [.root(.onboarding)]
}
private func toMain(user: User, pin: PIN?) {
routes = [.root(.main(user: user, pin: pin))]
}
}
// MARK: - Splash
struct SplashView: View {
@EnvironmentObject var auth: AuthState
var toOnboarding: () -> Void
var toMain: (User, PIN?) -> Void
var body: some View {
ZStack {
Color.pink.edgesIgnoringSafeArea(.all)
Text("SPLASH").font(.largeTitle)
}
.onAppear {
// Simulate some loading
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
if let user = auth.user {
toMain(user, auth.pin)
} else {
toOnboarding()
}
}
}
}
}
// MARK: - Main
struct MainView: View {
let user: User
let pin: PIN?
let signOut: () -> Void
var body: some View {
ZStack {
Color.blue.opacity(0.65).edgesIgnoringSafeArea(.all)
VStack {
Text("Hello \(user.personalInfo.firstname)!")
Button("Sign out") {
signOut()
}
}
}
.navigationTitle("Main")
}
}
// MARK: - Onboarding Flow
struct OnboardingCoordinator: View {
enum Screen {
case welcome // Screen
case termsOfService // Screen
case signUpFlow // Flow of multiple screens and subflows.
case setPINflow(User) // Flow of multiple screens
}
let done: (User, PIN?) -> Void
@EnvironmentObject var auth: AuthState
@State var routes: Routes<Screen> = [.root(.welcome)]
var body: some View {
NavigationView {
Router($routes) { screen, _ in
switch screen {
case .welcome:
WelcomeView(start: toTermsOfService)
case .termsOfService:
TermsOfServiceView(accept: toSignUp)
case .signUpFlow:
SignUpFlow(
routes: [
.push(.initial)
// .root(.initial, embedInNavigationView: false)
],
signedUpUser: toSetPIN
)
.environmentObject(auth)
case .setPINflow(let user):
SetPINFlow(
routes: [
.push(.initial)]
// .root(.initial, embedInNavigationView: false)]
,
user: user,
doneSettingPIN: { maybePin in
doneSettingUser(user, andPIN: maybePin)
}
)
.environmentObject(auth)
}
}
}
}
private func toTermsOfService() {
routes.push(.termsOfService)
}
private func toSignUp() {
print("๐ฎโ๐ฝ push-ing `signUpFlow`")
routes.push(.signUpFlow)
}
private func toSetPIN(user: User) {
print("๐ฎ๐ push-ing `setPINflow`")
routes.push(.setPINflow(user))
}
private func doneSettingUser(_ user: User, andPIN pin: PIN?) {
done(user, pin)
}
}
// MARK: - Welcome (Onb.)
struct WelcomeView: View {
var start: () -> Void
var body: some View {
ZStack {
Color.green.opacity(0.65).edgesIgnoringSafeArea(.all)
VStack {
Button("Start") {
start()
}
}
}
.buttonStyle(.borderedProminent)
.navigationTitle("Welcome")
}
}
// MARK: - Terms (Onb.)
struct TermsOfServiceView: View {
var accept: () -> Void
var body: some View {
ZStack {
Color.orange.opacity(0.65).edgesIgnoringSafeArea(.all)
VStack {
Text("We will steal your soul.")
Button("Accept terms") {
accept()
}
}
}
.buttonStyle(.borderedProminent)
.navigationTitle("Terms")
}
}
// MARK: - SignUp SubFlow (Onb.)
struct SignUpFlow: View {
enum Screen {
static let initial: Self = .credentials
case credentials
case personalInfo(credentials: User.Credentials)
}
@EnvironmentObject var auth: AuthState
@State var routes: Routes<Screen>
let signedUpUser: (User) -> Void
init(
// The sensible default value for `routes` is `.root(.initial)`, making
// this flow stand alone testable. However when this flow is a *subflow*
// of another flow (e.g. being subflow of Onboarding flow), we want to
// set `routes` to `.push(.initial)` instead.
routes: Routes<Screen> = [.root(.initial)],
signedUpUser: @escaping (User) -> Void
) {
self.routes = routes
self.signedUpUser = signedUpUser
}
var body: some View {
Router($routes) { screen, _ in
switch screen {
case .credentials:
CredentialsView(next: toPersonalInfo)
case .personalInfo(let credentials):
PersonalInfoView(credentials: credentials, done: done)
}
}
}
private func toPersonalInfo(credentials: User.Credentials) {
routes.push(.personalInfo(credentials: credentials))
}
private func done(user: User) {
auth.user = user
signedUpUser(user)
}
}
// MARK: - Credentials (Onb.SignUp)
struct CredentialsView: View {
@State var email = "[email protected]"
@State var password = "secretstuff"
private var credentials: User.Credentials? {
guard !email.isEmpty, !password.isEmpty else { return nil }
return .init(email: email, password: password)
}
var next: (User.Credentials) -> Void
var body: some View {
ZStack {
Color.yellow.opacity(0.65).edgesIgnoringSafeArea(.all)
VStack {
TextField("Email", text: $email)
SecureField("Password", text: $password)
Button("Next") {
next(credentials!)
}.disabled(credentials == nil)
}
}
.buttonStyle(.borderedProminent)
.textFieldStyle(.roundedBorder)
.navigationTitle("Credentials")
}
}
// MARK: - PersonalInfo (Onb.SignUp)
struct PersonalInfoView: View {
@State var firstname = "Jane"
@State var lastname = "Doe"
let credentials: User.Credentials
private var user: User? {
guard !firstname.isEmpty, !lastname.isEmpty else { return nil }
return .init(credentials: credentials, personalInfo: .init(firstname: firstname, lastname: lastname))
}
var done: (User) -> Void
var body: some View {
ZStack {
Color.brown.opacity(0.65).edgesIgnoringSafeArea(.all)
VStack {
TextField("Firstname", text: $firstname)
TextField("Lastname", text: $lastname)
Button("Sign Up") {
done(user!)
}.disabled(user == nil)
}
}
.buttonStyle(.borderedProminent)
.textFieldStyle(.roundedBorder)
.navigationTitle("Personal Info")
}
}
// MARK: - SetPIN SubFlow (Onb.)
struct SetPINFlow: View {
enum Screen {
static let initial: Self = .pinOnce
case pinOnce
case confirmPIN(PIN)
}
@EnvironmentObject var auth: AuthState
@State var routes: Routes<Screen>
let user: User
let doneSettingPIN: (PIN?) -> Void
init(
// The sensible default value for `routes` is `.root(.initial)`, making
// this flow stand alone testable. However when this flow is a *subflow*
// of another flow (e.g. being subflow of Onboarding flow), we want to
// set `routes` to `.push(.initial)` instead.
routes: Routes<Screen> = [.root(.initial)],
user: User,
doneSettingPIN: @escaping (PIN?) -> Void
) {
self.routes = routes
self.user = user
self.doneSettingPIN = doneSettingPIN
}
var body: some View {
Router($routes) { screen, _ in
switch screen {
case .pinOnce:
InputPINView(firstname: user.personalInfo.firstname, next: toConfirmPIN, skip: skip)
case .confirmPIN(let pinToConfirm):
ConfirmPINView(pinToConfirm: pinToConfirm, done: done, skip: skip)
}
}
}
private func toConfirmPIN(pin: PIN) {
routes.push(.confirmPIN(pin))
}
private func done(pin: PIN) {
auth.pin = pin
doneSettingPIN(pin)
}
private func skip() {
doneSettingPIN(nil)
}
}
// MARK: - InputPINView (Onb.SetPIN)
struct InputPINView: View {
let firstname: String
var next: (PIN) -> Void
var skip: () -> Void
@State var pin = "1234"
var body: some View {
ZStack {
Color.red.opacity(0.65).edgesIgnoringSafeArea(.all)
VStack {
Text("Hey \(firstname), secure your app by setting a PIN.").lineLimit(2)
SecureField("PIN", text: $pin)
Button("Next") {
next(pin)
}.disabled(pin.isEmpty)
}
}
.navigationTitle("Set PIN")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Skip") {
skip()
}
}
}
.buttonStyle(.borderedProminent)
.textFieldStyle(.roundedBorder)
}
}
// MARK: - ConfirmPINView (Onb.SetPIN)
struct ConfirmPINView: View {
let pinToConfirm: PIN
var done: (PIN) -> Void
var skip: () -> Void
@State var pin = "1234"
var body: some View {
ZStack {
Color.teal.opacity(0.65).edgesIgnoringSafeArea(.all)
VStack {
SecureField("PIN", text: $pin)
if pin != pinToConfirm {
Text("Mismatch between PINs").foregroundColor(.red)
}
Button("Confirm PIN") {
done(pin)
}.disabled(pin != pinToConfirm)
}
}
.navigationTitle("Confirm PIN")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Skip") {
skip()
}
}
}
.buttonStyle(.borderedProminent)
.textFieldStyle(.roundedBorder)
}
}
I have to select one of these scenarios, all suboptimal:
back
button)back
button (but unacceptable UI because of double navigation bar)back
behaviour).The problem with the back button is that the SignUpFlow itself has multiple screens, and when it finishes and we push the SetupPIN subflow, pressing the back button from any screen in the SetupPIN subflow, we get back to the first screen of the SignUpFlow and not the last one as expected. The result is that we loose the entire state of the SignUpFlow. In this example above that might not be so bad, since it is only two screens of state we lose. But imagine a subflow with many screens this is very problematic.
push(.initial)
This video is a run of the code, we see that after having pressed "Sign up" on "Personal Info" screen we push "SetupPIN" flow, starting with "Set PIN" (InputPINView
) screen, the back button in the navigation bar says back to "Credentials", which is wrong, and also pressing it indeed incorrectly takes us back to CredentialsView
, but we should really go back to PersonalInfoView
.
Focusing the most relevant piece of the code, this demo is running this code as body inside the OnboardingCoordinator
view:
var body: some View {
NavigationView {
Router($routes) { screen, _ in
switch screen {
case .welcome:
WelcomeView(start: toTermsOfService)
case .termsOfService:
TermsOfServiceView(accept: toSignUp)
case .signUpFlow:
SignUpFlow(
routes: [
.push(.initial)
],
signedUpUser: toSetPIN
)
.environmentObject(auth)
case .setPINflow(let user):
SetPINFlow(
routes: [
.push(.initial)]
,
user: user,
doneSettingPIN: { maybePin in
doneSettingUser(user, andPIN: maybePin)
}
)
.environmentObject(auth)
}
}
}
}
We are using push
as initial route for SignUpFlow
:
...
SignUpFlow(
routes: [
.push(.initial)
],
signedUpUser: toSetPIN
)
...
.root(.initial, embedInNavigationView: false)
If we instead do .root(.initial, embedInNavigationView: false)
. i.e. the body of CoordinatorView
is:
var body: some View {
NavigationView {
Router($routes) { screen, _ in
switch screen {
case .welcome:
WelcomeView(start: toTermsOfService)
case .termsOfService:
TermsOfServiceView(accept: toSignUp)
case .signUpFlow:
SignUpFlow(
routes: [
.root(.initial, embedInNavigationView: false)
],
signedUpUser: toSetPIN
)
.environmentObject(auth)
case .setPINflow(let user):
SetPINFlow(
routes: [
.root(.initial, embedInNavigationView: false)
],
user: user,
doneSettingPIN: { maybePin in
doneSettingUser(user, andPIN: maybePin)
}
)
.environmentObject(auth)
}
}
}
}
Focus on:
...
SignUpFlow(
routes: [
.root(.initial, embedInNavigationView: false)
],
signedUpUser: toSetPIN
)
...
We get the exact same behaviour:
However, if we...
.root(.initial, embedInNavigationView: true)
Specify that we embedInNavigationView
, then we get correct behaviour, but double navigation bar, which is of course completely unacceptable. Yes we can hide the first navigation bar, but then we do not get the back to Terms back button in Credentials, which is not OK either.
...
SignUpFlow(
routes: [
.root(.initial, embedInNavigationView: true)
],
signedUpUser: toSetPIN
)
...
Is there any solution to this? It feels like this is a bug in FlowStacks? Shouldn't FlowStacks be able to see pop
back to the last
element of the routes of the last route, i.e. when we press back from SetupPIN, we pop to SignUpFlow(routes: [.root(.credentials), .push(.personalInfo)])
and FlowStack see that that routes
[.root(.credentials), .push(.personalInfo)])
contains two elements and would display PersonalInfoView
screen!
If you agree and this is a bug hopefully it can be fixed quickly! Otherwise, do you have any workaround for now?
The only work around I can think of is to change the code, to "flatten" it, and let Credential
, PersonalInfo
, InputPin
and ConfirmPIN
all be cases of OnboardingCoordinator.Screen
, i.e. change from:
struct OnboardingCoordinator: View {
enum Screen {
case welcome // Screen
case termsOfService // Screen
case signUpFlow // Flow of multiple screens and subflows.
case setPINflow(User) // Flow of multiple screens
}
...
}
to
struct OnboardingCoordinator: View {
enum Screen {
case welcome
case termsOfService
case credentials
case personalInfo
case inputPIN
case confirmPIN
}
...
}
But I would really like to avoid that, becaues it reduced the modularity and testability of my app.
Really dig what you've got here! Such a beautifully simple approach to programmatic navigation.
Uncovered an issue with the following, possibly common, scenario:
enum Screen {
case intro
case example(Something)
}
extension Screen: Hashable, Identifiable {
var id: Self { self }
}
Adding Identifiable
conformance to this enum is nice and easy by adding Hashable
to both the enum and associated value. The issue is that the following public APIs for popTo(_:)
are now ambiguous:
extension NFlow where Screen: Equatable {
public mutating func popTo(_ screen: Screen) -> Bool {
popTo(where: { $0 == screen })
}
}
extension NFlow where Screen: Identifiable {
@discardableResult
public mutating func popTo(_ screen: Screen) -> Bool {
popTo(id: screen.id)
}
}
There is an example workaround available below, could submit this as a PR if you'd like.
extension NFlow where Screen: Identifiable & Equatable {
/// Avoids an ambiguity for `popTo` when `Screen` is both `Identifiable` and `Equatable`
@discardableResult
mutating func popTo(_ screen: Screen) -> Bool {
popTo(id: screen.id)
}
}
Cheers!
I have an SDK that pushes (through UIKit) a fullscreen cover on app start, and I have my own cover, also on app start (ask for notifications).
If SDK presents its cover and then I present my - only SDK's cover is presented.
And when SDK's cover is dismissed, I see my root view, but I get an assertion failure: it tries to push view on top of "invisible" sheet that is not embedded into navigation view.
If I embed it into navigation view, then assert's not firing, but push doesn't work either.
Can I somehow forcely update the hierarchy after SDK's modal dismissal?
Or, maybe, synchronize routes array with actual state (if view wasn't presented, then it should disappear from (or not even be added to) routes array)
Thank you for your help!
Hey! FlowStacks is great! I use it indirectly through [TCACoordinators[(https://github.com/johnpatrickmorgan/TCACoordinators) (so this Feature Request might spill over to TCACoordinators, depending on implementation?), however, it seems that one fundamental navigation primitive is missing!
When we completely replace the current navigation tree (navigation stack) with a new one, e.g. when the user completes some onboarding and ends with a signed in state, we want to replace the root (currently holding the root of the onboarding flow) with the root of main flow. We can do this using FlowStacks today simply by replacing the routes array with a new one, containing the root of main flow.:
routes = [.root(.main(.init()), embedInNavigationView: true)]
However, this is not animated! In UIKit we can animate this kind of replacement of navigation stack using:
// UIKit stuff
guard animated, let coordinator = transitionCoordinator else {
DispatchQueue.main.async { completion() }
return
}
coordinator.animate(alongsideTransition: nil) { _ in completion() }
I've done this is in this UIKit app
This creates a pretty nice animation! It would be great if we could achieve something similar using FlowStacks (if possible in SwiftUI)
EDIT:
It would be nice with a "flipping card" animation, like this:
Here is the complete source code for the demo movie above.
final class AuthState: ObservableObject {
@Published var isAuthenticated = false
public init() {}
static let shared = AuthState()
}
public struct CardView<FaceUp, FaceDown>: View where FaceUp: View, FaceDown: View {
private var faceUp: FaceUp
private var faceDown: FaceDown
private var isFaceUp: Bool
public enum Axis: Hashable, CaseIterable {
case x, y, z
fileprivate var value: (CGFloat, CGFloat, CGFloat) {
switch self {
case .x: return (1, 0, 0)
case .y: return (0, 1, 0)
case .z: return (0, 0, 1)
}
}
}
private var axis: Axis
public init(
isFaceUp: Bool = false,
axis: Axis = .y,
@ViewBuilder faceUp: () -> FaceUp,
@ViewBuilder faceDown: () -> FaceDown
) {
self.faceUp = faceUp()
self.faceDown = faceDown()
self.isFaceUp = isFaceUp
self.axis = axis
}
@ViewBuilder
private var content: some View {
if isFaceUp {
// Prevent rotation of faceUp by applying 180 rotation.
faceUp
.rotation3DEffect(
Angle.degrees(180),
axis: axis.value
)
} else {
faceDown
}
}
public var body: some View {
content
.rotation3DEffect(
Angle.degrees(isFaceUp ? 180: 0),
axis: axis.value
)
}
}
@main
struct ClipCardAnimationApp: App {
@ObservedObject var authState = AuthState.shared
@State var isFaceUp = false
var body: some Scene {
WindowGroup {
CardView(
isFaceUp: authState.isAuthenticated,
faceUp: MainView.init,
faceDown: WelcomeView.init
)
.animation(.easeOut(duration: 1), value: authState.isAuthenticated)
.environmentObject(AuthState.shared)
// Styling
.foregroundColor(.white)
.font(.largeTitle)
.buttonStyle(.borderedProminent)
}
}
}
struct WelcomeView: View {
@EnvironmentObject var auth: AuthState
var body: some View {
ForceFullScreen(backgroundColor: .yellow) {
VStack(spacing: 40) {
Text("Welcome View")
Button("Login") {
auth.isAuthenticated = true
}
}
}
}
}
struct MainView: View {
@EnvironmentObject var auth: AuthState
var body: some View {
ForceFullScreen(backgroundColor: .green) {
VStack {
Text("MainView")
Button("Log out") {
auth.isAuthenticated = false
}
}
}
}
}
public struct ForceFullScreen<Content>: View where Content: View {
private let content: Content
private let backgroundColor: Color
public init(
backgroundColor: Color = .clear,
@ViewBuilder content: @escaping () -> Content
) {
self.backgroundColor = backgroundColor
self.content = content()
}
public var body: some View {
ZStack {
backgroundColor
.edgesIgnoringSafeArea(.all)
content
.padding()
}
}
}
I'm using the coordinator with viewmodel approach. Right now I have the following setup.
enum SearchScreen {
case home(SearchViewModel)
case details(Int)
}
class SearchCoordinatorViewModel {
@Published var routes: Routes<SearchScreen>
init() {
routes = [.root(.home(SearchViewModel()))]
}
func showDetails(id: Int) {
routes.push(.details(id))
}
}
class SearchViewModel {
func showDetails(id: Int) {
// Not sure how to accomplish this
}
}
I would like to trigger the routing to a search result from the search screen. However I can't pass the routes publisher to the SearchViewModel because self isn't viable at the time that the root route is being created. I can't pass a closure that uses a weak self to SearchViewModel either for the same reason. Am I just thinking about it incorrectly? The only solutions that I've come up with is to put the routes in another object (Router), initialize routes with an empty array, and then give that object to the SearchViewModel. I'm not very fond of it which is why I'm asking here. Thanks.
I've noticed that when you present many covers or sheets in the example app and take a screenshot there is a huge spike in memory which when you have enough sheets/covers presented (above 10) causes the app to crash. This doesn't seem to happen when pushing views onto the navigation stack.
I've also tested using .sheet
directly in an example app and there doesn't seem to be the same issue. Any help resolving this would be greatly appreciated.
A declarative, efficient, and flexible JavaScript library for building user interfaces.
๐ Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
An Open Source Machine Learning Framework for Everyone
The Web framework for perfectionists with deadlines.
A PHP framework for web artisans
Bring data to life with SVG, Canvas and HTML. ๐๐๐
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
Some thing interesting about web. New door for the world.
A server is a program made to process requests and deliver data to clients.
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
Some thing interesting about visualization, use data art
Some thing interesting about game, make everyone happy.
We are working to build community through open source technology. NB: members must have two-factor auth.
Open source projects and samples from Microsoft.
Google โค๏ธ Open Source for everyone.
Alibaba Open Source for everyone
Data-Driven Documents codes.
China tencent open source team.