Giter VIP home page Giter VIP logo

flowstacks's Introduction

FlowStacks

This package takes SwiftUI's familiar and powerful NavigationStack API and gives it superpowers, allowing you to use the same API not just for push navigation, but also for presenting sheets and full-screen covers. And because it's implemented using the navigation APIs available in older SwiftUI versions, you can even use it on earlier versions of iOS, tvOS, watchOS and macOS.

You might like this library if:

✅ You want to support deeplinks into deeply nested navigation routes in your app.
✅ You want to easily re-use views within different navigation contexts.
✅ You want to easily go back to the root screen or a specific screen in the navigation stack.
✅ You want to use the coordinator pattern to keep navigation logic in a single place.
✅ You want to break an app's navigation into multiple reusable coordinators and compose them together.

Familiar APIs

If you already know SwiftUI's NavigationStack APIs, FlowStacks should feel familiar and intuitive. Just replace 'Navigation' with 'Flow' in type and function names:

NavigationStack -> FlowStack

NavigationLink -> FlowLink

NavigationPath -> FlowPath

navigationDestination -> flowDestination

NavigationStack's full API is replicated, so you can initialise a FlowStack with a binding to an Array, with a binding to a FlowPath, or with no binding at all. The only difference is that the array should be a [Route<MyScreen>]s instead of [MyScreen]. The Route enum combines the destination data with info about what style of presentation is used. Similarly, when you create a FlowLink, you must additionally specify the route style, e.g. .push, .sheet or .cover. As with NavigationStack, if the user taps the back button or swipes to dismiss a sheet, the routes array will be automatically updated to reflect the new navigation state.

Example

Click to expand an example
import FlowStacks
import SwiftUI

struct ContentView: View {
  @State var path = FlowPath()
  @State var isShowingWelcome = false

  var body: some View {
    FlowStack($path, withNavigation: true) {
      HomeView()
        .flowDestination(for: Int.self, destination: { number in
          NumberView(number: number)
        })
        .flowDestination(for: String.self, destination: { text in
          Text(text)
        })
        .flowDestination(isPresented: $isShowingWelcome, style: .sheet) {
          Text("Welcome to FlowStacks!")
        }
    }
  }
}

struct HomeView: View {
  @EnvironmentObject var navigator: FlowPathNavigator
  
  var body: some View {
    List {
      ForEach(0 ..< 10, id: \.self) { number in
        FlowLink(value: number, style: .sheet(withNavigation: true), label: { Text("Show \(number)") })
      }
      Button("Show 'hello'") {
        navigator.push("Hello")
      }
    }
    .navigationTitle("Home")
  }
}

struct NumberView: View {
  @EnvironmentObject var navigator: FlowPathNavigator
  let number: Int

  var body: some View {
    VStack(spacing: 8) {
      Text("\(number)")
      FlowLink(
        value: number + 1,
        style: .push,
        label: { Text("Show next number") }
      )
      Button("Go back to root") {
        navigator.goBackToRoot()
      }
    }
    .navigationTitle("\(number)")
  }
}

Additional features

As well as replicating the standard features of the new NavigationStack APIs, some helpful utilities have also been added.

FlowNavigator

A FlowNavigator object is available through the environment, giving access to the current routes array and the ability to update it via a number of convenience methods. The navigator can be accessed via the environment, e.g. for a FlowPath-backed stack:

@EnvironmentObject var navigator: FlowPathNavigator

Or for a FlowStack backed by a routes array, e.g. [Route<ScreenType>]:

@EnvironmentObject var navigator: FlowNavigator<ScreenType>

Here's an example of a FlowNavigator in use:

@EnvironmentObject var navigator: FlowNavigator<ScreenType>

var body: some View {
  VStack {
    Button("View detail") {
      navigator.push(.detail)
    }
    Button("Go back to profile") {
      navigator.goBackTo(.profile)
    }
    Button("Go back to root") {
      navigator.goBackToRoot()
    }
  }
}

Convenience methods

When interacting with a FlowNavigator (and also the original FlowPath or routes array), a number of convenience methods are available for easier navigation, including:

Method Effect
push Pushes a new screen onto the stack.
presentSheet Presents a new screen as a sheet.†
presentCover Presents a new screen as a full-screen cover.†
goBack Goes back one screen in the stack.
goBackToRoot Goes back to the very first screen in the stack.
goBackTo Goes back to a specific screen in the stack.
pop Pops the current screen if it was pushed.
dismiss Dismisses the most recently presented screen.

† Pass embedInNavigationView: true if you want to be able to push screens from the presented screen.

Deep-linking

Before the NavigationStack APIs were introduced, SwiftUI did not support pushing more than one screen in a single state update, e.g. when deep-linking to a screen multiple layers deep in a navigation hierarchy. FlowStacks works around this limitation: you can make any such changes, and the library will, behind the scenes, break down the larger update into a series of smaller updates that SwiftUI supports, with delays if necessary in between.

Bindings

The flow destination can be configured to work with a binding to its screen state in the routes array, rather than just a read-only value - just add $ before the screen argument in the flowDestination function's view-builder closure. The screen itself can then be responsible for updating its state within the routes array, e.g.:

import SwiftUINavigation

struct BindingExampleCoordinator: View {
  @State var path = FlowPath()
    
  var body: some View {
    FlowStack($path, withNavigation: true) {
      FlowLink(value: 1, style: .push, label: { Text("Push '1'") })
        .flowDestination(for: Int.self) { $number in
          EditNumberScreen(number: $number) // This screen can now change the number stored in the path.
        }
    }
  }

If you're using a typed Array of routes, you're probably using an enum to represent the screen, so it might be necessary to further extract the associated value for a particular case of that enum as a binding. You can do that using the SwiftUINavigation library, which includes a number of helpful Binding transformations for optional and enum state, e.g.:

Click to expand an example of using a Binding to a value in a typed Array of enum-based routes
import FlowStacks
import SwiftUI
import SwiftUINavigation

enum Screen: Hashable {
  case number(Int)
  case greeting(String)
}

struct BindingExampleCoordinator: View {
  @State var routes: Routes<Screen> = []

  var body: some View {
    FlowStack($routes, withNavigation: true) {
      HomeView()
        .flowDestination(for: Screen.self) { $screen in
          if let number = Binding(unwrapping: $screen, case: /Screen.number) {
            // Here `number` is a `Binding<Int>`, so `EditNumberScreen` can change its
            // value in the routes array.
            EditNumberScreen(number: number)
          } else if case let .greeting(greetingText) = screen {
            // Here `greetingText` is a plain `String`, as a binding is not needed.
            Text(greetingText)
          }
        }
    }
  }
}

struct HomeView: View {
  @EnvironmentObject var navigator: FlowPathNavigator

  var body: some View {
    VStack {
      FlowLink(value: Screen.number(42), style: .push, label: { Text("Show Number") })
      FlowLink(value: Screen.greeting("Hello world"), style: .push, label: { Text("Show Greeting") })
    }
  }
}

struct EditNumberScreen: View {
  @Binding var number: Int

  var body: some View {
    Stepper(
      label: { Text("\(number)") },
      onIncrement: { number += 1 },
      onDecrement: { number -= 1 }
    )
  }
}

Child flow coordinators

FlowStacks are designed to be composable, so that you can have multiple flow coordinators, each with its own FlowStack, and you can present or push a child coordinator from a parent. See Nesting FlowStacks for more info.

How does it work?

The library works by translating the array of routes into a hierarchy of nested NavigationLinks and presentation calls, expanding on the technique used in NavigationBackport.

Migrating from earlier versions

Please see the migration docs.

flowstacks's People

Contributors

bohdandatskiv avatar confusedvorlon avatar davidkmn avatar johnpatrickmorgan avatar ngeri avatar sandordilong avatar zntfdr avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

flowstacks's Issues

RouteSteps.calculateSteps returning empty

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.

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.

Handle deeplinks/push-notifications

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.

When using the popToRoot function, it appears that views are being dismissed one by one instead of directly navigating to the root view

Steps to reproduce

  • Navigate deep into the view hierarchy within the app.
  • Trigger the popToRoot function, either programmatically or through user interaction.

Expected behavior

  • Upon calling popToRoot, the app should immediately navigate back to the root view, dismissing all intermediate views in the process.

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()
        }
    }
}

Simulator Screen Recording - iPhone 15 Pro - 2024-02-07 at 20 31 42

Inconsistent state of Routes variable after present/push when VC is presented on top (from UIKit)

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!

`@Environment(\.dismiss)` no longer works when using 0.3.5

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!

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

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")
    }
}

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.

Various issues with starting a SubFlow from a Flow, see details below.

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.

Code of whole app

Click to expand
//
//  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)
	}
}

Problems

I have to select one of these scenarios, all suboptimal:

  1. Nice code, but bad app experience.
    • Nice UI (but incorrect behaviour of back button)
    • Correct behaviour of back button (but unacceptable UI because of double navigation bar)
  2. Messy code, but correct app experience (nice UI and correct 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.

Screen.Recording.2022-04-23.at.08.29.26.mov

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:

Screen.Recording.2022-04-23.at.08.32.09.mov

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
)
...
Screen.Recording.2022-04-23.at.08.37.10.mov

Solution

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.

Screen is dismissed twice when swiping down on a sheet

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!

EnvironmentObject FlowNavigator causes memory leak

Intro

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.

The problem

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.

How to reproduce this?

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.

Environment

  • Operating system: iOS 17.2
  • Device: iPhone 14 Pro
  • FlowStacks version: 0.3.8
  • How is FlowStacks embedded: SPM

Let me know, if you need more information. Thanks for the great work with FlowStacks!

Native MacOs app has strange behavior

I use this library in MacOS SwiftUi;
And it doesn't change screens at the same position; it opens a new type of window. I don't think that's right
Here is screenshot
screenshot
How can I fix this?

Huge Memory Spike When Taking Screenshot

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.

NavigationView state gets reset when presenting and dismissing view on NavigationView

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()
        }
    }
}

Using viewmodel pattern how should I pass the routes to other viewmodels?

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.

Combining Push with Present

I did not find a way to have both a push and present navigation type on the same view. Is this possible? Thank you

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

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?

[Feature Request] Embedding stack inside a custom view?

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 and NavigationViewCoordinator. ViewWrapperCoordinator is a coordinator you can either subclass or use right away to wrap your coordinator in a view, and NavigationViewCoordinator is a ViewWrapperCoordinator subclass that wraps your coordinator in a NavigationView.

Perhaps we can do something similar in this library as well?

Best,
Joseph

Call on navigator on nested view crashes the app

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

iPad sidebar support

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.

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,

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: 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!

[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

[Feature Request] `replaceStack(with routes: [Route<Screen>], animated: Bool = true)`

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:

Screen.Recording.2022-04-19.at.18.12.57.mov

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()
		}
	}
}

Xcode warning in 0.3.5 version

[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!!

Ambiguous use of popTo when Screen is both Equatable and Identifiable

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!

If you press routes.push more than three times, the view stack does not stack normally.

RPReplay_Final1654851438.MP4

navitest.zip

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()
            }
        }
    }
}

17 os navigation problems

onBack() and .push() on 17os sometimes work weird.
For instance, routes.push(.screen3) from screen2 go back to screen1

`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:

/// The root of the stack. The presentation style is irrelevant as it will not be presented.
/// - Parameter screen: the screen to be shown.
public static func root(_ screen: Screen, embedInNavigationView: Bool = false) -> Route {
return .sheet(screen, embedInNavigationView: embedInNavigationView)
}

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

var canPush: Bool {
for route in self.reversed() {
switch route.style {
case .push:
continue
case .cover(let embedInNavigationView), .sheet(let embedInNavigationView):
return embedInNavigationView
}
}
// We have to assume that the routes are being pushed onto a navigation view outside this array.
return true
}

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!

Who is responsible for removing the node from the Routes when the sheet is dismiss from a swipe ?

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! 😂

Dismiss when nothing is presented remove the root node

Hi @johnpatrickmorgan,

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?

How do I animate between root screens.

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?

[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!

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.