├── .swift-version ├── FlowStacksApp ├── Assets.xcassets │ ├── Contents.json │ └── AppIcon.appiconset │ │ └── Contents.json ├── Package.swift ├── Shared │ ├── ProcessArguments.swift │ ├── View+indexedA11y.swift │ ├── SimpleStepper.swift │ ├── Deeplink.swift │ ├── FlowStacksApp.swift │ ├── NumberVMFlow.swift │ ├── NumberCoordinator.swift │ ├── ArrayBindingView.swift │ ├── NoBindingView.swift │ └── FlowPathView.swift └── Info.plist ├── Sources └── FlowStacks │ ├── UnchangedViewModifier.swift │ ├── NonReactiveState.swift │ ├── Unobserved.swift │ ├── ConditionalViewBuilder.swift │ ├── View+onFirstAppear.swift │ ├── DestinationBuilderModifier.swift │ ├── View+UseNavigationStack.swift │ ├── View+sheet.swift │ ├── FlowNavigator.swift │ ├── DestinationBuilderView.swift │ ├── FlowPath.swift │ ├── View+cover.swift │ ├── View+push.swift │ ├── RouteProtocol.swift │ ├── RoutesHolder.swift │ ├── View+show.swift │ ├── LocalDestinationBuilderModifier.swift │ ├── Router.swift │ ├── FlowLink.swift │ ├── EnvironmentValues+keys.swift │ ├── RouteStyle.swift │ ├── DestinationBuilderHolder.swift │ ├── ScreenModifier.swift │ ├── Node.swift │ ├── Route.swift │ ├── EmbedModifier.swift │ ├── FlowPath+calculateSteps.swift │ ├── FlowPath.CodableRepresentation.swift │ ├── View+flowDestination.swift │ ├── FlowStack.swift │ └── Convenience methods │ ├── FlowPath+convenienceMethods.swift │ ├── FlowNavigator+convenienceMethods.swift │ └── Array+convenienceMethods.swift ├── .gitignore ├── Package.swift ├── LICENSE ├── FlowStacks.podspec ├── .swiftformat ├── Docs ├── Migration │ └── Migrating to 1.0.md └── Nesting FlowStacks.md ├── FlowStacksApp.xcodeproj └── xcshareddata │ └── xcschemes │ ├── FlowStacksApp (macOS).xcscheme │ ├── FlowStacksApp (tvOS).xcscheme │ └── FlowStacksApp (iOS).xcscheme ├── Tests └── FlowStacksTests │ ├── ConvenienceMethodsTests.swift │ └── CalculateStepsTests.swift ├── FlowStacksAppUITests ├── NestedFlowStacksUITests.swift ├── FlowStacksUITests.swift └── NumbersUITests.swift └── README.md /.swift-version: -------------------------------------------------------------------------------- 1 | 5.10 -------------------------------------------------------------------------------- /FlowStacksApp/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /FlowStacksApp/Package.swift: -------------------------------------------------------------------------------- 1 | import PackageDescription 2 | 3 | let package = Package( 4 | name: "", 5 | products: [], 6 | dependencies: [], 7 | targets: [] 8 | ) 9 | -------------------------------------------------------------------------------- /Sources/FlowStacks/UnchangedViewModifier.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A view modifier that makes no changes to the content. 4 | public struct UnchangedViewModifier: ViewModifier { 5 | public func body(content: Content) -> some View { 6 | content 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Sources/FlowStacks/NonReactiveState.swift: -------------------------------------------------------------------------------- 1 | /// This provides a mechanism to store state attached to a SwiftUI view's lifecycle, without causing the view to re-render when the value changes. 2 | class NonReactiveState { 3 | var value: T 4 | 5 | init(value: T) { 6 | self.value = value 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Sources/FlowStacks/Unobserved.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A wrapper that allows access to an observable object without publishing its changes. 4 | class Unobserved: ObservableObject { 5 | let object: Object 6 | 7 | init(object: Object) { 8 | self.object = object 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /FlowStacksApp/Shared/ProcessArguments.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import FlowStacks 3 | 4 | enum ProcessArguments { 5 | static var navigationStackPolicy: UseNavigationStackPolicy { 6 | // Allows the policy to be set from UI tests. 7 | ProcessInfo.processInfo.arguments.contains("USE_NAVIGATIONSTACK") ? .whenAvailable : .never 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | ### SwiftPackageManager ### 3 | Packages 4 | .build/ 5 | xcuserdata 6 | DerivedData/ 7 | 8 | ## User settings 9 | xcuserdata/ 10 | 11 | ## Gcc Patch 12 | /*.gcno 13 | 14 | ### Xcode Patch ### 15 | *.xcodeproj/* 16 | !*.xcodeproj/project.pbxproj 17 | !*.xcodeproj/xcshareddata/ 18 | !*.xcworkspace/contents.xcworkspacedata 19 | **/xcshareddata/WorkspaceSettings.xcsettings 20 | -------------------------------------------------------------------------------- /Sources/FlowStacks/ConditionalViewBuilder.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// Builds a view given optional data and a function for transforming the data into a view. 4 | struct ConditionalViewBuilder: View { 5 | @Binding var data: Data? 6 | var buildView: (Data) -> DestinationView 7 | 8 | var body: some View { 9 | if let data { 10 | buildView(data) 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /FlowStacksApp/Shared/View+indexedA11y.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension View { 4 | func indexedA11y(_ id: String) -> some View { 5 | modifier(IndexedA11yIdModifier(id: id)) 6 | } 7 | } 8 | 9 | struct IndexedA11yIdModifier: ViewModifier { 10 | @Environment(\.routeIndex) var routeIndex 11 | @Environment(\.nestingIndex) var nestingIndex 12 | var id: String 13 | 14 | func body(content: Content) -> some View { 15 | content.accessibilityIdentifier("\(id) - route \(nestingIndex ?? -1):\(routeIndex ?? -1)") 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /FlowStacksApp/Shared/SimpleStepper.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct SimpleStepper: View { 4 | @Binding var number: Int 5 | 6 | var body: some View { 7 | #if os(tvOS) 8 | HStack { 9 | Text("\(number)") 10 | Button("-") { number -= 1 }.buttonStyle(.plain) 11 | Button("+") { number += 1 }.buttonStyle(.plain) 12 | } 13 | #else 14 | Stepper(label: { Text("\(number)").font(.body) }, onIncrement: { number += 1 }, onDecrement: { number -= 1 }) 15 | .fixedSize() 16 | #endif 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/FlowStacks/View+onFirstAppear.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | private struct OnFirstAppear: ViewModifier { 4 | let action: (() -> Void)? 5 | 6 | @State private var hasAppeared = false 7 | 8 | func body(content: Content) -> some View { 9 | content.onAppear { 10 | if !hasAppeared { 11 | hasAppeared = true 12 | action?() 13 | } 14 | } 15 | } 16 | } 17 | 18 | extension View { 19 | func onFirstAppear(perform action: (() -> Void)? = nil) -> some View { 20 | modifier(OnFirstAppear(action: action)) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/FlowStacks/DestinationBuilderModifier.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | /// Modifier for appending a new destination builder. 5 | struct DestinationBuilderModifier: ViewModifier { 6 | let typedDestinationBuilder: (Binding) -> AnyView 7 | 8 | @EnvironmentObject var destinationBuilder: DestinationBuilderHolder 9 | 10 | func body(content: Content) -> some View { 11 | destinationBuilder.appendBuilder(typedDestinationBuilder) 12 | 13 | return content 14 | .environmentObject(destinationBuilder) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /FlowStacksApp/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleURLTypes 6 | 7 | 8 | CFBundleTypeRole 9 | Viewer 10 | CFBundleURLName 11 | uk.johnpatrickmorgan.FlowStacksApp 12 | CFBundleURLSchemes 13 | 14 | flowstacksapp 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.10 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "FlowStacks", 7 | platforms: [ 8 | .iOS(.v14), .watchOS(.v7), .macOS(.v11), .tvOS(.v14), 9 | ], 10 | products: [ 11 | .library( 12 | name: "FlowStacks", 13 | targets: ["FlowStacks"] 14 | ), 15 | ], 16 | dependencies: [], 17 | targets: [ 18 | .target( 19 | name: "FlowStacks", 20 | dependencies: [] 21 | ), 22 | .testTarget( 23 | name: "FlowStacksTests", 24 | dependencies: ["FlowStacks"] 25 | ), 26 | ] 27 | ) 28 | -------------------------------------------------------------------------------- /Sources/FlowStacks/View+UseNavigationStack.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public extension View { 4 | /// Sets the policy for whether to use SwiftUI's built-in `NavigationStack` when available (i.e. when the SwiftUI 5 | /// version includes it). The default behaviour is to never use `NavigationStack` - instead `NavigationView` 6 | /// will be used on all versions, even when the API is available. 7 | /// - Parameter policy: The policy to use 8 | /// - Returns: A view with the policy set for all child views via a private environment value. 9 | func useNavigationStack(_ policy: UseNavigationStackPolicy) -> some View { 10 | environment(\.useNavigationStack, policy) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/FlowStacks/View+sheet.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct SheetModifier: ViewModifier { 4 | var isActiveBinding: Binding 5 | var destination: Destination 6 | 7 | func body(content: Content) -> some View { 8 | content 9 | .sheet( 10 | isPresented: isActiveBinding, 11 | onDismiss: nil, 12 | content: { 13 | destination 14 | .environment(\.parentNavigationStackType, nil) 15 | } 16 | ) 17 | } 18 | } 19 | 20 | extension View { 21 | func sheet(isActive: Binding, destination: some View) -> some View { 22 | modifier(SheetModifier(isActiveBinding: isActive, destination: destination)) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/FlowStacks/FlowNavigator.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A navigator to use when the `FlowStack` is initialized with a `FlowPath` binding or no binding. 4 | public typealias FlowPathNavigator = FlowNavigator 5 | 6 | /// An object available via the environment that gives access to the current routes array. 7 | @MainActor 8 | public class FlowNavigator: ObservableObject { 9 | let routesBinding: Binding<[Route]> 10 | 11 | /// The current routes array. 12 | public var routes: [Route] { 13 | get { routesBinding.wrappedValue } 14 | set { routesBinding.wrappedValue = newValue } 15 | } 16 | 17 | public init(_ routesBinding: Binding<[Route]>) { 18 | self.routesBinding = routesBinding 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /FlowStacksApp/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | }, 8 | { 9 | "appearances" : [ 10 | { 11 | "appearance" : "luminosity", 12 | "value" : "dark" 13 | } 14 | ], 15 | "idiom" : "universal", 16 | "platform" : "ios", 17 | "size" : "1024x1024" 18 | }, 19 | { 20 | "appearances" : [ 21 | { 22 | "appearance" : "luminosity", 23 | "value" : "tinted" 24 | } 25 | ], 26 | "idiom" : "universal", 27 | "platform" : "ios", 28 | "size" : "1024x1024" 29 | } 30 | ], 31 | "info" : { 32 | "author" : "xcode", 33 | "version" : 1 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/FlowStacks/DestinationBuilderView.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | /// Builds a view from the given Data, using the destination builder environment object. 5 | struct DestinationBuilderView: View { 6 | let data: Binding 7 | 8 | @EnvironmentObject var destinationBuilder: DestinationBuilderHolder 9 | 10 | var body: some View { 11 | DataDependentView(data: data, content: { destinationBuilder.build(data) }).equatable() 12 | } 13 | } 14 | 15 | struct DataDependentView: View, Equatable { 16 | static func == (lhs: DataDependentView, rhs: DataDependentView) -> Bool { 17 | return lhs.data.wrappedValue == rhs.data.wrappedValue 18 | } 19 | 20 | let data: Binding 21 | let content: () -> Content 22 | 23 | var body: some View { 24 | content() 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 johnpatrickmorgan 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Sources/FlowStacks/FlowPath.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | /// A type-erased wrapper for an Array of any Hashable types, to be displayed in a ``FlowStack``. 5 | public struct FlowPath: Equatable { 6 | /// The routes array for the FlowPath. 7 | public var routes: [Route] 8 | 9 | /// The number of routes in the path. 10 | public var count: Int { routes.count } 11 | 12 | /// Whether the path is empty. 13 | public var isEmpty: Bool { routes.isEmpty } 14 | 15 | /// Creates a ``FlowPath`` with an initial array of routes. 16 | /// - Parameter routes: The routes for the ``FlowPath``. 17 | public init(_ routes: [Route] = []) { 18 | self.routes = routes 19 | } 20 | 21 | /// Creates a ``FlowPath`` with an initial sequence of routes. 22 | /// - Parameter routes: The routes for the ``FlowPath``. 23 | public init(_ routes: some Sequence>) { 24 | self.init(routes.map { $0.map { $0 as AnyHashable } }) 25 | } 26 | 27 | public mutating func append(_ value: Route) { 28 | routes.append(value.erased()) 29 | } 30 | 31 | public mutating func removeLast(_ k: Int = 1) { 32 | routes.removeLast(k) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /FlowStacks.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | 3 | s.name = 'FlowStacks' 4 | s.version = '0.8.3' 5 | s.summary = 'Hoist navigation state into a coordinator in SwiftUI.' 6 | 7 | s.description = <<-DESC 8 | FlowStacks allows you to hoist SwiftUI navigation or presentation state into a 9 | higher-level coordinator view. The coordinator pattern allows you to write isolated views 10 | that have zero knowledge of their context within an app. 11 | DESC 12 | 13 | s.homepage = 'https://github.com/johnpatrickmorgan/FlowStacks' 14 | s.license = { :type => 'MIT', :file => 'LICENSE' } 15 | s.author = { 'johnpatrickmorgan' => 'johnpatrickmorganuk@gmail.com' } 16 | s.source = { :git => 'https://github.com/johnpatrickmorgan/FlowStacks.git', :tag => s.version.to_s } 17 | s.social_media_url = 'https://twitter.com/jpmmusic' 18 | 19 | 20 | s.ios.deployment_target = '14.0' 21 | s.osx.deployment_target = '11.0' 22 | s.watchos.deployment_target = '7.0' 23 | s.tvos.deployment_target = '14.0' 24 | 25 | s.swift_version = '5.10' 26 | 27 | s.source_files = 'Sources/**/*' 28 | 29 | s.frameworks = 'Foundation', 'SwiftUI' 30 | 31 | end 32 | -------------------------------------------------------------------------------- /Sources/FlowStacks/View+cover.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct CoverModifier: ViewModifier { 4 | var isActiveBinding: Binding 5 | var destination: Destination 6 | 7 | func body(content: Content) -> some View { 8 | #if os(macOS) // Covers are unavailable on macOS 9 | content 10 | .sheet( 11 | isPresented: isActiveBinding, 12 | onDismiss: nil, 13 | content: { destination.environment(\.parentNavigationStackType, nil) } 14 | ) 15 | #else 16 | if #available(iOS 14.0, tvOS 14.0, macOS 99.9, *) { 17 | content 18 | .fullScreenCover( 19 | isPresented: isActiveBinding, 20 | onDismiss: nil, 21 | content: { destination.environment(\.parentNavigationStackType, nil) } 22 | ) 23 | } else { // Covers are unavailable on prior versions 24 | content 25 | .sheet( 26 | isPresented: isActiveBinding, 27 | onDismiss: nil, 28 | content: { destination.environment(\.parentNavigationStackType, nil) } 29 | ) 30 | } 31 | #endif 32 | } 33 | } 34 | 35 | extension View { 36 | func cover(isActive: Binding, destination: some View) -> some View { 37 | modifier(CoverModifier(isActiveBinding: isActive, destination: destination)) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /FlowStacksApp/Shared/Deeplink.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum Deeplink { 4 | case numberCoordinator(NumberDeeplink) 5 | case viewModelTab(ViewModelTabDeeplink) 6 | 7 | init?(url: URL) { 8 | guard url.scheme == "flowstacksapp" else { return nil } 9 | switch url.host { 10 | case "numbers": 11 | guard let numberDeeplink = NumberDeeplink(pathComponents: url.pathComponents.dropFirst()) else { 12 | return nil 13 | } 14 | self = .numberCoordinator(numberDeeplink) 15 | case "vm-numbers": 16 | guard let numberDeeplink = ViewModelTabDeeplink(pathComponents: url.pathComponents.dropFirst()) else { 17 | return nil 18 | } 19 | self = .viewModelTab(numberDeeplink) 20 | 21 | default: 22 | return nil 23 | } 24 | } 25 | } 26 | 27 | enum NumberDeeplink { 28 | case numbers([Int]) 29 | 30 | init?(pathComponents: some Collection) { 31 | let numbers = pathComponents.compactMap(Int.init) 32 | guard numbers.count == pathComponents.count else { 33 | return nil 34 | } 35 | self = .numbers(numbers) 36 | } 37 | } 38 | 39 | enum ViewModelTabDeeplink { 40 | case numbers([Int]) 41 | 42 | init?(pathComponents: some Collection) { 43 | let numbers = pathComponents.compactMap(Int.init) 44 | guard numbers.count == pathComponents.count else { 45 | return nil 46 | } 47 | self = .numbers(numbers) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/FlowStacks/View+push.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct PushModifier: ViewModifier { 4 | @Binding var isActive: Bool 5 | var destination: Destination 6 | 7 | @Environment(\.parentNavigationStackType) var parentNavigationStackType 8 | 9 | func body(content: Content) -> some View { 10 | if #available(iOS 16.0, *, macOS 13.0, *, watchOS 9.0, *, tvOS 16.0, *), parentNavigationStackType == .navigationStack { 11 | // NOTE: Pushing is already handled by the data binding provided to NavigationStack. 12 | content 13 | } else { 14 | content 15 | .background( 16 | NavigationLink(destination: destination, isActive: $isActive, label: EmptyView.init) 17 | .hidden() 18 | ) 19 | .onChange(of: isActive) { isActive in 20 | if isActive, parentNavigationStackType == nil { 21 | print( 22 | """ 23 | Attempting to push from a view that is not embedded in a navigation view. \ 24 | Did you mean to pass `withNavigation: true` when creating the FlowStack or \ 25 | presenting the sheet/cover? 26 | """ 27 | ) 28 | } 29 | } 30 | } 31 | } 32 | } 33 | 34 | extension View { 35 | func push(isActive: Binding, destination: some View) -> some View { 36 | modifier(PushModifier(isActive: isActive, destination: destination)) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /FlowStacksApp/Shared/FlowStacksApp.swift: -------------------------------------------------------------------------------- 1 | import FlowStacks 2 | import SwiftUI 3 | 4 | @main 5 | struct FlowStacksApp: App { 6 | enum Tab: Hashable { 7 | case numberCoordinator 8 | case flowPath 9 | case arrayBinding 10 | case noBinding 11 | case viewModel 12 | } 13 | 14 | @State var selectedTab: Tab = .numberCoordinator 15 | 16 | var body: some Scene { 17 | WindowGroup { 18 | TabView(selection: $selectedTab) { 19 | NumberCoordinator() 20 | .tabItem { Text("Numbers") } 21 | .tag(Tab.numberCoordinator) 22 | FlowPathView() 23 | .tabItem { Text("FlowPath") } 24 | .tag(Tab.flowPath) 25 | ArrayBindingView() 26 | .tabItem { Text("ArrayBinding") } 27 | .tag(Tab.arrayBinding) 28 | NoBindingView() 29 | .tabItem { Text("NoBinding") } 30 | .tag(Tab.noBinding) 31 | NumberVMFlow(viewModel: .init(initialNumber: 64)) 32 | .tabItem { Text("ViewModel") } 33 | .tag(Tab.viewModel) 34 | } 35 | .onOpenURL { url in 36 | guard let deeplink = Deeplink(url: url) else { return } 37 | follow(deeplink) 38 | } 39 | .useNavigationStack(ProcessArguments.navigationStackPolicy) 40 | } 41 | } 42 | 43 | private func follow(_ deeplink: Deeplink) { 44 | // Test deeplinks from CLI with, e.g.: 45 | // `xcrun simctl openurl booted flowstacksapp://numbers/42/13` 46 | switch deeplink { 47 | case .numberCoordinator: 48 | selectedTab = .numberCoordinator 49 | case .viewModelTab: 50 | selectedTab = .viewModel 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/FlowStacks/RouteProtocol.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// The RouteProtocol is used to restrict the extensions on Array so that they do not 4 | /// pollute autocomplete for Arrays containing other types. 5 | public protocol RouteProtocol { 6 | associatedtype Screen 7 | 8 | static func push(_ screen: Screen) -> Self 9 | static func sheet(_ screen: Screen, withNavigation: Bool) -> Self 10 | #if os(macOS) 11 | // Full-screen cover unavailable. 12 | #else 13 | static func cover(_ screen: Screen, withNavigation: Bool) -> Self 14 | #endif 15 | var screen: Screen { get set } 16 | var withNavigation: Bool { get } 17 | var isPresented: Bool { get } 18 | 19 | var style: RouteStyle { get } 20 | } 21 | 22 | public extension RouteProtocol { 23 | /// A sheet presentation. 24 | /// - Parameter screen: the screen to be shown. 25 | static func sheet(_ screen: Screen) -> Self { 26 | sheet(screen, withNavigation: false) 27 | } 28 | 29 | #if os(macOS) 30 | // Full-screen cover unavailable. 31 | #else 32 | /// A full-screen cover presentation. 33 | /// - Parameter screen: the screen to be shown. 34 | @available(OSX, unavailable, message: "Not available on OS X.") 35 | static func cover(_ screen: Screen) -> Self { 36 | cover(screen, withNavigation: false) 37 | } 38 | #endif 39 | 40 | /// The root of the stack. The presentation style is irrelevant as it will not be presented. 41 | /// - Parameter screen: the screen to be shown. 42 | static func root(_ screen: Screen, withNavigation: Bool = false) -> Self { 43 | sheet(screen, withNavigation: withNavigation) 44 | } 45 | } 46 | 47 | extension Route: RouteProtocol {} 48 | -------------------------------------------------------------------------------- /Sources/FlowStacks/RoutesHolder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | /// An object that publishes changes to the routes array it holds. 5 | @MainActor 6 | class RoutesHolder: ObservableObject { 7 | var task: Task? 8 | var usingNavigationStack = false 9 | 10 | @Published var routes: [Route] = [] { 11 | didSet { 12 | guard routes != oldValue else { return } 13 | 14 | let didUpdateSynchronously = synchronouslyUpdateIfSupported(to: routes) 15 | guard !didUpdateSynchronously else { return } 16 | 17 | task?.cancel() 18 | task = Task { @MainActor in 19 | await updateRoutesWithDelays(to: routes) 20 | } 21 | } 22 | } 23 | 24 | @Published var delayedRoutes: [Route] = [] 25 | 26 | var boundRoutes: [Route] { 27 | get { 28 | delayedRoutes 29 | } 30 | set { 31 | routes = newValue 32 | } 33 | } 34 | 35 | func synchronouslyUpdateIfSupported(to newRoutes: [Route]) -> Bool { 36 | guard FlowPath.canSynchronouslyUpdate(from: delayedRoutes, to: newRoutes, allowNavigationUpdatesInOne: usingNavigationStack) else { 37 | return false 38 | } 39 | delayedRoutes = newRoutes 40 | return true 41 | } 42 | 43 | func updateRoutesWithDelays(to newRoutes: [Route]) async { 44 | let steps = FlowPath.calculateSteps(from: delayedRoutes, to: newRoutes, allowNavigationUpdatesInOne: usingNavigationStack) 45 | 46 | delayedRoutes = steps.first! 47 | await scheduleRemainingSteps(steps: Array(steps.dropFirst())) 48 | } 49 | 50 | func scheduleRemainingSteps(steps: [[Route]]) async { 51 | guard let firstStep = steps.first else { 52 | return 53 | } 54 | delayedRoutes = firstStep 55 | do { 56 | try await Task.sleep(nanoseconds: UInt64(0.65 * 1_000_000_000)) 57 | try Task.checkCancellation() 58 | await scheduleRemainingSteps(steps: Array(steps.dropFirst())) 59 | } catch {} 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Sources/FlowStacks/View+show.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ShowModifier: ViewModifier { 4 | var isActiveBinding: Binding 5 | var routeStyle: RouteStyle? 6 | var destination: Destination 7 | 8 | func isActiveBinding(enabled: Bool) -> Binding { 9 | Binding { 10 | enabled && isActiveBinding.wrappedValue 11 | } set: { 12 | isActiveBinding.wrappedValue = $0 13 | } 14 | } 15 | 16 | func body(content: Content) -> some View { 17 | /// NOTE: On iOS 14.4 and below, a bug prevented multiple sheet/fullScreenCover modifiers being chained 18 | /// on the same view, so we conditionally add the sheet/cover modifiers as a workaround. See 19 | /// https://developer.apple.com/documentation/ios-ipados-release-notes/ios-ipados-14_5-release-notes 20 | if #available(iOS 14.5, *) { 21 | content 22 | .push(isActive: isActiveBinding(enabled: routeStyle?.isPush ?? false), destination: destination) 23 | .sheet(isActive: isActiveBinding(enabled: routeStyle?.isSheet ?? false), destination: destination) 24 | .cover(isActive: isActiveBinding(enabled: routeStyle?.isCover ?? false), destination: destination) 25 | } else { 26 | if routeStyle?.isSheet == true { 27 | content 28 | .push(isActive: routeStyle?.isPush == true ? isActiveBinding : .constant(false), destination: destination) 29 | .sheet(isActive: routeStyle?.isSheet == true ? isActiveBinding : .constant(false), destination: destination) 30 | } else { 31 | content 32 | .push(isActive: routeStyle?.isPush == true ? isActiveBinding : .constant(false), destination: destination) 33 | .cover(isActive: routeStyle?.isCover == true ? isActiveBinding : .constant(false), destination: destination) 34 | } 35 | } 36 | } 37 | } 38 | 39 | extension View { 40 | func show(isActive: Binding, routeStyle: RouteStyle?, destination: some View) -> some View { 41 | modifier(ShowModifier(isActiveBinding: isActive, routeStyle: routeStyle, destination: destination)) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | --acronyms ID,URL,UUID 2 | --allman false 3 | --anonymousforeach convert 4 | --assetliterals visual-width 5 | --asynccapturing 6 | --beforemarks 7 | --binarygrouping 4,8 8 | --categorymark "MARK: %c" 9 | --classthreshold 0 10 | --closingparen balanced 11 | --closurevoid remove 12 | --commas always 13 | --conflictmarkers reject 14 | --decimalgrouping 3,6 15 | --doccomments before-declarations 16 | --elseposition same-line 17 | --emptybraces no-space 18 | --enumnamespaces always 19 | --enumthreshold 0 20 | --exponentcase lowercase 21 | --exponentgrouping disabled 22 | --extensionacl on-extension 23 | --extensionlength 0 24 | --extensionmark "MARK: - %t + %c" 25 | --fractiongrouping disabled 26 | --fragment false 27 | --funcattributes preserve 28 | --generictypes 29 | --groupedextension "MARK: %c" 30 | --guardelse auto 31 | --header ignore 32 | --hexgrouping 4,8 33 | --hexliteralcase uppercase 34 | --ifdef indent 35 | --importgrouping alpha 36 | --indent 2 37 | --indentcase false 38 | --indentstrings false 39 | --lifecycle 40 | --lineaftermarks true 41 | --linebreaks lf 42 | --markcategories true 43 | --markextensions always 44 | --marktypes always 45 | --maxwidth none 46 | --modifierorder 47 | --nevertrailing 48 | --nospaceoperators 49 | --nowrapoperators 50 | --octalgrouping 4,8 51 | --onelineforeach ignore 52 | --operatorfunc spaced 53 | --organizetypes actor,class,enum,struct 54 | --patternlet hoist 55 | --ranges spaced 56 | --redundanttype infer-locals-only 57 | --self remove 58 | --selfrequired 59 | --semicolons inline 60 | --shortoptionals except-properties 61 | --smarttabs enabled 62 | --someany true 63 | --stripunusedargs always 64 | --structthreshold 0 65 | --tabwidth unspecified 66 | --throwcapturing 67 | --trailingclosures 68 | --trimwhitespace always 69 | --typeattributes preserve 70 | --typeblanklines remove 71 | --typemark "MARK: - %t" 72 | --varattributes preserve 73 | --voidtype void 74 | --wraparguments preserve 75 | --wrapcollections preserve 76 | --wrapconditions preserve 77 | --wrapeffects preserve 78 | --wrapenumcases always 79 | --wrapparameters default 80 | --wrapreturntype preserve 81 | --wrapternary default 82 | --wraptypealiases preserve 83 | --xcodeindentation disabled 84 | --yodaswap always 85 | -------------------------------------------------------------------------------- /Docs/Migration/Migrating to 1.0.md: -------------------------------------------------------------------------------- 1 | # Migrating to 1.0 2 | 3 | Before its API was brought more in line with `NavigationStack` APIs, previous versions of `FlowStacks` had two major differences: 4 | 5 | - Previously the `Router` (now the `FlowStack`) handled both state management _and_ building destination views. The latter has now been decoupled into a separate function `flowDestination(...)`. This decoupling you more flexibility over where you set up flow destinations, but for easy migration, you can keep them in the same place as you initialise the `FlowStack`. 6 | - Previously the root screen was part of the routes array. The root screen is no longer part of the routes array. Usually that will mean removing the root screen's case from your Screen enum and moving its view creation into the FlowStack initialiser. It might be awkward if your flow supported more than one root screen. In those cases, you will probably want to split the flow into two separate flows, each with its own `FlowStack` and a parent view that switches between them as needed. 7 | 8 | Here's an example migration: 9 | 10 |
11 | Previous API 12 | 13 | ```swift 14 | enum Screen { 15 | case home 16 | case numberList 17 | case numberDetail(Int) 18 | } 19 | 20 | struct AppCoordinator: View { 21 | @State var routes: Routes = [.root(.home)] 22 | 23 | var body: some View { 24 | Router($routes, embedInNavigationView: true) { screen, _ in 25 | switch screen { 26 | case .home: 27 | HomeView() 28 | case .numberList: 29 | NumberListView() 30 | case .numberDetail(let number): 31 | NumberDetailView(number: number) 32 | } 33 | } 34 | } 35 | } 36 | ``` 37 | 38 |
39 | 40 |
41 | New API 42 | 43 | ```swift 44 | enum Screen: Hashable { 45 | case numberList 46 | case numberDetail(Int) 47 | } 48 | 49 | struct AppCoordinator: View { 50 | @State var routes: [Route] = [] 51 | 52 | var body: some View { 53 | FlowStack($routes, withNavigation: true) { 54 | HomeView() 55 | .flowDestination(for: Screen.self) { screen in 56 | switch screen { 57 | case .numberList: 58 | NumberListView() 59 | case .numberDetail(let number): 60 | NumberDetailView(number: number) 61 | } 62 | } 63 | } 64 | } 65 | ``` 66 | 67 |
68 | -------------------------------------------------------------------------------- /Sources/FlowStacks/LocalDestinationBuilderModifier.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | /// Uniquely identifies an instance of a local destination builder. 5 | struct LocalDestinationID: RawRepresentable, Hashable { 6 | let rawValue: UUID 7 | } 8 | 9 | /// Persistent object to hold the local destination ID and remove it when the destination builder is removed. 10 | class LocalDestinationIDHolder: ObservableObject { 11 | let id = LocalDestinationID(rawValue: UUID()) 12 | weak var destinationBuilder: DestinationBuilderHolder? 13 | 14 | deinit { 15 | // On iOS 15, there are some extraneous re-renders after LocalDestinationBuilderModifier is removed from 16 | // the view tree. Dispatching async allows those re-renders to succeed before removing the local builder. 17 | DispatchQueue.main.async { [destinationBuilder, id] in 18 | destinationBuilder?.removeLocalBuilder(identifier: id) 19 | } 20 | } 21 | } 22 | 23 | /// Modifier that appends a local destination builder and ensures the Bool binding is observed and updated. 24 | struct LocalDestinationBuilderModifier: ViewModifier { 25 | let isPresented: Binding 26 | let routeStyle: RouteStyle 27 | let builder: () -> AnyView 28 | 29 | @StateObject var destinationID = LocalDestinationIDHolder() 30 | @EnvironmentObject var destinationBuilder: DestinationBuilderHolder 31 | @EnvironmentObject var routesHolder: RoutesHolder 32 | 33 | func body(content: Content) -> some View { 34 | destinationBuilder.appendLocalBuilder(identifier: destinationID.id, builder) 35 | destinationID.destinationBuilder = destinationBuilder 36 | 37 | return content 38 | .environmentObject(destinationBuilder) 39 | .onChange(of: routesHolder.routes) { _ in 40 | if isPresented.wrappedValue { 41 | if !routesHolder.routes.contains(where: { ($0.screen as? LocalDestinationID) == destinationID.id }) { 42 | isPresented.wrappedValue = false 43 | } 44 | } 45 | } 46 | .onChange(of: isPresented.wrappedValue) { isPresented in 47 | if isPresented { 48 | routesHolder.routes.append(Route(screen: destinationID.id, style: routeStyle)) 49 | } else { 50 | let index = routesHolder.routes.lastIndex(where: { ($0.screen as? LocalDestinationID) == destinationID.id }) 51 | if let index { 52 | routesHolder.routes.remove(at: index) 53 | } 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/FlowStacks/Router.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | struct Router: View { 5 | let rootView: RootView 6 | /// A view modifier that is applied to any `NavigationView`s created by the router. 7 | let navigationViewModifier: NavigationViewModifier 8 | let screenModifier: ScreenModifier 9 | let withNavigation: Bool 10 | 11 | @Environment(\.useNavigationStack) var useNavigationStack 12 | 13 | @Binding var screens: [Route] 14 | 15 | init(rootView: RootView, navigationViewModifier: NavigationViewModifier, screenModifier: ScreenModifier, screens: Binding<[Route]>, withNavigation: Bool) { 16 | self.rootView = rootView 17 | self.navigationViewModifier = navigationViewModifier 18 | self.screenModifier = screenModifier 19 | self.withNavigation = withNavigation 20 | _screens = screens 21 | } 22 | 23 | var nextPresentedIndex: Int { 24 | if #available(iOS 16.0, *, macOS 13.0, *, watchOS 9.0, *, tvOS 16.0, *), useNavigationStack == .whenAvailable { 25 | screens.firstIndex(where: \.isPresented) ?? screens.endIndex 26 | } else { 27 | 0 28 | } 29 | } 30 | 31 | var pushedScreens: some View { 32 | Node(allRoutes: $screens, truncateToIndex: { screens = Array(screens.prefix($0)) }, index: nextPresentedIndex, navigationViewModifier: navigationViewModifier, screenModifier: screenModifier) 33 | } 34 | 35 | private var isActiveBinding: Binding { 36 | Binding( 37 | get: { 38 | screens.indices.contains(nextPresentedIndex) 39 | }, 40 | set: { isShowing in 41 | guard !isShowing else { return } 42 | guard !screens.isEmpty else { return } 43 | screens = Array(screens.prefix(upTo: nextPresentedIndex)) 44 | } 45 | ) 46 | } 47 | 48 | var nextRouteStyle: RouteStyle? { 49 | screens[safe: nextPresentedIndex]?.style 50 | } 51 | 52 | var body: some View { 53 | rootView 54 | .modifier(screenModifier) 55 | .modifier( 56 | EmbedModifier( 57 | withNavigation: withNavigation, 58 | navigationViewModifier: navigationViewModifier, 59 | screenModifier: screenModifier, 60 | routes: $screens, 61 | navigationStackIndex: -1, 62 | isActive: isActiveBinding, 63 | nextRouteStyle: nextRouteStyle, 64 | destination: pushedScreens 65 | ) 66 | ) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Sources/FlowStacks/FlowLink.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | /// When value is non-nil, shows the destination associated with its type. 5 | public struct FlowLink: View { 6 | var route: Route

? 7 | var label: Label 8 | 9 | @EnvironmentObject var routesHolder: Unobserved 10 | 11 | init(route: Route

?, @ViewBuilder label: () -> Label) { 12 | self.route = route 13 | self.label = label() 14 | } 15 | 16 | /// Creates a flow link that presents the view corresponding to a value. 17 | /// - Parameters: 18 | /// - value: An optional value to present. When the user selects the link, SwiftUI stores a copy of the value. Pass a nil value to disable the link. 19 | /// - style: The mode of presentation, e.g. `.push` or `.sheet`. 20 | /// - label: A label that describes the view that this link presents. 21 | public init(value: P?, style: RouteStyle, @ViewBuilder label: () -> Label) { 22 | self.init(route: value.map { Route(screen: $0, style: style) }, label: label) 23 | } 24 | 25 | public var body: some View { 26 | // TODO: Ensure this button is styled more like a NavigationLink within a List. 27 | // See: https://gist.github.com/tgrapperon/034069d6116ff69b6240265132fd9ef7 28 | Button( 29 | action: { 30 | guard let route else { return } 31 | routesHolder.object.routes.append(route.erased()) 32 | }, 33 | label: { label } 34 | ) 35 | } 36 | } 37 | 38 | public extension FlowLink where Label == Text { 39 | /// Creates a flow link that presents a destination view, with a text label that the link generates from a title string. 40 | /// - Parameters: 41 | /// - title: A string for creating a text label. 42 | /// - value: A view for the navigation link to present. 43 | /// - style: The mode of presentation, e.g. `.push` or `.sheet`. 44 | init(_ title: some StringProtocol, value: P?, style: RouteStyle) { 45 | self.init(route: value.map { Route(screen: $0, style: style) }) { Text(title) } 46 | } 47 | 48 | /// Creates a flow link that presents a destination view, with a text label that the link generates from a localized string key. 49 | /// - Parameters: 50 | /// - titleKey: A localized string key for creating a text label. 51 | /// - value: A view for the navigation link to present. 52 | /// - style: The mode of presentation, e.g. `.push` or `.sheet`. 53 | init(_ titleKey: LocalizedStringKey, value: P?, style: RouteStyle) { 54 | self.init(route: value.map { Route(screen: $0, style: style) }) { Text(titleKey) } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/FlowStacks/EnvironmentValues+keys.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public enum UseNavigationStackPolicy { 4 | case whenAvailable 5 | case never 6 | } 7 | 8 | struct UseNavigationStackPolicyKey: EnvironmentKey { 9 | static var defaultValue: UseNavigationStackPolicy { 10 | if #available(iOS 26.0, *, macOS 26.0, *, watchOS 26.0, *, tvOS 26.0, *) { 11 | // NavigationView has problems on iOS 26. 12 | // See https://github.com/johnpatrickmorgan/TCACoordinators/issues/90 13 | .whenAvailable 14 | } else { 15 | .never 16 | } 17 | } 18 | } 19 | 20 | enum ParentNavigationStackType { 21 | case navigationView, navigationStack 22 | } 23 | 24 | struct ParentNavigationStackKey: EnvironmentKey { 25 | static let defaultValue: ParentNavigationStackType? = nil 26 | } 27 | 28 | enum FlowStackDataType { 29 | case typedArray, flowPath, noBinding 30 | } 31 | 32 | struct FlowStackDataTypeKey: EnvironmentKey { 33 | static let defaultValue: FlowStackDataType? = nil 34 | } 35 | 36 | extension EnvironmentValues { 37 | var useNavigationStack: UseNavigationStackPolicy { 38 | get { self[UseNavigationStackPolicyKey.self] } 39 | set { self[UseNavigationStackPolicyKey.self] = newValue } 40 | } 41 | 42 | var parentNavigationStackType: ParentNavigationStackType? { 43 | get { self[ParentNavigationStackKey.self] } 44 | set { self[ParentNavigationStackKey.self] = newValue } 45 | } 46 | 47 | var flowStackDataType: FlowStackDataType? { 48 | get { self[FlowStackDataTypeKey.self] } 49 | set { self[FlowStackDataTypeKey.self] = newValue } 50 | } 51 | } 52 | 53 | struct RouteStyleKey: EnvironmentKey { 54 | static let defaultValue: RouteStyle? = nil 55 | } 56 | 57 | public extension EnvironmentValues { 58 | /// If the view is part of a route within a FlowStack, this denotes the presentation style of the route within the stack. 59 | internal(set) var routeStyle: RouteStyle? { 60 | get { self[RouteStyleKey.self] } 61 | set { self[RouteStyleKey.self] = newValue } 62 | } 63 | } 64 | 65 | struct RouteIndexKey: EnvironmentKey { 66 | static let defaultValue: Int? = nil 67 | } 68 | 69 | public extension EnvironmentValues { 70 | /// If the view is part of a route within a FlowStack, this denotes the index of the route within the stack. 71 | internal(set) var routeIndex: Int? { 72 | get { self[RouteIndexKey.self] } 73 | set { self[RouteIndexKey.self] = newValue } 74 | } 75 | } 76 | 77 | struct NestingIndexKey: EnvironmentKey { 78 | static let defaultValue: Int? = nil 79 | } 80 | 81 | public extension EnvironmentValues { 82 | /// If the view is part of a route within a FlowStack, this denotes the number of nested FlowStacks above this view in the hierarchy. 83 | internal(set) var nestingIndex: Int? { 84 | get { self[NestingIndexKey.self] } 85 | set { self[NestingIndexKey.self] = newValue } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Sources/FlowStacks/RouteStyle.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// The style with which a route is shown, i.e., if the route is pushed, presented 4 | /// as a sheet or presented as a full-screen cover. 5 | public enum RouteStyle: Hashable, Codable, Sendable { 6 | /// A push navigation. Only valid if the most recently presented screen is embedded in a `NavigationView`. 7 | case push 8 | 9 | /// A sheet presentation. 10 | /// - Parameter withNavigation: whether the presented screen should be embedded in a `NavigationView`. 11 | case sheet(withNavigation: Bool) 12 | 13 | /// A full-screen cover presentation. 14 | /// - Parameter withNavigation: whether the presented screen should be embedded in a `NavigationView`. 15 | @available(OSX, unavailable, message: "Not available on OS X.") 16 | case cover(withNavigation: Bool) 17 | 18 | /// A sheet presentation. 19 | public static let sheet = RouteStyle.sheet(withNavigation: false) 20 | 21 | /// A full-screen cover presentation. 22 | @available(OSX, unavailable, message: "Not available on OS X.") 23 | public static let cover = RouteStyle.cover(withNavigation: false) 24 | 25 | /// Whether the route style is `sheet`. 26 | public var isSheet: Bool { 27 | switch self { 28 | case .sheet: 29 | true 30 | case .cover, .push: 31 | false 32 | } 33 | } 34 | 35 | /// Whether the route style is `cover`. 36 | public var isCover: Bool { 37 | switch self { 38 | case .cover: 39 | true 40 | case .sheet, .push: 41 | false 42 | } 43 | } 44 | 45 | /// Whether the route style is `push`. 46 | public var isPush: Bool { 47 | switch self { 48 | case .push: 49 | true 50 | case .sheet, .cover: 51 | false 52 | } 53 | } 54 | } 55 | 56 | public extension Route { 57 | /// Whether the route is pushed, presented as a sheet or presented as a full-screen 58 | /// cover. 59 | var style: RouteStyle { 60 | switch self { 61 | case .push: 62 | return .push 63 | case let .sheet(_, withNavigation): 64 | return .sheet(withNavigation: withNavigation) 65 | #if os(macOS) 66 | #else 67 | case let .cover(_, withNavigation): 68 | return .cover(withNavigation: withNavigation) 69 | #endif 70 | } 71 | } 72 | 73 | /// Initialises a ``Route`` with the given screen data and route style 74 | /// - Parameters: 75 | /// - screen: The screen data. 76 | /// - style: The route style, e.g. `push`. 77 | init(screen: Screen, style: RouteStyle) { 78 | switch style { 79 | case .push: 80 | self = .push(screen) 81 | case let .sheet(withNavigation): 82 | self = .sheet(screen, withNavigation: withNavigation) 83 | #if os(macOS) 84 | #else 85 | case let .cover(withNavigation): 86 | self = .cover(screen, withNavigation: withNavigation) 87 | #endif 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Sources/FlowStacks/DestinationBuilderHolder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | /// Keeps hold of the destination builder closures for a given type or local destination ID. 5 | @MainActor 6 | class DestinationBuilderHolder: ObservableObject { 7 | static func identifier(for type: Any.Type) -> String { 8 | String(reflecting: type) 9 | } 10 | 11 | var builders: [String: @MainActor (Binding) -> AnyView?] = [:] 12 | 13 | init() { 14 | builders = [:] 15 | } 16 | 17 | func appendBuilder(_ builder: @escaping (Binding) -> AnyView) { 18 | let key = Self.identifier(for: T.self) 19 | builders[key] = { data in 20 | let binding = Binding( 21 | get: { data.wrappedValue as! T }, 22 | set: { newValue, transaction in 23 | data.transaction(transaction).wrappedValue = newValue 24 | } 25 | ) 26 | return builder(binding) 27 | } 28 | } 29 | 30 | func appendLocalBuilder(identifier: LocalDestinationID, _ builder: @escaping () -> AnyView) { 31 | let key = identifier.rawValue.uuidString 32 | builders[key] = { _ in builder() } 33 | } 34 | 35 | func removeLocalBuilder(identifier: LocalDestinationID) { 36 | let key = identifier.rawValue.uuidString 37 | builders[key] = nil 38 | } 39 | 40 | func build(_ binding: Binding) -> AnyView { 41 | var base = binding.wrappedValue.base 42 | var key = Self.identifier(for: type(of: base)) 43 | // NOTE: - `wrappedValue` might be nested `AnyHashable` e.g. `AnyHashable>>`. 44 | // And `base as? AnyHashable` will always produce a `AnyHashable` so we need check if key contains 'AnyHashable' to break the looping. 45 | while key == "Swift.AnyHashable", let anyHashable = base as? AnyHashable { 46 | base = anyHashable.base 47 | key = Self.identifier(for: type(of: base)) 48 | } 49 | if let identifier = base as? LocalDestinationID { 50 | let key = identifier.rawValue.uuidString 51 | if let builder = builders[key], let output = builder(binding) { 52 | return output 53 | } 54 | assertionFailure("No view builder found for type \(key)") 55 | } else { 56 | let key = Self.identifier(for: type(of: base)) 57 | 58 | if let builder = builders[key], let output = builder(binding) { 59 | return output 60 | } 61 | var possibleMirror: Mirror? = Mirror(reflecting: base) 62 | while let mirror = possibleMirror { 63 | let mirrorKey = Self.identifier(for: mirror.subjectType) 64 | 65 | if let builder = builders[mirrorKey], let output = builder(binding) { 66 | return output 67 | } 68 | possibleMirror = mirror.superclassMirror 69 | } 70 | assertionFailure("No view builder found for type \(type(of: base))") 71 | } 72 | return AnyView(Image(systemName: "exclamationmark.triangle")) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Sources/FlowStacks/ScreenModifier.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// Assigns required environment objects to a screen. It's not feasible to only rely on NavigationView propagating these, as a 4 | /// nested FlowStack using its parent's navigation view would not have the child's environment objects propagated to 5 | /// pushed screens. 6 | struct ScreenModifier: ViewModifier { 7 | var path: RoutesHolder 8 | var destinationBuilder: DestinationBuilderHolder 9 | var navigator: FlowNavigator 10 | @Binding var typedPath: [Route] 11 | var nestingIndex: Int 12 | // NOTE: Using `Environment(\.scenePhase)` doesn't work if the app uses UIKIt lifecycle events (via AppDelegate/SceneDelegate). 13 | // We do not need to re-render the view when appIsActive changes, and doing so can cause animation glitches, so it is wrapped 14 | // in `NonReactiveState`. 15 | @State var appIsActive = NonReactiveState(value: true) 16 | 17 | func body(content: Content) -> some View { 18 | content 19 | .environmentObject(path) 20 | .environmentObject(Unobserved(object: path)) 21 | .environmentObject(destinationBuilder) 22 | .environmentObject(navigator) 23 | .environment(\.nestingIndex, nestingIndex) 24 | .onChange(of: path.routes) { routes in 25 | guard routes != typedPath.map({ $0.erased() }) else { return } 26 | typedPath = routes.compactMap { route in 27 | // NOTE: Routes may have been added via other methods (e.g. `flowDestination(item: )`) but cannot be part of the typed routes array. 28 | guard let screen = route.screen as? Data else { return nil } 29 | return Route(screen: screen, style: route.style) 30 | } 31 | } 32 | .onChange(of: typedPath) { typedPath in 33 | guard appIsActive.value else { return } 34 | guard path.routes != typedPath.map({ $0.erased() }) else { return } 35 | path.routes = typedPath.map { $0.erased() } 36 | } 37 | #if os(iOS) 38 | .onReceive(NotificationCenter.default.publisher(for: didBecomeActive)) { _ in 39 | appIsActive.value = true 40 | path.routes = typedPath.map { $0.erased() } 41 | } 42 | .onReceive(NotificationCenter.default.publisher(for: willResignActive)) { _ in 43 | appIsActive.value = false 44 | } 45 | #elseif os(tvOS) 46 | .onReceive(NotificationCenter.default.publisher(for: didBecomeActive)) { _ in 47 | appIsActive.value = true 48 | path.routes = typedPath.map { $0.erased() } 49 | } 50 | .onReceive(NotificationCenter.default.publisher(for: willResignActive)) { _ in 51 | appIsActive.value = false 52 | } 53 | #endif 54 | } 55 | } 56 | 57 | #if os(iOS) 58 | private let didBecomeActive = UIApplication.didBecomeActiveNotification 59 | private let willResignActive = UIApplication.willResignActiveNotification 60 | #elseif os(tvOS) 61 | private let didBecomeActive = UIApplication.didBecomeActiveNotification 62 | private let willResignActive = UIApplication.willResignActiveNotification 63 | #endif 64 | -------------------------------------------------------------------------------- /Docs/Nesting FlowStacks.md: -------------------------------------------------------------------------------- 1 | # Nesting FlowStacks 2 | 3 | Sometimes, it can be useful to break your app's screen flows into several distinct flows of related screens. FlowStacks supports nesting coordinators, though there are some limitations to keep in mind. 4 | 5 | 'Coordinator' here is used to describe a view that contains a `FlowStack` to manage a flow of screens. Coordinators are just SwiftUI views, so they can be shown in all the normal ways views can. They can even be pushed onto a parent coordinator's `FlowStack`, allowing you to break out parts of your navigation flow into distinct child coordinators. 6 | 7 | The approach to nesting coordinators will depend on how you are instantiating your `FlowStack`s. A `FlowStack` can be instantiated with either: 8 | 9 | 1. a binding to a `FlowPath`, which can support any `Hashable` data, 10 | 1. a binding to a typed routes array, e.g. `[Route]`, or 11 | 1. no binding at all. 12 | 13 | ## Approach 1: Nested FlowStack inherits its parent FlowStack's state 14 | 15 | If the child FlowStack is instantiated without its own data binding, it can share its parent's data binding as its own source of truth, as long as the parent's data binding is not a typed routes array (since that only supports a single type). In this approach, any type can be pushed onto the path, and as long as somewhere in the stack you have declared how to build a destination for that data type (using the `flowDestination` modifier), the screen will be shown. That means you can nest any number of child `FlowStack`s of this type, and they will all share the same path state - parent and children all have access to the same shared path that includes all coordinators' screens. That means: 16 | 17 | - Both parent and child can push new routes onto the path, and the parent's path will include the ones its child has pushed. 18 | - Calling `goBackToRoot` from the child will go all the way back to the parent's root screen. 19 | - The parent is responsible for whether the child should be shown with navigation or not. 20 | 21 | 22 | ## Approach 2: Nested FlowStack holds its own state and takes over navigation duties from its parent FlowStack 23 | 24 | If the child has its own data binding (i.e., a `FlowPath` or typed routes array), or its parent FlowStack holds a typed routes array, it's still possible to nest cooordinators, but there are some things to keep in mind. Since only `MyScreen` routes can be added to the array, any nested child `FlowStack`s cannot share the same path state. They will instead have their own independent array of routes. When doing so, it is essential that the child coordinator is always at the top of the parent's routes stack, as it will take over responsibility for pushing and presenting new screens. Otherwise, the parent might attempt to push screen(s) when the child is already pushing screen(s), causing a conflict. 25 | 26 | That means: 27 | 28 | - Only the child can push new routes onto the path: it assumes responsibility for navigation until it is removed from its parent's path. 29 | - Calling `goBackToRoot` from the child will go back to the child's root screen. 30 | - The child is responsible for whether its root should be shown with navigation or not. 31 | -------------------------------------------------------------------------------- /FlowStacksApp.xcodeproj/xcshareddata/xcschemes/FlowStacksApp (macOS).xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /FlowStacksApp.xcodeproj/xcshareddata/xcschemes/FlowStacksApp (tvOS).xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /Sources/FlowStacks/Node.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | struct Node: View { 5 | @Binding var allRoutes: [Route] 6 | let truncateToIndex: (Int) -> Void 7 | let index: Int 8 | let route: Route? 9 | let navigationViewModifier: Modifier 10 | let screenModifier: ScreenModifier 11 | 12 | @Environment(\.useNavigationStack) var useNavigationStack 13 | 14 | @State var isAppeared = false 15 | 16 | init(allRoutes: Binding<[Route]>, truncateToIndex: @escaping (Int) -> Void, index: Int, navigationViewModifier: Modifier, screenModifier: ScreenModifier) { 17 | _allRoutes = allRoutes 18 | self.truncateToIndex = truncateToIndex 19 | self.index = index 20 | self.navigationViewModifier = navigationViewModifier 21 | self.screenModifier = screenModifier 22 | route = allRoutes.wrappedValue[safe: index] 23 | } 24 | 25 | private var isActiveBinding: Binding { 26 | return Binding( 27 | get: { allRoutes.count > nextPresentedIndex }, 28 | set: { isShowing in 29 | guard !isShowing else { return } 30 | guard allRoutes.count > nextPresentedIndex else { return } 31 | guard isAppeared else { return } 32 | 33 | truncateToIndex(nextPresentedIndex) 34 | } 35 | ) 36 | } 37 | 38 | var nextPresentedIndex: Int { 39 | if #available(iOS 16.0, *, macOS 13.0, *, watchOS 9.0, *, tvOS 16.0, *), useNavigationStack == .whenAvailable { 40 | allRoutes.indices.contains(index + 1) ? allRoutes[(index + 1)...].firstIndex(where: \.isPresented) ?? allRoutes.endIndex : allRoutes.endIndex 41 | } else { 42 | index + 1 43 | } 44 | } 45 | 46 | var next: some View { 47 | Node(allRoutes: $allRoutes, truncateToIndex: truncateToIndex, index: nextPresentedIndex /* index + 1 */, navigationViewModifier: navigationViewModifier, screenModifier: screenModifier) 48 | } 49 | 50 | var nextRouteStyle: RouteStyle? { 51 | allRoutes[safe: nextPresentedIndex /* index + 1 */ ]?.style 52 | } 53 | 54 | var body: some View { 55 | if let route = allRoutes[safe: index] ?? route { 56 | let binding = Binding(get: { 57 | allRoutes[safe: index]?.screen ?? route.screen 58 | }, set: { newValue in 59 | guard let typedData = newValue as? Screen else { return } 60 | allRoutes[index].screen = typedData 61 | }) 62 | 63 | DestinationBuilderView(data: binding) 64 | .modifier(screenModifier) 65 | .modifier( 66 | EmbedModifier( 67 | withNavigation: route.withNavigation, 68 | navigationViewModifier: navigationViewModifier, 69 | screenModifier: screenModifier, 70 | routes: $allRoutes, 71 | navigationStackIndex: index, 72 | isActive: isActiveBinding, 73 | nextRouteStyle: nextRouteStyle, 74 | destination: next 75 | ) 76 | ) 77 | .environment(\.routeStyle, allRoutes[safe: index]?.style) 78 | .environment(\.routeIndex, index) 79 | .onAppear { isAppeared = true } 80 | .onDisappear { isAppeared = false } 81 | } 82 | } 83 | } 84 | 85 | extension Collection { 86 | /// Returns the element at the specified index if it is within bounds, otherwise nil. 87 | subscript(safe index: Index) -> Element? { 88 | indices.contains(index) ? self[index] : nil 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Tests/FlowStacksTests/ConvenienceMethodsTests.swift: -------------------------------------------------------------------------------- 1 | @testable import FlowStacks 2 | import XCTest 3 | 4 | final class ConvenienceMethodsTests: XCTestCase { 5 | func testGoBackToType() { 6 | var path = FlowPath([.push(1), .push("two"), .push(true)]) 7 | path.goBackTo(type: String.self) 8 | XCTAssertEqual(path.count, 2) 9 | path.goBackTo(type: Int.self) 10 | XCTAssertEqual(path.count, 1) 11 | } 12 | 13 | func testGoBackToInstance() { 14 | var path = FlowPath([.push(1), .push("two"), .push(true)]) 15 | path.goBackTo("non-matching") 16 | XCTAssertEqual(path.count, 3) 17 | path.goBackTo("two") 18 | XCTAssertEqual(path.count, 2) 19 | path.goBackTo(1) 20 | XCTAssertEqual(path.count, 1) 21 | } 22 | 23 | func testGoBackToRoot() { 24 | var path = FlowPath([.push(1), .push("two"), .push(true)]) 25 | path.goBackToRoot() 26 | XCTAssertEqual(path.count, 0) 27 | } 28 | 29 | func testGoBackToIndex() { 30 | var path = FlowPath([.push(1), .push("two"), .push(true)]) 31 | path.goBackTo(index: 2) 32 | XCTAssertEqual(path.count, 3) 33 | path.goBackTo(index: 1) 34 | XCTAssertEqual(path.count, 2) 35 | path.goBackTo(index: 0) 36 | XCTAssertEqual(path.count, 1) 37 | path.goBackTo(index: -1) 38 | XCTAssertEqual(path.count, 0) 39 | } 40 | 41 | func testPopToType() { 42 | var path = FlowPath([.push(1), .push("two"), .push(true)]) 43 | path.popTo(type: String.self) 44 | XCTAssertEqual(path.count, 2) 45 | path.popTo(type: Int.self) 46 | XCTAssertEqual(path.count, 1) 47 | } 48 | 49 | func testPopToInstance() { 50 | var path = FlowPath([.push(1), .push("two"), .push(true)]) 51 | path.popTo("non-matching") 52 | XCTAssertEqual(path.count, 3) 53 | path.popTo("two") 54 | XCTAssertEqual(path.count, 2) 55 | path.popTo(1) 56 | XCTAssertEqual(path.count, 1) 57 | } 58 | 59 | func testPopToRoot() { 60 | var path = FlowPath([.push(1), .push("two"), .push(true)]) 61 | path.popToRoot() 62 | XCTAssertEqual(path.count, 0) 63 | } 64 | 65 | func testPopToIndex() { 66 | var path = FlowPath([.push(1), .push("two"), .push(true)]) 67 | path.popTo(index: 2) 68 | XCTAssertEqual(path.count, 3) 69 | path.popTo(index: 1) 70 | XCTAssertEqual(path.count, 2) 71 | path.popTo(index: 0) 72 | XCTAssertEqual(path.count, 1) 73 | path.popTo(index: -1) 74 | XCTAssertEqual(path.count, 0) 75 | } 76 | 77 | func testPopToCurrentNavigationRootWithoutPresentedRoutes() { 78 | var path = FlowPath([.push(1), .push("two"), .push(true)]) 79 | path.popToCurrentNavigationRoot() 80 | XCTAssertEqual(path.count, 0) 81 | } 82 | 83 | func testPopToCurrentNavigationRootWithPresentedRoutes() { 84 | var path = FlowPath([.push(1), .sheet("two"), .push(true)]) 85 | path.popToCurrentNavigationRoot() 86 | XCTAssertEqual(path.count, 2) 87 | } 88 | 89 | func testDismissAllWhereFirstIsPushed() { 90 | var path = FlowPath([.push(1), .sheet("two"), .push(3), .cover("four"), .push(5), .cover("six"), .push(7)]) 91 | path.dismiss() 92 | XCTAssertEqual(path.count, 5) 93 | path.dismissAll() 94 | XCTAssertEqual(path.count, 1) 95 | } 96 | 97 | func testDismissAllWhereFirstIsPresented() { 98 | var path = FlowPath([.cover(1), .sheet("two"), .push(3), .cover("four"), .push(5), .cover("six"), .push(7)]) 99 | path.dismissAll() 100 | XCTAssertEqual(path.count, 0) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Sources/FlowStacks/Route.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public typealias Routes = [Route] 4 | 5 | /// A step in the navigation flow of an app, encompassing a Screen and how it should be shown, 6 | /// e.g. via a push navigation, a sheet or a full-screen cover. 7 | public enum Route { 8 | /// A push navigation. Only valid if the most recently presented screen is embedded in a `NavigationView`. 9 | /// - Parameter screen: the screen to be shown. 10 | case push(Screen) 11 | 12 | /// A sheet presentation. 13 | /// - Parameter screen: the screen to be shown. 14 | /// - Parameter withNavigation: whether the presented screen should be embedded in a `NavigationView`. 15 | case sheet(Screen, withNavigation: Bool) 16 | 17 | /// A full-screen cover presentation. 18 | /// - Parameter screen: the screen to be shown. 19 | /// - Parameter withNavigation: whether the presented screen should be embedded in a `NavigationView`. 20 | @available(OSX, unavailable, message: "Not available on OS X.") 21 | case cover(Screen, withNavigation: Bool) 22 | 23 | /// The screen to be shown. 24 | public var screen: Screen { 25 | get { 26 | switch self { 27 | case let .push(screen), let .sheet(screen, _), let .cover(screen, _): 28 | screen 29 | } 30 | } 31 | set { 32 | switch self { 33 | case .push: 34 | self = .push(newValue) 35 | case let .sheet(_, withNavigation): 36 | self = .sheet(newValue, withNavigation: withNavigation) 37 | #if os(macOS) 38 | #else 39 | case let .cover(_, withNavigation): 40 | self = .cover(newValue, withNavigation: withNavigation) 41 | #endif 42 | } 43 | } 44 | } 45 | 46 | /// Whether the presented screen should be embedded in a `NavigationView`. 47 | public var withNavigation: Bool { 48 | switch self { 49 | case .push: 50 | false 51 | case let .sheet(_, withNavigation), let .cover(_, withNavigation): 52 | withNavigation 53 | } 54 | } 55 | 56 | /// Whether the route is presented (via a sheet or cover presentation). 57 | public var isPresented: Bool { 58 | switch self { 59 | case .push: 60 | false 61 | case .sheet, .cover: 62 | true 63 | } 64 | } 65 | 66 | /// Transforms the screen data within the route. 67 | /// - Parameter transform: The transform to be applied. 68 | /// - Returns: A new route with the same route style, but transformed screen data. 69 | public func map(_ transform: (Screen) -> NewScreen) -> Route { 70 | switch self { 71 | case .push: 72 | return .push(transform(screen)) 73 | case let .sheet(_, withNavigation): 74 | return .sheet(transform(screen), withNavigation: withNavigation) 75 | #if os(macOS) 76 | #else 77 | case let .cover(_, withNavigation): 78 | return .cover(transform(screen), withNavigation: withNavigation) 79 | #endif 80 | } 81 | } 82 | } 83 | 84 | extension Route: Equatable where Screen: Equatable {} 85 | 86 | extension Route: Hashable where Screen: Hashable {} 87 | 88 | extension Route: Codable where Screen: Codable {} 89 | 90 | extension Route where Screen: Hashable { 91 | func erased() -> Route { 92 | if let anyHashableSelf = self as? Route { 93 | return anyHashableSelf 94 | } 95 | return map { $0 } 96 | } 97 | } 98 | 99 | extension Hashable { 100 | var erasedToAnyHashable: AnyHashable { 101 | get { 102 | return self 103 | } 104 | set { 105 | self = newValue as! Self 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /FlowStacksAppUITests/NestedFlowStacksUITests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | final class NestedFlowStacksUITests: XCTestCase { 4 | override func setUpWithError() throws { 5 | continueAfterFailure = false 6 | } 7 | 8 | func testNestedNavigationViaPathWithNavigationView() { 9 | launchAndRunNestedNavigationTests(tabTitle: "FlowPath", useNavigationStack: false, app: XCUIApplication()) 10 | } 11 | 12 | func testNestedNavigationViaNoneWithNavigationView() { 13 | launchAndRunNestedNavigationTests(tabTitle: "NoBinding", useNavigationStack: false, app: XCUIApplication()) 14 | } 15 | 16 | func testNestedNavigationViaArrayWithNavigationView() { 17 | launchAndRunNestedNavigationTests(tabTitle: "ArrayBinding", useNavigationStack: false, app: XCUIApplication()) 18 | } 19 | 20 | func testNestedNavigationViaPathWithNavigationStack() { 21 | launchAndRunNestedNavigationTests(tabTitle: "FlowPath", useNavigationStack: true, app: XCUIApplication()) 22 | } 23 | 24 | func testNestedNavigationViaNoneWithNavigationStack() { 25 | launchAndRunNestedNavigationTests(tabTitle: "NoBinding", useNavigationStack: true, app: XCUIApplication()) 26 | } 27 | 28 | func testNestedNavigationViaArrayWithNavigationStack() { 29 | launchAndRunNestedNavigationTests(tabTitle: "ArrayBinding", useNavigationStack: true, app: XCUIApplication()) 30 | } 31 | 32 | func launchAndRunNestedNavigationTests(tabTitle: String, useNavigationStack: Bool, app: XCUIApplication) { 33 | if useNavigationStack { 34 | if #available(iOS 16.0, *, macOS 13.0, *, watchOS 9.0, *, tvOS 16.0, *) { 35 | app.launchArguments = ["USE_NAVIGATIONSTACK"] 36 | } else { 37 | // Navigation Stack unavailable, so test can be skipped 38 | return 39 | } 40 | } else if #available(iOS 26.0, *, macOS 26.0, *, watchOS 26.0, *, tvOS 26.0, *) { 41 | // NavigationView has issues on v26.0, so it is not supported. 42 | return 43 | } 44 | app.launch() 45 | 46 | let navigationTimeout = 0.8 47 | 48 | XCTAssertTrue(app.tabBars.buttons[tabTitle].waitForExistence(timeout: 3)) 49 | app.tabBars.buttons[tabTitle].tap() 50 | XCTAssertTrue(app.navigationBars["Home"].waitForExistence(timeout: 2)) 51 | 52 | app.buttons["Pick a number - route 1:-1"].tap() 53 | XCTAssertTrue(app.navigationBars["List"].waitForExistence(timeout: navigationTimeout)) 54 | 55 | app.buttons["Show 1 - route 1:0"].tap() 56 | XCTAssertTrue(app.navigationBars["1"].waitForExistence(timeout: navigationTimeout)) 57 | 58 | app.buttons["FlowPath Child - route 1:1"].tap() 59 | XCTAssertTrue(app.navigationBars["Home"].waitForExistence(timeout: navigationTimeout)) 60 | 61 | app.buttons["Pick a number - route 2:-1"].firstMatch.tap() 62 | XCTAssertTrue(app.navigationBars["List"].waitForExistence(timeout: navigationTimeout)) 63 | 64 | app.buttons["Show 1 - route 2:0"].tap() 65 | XCTAssertTrue(app.navigationBars["1"].waitForExistence(timeout: navigationTimeout)) 66 | 67 | app.buttons["NoBinding Child - route 2:1"].firstMatch.tap() 68 | XCTAssertTrue(app.navigationBars["Home"].waitForExistence(timeout: navigationTimeout)) 69 | 70 | app.buttons["Pick a number - route 2:2"].firstMatch.tap() 71 | XCTAssertTrue(app.navigationBars["List"].waitForExistence(timeout: navigationTimeout)) 72 | 73 | app.buttons["Show 1 - route 2:3"].tap() 74 | XCTAssertTrue(app.navigationBars["1"].waitForExistence(timeout: navigationTimeout)) 75 | 76 | app.buttons["Go back to root - route 2:4"].tap() 77 | XCTAssertTrue(app.navigationBars["Home"].waitForExistence(timeout: navigationTimeout)) 78 | 79 | // Goes back to root of FlowPath child. 80 | XCTAssertTrue(app.buttons["Pick a number - route 2:-1"].exists) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Sources/FlowStacks/EmbedModifier.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// Embeds a view in a NavigationView or NavigationStack. 4 | struct EmbedModifier: ViewModifier { 5 | var withNavigation: Bool 6 | let navigationViewModifier: NavigationViewModifier 7 | let screenModifier: ScreenModifier 8 | @Environment(\.useNavigationStack) var useNavigationStack 9 | @Environment(\.routeIndex) var routeIndex 10 | @Binding var routes: [Route] 11 | let navigationStackIndex: Int 12 | let isActive: Binding 13 | let nextRouteStyle: RouteStyle? 14 | let destination: Destination 15 | 16 | @ViewBuilder 17 | func wrapped(content: Content) -> some View { 18 | if #available(iOS 16.0, *, macOS 13.0, *, watchOS 9.0, *, tvOS 16.0, *), useNavigationStack == .whenAvailable { 19 | let path = $routes[navigationStackFrom: navigationStackIndex + 1] 20 | NavigationStack(path: path.indexed) { 21 | content 22 | .navigationDestination(for: Indexed>.self) { indexedRoute in 23 | let route = indexedRoute.value 24 | let binding = path[safe: indexedRoute.index]?.screen.erasedToAnyHashable ?? .constant(indexedRoute.value.screen) 25 | DestinationBuilderView(data: binding) 26 | .environment(\.routeStyle, route.style) 27 | .environment(\.routeIndex, indexedRoute.index + 1 + (routeIndex ?? -1)) 28 | .modifier(screenModifier) 29 | .environment(\.parentNavigationStackType, .navigationStack) 30 | } 31 | .environment(\.parentNavigationStackType, .navigationStack) 32 | } 33 | .show( 34 | isActive: isActive, 35 | routeStyle: nextRouteStyle, 36 | destination: destination 37 | ) 38 | .modifier(navigationViewModifier) 39 | } else { 40 | NavigationView { 41 | content 42 | .show( 43 | isActive: isActive, 44 | routeStyle: nextRouteStyle, 45 | destination: destination 46 | ) 47 | } 48 | .modifier(navigationViewModifier) 49 | .navigationViewStyle(supportedNavigationViewStyle) 50 | .environment(\.parentNavigationStackType, .navigationView) 51 | } 52 | } 53 | 54 | func body(content: Content) -> some View { 55 | if withNavigation { 56 | wrapped(content: content) 57 | } else { 58 | content 59 | .show( 60 | isActive: isActive, 61 | routeStyle: nextRouteStyle, 62 | destination: destination 63 | ) 64 | } 65 | } 66 | } 67 | 68 | /// There are spurious state updates when using the `column` navigation view style, so 69 | /// the navigation view style is forced to `stack` where possible. 70 | private var supportedNavigationViewStyle: some NavigationViewStyle { 71 | #if os(macOS) 72 | .automatic 73 | #else 74 | .stack 75 | #endif 76 | } 77 | 78 | struct Indexed: Hashable { 79 | var index: Int 80 | var value: T 81 | } 82 | 83 | extension Array where Element: Hashable { 84 | var indexed: [Indexed] { 85 | get { 86 | enumerated().map(Indexed.init) 87 | } 88 | set { 89 | self = newValue.map(\.value) 90 | } 91 | } 92 | } 93 | 94 | extension Array where Element: RouteProtocol { 95 | subscript(navigationStackFrom initialIndex: Int) -> [Element] { 96 | get { 97 | guard !isEmpty, initialIndex < endIndex else { return [] } 98 | let remainder = self[initialIndex...] 99 | let finalIndex = remainder.firstIndex(where: { $0.isPresented }) ?? endIndex 100 | return Array(self[initialIndex ..< finalIndex]) 101 | } 102 | set { 103 | // TODO: Handle if change is not on top of stack? 104 | removeSubrange(initialIndex ..< endIndex) 105 | append(contentsOf: newValue) 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /Sources/FlowStacks/FlowPath+calculateSteps.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | extension FlowPath { 5 | /// Calculates the minimal number of steps to update from one routes array to another, within the constraints of SwiftUI. 6 | /// For a given update to an array of routes, returns the minimum intermediate steps. 7 | /// required to ensure each update is supported by SwiftUI. 8 | /// - Parameters: 9 | /// - start: The initial state. 10 | /// - end: The goal state. 11 | /// - allowMultipleDismissalsInOneStep: Whether the platform allows multiple layers of presented screens to be dismissed in one update. 12 | /// - Returns: A series of state updates from the start to end. 13 | static func calculateSteps(from start: [Route], to end: [Route], allowMultipleDismissalsInOne: Bool, allowNavigationUpdatesInOne: Bool) -> [[Route]] { 14 | let pairs = Array(zip(start, end)) 15 | let firstDivergingIndex = pairs 16 | .firstIndex(where: { $0.style != $1.style }) ?? pairs.endIndex 17 | let firstDivergingPresentationIndex = start[firstDivergingIndex ..< start.count] 18 | .firstIndex(where: { $0.isPresented }) ?? start.endIndex 19 | 20 | // Initial step is to change screen content without changing navigation structure. 21 | let initialStep = Array(end[.. firstDivergingPresentationIndex { 27 | // On iOS 17, this can be performed in one step. 28 | steps.append(Array(end[.. firstDivergingPresentationIndex { 32 | var dismissed: Route? = dismissStep.popLast() 33 | // Ignore pushed screens as they can be dismissed en masse. 34 | while dismissed?.isPresented == false, dismissStep.count > firstDivergingPresentationIndex { 35 | dismissed = dismissStep.popLast() 36 | } 37 | steps.append(dismissStep) 38 | } 39 | } 40 | 41 | // Pop extraneous pushed screens. 42 | if var popStep = steps.last, popStep.count > firstDivergingIndex { 43 | while let popped = popStep.last, popped.style == .push, popStep.count > firstDivergingIndex { 44 | popStep.removeLast() 45 | } 46 | steps.append(popStep) 47 | } 48 | 49 | // Push or present each new step. 50 | while var newStep = steps.last, newStep.count < end.count { 51 | newStep.append(end[newStep.count]) 52 | if allowNavigationUpdatesInOne, newStep.last!.style.isPush == true, steps.count > 1 { 53 | steps[steps.count - 1] = newStep 54 | } else { 55 | steps.append(newStep) 56 | } 57 | } 58 | 59 | return steps 60 | } 61 | 62 | /// Calculates the minimal number of steps to update from one routes array to another, within the constraints of SwiftUI. 63 | /// For a given update to an array of routes, returns the minimum intermediate steps. 64 | /// required to ensure each update is supported by SwiftUI. 65 | /// - Parameters: 66 | /// - start: The initial state. 67 | /// - end: The goal state. 68 | /// - Returns: A series of state updates from the start to end. 69 | public static func calculateSteps(from start: [Route], to end: [Route], allowNavigationUpdatesInOne: Bool) -> [[Route]] { 70 | let allowMultipleDismissalsInOne: Bool 71 | if #available(iOS 17.0, *) { 72 | allowMultipleDismissalsInOne = true 73 | } else { 74 | allowMultipleDismissalsInOne = false 75 | } 76 | return calculateSteps(from: start, to: end, allowMultipleDismissalsInOne: allowMultipleDismissalsInOne, allowNavigationUpdatesInOne: allowNavigationUpdatesInOne) 77 | } 78 | 79 | static func canSynchronouslyUpdate(from start: [Route], to end: [Route], allowNavigationUpdatesInOne: Bool) -> Bool { 80 | // If there are less than 3 steps, the transformation can be applied in one update. 81 | let steps = calculateSteps(from: start, to: end, allowNavigationUpdatesInOne: allowNavigationUpdatesInOne) 82 | return steps.count < 3 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /FlowStacksApp.xcodeproj/xcshareddata/xcschemes/FlowStacksApp (iOS).xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 43 | 49 | 50 | 51 | 52 | 53 | 63 | 65 | 71 | 72 | 73 | 74 | 77 | 78 | 79 | 80 | 86 | 88 | 94 | 95 | 96 | 97 | 99 | 100 | 103 | 104 | 105 | -------------------------------------------------------------------------------- /FlowStacksAppUITests/FlowStacksUITests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | final class FlowStacksUITests: XCTestCase { 4 | override func setUpWithError() throws { 5 | continueAfterFailure = false 6 | } 7 | 8 | func testNavigationViaPathWithNavigationView() { 9 | launchAndRunNavigationTests(tabTitle: "FlowPath", useNavigationStack: false, app: XCUIApplication()) 10 | } 11 | 12 | func testNavigationViaArrayWithNavigationView() { 13 | launchAndRunNavigationTests(tabTitle: "ArrayBinding", useNavigationStack: false, app: XCUIApplication()) 14 | } 15 | 16 | func testNavigationViaNoneWithNavigationView() { 17 | launchAndRunNavigationTests(tabTitle: "NoBinding", useNavigationStack: false, app: XCUIApplication()) 18 | } 19 | 20 | func testNavigationViaPathWithNavigationStack() { 21 | launchAndRunNavigationTests(tabTitle: "FlowPath", useNavigationStack: true, app: XCUIApplication()) 22 | } 23 | 24 | func testNavigationViaArrayWithNavigationStack() { 25 | launchAndRunNavigationTests(tabTitle: "ArrayBinding", useNavigationStack: true, app: XCUIApplication()) 26 | } 27 | 28 | func testNavigationViaNoneWithNavigationStack() { 29 | launchAndRunNavigationTests(tabTitle: "NoBinding", useNavigationStack: true, app: XCUIApplication()) 30 | } 31 | 32 | func launchAndRunNavigationTests(tabTitle: String, useNavigationStack: Bool, app: XCUIApplication) { 33 | if useNavigationStack { 34 | if #available(iOS 16.0, *, macOS 13.0, *, watchOS 9.0, *, tvOS 16.0, *) { 35 | app.launchArguments = ["USE_NAVIGATIONSTACK"] 36 | } else { 37 | // Navigation Stack unavailable, so test can be skipped 38 | return 39 | } 40 | } else if #available(iOS 26.0, *, macOS 26.0, *, watchOS 26.0, *, tvOS 26.0, *) { 41 | // NavigationView has issues on v26.0, so it is not supported. 42 | return 43 | } 44 | 45 | app.launch() 46 | 47 | let navigationTimeout = 0.8 48 | 49 | XCTAssertTrue(app.tabBars.buttons[tabTitle].waitForExistence(timeout: 3)) 50 | app.tabBars.buttons[tabTitle].tap() 51 | XCTAssertTrue(app.navigationBars["Home"].waitForExistence(timeout: 2)) 52 | 53 | app.buttons["Pick a number - route 1:-1"].tap() 54 | XCTAssertTrue(app.navigationBars["List"].waitForExistence(timeout: navigationTimeout)) 55 | 56 | app.navigationBars["List"].swipeSheetDown() 57 | XCTAssertTrue(app.navigationBars["Home"].waitForExistence(timeout: navigationTimeout)) 58 | 59 | app.buttons["99 Red balloons"].tap() 60 | XCTAssertTrue(app.navigationBars["Visualise 99"].waitForExistence(timeout: 2 * navigationTimeout)) 61 | 62 | app.navigationBars.buttons.element(boundBy: 0).tap() 63 | app.navigationBars.buttons.element(boundBy: 0).tap() 64 | XCTAssertTrue(app.navigationBars["Home"].waitForExistence(timeout: navigationTimeout)) 65 | 66 | app.buttons["Pick a number - route 1:-1"].tap() 67 | XCTAssertTrue(app.navigationBars["List"].waitForExistence(timeout: navigationTimeout)) 68 | 69 | app.buttons["Show 1 - route 1:0"].tap() 70 | XCTAssertTrue(app.navigationBars["1"].waitForExistence(timeout: navigationTimeout)) 71 | 72 | app.buttons["Show next number"].tap() 73 | XCTAssertTrue(app.navigationBars["2"].waitForExistence(timeout: navigationTimeout)) 74 | 75 | app.buttons["Show next number"].tap() 76 | XCTAssertTrue(app.navigationBars["3"].waitForExistence(timeout: navigationTimeout)) 77 | 78 | app.buttons["Show next number"].tap() 79 | XCTAssertTrue(app.navigationBars["4"].waitForExistence(timeout: navigationTimeout)) 80 | 81 | app.buttons["Go back to root"].tap() 82 | XCTAssertTrue(app.navigationBars["Home"].waitForExistence(timeout: navigationTimeout)) 83 | 84 | if #available(iOS 15.0, *) { 85 | // This test fails on iOS 14, despite working in real use. 86 | app.buttons["Push local destination"].tap() 87 | XCTAssertTrue(app.staticTexts["Local destination"].waitForExistence(timeout: navigationTimeout * 2)) 88 | 89 | app.navigationBars.buttons.element(boundBy: 0).tap() 90 | XCTAssertTrue(app.navigationBars["Home"].waitForExistence(timeout: navigationTimeout)) 91 | XCTAssertTrue(app.buttons["Push local destination"].isEnabled) 92 | } 93 | 94 | if tabTitle != "ArrayBinding" { 95 | app.buttons["Show Class Destination"].tap() 96 | XCTAssertTrue(app.staticTexts["Sample data"].waitForExistence(timeout: navigationTimeout)) 97 | 98 | app.navigationBars.buttons.element(boundBy: 0).tap() 99 | XCTAssertTrue(app.navigationBars["Home"].waitForExistence(timeout: navigationTimeout)) 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /FlowStacksApp/Shared/NumberVMFlow.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import FlowStacks 3 | import SwiftUI 4 | 5 | struct NumberVMFlow: View { 6 | @ObservedObject var viewModel: ViewModel 7 | 8 | var body: some View { 9 | FlowStack($viewModel.routes, withNavigation: true) { 10 | NumberVMView(viewModel: viewModel.initialScreenViewModel) 11 | .flowDestination(for: ScreenViewModel.self) { screenVM in 12 | if case let .number(vm) = screenVM { 13 | NumberVMView(viewModel: vm) 14 | } 15 | } 16 | } 17 | .onOpenURL { url in 18 | viewModel.open(url) 19 | } 20 | } 21 | } 22 | 23 | extension NumberVMFlow { 24 | class ViewModel: ObservableObject { 25 | let initialScreenViewModel: NumberVMView.ViewModel 26 | @Published var routes: Routes 27 | 28 | init(initialNumber: Int, routes: Routes = []) { 29 | initialScreenViewModel = .init(number: initialNumber) 30 | self.routes = routes 31 | 32 | initialScreenViewModel.goRandom = { [weak self] in self?.goRandom() } 33 | } 34 | 35 | func open(_ url: URL) { 36 | guard let deepLink = Deeplink(url: url) else { 37 | return 38 | } 39 | follow(deepLink) 40 | } 41 | 42 | func follow(_ deeplink: Deeplink) { 43 | guard case let .viewModelTab(link) = deeplink else { 44 | return 45 | } 46 | switch link { 47 | case let .numbers(numbers): 48 | for number in numbers { 49 | routes.push(.number(.init(number: number, goRandom: goRandom))) 50 | } 51 | } 52 | } 53 | 54 | func goRandom() { 55 | func screenViewModel(_ number: Int) -> ScreenViewModel { 56 | .number(.init(number: number, goRandom: goRandom)) 57 | } 58 | let options: [[Route]] = [ 59 | [], 60 | [ 61 | .push(screenViewModel(1)), 62 | ], 63 | [ 64 | .push(screenViewModel(1)), 65 | .push(screenViewModel(2)), 66 | .push(screenViewModel(3)), 67 | ], 68 | [ 69 | .push(screenViewModel(1)), 70 | .push(screenViewModel(2)), 71 | .sheet(screenViewModel(3), withNavigation: true), 72 | .push(screenViewModel(4)), 73 | ], 74 | [ 75 | .sheet(screenViewModel(1), withNavigation: true), 76 | .push(screenViewModel(2)), 77 | .sheet(screenViewModel(3), withNavigation: true), 78 | .push(screenViewModel(4)), 79 | ], 80 | ] 81 | routes = options.randomElement()! 82 | } 83 | } 84 | } 85 | 86 | // ScreenVM 87 | 88 | enum ScreenViewModel: Hashable { 89 | case number(NumberVMView.ViewModel) 90 | } 91 | 92 | // NumberVMView 93 | 94 | struct NumberVMView: View { 95 | @ObservedObject var viewModel: ViewModel 96 | @EnvironmentObject var navigator: FlowNavigator 97 | 98 | var body: some View { 99 | VStack(spacing: 8) { 100 | SimpleStepper(number: $viewModel.number) 101 | FlowLink(value: viewModel.doubleViewModel(), style: .cover(withNavigation: true), label: { Text("Present Double (cover)") }) 102 | FlowLink(value: viewModel.doubleViewModel(), style: .sheet(withNavigation: true), label: { Text("Present Double (sheet)") }) 103 | FlowLink(value: viewModel.incrementedViewModel(), style: .push, label: { Text("Push next") }) 104 | if let goRandom = viewModel.goRandom { 105 | Button("Go random", action: goRandom) 106 | } 107 | if !navigator.routes.isEmpty { 108 | Button("Go back", action: { navigator.goBack() }) 109 | Button("Go back to root", action: { 110 | navigator.goBackToRoot() 111 | }) 112 | } 113 | } 114 | .padding() 115 | .navigationTitle("\(viewModel.number)") 116 | } 117 | } 118 | 119 | extension NumberVMView { 120 | class ViewModel: ObservableObject, Hashable { 121 | static func == (lhs: NumberVMView.ViewModel, rhs: NumberVMView.ViewModel) -> Bool { 122 | lhs.number == rhs.number 123 | } 124 | 125 | func hash(into hasher: inout Hasher) { 126 | hasher.combine(number) 127 | } 128 | 129 | @Published var number: Int 130 | var goRandom: (() -> Void)? 131 | 132 | init(number: Int, goRandom: (() -> Void)? = nil) { 133 | self.number = number 134 | self.goRandom = goRandom 135 | } 136 | 137 | func doubleViewModel() -> ScreenViewModel { 138 | .number(.init(number: number * 2, goRandom: goRandom)) 139 | } 140 | 141 | func incrementedViewModel() -> ScreenViewModel { 142 | .number(.init(number: number + 1, goRandom: goRandom)) 143 | } 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /Sources/FlowStacks/FlowPath.CodableRepresentation.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension FlowPath { 4 | /// A codable representation of a FlowPath. 5 | struct CodableRepresentation { 6 | static let encoder = JSONEncoder() 7 | static let decoder = JSONDecoder() 8 | 9 | var elements: [Route] 10 | } 11 | 12 | var codable: CodableRepresentation? { 13 | let codableElements = routes.compactMap { route -> Route? in 14 | guard let codableScreen = route.screen as? Codable else { 15 | return nil 16 | } 17 | return Route(screen: codableScreen, style: route.style) 18 | } 19 | guard codableElements.count == routes.count else { 20 | return nil 21 | } 22 | return CodableRepresentation(elements: codableElements) 23 | } 24 | 25 | init(_ codable: CodableRepresentation) { 26 | // NOTE: Casting to Any first prevents the compiler from flagging the cast to AnyHashable as one that 27 | // always fails (which it isn't, thanks to the compiler magic around AnyHashable). 28 | self.init(codable.elements.map { $0.map { ($0 as Any) as! AnyHashable } }) 29 | } 30 | } 31 | 32 | extension FlowPath.CodableRepresentation: Encodable { 33 | fileprivate func generalEncodingError(_ description: String) -> EncodingError { 34 | let context = EncodingError.Context(codingPath: [], debugDescription: description) 35 | return EncodingError.invalidValue(elements, context) 36 | } 37 | 38 | fileprivate static func encodeExistential(_ element: Encodable) throws -> Data { 39 | func encodeOpened(_ element: some Encodable) throws -> Data { 40 | try FlowPath.CodableRepresentation.encoder.encode(element) 41 | } 42 | return try _openExistential(element, do: encodeOpened(_:)) 43 | } 44 | 45 | /// Encodes the representation into the encoder's unkeyed container. 46 | /// - Parameter encoder: The encoder to use. 47 | public func encode(to encoder: Encoder) throws { 48 | var container = encoder.unkeyedContainer() 49 | for element in elements.reversed() { 50 | guard let typeName = _mangledTypeName(type(of: element.screen)) else { 51 | throw generalEncodingError( 52 | "Unable to create '_mangledTypeName' from \(String(describing: type(of: element)))" 53 | ) 54 | } 55 | try container.encode(element.style) 56 | try container.encode(typeName) 57 | #if swift(<5.7) 58 | let data = try Self.encodeExistential(element.screen) 59 | let string = String(decoding: data, as: UTF8.self) 60 | try container.encode(string) 61 | #else 62 | let string = try String(decoding: Self.encoder.encode(element.screen), as: UTF8.self) 63 | try container.encode(string) 64 | #endif 65 | } 66 | } 67 | } 68 | 69 | extension FlowPath.CodableRepresentation: Decodable { 70 | public init(from decoder: Decoder) throws { 71 | var container = try decoder.unkeyedContainer() 72 | elements = [] 73 | while !container.isAtEnd { 74 | let style = try container.decode(RouteStyle.self) 75 | let typeName = try container.decode(String.self) 76 | guard let type = _typeByName(typeName) else { 77 | throw DecodingError.dataCorruptedError( 78 | in: container, 79 | debugDescription: "Cannot instantiate type from name '\(typeName)'." 80 | ) 81 | } 82 | guard let codableType = type as? Codable.Type else { 83 | throw DecodingError.dataCorruptedError( 84 | in: container, 85 | debugDescription: "\(typeName) does not conform to Codable." 86 | ) 87 | } 88 | let encodedValue = try container.decode(String.self) 89 | let data = Data(encodedValue.utf8) 90 | #if swift(<5.7) 91 | func decodeExistential(type: Codable.Type) throws -> Codable { 92 | func decodeOpened(type _: A.Type) throws -> A { 93 | try FlowPath.CodableRepresentation.decoder.decode(A.self, from: data) 94 | } 95 | return try _openExistential(type, do: decodeOpened) 96 | } 97 | let value = try decodeExistential(type: codableType) 98 | #else 99 | let value = try Self.decoder.decode(codableType, from: data) 100 | #endif 101 | elements.insert(Route(screen: value, style: style), at: 0) 102 | } 103 | } 104 | } 105 | 106 | extension FlowPath.CodableRepresentation: Equatable { 107 | public static func == (lhs: Self, rhs: Self) -> Bool { 108 | do { 109 | let encodedLhs = try encodeExistential(lhs) 110 | let encodedRhs = try encodeExistential(rhs) 111 | return encodedLhs == encodedRhs 112 | } catch { 113 | return false 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /FlowStacksApp/Shared/NumberCoordinator.swift: -------------------------------------------------------------------------------- 1 | import FlowStacks 2 | import SwiftUI 3 | 4 | struct NumberCoordinator: View { 5 | @State var initialNumber = 0 6 | @State var routes: Routes = [] 7 | 8 | func goRandom() { 9 | let options: [[Route]] = [ 10 | [], 11 | [.push(1), .push(2), .push(3), .sheet(4, withNavigation: true), .push(5)], 12 | [.push(1), .push(2), .push(3)], 13 | [.push(1), .sheet(2, withNavigation: true), .push(3), .sheet(4, withNavigation: true), .push(5)], 14 | [.sheet(1, withNavigation: true), .cover(2, withNavigation: true), .push(3), .sheet(4, withNavigation: true), .push(5)], 15 | ] 16 | routes = options.randomElement()! 17 | } 18 | 19 | var body: some View { 20 | FlowStack($routes, withNavigation: true, navigationViewModifier: AccentColorModifier(color: .green)) { 21 | NumberView(number: $initialNumber, goRandom: goRandom) 22 | .flowDestination(for: Int.self) { number in 23 | NumberView( 24 | number: number, 25 | goRandom: goRandom 26 | ) 27 | } 28 | } 29 | .onOpenURL { url in 30 | guard let deeplink = Deeplink(url: url) else { return } 31 | follow(deeplink) 32 | } 33 | } 34 | 35 | @MainActor 36 | private func follow(_ deeplink: Deeplink) { 37 | guard case let .numberCoordinator(link) = deeplink else { 38 | return 39 | } 40 | switch link { 41 | case let .numbers(numbers): 42 | for number in numbers { 43 | routes.push(number) 44 | } 45 | } 46 | } 47 | } 48 | 49 | private struct NumberView: View { 50 | @EnvironmentObject var navigator: FlowNavigator 51 | @Environment(\.routeStyle) var routeStyle 52 | @Environment(\.routeIndex) var routeIndex 53 | 54 | @State private var colorShown: Color? 55 | @Binding var number: Int 56 | let goRandom: (() -> Void)? 57 | 58 | var body: some View { 59 | VStack(spacing: 8) { 60 | SimpleStepper(number: $number) 61 | Button("Present Double (cover)") { 62 | navigator.presentCover(number * 2, withNavigation: true) 63 | } 64 | .accessibilityIdentifier("Present Double (cover) from \(number)") 65 | Button("Present Double (sheet)") { 66 | navigator.presentSheet(number * 2, withNavigation: true) 67 | } 68 | .accessibilityIdentifier("Present Double (sheet) from \(number)") 69 | Button("Push next") { 70 | navigator.push(number + 1) 71 | } 72 | .accessibilityIdentifier("Push next from \(number)") 73 | Button("Show red") { colorShown = .red } 74 | .accessibilityIdentifier("Show red from \(number)") 75 | if let goRandom { 76 | Button("Go random", action: goRandom) 77 | } 78 | if navigator.canGoBack() { 79 | Button("Go back") { navigator.goBack() } 80 | .accessibilityIdentifier("Go back from \(number)") 81 | Button("Go back to root") { navigator.goBackToRoot() } 82 | .accessibilityIdentifier("Go back to root from \(number)") 83 | } 84 | if let routeStyle, let routeIndex { 85 | Text("\(routeStyle.description) (\(routeIndex))") 86 | .font(.footnote).foregroundColor(.gray) 87 | } 88 | } 89 | .padding() 90 | .background(Color.white) 91 | .flowDestination(item: $colorShown, style: .sheet(withNavigation: true)) { color in 92 | Text(String(describing: color)).foregroundColor(color) 93 | .navigationTitle("Color") 94 | } 95 | .navigationTitle("\(number)") 96 | } 97 | } 98 | 99 | // Included so that the same example code can be used for macOS too. 100 | #if os(macOS) 101 | extension Route { 102 | static func cover(_ screen: Screen, withNavigation: Bool = false) -> Route { 103 | sheet(screen, withNavigation: withNavigation) 104 | } 105 | } 106 | 107 | extension RouteStyle { 108 | static func cover(withNavigation: Bool = false) -> RouteStyle { 109 | .sheet(withNavigation: withNavigation) 110 | } 111 | } 112 | 113 | extension Array where Element: RouteProtocol { 114 | mutating func presentCover(_ screen: Element.Screen, withNavigation: Bool = false) { 115 | presentSheet(screen, withNavigation: withNavigation) 116 | } 117 | } 118 | 119 | extension FlowNavigator { 120 | func presentCover(_ screen: Screen, withNavigation: Bool = false) { 121 | presentSheet(screen, withNavigation: withNavigation) 122 | } 123 | } 124 | #endif 125 | 126 | struct AccentColorModifier: ViewModifier { 127 | let color: Color 128 | 129 | func body(content: Content) -> some View { 130 | if #available(iOS 16.0, macOS 13.0, tvOS 16.0, *) { 131 | content.tint(color) 132 | } else { 133 | content.accentColor(color) 134 | } 135 | } 136 | } 137 | 138 | private extension RouteStyle { 139 | var description: String { 140 | switch self { 141 | case .push: 142 | return "push" 143 | case let .cover(withNavigation): 144 | return "cover" + (withNavigation ? "WithNavigation" : "") 145 | case let .sheet(withNavigation): 146 | return "sheet" + (withNavigation ? "WithNavigation" : "") 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /FlowStacksAppUITests/NumbersUITests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | let navigationTimeout = 0.8 4 | 5 | final class NumbersUITests: XCTestCase { 6 | override func setUpWithError() throws { 7 | continueAfterFailure = false 8 | } 9 | 10 | func testNumbersTabWithoutNavigationStack() { 11 | testNumbersTab(useNavigationStack: false) 12 | } 13 | 14 | func testNumbersTabWithNavigationStack() { 15 | testNumbersTab(useNavigationStack: true) 16 | } 17 | 18 | func testNumbersTab(useNavigationStack: Bool) { 19 | XCUIDevice.shared.orientation = .portrait 20 | let app = XCUIApplication() 21 | 22 | if useNavigationStack { 23 | if #available(iOS 16.0, *, macOS 13.0, *, watchOS 9.0, *, tvOS 16.0, *) { 24 | app.launchArguments = ["USE_NAVIGATIONSTACK"] 25 | } else { 26 | // Navigation Stack unavailable, so test can be skipped 27 | return 28 | } 29 | } else if #available(iOS 26.0, *, macOS 26.0, *, watchOS 26.0, *, tvOS 26.0, *) { 30 | // NavigationView has issues on v26.0, so it is not supported. 31 | return 32 | } 33 | 34 | app.launch() 35 | 36 | XCTAssertTrue(app.tabBars.buttons["Numbers"].waitForExistence(timeout: 3)) 37 | app.tabBars.buttons["Numbers"].tap() 38 | XCTAssertTrue(app.navigationBars["0"].waitForExistence(timeout: navigationTimeout)) 39 | 40 | app.buttons["Push next"].firstMatch.tap() 41 | XCTAssertTrue(app.navigationBars["1"].waitForExistence(timeout: navigationTimeout)) 42 | XCTAssertTrue(app.staticTexts["push (0)"].exists) 43 | 44 | app.buttons["Present Double (cover) from 1"].firstMatch.tap() 45 | XCTAssertTrue(app.navigationBars["2"].waitForExistence(timeout: navigationTimeout)) 46 | XCTAssertTrue(app.staticTexts["coverWithNavigation (1)"].exists) 47 | 48 | app.buttons["Present Double (cover) from 2"].firstMatch.tap() 49 | XCTAssertTrue(app.navigationBars["4"].waitForExistence(timeout: navigationTimeout)) 50 | XCTAssertTrue(app.staticTexts["coverWithNavigation (2)"].exists) 51 | 52 | app.buttons["Present Double (sheet) from 4"].tap() 53 | XCTAssertTrue(app.navigationBars["8"].waitForExistence(timeout: navigationTimeout)) 54 | XCTAssertTrue(app.staticTexts["sheetWithNavigation (3)"].exists) 55 | 56 | app.buttons["Present Double (sheet) from 8"].firstMatch.tap() 57 | XCTAssertTrue(app.navigationBars["16"].waitForExistence(timeout: navigationTimeout)) 58 | XCTAssertTrue(app.staticTexts["sheetWithNavigation (4)"].exists) 59 | 60 | app.buttons["Push next from 16"].firstMatch.tap() 61 | XCTAssertTrue(app.navigationBars["17"].waitForExistence(timeout: navigationTimeout)) 62 | XCTAssertTrue(app.staticTexts["push (5)"].exists) 63 | 64 | app.buttons["Push next from 17"].firstMatch.tap() 65 | XCTAssertTrue(app.navigationBars["18"].waitForExistence(timeout: navigationTimeout)) 66 | XCTAssertTrue(app.staticTexts["push (6)"].exists) 67 | 68 | app.buttons["Present Double (sheet) from 18"].firstMatch.tap() 69 | XCTAssertTrue(app.navigationBars["36"].waitForExistence(timeout: navigationTimeout)) 70 | XCTAssertTrue(app.staticTexts["sheetWithNavigation (7)"].exists) 71 | 72 | app.buttons["Push next from 36"].firstMatch.tap() 73 | XCTAssertTrue(app.navigationBars["37"].waitForExistence(timeout: navigationTimeout)) 74 | XCTAssertTrue(app.staticTexts["push (8)"].exists) 75 | 76 | app.buttons["Push next from 37"].firstMatch.tap() 77 | XCTAssertTrue(app.navigationBars["38"].waitForExistence(timeout: navigationTimeout)) 78 | XCTAssertTrue(app.staticTexts["push (9)"].exists) 79 | 80 | app.navigationBars.buttons["37"].tap() 81 | XCTAssertTrue(app.navigationBars["37"].waitForExistence(timeout: navigationTimeout)) 82 | XCTAssertTrue(app.staticTexts["push (8)"].exists) 83 | 84 | app.buttons["Go back from 37"].firstMatch.tap() 85 | XCTAssertTrue(app.navigationBars["36"].waitForExistence(timeout: navigationTimeout)) 86 | XCTAssertTrue(app.staticTexts["sheetWithNavigation (7)"].exists) 87 | 88 | app.buttons["Go back from 36"].firstMatch.tap() 89 | XCTAssertTrue(app.navigationBars["18"].waitForExistence(timeout: navigationTimeout)) 90 | XCTAssertTrue(app.staticTexts["push (6)"].exists) 91 | 92 | app.buttons["Go back from 18"].firstMatch.tap() 93 | XCTAssertTrue(app.navigationBars["17"].waitForExistence(timeout: navigationTimeout)) 94 | XCTAssertTrue(app.staticTexts["push (5)"].exists) 95 | 96 | app.buttons["Present Double (sheet) from 17"].firstMatch.tap() 97 | XCTAssertTrue(app.navigationBars["34"].waitForExistence(timeout: navigationTimeout)) 98 | XCTAssertTrue(app.staticTexts["sheetWithNavigation (6)"].exists) 99 | 100 | app.navigationBars["34"].swipeSheetDown() 101 | XCTAssertTrue(app.navigationBars["17"].waitForExistence(timeout: navigationTimeout)) 102 | XCTAssertTrue(app.staticTexts["push (5)"].exists) 103 | 104 | app.buttons["Show red from 17"].firstMatch.tap() 105 | XCTAssertTrue(app.navigationBars["Color"].waitForExistence(timeout: navigationTimeout)) 106 | XCTAssertTrue(app.staticTexts["red"].exists) 107 | app.navigationBars["Color"].swipeSheetDown() 108 | 109 | app.buttons["Go back to root from 17"].firstMatch.tap() 110 | XCTAssertTrue(app.navigationBars["0"].waitForExistence(timeout: navigationTimeout * 5)) 111 | } 112 | } 113 | 114 | extension XCUIElement { 115 | func swipeSheetDown() { 116 | let start = coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)) 117 | let end = coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 8)) 118 | 119 | start.press(forDuration: 0.05, thenDragTo: end, withVelocity: .fast, thenHoldForDuration: 0.0) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /FlowStacksApp/Shared/ArrayBindingView.swift: -------------------------------------------------------------------------------- 1 | import FlowStacks 2 | import SwiftUI 3 | 4 | private enum Screen: Hashable { 5 | case number(Int) 6 | case numberList(NumberList) 7 | case visualisation(EmojiVisualisation) 8 | case child(ChildFlowStack.ChildType) 9 | } 10 | 11 | struct ArrayBindingView: View { 12 | @State private var savedRoutes: [Route]? 13 | @State private var routes: [Route] = [] 14 | 15 | var body: some View { 16 | VStack { 17 | HStack { 18 | Button("Save", action: saveRoutes) 19 | .disabled(savedRoutes == routes) 20 | Button("Restore", action: restoreRoutes) 21 | .disabled(savedRoutes == nil) 22 | } 23 | FlowStack($routes, withNavigation: true) { 24 | HomeView() 25 | .flowDestination(for: Screen.self, destination: { screen in 26 | switch screen { 27 | case let .numberList(numberList): 28 | NumberListView(numberList: numberList) 29 | case let .number(number): 30 | NumberView(number: number) 31 | case let .visualisation(visualisation): 32 | EmojiView(visualisation: visualisation) 33 | case let .child(child): 34 | ChildFlowStack(childType: child) 35 | } 36 | }) 37 | } 38 | } 39 | } 40 | 41 | func saveRoutes() { 42 | savedRoutes = routes 43 | } 44 | 45 | func restoreRoutes() { 46 | guard let savedRoutes else { return } 47 | routes = savedRoutes 48 | } 49 | } 50 | 51 | private struct HomeView: View { 52 | @State var isPushing = false 53 | @EnvironmentObject var navigator: FlowNavigator 54 | 55 | var body: some View { 56 | VStack(spacing: 8) { 57 | // Push via FlowLink 58 | FlowLink(value: Screen.numberList(NumberList(range: 0 ..< 10)), style: .sheet(withNavigation: true), label: { Text("Pick a number") }) 59 | .indexedA11y("Pick a number") 60 | // Push via navigator 61 | Button("99 Red balloons", action: show99RedBalloons) 62 | // Push via Bool binding 63 | Button("Push local destination", action: { isPushing = true }).disabled(isPushing) 64 | }.navigationTitle("Home") 65 | .flowDestination(isPresented: $isPushing, style: .push) { 66 | Text("Local destination") 67 | } 68 | } 69 | 70 | func show99RedBalloons() { 71 | navigator.push(.number(99)) 72 | navigator.push(.visualisation(EmojiVisualisation(emoji: "🎈", count: 99))) 73 | } 74 | } 75 | 76 | private struct NumberListView: View { 77 | @EnvironmentObject var navigator: FlowNavigator 78 | let numberList: NumberList 79 | var body: some View { 80 | List { 81 | ForEach(numberList.range, id: \.self) { number in 82 | FlowLink("\(number)", value: Screen.number(number), style: .push) 83 | .indexedA11y("Show \(number)") 84 | } 85 | Button("Go back", action: { navigator.goBack() }) 86 | }.navigationTitle("List") 87 | } 88 | } 89 | 90 | private struct NumberView: View { 91 | @EnvironmentObject var navigator: FlowNavigator 92 | @State var number: Int 93 | 94 | var body: some View { 95 | VStack(spacing: 8) { 96 | Text("\(number)").font(.title) 97 | SimpleStepper(number: $number) 98 | FlowLink( 99 | value: Screen.number(number + 1), 100 | style: .push, 101 | label: { Text("Show next number") } 102 | ) 103 | FlowLink( 104 | value: Screen.visualisation(.init(emoji: "🐑", count: number)), 105 | style: .sheet, 106 | label: { Text("Visualise with sheep") } 107 | ) 108 | // NOTE: When presenting a child that handles its own state, the child determines whether its root is shown with navigation. 109 | FlowLink(value: Screen.child(.flowPath), style: .sheet(withNavigation: false), label: { Text("FlowPath Child") }) 110 | .indexedA11y("FlowPath Child") 111 | FlowLink(value: Screen.child(.noBinding), style: .sheet(withNavigation: false), label: { Text("NoBinding Child") }) 112 | .indexedA11y("NoBinding Child") 113 | Button("Go back to root", action: { navigator.goBackToRoot() }) 114 | .indexedA11y("Go back to root") 115 | }.navigationTitle("\(number)") 116 | } 117 | } 118 | 119 | private struct EmojiView: View { 120 | @EnvironmentObject var navigator: FlowNavigator 121 | let visualisation: EmojiVisualisation 122 | 123 | var body: some View { 124 | Text(visualisation.text) 125 | .navigationTitle("Visualise \(visualisation.count)") 126 | Button("Go back", action: { navigator.goBack() }) 127 | } 128 | } 129 | 130 | // MARK: - State 131 | 132 | private struct EmojiVisualisation: Hashable, Codable { 133 | let emoji: String 134 | let count: Int 135 | 136 | var text: String { 137 | Array(repeating: emoji, count: count).joined() 138 | } 139 | } 140 | 141 | private struct NumberList: Hashable, Codable { 142 | let range: Range 143 | } 144 | 145 | private class ClassDestination { 146 | let data: String 147 | 148 | init(data: String) { 149 | self.data = data 150 | } 151 | } 152 | 153 | extension ClassDestination: Hashable { 154 | static func == (lhs: ClassDestination, rhs: ClassDestination) -> Bool { 155 | lhs.data == rhs.data 156 | } 157 | 158 | func hash(into hasher: inout Hasher) { 159 | hasher.combine(data) 160 | } 161 | } 162 | 163 | private class SampleClassDestination: ClassDestination { 164 | init() { super.init(data: "Sample data") } 165 | } 166 | 167 | private struct ChildFlowStack: View { 168 | enum ChildType: Hashable { 169 | case flowPath, noBinding 170 | } 171 | 172 | let childType: ChildType 173 | 174 | var body: some View { 175 | switch childType { 176 | case .flowPath: 177 | FlowPathView() 178 | case .noBinding: 179 | NoBindingView() 180 | } 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /Tests/FlowStacksTests/CalculateStepsTests.swift: -------------------------------------------------------------------------------- 1 | @testable import FlowStacks 2 | import XCTest 3 | 4 | final class CalculateStepsTests: XCTestCase { 5 | typealias RouterState = [Route] 6 | 7 | func testPushOneAtATime() { 8 | let start: RouterState = [] 9 | let end: RouterState = [ 10 | .push(-2), 11 | .push(-3), 12 | .push(-4), 13 | ] 14 | 15 | let steps = FlowPath.calculateSteps(from: start, to: end, allowNavigationUpdatesInOne: false) 16 | 17 | let expectedSteps: [RouterState] = [ 18 | [ 19 | ], 20 | [ 21 | .push(-2), 22 | ], 23 | [ 24 | .push(-2), 25 | .push(-3), 26 | ], 27 | end, 28 | ] 29 | XCTAssertEqual(steps, expectedSteps) 30 | } 31 | 32 | func testPushAllAtOnceInNavigationStack() { 33 | let start: RouterState = [] 34 | let end: RouterState = [ 35 | .push(-2), 36 | .push(-3), 37 | .push(-4), 38 | ] 39 | 40 | let steps = FlowPath.calculateSteps(from: start, to: end, allowNavigationUpdatesInOne: true) 41 | 42 | let expectedSteps: [RouterState] = [ 43 | [], 44 | end, 45 | ] 46 | XCTAssertEqual(steps, expectedSteps) 47 | } 48 | 49 | func testPopAllAtOnce() { 50 | let start: RouterState = [ 51 | .push(2), 52 | .push(3), 53 | .push(4), 54 | ] 55 | let end: RouterState = [ 56 | ] 57 | 58 | let steps = FlowPath.calculateSteps(from: start, to: end, allowNavigationUpdatesInOne: false) 59 | 60 | let expectedSteps: [RouterState] = [ 61 | [ 62 | .push(2), 63 | .push(3), 64 | .push(4), 65 | ], 66 | end, 67 | ] 68 | XCTAssertEqual(steps, expectedSteps) 69 | } 70 | 71 | func testPresentOneAtATime() { 72 | let start: RouterState = [] 73 | let end: RouterState = [ 74 | .sheet(-2), 75 | .cover(-3), 76 | .sheet(-4), 77 | ] 78 | 79 | let steps = FlowPath.calculateSteps(from: start, to: end, allowNavigationUpdatesInOne: false) 80 | 81 | let expectedSteps: [RouterState] = [ 82 | [ 83 | ], 84 | [ 85 | .sheet(-2), 86 | ], 87 | [ 88 | .sheet(-2), 89 | .cover(-3), 90 | ], 91 | end, 92 | ] 93 | XCTAssertEqual(steps, expectedSteps) 94 | } 95 | 96 | func testDismissOneAtATime() { 97 | let start: RouterState = [ 98 | .sheet(2), 99 | .cover(3), 100 | .sheet(4), 101 | ] 102 | let end: RouterState = [ 103 | ] 104 | 105 | let steps = FlowPath.calculateSteps(from: start, to: end, allowMultipleDismissalsInOne: false, allowNavigationUpdatesInOne: false) 106 | 107 | let expectedSteps: [RouterState] = [ 108 | [ 109 | .sheet(2), 110 | .cover(3), 111 | .sheet(4), 112 | ], 113 | [ 114 | .sheet(2), 115 | .cover(3), 116 | ], 117 | [ 118 | .sheet(2), 119 | ], 120 | end, 121 | ] 122 | XCTAssertEqual(steps, expectedSteps) 123 | } 124 | 125 | func testPresentAndPushOneAtATime() { 126 | let start: RouterState = [] 127 | let end: RouterState = [ 128 | .push(-2), 129 | .push(-3), 130 | .sheet(-4), 131 | .sheet(-5), 132 | ] 133 | 134 | let steps = FlowPath.calculateSteps(from: start, to: end, allowNavigationUpdatesInOne: false) 135 | 136 | let expectedSteps: [RouterState] = [ 137 | [ 138 | ], 139 | [ 140 | .push(-2), 141 | ], 142 | [ 143 | .push(-2), 144 | .push(-3), 145 | ], 146 | [ 147 | .push(-2), 148 | .push(-3), 149 | .sheet(-4), 150 | ], 151 | end, 152 | ] 153 | XCTAssertEqual(steps, expectedSteps) 154 | } 155 | 156 | func testBackToCommonAncestorFirst() { 157 | let start: RouterState = [ 158 | .push(2), 159 | .push(3), 160 | .push(4), 161 | ] 162 | let end: RouterState = [ 163 | .push(-2), 164 | .push(-3), 165 | .sheet(-4), 166 | .sheet(-5), 167 | ] 168 | 169 | let steps = FlowPath.calculateSteps(from: start, to: end, allowNavigationUpdatesInOne: false) 170 | 171 | let expectedSteps: [RouterState] = [ 172 | [ 173 | .push(-2), 174 | .push(-3), 175 | .push(4), 176 | ], 177 | [ 178 | .push(-2), 179 | .push(-3), 180 | ], 181 | [ 182 | .push(-2), 183 | .push(-3), 184 | .sheet(-4), 185 | ], 186 | end, 187 | ] 188 | XCTAssertEqual(steps, expectedSteps) 189 | } 190 | 191 | func testBackToCommonAncestorFirstWithoutPoppingWithinExtraPresentationLayers() { 192 | let start: RouterState = [ 193 | .sheet(2), 194 | .push(3), 195 | .sheet(4), 196 | .push(5), 197 | ] 198 | let end: RouterState = [ 199 | .push(-2), 200 | ] 201 | 202 | let steps = FlowPath.calculateSteps(from: start, to: end, allowMultipleDismissalsInOne: false, allowNavigationUpdatesInOne: false) 203 | 204 | let expectedSteps: [RouterState] 205 | 206 | expectedSteps = [ 207 | [ 208 | .sheet(2), 209 | .push(3), 210 | .sheet(4), 211 | .push(5), 212 | ], 213 | [ 214 | .sheet(2), 215 | .push(3), 216 | ], 217 | [ 218 | ], 219 | end, 220 | ] 221 | XCTAssertEqual(steps, expectedSteps) 222 | } 223 | 224 | func testSimultaneousDismissalsWhenSupported() { 225 | let start: RouterState = [ 226 | .sheet(2), 227 | .push(3), 228 | .sheet(4), 229 | .push(5), 230 | ] 231 | let end: RouterState = [ 232 | ] 233 | 234 | let steps = FlowPath.calculateSteps(from: start, to: end, allowMultipleDismissalsInOne: true, allowNavigationUpdatesInOne: false) 235 | 236 | let expectedSteps: [RouterState] 237 | 238 | expectedSteps = [ 239 | [ 240 | .sheet(2), 241 | .push(3), 242 | .sheet(4), 243 | .push(5), 244 | ], 245 | end, 246 | ] 247 | XCTAssertEqual(steps, expectedSteps) 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /FlowStacksApp/Shared/NoBindingView.swift: -------------------------------------------------------------------------------- 1 | import FlowStacks 2 | import SwiftUI 3 | 4 | struct NoBindingView: View { 5 | var body: some View { 6 | FlowStack(withNavigation: true) { 7 | HomeView() 8 | .flowDestination(for: NumberList.self, destination: { numberList in 9 | NumberListView(numberList: numberList) 10 | }) 11 | .flowDestination(for: Number.self, destination: { $number in 12 | NumberView(number: $number.value) 13 | }) 14 | .flowDestination(for: EmojiVisualisation.self, destination: { visualisation in 15 | EmojiView(visualisation: visualisation) 16 | }) 17 | .flowDestination(for: ClassDestination.self, destination: { destination in 18 | ClassDestinationView(destination: destination) 19 | }) 20 | .flowDestination(for: ChildFlowStack.ChildType.self) { childType in 21 | ChildFlowStack(childType: childType) 22 | } 23 | } 24 | } 25 | } 26 | 27 | private struct HomeView: View { 28 | @EnvironmentObject var navigator: FlowPathNavigator 29 | @State var isPushing = false 30 | 31 | var body: some View { 32 | VStack(spacing: 8) { 33 | // Push via link 34 | FlowLink( 35 | value: NumberList(range: 0 ..< 10), 36 | style: .sheet(withNavigation: true), 37 | label: { Text("Pick a number") } 38 | ).indexedA11y("Pick a number") 39 | // Push via navigator 40 | Button("99 Red balloons", action: show99RedBalloons) 41 | // Push child class via navigator 42 | Button("Show Class Destination", action: showClassDestination) 43 | // Push via Bool binding 44 | Button("Push local destination", action: { isPushing = true }).disabled(isPushing) 45 | }.navigationTitle("Home") 46 | .flowDestination(isPresented: $isPushing, style: .push, destination: { 47 | Text("Local destination") 48 | }) 49 | } 50 | 51 | func show99RedBalloons() { 52 | navigator.push(Number(value: 99)) 53 | navigator.push(EmojiVisualisation(emoji: "🎈", count: 99)) 54 | } 55 | 56 | func showClassDestination() { 57 | navigator.push(SampleClassDestination()) 58 | } 59 | } 60 | 61 | private struct NumberListView: View { 62 | @EnvironmentObject var navigator: FlowPathNavigator 63 | let numberList: NumberList 64 | var body: some View { 65 | List { 66 | ForEach(numberList.range, id: \.self) { number in 67 | FlowLink("\(number)", value: Number(value: number), style: .push) 68 | .indexedA11y("Show \(number)") 69 | } 70 | Button("Go back", action: { navigator.goBack() }) 71 | }.navigationTitle("List") 72 | } 73 | } 74 | 75 | private struct NumberView: View { 76 | @EnvironmentObject var navigator: FlowPathNavigator 77 | @Binding var number: Int 78 | 79 | var body: some View { 80 | VStack(spacing: 8) { 81 | Text("\(number)").font(.title) 82 | SimpleStepper(number: $number) 83 | FlowLink( 84 | value: Number(value: number + 1), 85 | style: .push, 86 | label: { Text("Show next number") } 87 | ) 88 | FlowLink( 89 | value: EmojiVisualisation(emoji: "🐑", count: Int(number)), 90 | style: .sheet, 91 | label: { Text("Visualise with sheep") } 92 | ) 93 | // NOTE: When presenting a child that handles its own state, the child determines whether its root is shown with navigation. 94 | FlowLink(value: ChildFlowStack.ChildType.flowPath, style: .sheet(withNavigation: false), label: { Text("FlowPath Child") }) 95 | .indexedA11y("FlowPath Child") 96 | // NOTE: When presenting a child that defers to the parent state, the parent determines whether it is shown with navigation. 97 | FlowLink(value: ChildFlowStack.ChildType.noBinding, style: .sheet(withNavigation: true), label: { Text("NoBinding Child") }) 98 | .indexedA11y("NoBinding Child") 99 | Button("Go back to root") { 100 | navigator.goBackToRoot() 101 | } 102 | .indexedA11y("Go back to root") 103 | }.navigationTitle("\(number)") 104 | } 105 | } 106 | 107 | private struct EmojiView: View { 108 | @EnvironmentObject var navigator: FlowPathNavigator 109 | let visualisation: EmojiVisualisation 110 | 111 | var body: some View { 112 | VStack { 113 | Text(visualisation.text) 114 | .navigationTitle("Visualise \(visualisation.count)") 115 | Button("Go back", action: { navigator.goBack() }) 116 | } 117 | } 118 | } 119 | 120 | private struct ClassDestinationView: View { 121 | @EnvironmentObject var navigator: FlowPathNavigator 122 | let destination: ClassDestination 123 | 124 | var body: some View { 125 | VStack { 126 | Text(destination.data) 127 | .navigationTitle("A ClassDestination") 128 | Button("Go back", action: { navigator.goBack() }) 129 | } 130 | } 131 | } 132 | 133 | // MARK: - State 134 | 135 | private struct EmojiVisualisation: Hashable, Codable { 136 | let emoji: String 137 | let count: Int 138 | 139 | var text: String { 140 | Array(repeating: emoji, count: count).joined() 141 | } 142 | } 143 | 144 | private struct Number: Hashable, Codable { 145 | var value: Int 146 | } 147 | 148 | private struct NumberList: Hashable, Codable { 149 | let range: Range 150 | } 151 | 152 | private class ClassDestination { 153 | let data: String 154 | 155 | init(data: String) { 156 | self.data = data 157 | } 158 | } 159 | 160 | extension ClassDestination: Hashable { 161 | static func == (lhs: ClassDestination, rhs: ClassDestination) -> Bool { 162 | lhs.data == rhs.data 163 | } 164 | 165 | func hash(into hasher: inout Hasher) { 166 | hasher.combine(data) 167 | } 168 | } 169 | 170 | private class SampleClassDestination: ClassDestination { 171 | init() { super.init(data: "Sample data") } 172 | } 173 | 174 | private struct ChildFlowStack: View, Codable { 175 | enum ChildType: Hashable, Codable { 176 | case flowPath, noBinding 177 | } 178 | 179 | let childType: ChildType 180 | 181 | var body: some View { 182 | switch childType { 183 | case .flowPath: 184 | FlowPathView() 185 | case .noBinding: 186 | NoBindingView() 187 | } 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /Sources/FlowStacks/View+flowDestination.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | public extension View { 5 | /// Associates a destination view with a presented data type for use within a ``FlowStack``. 6 | /// - Parameters: 7 | /// - dataType: The type of data that this destination matches. 8 | /// - destination: A view builder that defines a view to display when the stack’s state contains a value of the given type. The closure takes one argument, which is a binding to the value of the data to present. 9 | /// - Returns: The view configured so it can present data of the given type. 10 | func flowDestination(for dataType: D.Type, @ViewBuilder destination builder: @escaping (Binding) -> some View) -> some View { 11 | modifier(DestinationBuilderModifier(typedDestinationBuilder: { AnyView(builder($0)) })) 12 | } 13 | 14 | /// Associates a destination view with a presented data type for use within a ``FlowStack``. 15 | /// - Parameters: 16 | /// - dataType: The type of data that this destination matches. 17 | /// - destination: A view builder that defines a view to display when the stack’s state contains a value of the given type. The closure takes one argument, which is the value of the data to present. 18 | /// - Returns: The view configured so it can present data of the given type. 19 | func flowDestination(for dataType: D.Type, @ViewBuilder destination builder: @escaping (D) -> some View) -> some View { 20 | flowDestination(for: dataType) { binding in builder(binding.wrappedValue) } 21 | } 22 | } 23 | 24 | public extension View { 25 | /// Associates a destination view with a binding that can be used to show 26 | /// the view within a ``FlowStack``. 27 | /// 28 | /// In general, favor binding a path to a flow stack for programmatic 29 | /// navigation. Add this view modifer to a view inside a ``FlowStack`` 30 | /// to programmatically push a single view onto the stack. This is useful 31 | /// for building components that can push an associated view. For example, 32 | /// you can present a `ColorDetail` view for a particular color: 33 | /// 34 | /// @State private var showDetails = false 35 | /// var favoriteColor: Color 36 | /// 37 | /// FlowStack { 38 | /// VStack { 39 | /// Circle() 40 | /// .fill(favoriteColor) 41 | /// Button("Show details") { 42 | /// showDetails = true 43 | /// } 44 | /// } 45 | /// .flowDestination(isPresented: $showDetails, style: .sheet) { 46 | /// ColorDetail(color: favoriteColor) 47 | /// } 48 | /// .navigationTitle("My Favorite Color") 49 | /// } 50 | /// 51 | /// Do not put a navigation destination modifier inside a "lazy" container, 52 | /// like ``List`` or ``LazyVStack``. These containers create child views 53 | /// only when needed to render on screen. Add the navigation destination 54 | /// modifier outside these containers so that the navigation stack can 55 | /// always see the destination. 56 | /// 57 | /// - Parameters: 58 | /// - isPresented: A binding to a Boolean value that indicates whether 59 | /// `destination` is currently presented. 60 | /// - destination: A view to present. 61 | func flowDestination(isPresented: Binding, style: RouteStyle, @ViewBuilder destination: () -> some View) -> some View { 62 | let builtDestination = AnyView(destination()) 63 | return modifier( 64 | LocalDestinationBuilderModifier( 65 | isPresented: isPresented, 66 | routeStyle: style, 67 | builder: { builtDestination } 68 | ) 69 | ) 70 | } 71 | } 72 | 73 | public extension View { 74 | /// Associates a destination view with a bound value for use within a 75 | /// ``FlowStack``. 76 | /// 77 | /// Add this view modifer to a view inside a ``FlowStack`` to describe 78 | /// the view that the flow stack displays when presenting a particular kind of data. Programmatically 79 | /// update the binding to display or remove the view. For example: 80 | /// 81 | /// ``` 82 | /// @State private var colorShown: Color? 83 | /// 84 | /// FlowStack(withNavigation: false) { 85 | /// List { 86 | /// Button("Red") { colorShown = .red } 87 | /// Button("Pink") { colorShown = .pink } 88 | /// Button("Green") { colorShown = .green } 89 | /// } 90 | /// .flowDestination(item: $colorShown, style: .sheet) { color in 91 | /// Text(String(describing: color)) 92 | /// } 93 | /// } 94 | /// ``` 95 | /// 96 | /// When the person using the app taps on the Red button, the red color 97 | /// is pushed onto the navigation stack. You can pop the view 98 | /// by setting `colorShown` back to `nil`. 99 | /// 100 | /// You can add more than one navigation destination modifier to the stack 101 | /// if it needs to present more than one kind of data. 102 | /// 103 | /// Do not put a navigation destination modifier inside a "lazy" container, 104 | /// like ``List`` or ``LazyVStack``. These containers create child views 105 | /// only when needed to render on screen. Add the navigation destination 106 | /// modifier outside these containers so that the navigation view can 107 | /// always see the destination. 108 | /// 109 | /// - Parameters: 110 | /// - item: A binding to the data presented, or `nil` if nothing is 111 | /// currently presented. 112 | /// - style: The route style, e.g. sheet, cover, push. 113 | /// - destination: A view builder that defines a view to display 114 | /// when `item` is not `nil`. 115 | func flowDestination(item: Binding, style: RouteStyle, @ViewBuilder destination: @escaping (D) -> some View) -> some View { 116 | flowDestination( 117 | isPresented: Binding( 118 | get: { item.wrappedValue != nil }, 119 | set: { isActive, transaction in 120 | if !isActive { 121 | item.transaction(transaction).wrappedValue = nil 122 | } 123 | } 124 | ), 125 | style: style, 126 | destination: { ConditionalViewBuilder(data: item, buildView: destination) } 127 | ) 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /FlowStacksApp/Shared/FlowPathView.swift: -------------------------------------------------------------------------------- 1 | import FlowStacks 2 | import SwiftUI 3 | 4 | struct FlowPathView: View { 5 | @State var encodedPathData: Data? 6 | @State var path = FlowPath() 7 | 8 | var body: some View { 9 | VStack { 10 | HStack { 11 | Button("Encode", action: encodePath) 12 | .disabled(try! encodedPathData == JSONEncoder().encode(path.codable)) 13 | Button("Decode", action: decodePath) 14 | .disabled(encodedPathData == nil) 15 | } 16 | FlowStack($path, withNavigation: true) { 17 | HomeView() 18 | .flowDestination(for: NumberList.self, destination: { numberList in 19 | NumberListView(numberList: numberList) 20 | }) 21 | .flowDestination(for: Number.self, destination: { $number in 22 | NumberView(number: $number.value) 23 | }) 24 | .flowDestination(for: EmojiVisualisation.self, destination: { visualisation in 25 | EmojiView(visualisation: visualisation) 26 | }) 27 | .flowDestination(for: ClassDestination.self, destination: { destination in 28 | ClassDestinationView(destination: destination) 29 | }) 30 | .flowDestination(for: ChildFlowStack.ChildType.self) { childType in 31 | ChildFlowStack(childType: childType) 32 | } 33 | } 34 | } 35 | } 36 | 37 | func encodePath() { 38 | guard let codable = path.codable else { 39 | return 40 | } 41 | encodedPathData = try! JSONEncoder().encode(codable) 42 | } 43 | 44 | func decodePath() { 45 | guard let encodedPathData else { 46 | return 47 | } 48 | let codable = try! JSONDecoder().decode(FlowPath.CodableRepresentation.self, from: encodedPathData) 49 | path = FlowPath(codable) 50 | } 51 | } 52 | 53 | private struct HomeView: View { 54 | @EnvironmentObject var navigator: FlowPathNavigator 55 | @State var isPushing = false 56 | 57 | var body: some View { 58 | VStack(spacing: 8) { 59 | // Push via link 60 | FlowLink( 61 | value: NumberList(range: 0 ..< 10), 62 | style: .sheet(withNavigation: true), 63 | label: { Text("Pick a number") } 64 | ).indexedA11y("Pick a number") 65 | // Push via navigator 66 | Button("99 Red balloons", action: show99RedBalloons) 67 | // Push child class via navigator 68 | Button("Show Class Destination", action: showClassDestination) 69 | // Push via Bool binding 70 | Button("Push local destination", action: { isPushing = true }).disabled(isPushing) 71 | } 72 | .flowDestination(isPresented: $isPushing, style: .push, destination: { 73 | Text("Local destination") 74 | }) 75 | .navigationTitle("Home") 76 | } 77 | 78 | func show99RedBalloons() { 79 | navigator.push(Number(value: 99)) 80 | navigator.push(EmojiVisualisation(emoji: "🎈", count: 99)) 81 | } 82 | 83 | func showClassDestination() { 84 | navigator.push(SampleClassDestination()) 85 | } 86 | } 87 | 88 | private struct NumberListView: View { 89 | @EnvironmentObject var navigator: FlowPathNavigator 90 | let numberList: NumberList 91 | var body: some View { 92 | List { 93 | ForEach(numberList.range, id: \.self) { number in 94 | FlowLink("\(number)", value: Number(value: number), style: .push) 95 | .indexedA11y("Show \(number)") 96 | } 97 | Button("Go back", action: { navigator.goBack() }) 98 | }.navigationTitle("List") 99 | } 100 | } 101 | 102 | private struct NumberView: View { 103 | @EnvironmentObject var navigator: FlowPathNavigator 104 | @Binding var number: Int 105 | 106 | var body: some View { 107 | VStack(spacing: 8) { 108 | Text("\(number)").font(.title) 109 | SimpleStepper(number: $number) 110 | FlowLink( 111 | value: Number(value: number + 1), 112 | style: .push, 113 | label: { Text("Show next number") } 114 | ) 115 | FlowLink( 116 | value: EmojiVisualisation(emoji: "🐑", count: number), 117 | style: .sheet, 118 | label: { Text("Visualise with sheep") } 119 | ) 120 | // NOTE: When presenting a child that handles its own state, the child determines whether its root is shown with navigation. 121 | FlowLink(value: ChildFlowStack.ChildType.flowPath, style: .sheet(withNavigation: false), label: { Text("FlowPath Child") }) 122 | .indexedA11y("FlowPath Child") 123 | // NOTE: When presenting a child that defers to the parent state, the parent determines whether it is shown with navigation. 124 | FlowLink(value: ChildFlowStack.ChildType.noBinding, style: .sheet(withNavigation: true), label: { Text("NoBinding Child") }) 125 | .indexedA11y("NoBinding Child") 126 | Button("Go back to root", action: { navigator.goBackToRoot() }) 127 | .indexedA11y("Go back to root") 128 | }.navigationTitle("\(number)") 129 | } 130 | } 131 | 132 | private struct EmojiView: View { 133 | @EnvironmentObject var navigator: FlowPathNavigator 134 | let visualisation: EmojiVisualisation 135 | 136 | var body: some View { 137 | VStack { 138 | Text(visualisation.text) 139 | .navigationTitle("Visualise \(visualisation.count)") 140 | Button("Go back", action: { navigator.goBack() }) 141 | } 142 | } 143 | } 144 | 145 | private struct ClassDestinationView: View { 146 | @EnvironmentObject var navigator: FlowPathNavigator 147 | let destination: ClassDestination 148 | 149 | var body: some View { 150 | VStack { 151 | Text(destination.data) 152 | .navigationTitle("A ClassDestination") 153 | Button("Go back", action: { navigator.goBack() }) 154 | } 155 | } 156 | } 157 | 158 | // MARK: - State 159 | 160 | private struct EmojiVisualisation: Hashable, Codable { 161 | let emoji: String 162 | let count: Int 163 | 164 | var text: String { 165 | Array(repeating: emoji, count: count).joined() 166 | } 167 | } 168 | 169 | private struct Number: Hashable, Codable { 170 | var value: Int 171 | } 172 | 173 | private struct NumberList: Hashable, Codable { 174 | let range: Range 175 | } 176 | 177 | private class ClassDestination { 178 | let data: String 179 | 180 | init(data: String) { 181 | self.data = data 182 | } 183 | } 184 | 185 | extension ClassDestination: Hashable { 186 | static func == (lhs: ClassDestination, rhs: ClassDestination) -> Bool { 187 | lhs.data == rhs.data 188 | } 189 | 190 | func hash(into hasher: inout Hasher) { 191 | hasher.combine(data) 192 | } 193 | } 194 | 195 | private class SampleClassDestination: ClassDestination { 196 | init() { super.init(data: "Sample data") } 197 | } 198 | 199 | private struct ChildFlowStack: View, Codable { 200 | enum ChildType: Hashable, Codable { 201 | case flowPath, noBinding 202 | } 203 | 204 | let childType: ChildType 205 | 206 | var body: some View { 207 | switch childType { 208 | case .flowPath: 209 | FlowPathView() 210 | case .noBinding: 211 | NoBindingView() 212 | } 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /Sources/FlowStacks/FlowStack.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | /// A view that manages state for presenting and pushing screens.. 5 | public struct FlowStack: View { 6 | var withNavigation: Bool 7 | var dataType: FlowStackDataType 8 | var navigationViewModifier: NavigationViewModifier 9 | @Environment(\.flowStackDataType) var parentFlowStackDataType 10 | @Environment(\.parentNavigationStackType) var parentNavigationStackType 11 | @Environment(\.nestingIndex) var nestingIndex 12 | @Environment(\.useNavigationStack) var useNavigationStack 13 | @Environment(\.routeStyle) var routeStyle 14 | @EnvironmentObject var routesHolder: RoutesHolder 15 | @EnvironmentObject var inheritedDestinationBuilder: DestinationBuilderHolder 16 | @Binding var externalTypedPath: [Route] 17 | @State var internalTypedPath: [Route] = [] 18 | @StateObject var path = RoutesHolder() 19 | @StateObject var destinationBuilder = DestinationBuilderHolder() 20 | var root: Root 21 | var useInternalTypedPath: Bool 22 | 23 | var deferToParentFlowStack: Bool { 24 | (parentFlowStackDataType == .flowPath || parentFlowStackDataType == .noBinding) && dataType == .noBinding 25 | } 26 | 27 | var screenModifier: some ViewModifier { 28 | ScreenModifier( 29 | path: path, 30 | destinationBuilder: parentFlowStackDataType == nil ? destinationBuilder : inheritedDestinationBuilder, 31 | navigator: FlowNavigator(useInternalTypedPath ? $internalTypedPath : $externalTypedPath), 32 | typedPath: useInternalTypedPath ? $internalTypedPath : $externalTypedPath, 33 | nestingIndex: (nestingIndex ?? 0) + 1 34 | ) 35 | } 36 | 37 | var shouldUseNavigationStack: Bool { 38 | if #available(iOS 16.0, *, macOS 13.0, *, watchOS 9.0, *, tvOS 16.0, *) { 39 | return useNavigationStack == .whenAvailable 40 | } else { 41 | return false 42 | } 43 | } 44 | 45 | @ViewBuilder 46 | var router: some View { 47 | Router( 48 | rootView: root.environment(\.routeIndex, -1), 49 | navigationViewModifier: navigationViewModifier, 50 | screenModifier: screenModifier, 51 | screens: $path.boundRoutes, 52 | withNavigation: withNavigation && !deferToParentFlowStack 53 | ) 54 | } 55 | 56 | public var body: some View { 57 | if deferToParentFlowStack { 58 | root 59 | } else { 60 | if parentNavigationStackType == .navigationStack, parentFlowStackDataType != nil, !deferToParentFlowStack, routeStyle == .push, path.routes.first?.style == .push { 61 | let _ = assertionFailure(""" 62 | Unsupported nesting of FlowStacks. It is not possible to push from a child FlowStack when the navigation stack \ 63 | is owned by the parent, where the child is also managing its own state. onto a parent's navigation stack when \ 64 | using NavigationStack and the child manages its own state. 65 | """) 66 | } 67 | router 68 | .modifier(screenModifier) 69 | .environment(\.flowStackDataType, dataType) 70 | .onFirstAppear { 71 | path.usingNavigationStack = shouldUseNavigationStack 72 | path.routes = externalTypedPath.map { $0.erased() } 73 | } 74 | .onChange(of: shouldUseNavigationStack) { _ in 75 | path.usingNavigationStack = shouldUseNavigationStack 76 | } 77 | } 78 | } 79 | 80 | init(routes: Binding<[Route]>?, withNavigation: Bool = false, navigationViewModifier: NavigationViewModifier, dataType: FlowStackDataType, @ViewBuilder root: () -> Root) { 81 | _externalTypedPath = routes ?? .constant([]) 82 | self.root = root() 83 | self.withNavigation = withNavigation 84 | self.navigationViewModifier = navigationViewModifier 85 | self.dataType = dataType 86 | useInternalTypedPath = routes == nil 87 | } 88 | 89 | /// Initialises a ``FlowStack`` with a binding to an Array of routes. 90 | /// - Parameters: 91 | /// - routes: The array of routes that will manage navigation state. 92 | /// - withNavigation: Whether the root view should be wrapped in a navigation view. 93 | /// - navigationViewModifier: A modifier for styling any navigation views the FlowStack creates. 94 | /// - root: The root view for the ``FlowStack``. 95 | public init(_ routes: Binding<[Route]>, withNavigation: Bool = false, navigationViewModifier: NavigationViewModifier, @ViewBuilder root: () -> Root) { 96 | self.init(routes: routes, withNavigation: withNavigation, navigationViewModifier: navigationViewModifier, dataType: .typedArray, root: root) 97 | } 98 | } 99 | 100 | public extension FlowStack where Data == AnyHashable { 101 | /// Initialises a ``FlowStack`` without any binding - the stack of routes will be managed internally by the ``FlowStack``. 102 | /// - Parameters: 103 | /// - withNavigation: Whether the root view should be wrapped in a navigation view. 104 | /// - navigationViewModifier: A modifier for styling any navigation views the FlowStack creates. 105 | /// - root: The root view for the ``FlowStack``. 106 | init(withNavigation: Bool = false, navigationViewModifier: NavigationViewModifier, @ViewBuilder root: () -> Root) { 107 | self.init(routes: nil, withNavigation: withNavigation, navigationViewModifier: navigationViewModifier, dataType: .noBinding, root: root) 108 | } 109 | 110 | /// Initialises a ``FlowStack`` with a binding to a ``FlowPath``. 111 | /// - Parameters: 112 | /// - path: The FlowPath that will manage navigation state. 113 | /// - withNavigation: Whether the root view should be wrapped in a navigation view. 114 | /// - navigationViewModifier: A modifier for styling any navigation views the FlowStack creates. 115 | /// - root: The root view for the ``FlowStack``. 116 | init(_ path: Binding, withNavigation: Bool = false, navigationViewModifier: NavigationViewModifier, @ViewBuilder root: () -> Root) { 117 | let path = Binding( 118 | get: { path.wrappedValue.routes }, 119 | set: { path.wrappedValue.routes = $0 } 120 | ) 121 | self.init(routes: path, withNavigation: withNavigation, navigationViewModifier: navigationViewModifier, dataType: .flowPath, root: root) 122 | } 123 | } 124 | 125 | public extension FlowStack where NavigationViewModifier == UnchangedViewModifier { 126 | /// Initialises a ``FlowStack`` with a binding to an Array of routes. 127 | /// - Parameters: 128 | /// - routes: The array of routes that will manage navigation state. 129 | /// - withNavigation: Whether the root view should be wrapped in a navigation view. 130 | /// - root: The root view for the ``FlowStack``. 131 | init(_ routes: Binding<[Route]>, withNavigation: Bool = false, @ViewBuilder root: () -> Root) { 132 | self.init(routes: routes, withNavigation: withNavigation, navigationViewModifier: UnchangedViewModifier(), dataType: .typedArray, root: root) 133 | } 134 | } 135 | 136 | public extension FlowStack where NavigationViewModifier == UnchangedViewModifier, Data == AnyHashable { 137 | /// Initialises a ``FlowStack`` without any binding - the stack of routes will be managed internally by the ``FlowStack``. 138 | /// - Parameters: 139 | /// - withNavigation: Whether the root view should be wrapped in a navigation view. 140 | /// - root: The root view for the ``FlowStack``. 141 | init(withNavigation: Bool = false, @ViewBuilder root: () -> Root) { 142 | self.init(withNavigation: withNavigation, navigationViewModifier: UnchangedViewModifier(), root: root) 143 | } 144 | 145 | /// Initialises a ``FlowStack`` with a binding to a ``FlowPath``. 146 | /// - Parameters: 147 | /// - path: The FlowPath that will manage navigation state. 148 | /// - withNavigation: Whether the root view should be wrapped in a navigation view. 149 | /// - root: The root view for the ``FlowStack``. 150 | init(_ path: Binding, withNavigation: Bool = false, @ViewBuilder root: () -> Root) { 151 | self.init(path, withNavigation: withNavigation, navigationViewModifier: UnchangedViewModifier(), root: root) 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /Sources/FlowStacks/Convenience methods/FlowPath+convenienceMethods.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension FlowPath { 4 | /// Whether the FlowPath is able to push new screens. If it is not possible to determine, 5 | /// `nil` will be returned, e.g. if there is no `NavigationView` in this routes stack but it's possible 6 | /// a `NavigationView` has been added outside the FlowStack.. 7 | var canPush: Bool? { 8 | routes.canPush 9 | } 10 | 11 | /// Pushes a new screen via a push navigation. 12 | /// This should only be called if the most recently presented screen is embedded in a `NavigationView`. 13 | /// - Parameter screen: The screen to push. 14 | mutating func push(_ screen: AnyHashable) { 15 | routes.push(screen) 16 | } 17 | 18 | /// Presents a new screen via a sheet presentation. 19 | /// - Parameter screen: The screen to push. 20 | mutating func presentSheet(_ screen: AnyHashable, withNavigation: Bool = false) { 21 | routes.presentSheet(screen, withNavigation: withNavigation) 22 | } 23 | 24 | #if os(macOS) 25 | #else 26 | /// Presents a new screen via a full-screen cover presentation. 27 | /// - Parameter screen: The screen to push. 28 | @available(OSX, unavailable, message: "Not available on OS X.") 29 | mutating func presentCover(_ screen: AnyHashable, withNavigation: Bool = false) { 30 | routes.presentCover(screen, withNavigation: withNavigation) 31 | } 32 | #endif 33 | } 34 | 35 | // MARK: - Go back 36 | 37 | public extension FlowPath { 38 | /// Returns true if it's possible to go back the given number of screens. 39 | /// - Parameter count: The number of screens to go back. Defaults to 1. 40 | func canGoBack(_: Int = 1) -> Bool { 41 | routes.canGoBack() 42 | } 43 | 44 | /// Goes back a given number of screens off the stack 45 | /// - Parameter count: The number of screens to go back. Defaults to 1. 46 | mutating func goBack(_ count: Int = 1) { 47 | routes.goBack(count) 48 | } 49 | 50 | /// Goes back to a given index in the array of screens. The resulting screen count 51 | /// will be index + 1. 52 | /// - Parameter index: The index that should become top of the stack. 53 | mutating func goBackTo(index: Int) { 54 | routes.goBackTo(index: index) 55 | } 56 | 57 | /// Goes back to the root screen (index -1). The resulting screen count 58 | /// will be 0. 59 | mutating func goBackToRoot() { 60 | routes.goBackToRoot() 61 | } 62 | 63 | /// Goes back to the topmost (most recently shown) screen in the stack 64 | /// that satisfies the given condition. If no screens satisfy the condition, 65 | /// the routes array will be unchanged. 66 | /// - Parameter condition: The predicate indicating which screen to go back to. 67 | /// - Returns: A `Bool` indicating whether a screen was found. 68 | @discardableResult 69 | mutating func goBackTo(where condition: (Route) -> Bool) -> Bool { 70 | routes.goBackTo(where: condition) 71 | } 72 | 73 | /// Goes back to the topmost (most recently shown) screen in the stack 74 | /// that satisfies the given condition. If no screens satisfy the condition, 75 | /// the routes array will be unchanged. 76 | /// - Parameter condition: The predicate indicating which screen to go back to. 77 | /// - Returns: A `Bool` indicating whether a screen was found. 78 | @discardableResult 79 | mutating func goBackTo(where condition: (AnyHashable) -> Bool) -> Bool { 80 | routes.goBackTo(where: condition) 81 | } 82 | } 83 | 84 | public extension FlowPath { 85 | /// Goes back to the topmost (most recently shown) screen in the stack 86 | /// equal to the given screen. If no screens are found, 87 | /// the routes array will be unchanged. 88 | /// - Parameter screen: The predicate indicating which screen to go back to. 89 | /// - Returns: A `Bool` indicating whether a matching screen was found. 90 | @discardableResult 91 | mutating func goBackTo(_ screen: AnyHashable) -> Bool { 92 | routes.goBackTo(screen) 93 | } 94 | 95 | /// Goes back to the topmost (most recently shown) screen in the stack 96 | /// whose type matches the given type. If no screens satisfy the condition, 97 | /// the routes array will be unchanged. 98 | /// - Parameter type: The type of the screen to go back to. 99 | /// - Returns: A `Bool` indicating whether a screen was found. 100 | @discardableResult 101 | mutating func goBackTo(type _: T.Type) -> Bool { 102 | goBackTo(where: { $0.screen is T }) 103 | } 104 | } 105 | 106 | // MARK: - Pop 107 | 108 | public extension FlowPath { 109 | /// Pops a given number of screens off the stack. Only screens that have been pushed will 110 | /// be popped. 111 | /// - Parameter count: The number of screens to go back. Defaults to 1. 112 | mutating func pop(_ count: Int = 1) { 113 | routes.pop(count) 114 | } 115 | 116 | /// Pops to a given index in the array of screens. The resulting screen count 117 | /// will be index + 1. Only screens that have been pushed will 118 | /// be popped. 119 | /// - Parameter index: The index that should become top of the stack. 120 | mutating func popTo(index: Int) { 121 | routes.popTo(index: index) 122 | } 123 | 124 | /// Pops to the root screen (index -1). The resulting screen count 125 | /// will be 0. Only screens that have been pushed will 126 | /// be popped. 127 | mutating func popToRoot() { 128 | routes.popToRoot() 129 | } 130 | 131 | /// Pops all screens in the current navigation stack only, without dismissing any screens. 132 | mutating func popToCurrentNavigationRoot() { 133 | routes.popToCurrentNavigationRoot() 134 | } 135 | 136 | /// Pops to the topmost (most recently pushed) screen in the stack 137 | /// that satisfies the given condition. If no screens satisfy the condition, 138 | /// the routes array will be unchanged. Only screens that have been pushed will 139 | /// be popped. 140 | /// - Parameter condition: The predicate indicating which screen to pop to. 141 | /// - Returns: A `Bool` indicating whether a screen was found. 142 | @discardableResult 143 | mutating func popTo(where condition: (Route) -> Bool) -> Bool { 144 | routes.popTo(where: condition) 145 | } 146 | 147 | /// Pops to the topmost (most recently pushed) screen in the stack 148 | /// that satisfies the given condition. If no screens satisfy the condition, 149 | /// the routes array will be unchanged. Only screens that have been pushed will 150 | /// be popped. 151 | /// - Parameter condition: The predicate indicating which screen to pop to. 152 | /// - Returns: A `Bool` indicating whether a screen was found. 153 | @discardableResult 154 | mutating func popTo(where condition: (AnyHashable) -> Bool) -> Bool { 155 | routes.popTo(where: condition) 156 | } 157 | } 158 | 159 | public extension FlowPath { 160 | /// Pops to the topmost (most recently pushed) screen in the stack 161 | /// equal to the given screen. If no screens are found, 162 | /// the routes array will be unchanged. Only screens that have been pushed will 163 | /// be popped. 164 | /// - Parameter screen: The predicate indicating which screen to go back to. 165 | /// - Returns: A `Bool` indicating whether a matching screen was found. 166 | @discardableResult 167 | mutating func popTo(_ screen: AnyHashable) -> Bool { 168 | routes.popTo(screen) 169 | } 170 | 171 | /// Pops to the topmost (most recently shown) screen in the stack 172 | /// whose type matches the given type. If no screens satisfy the condition, 173 | /// the routes array will be unchanged. Only screens that have been pushed will 174 | /// be popped. 175 | /// - Parameter type: The type of the screen to go back to. 176 | /// - Returns: A `Bool` indicating whether a screen was found. 177 | @discardableResult 178 | mutating func popTo(type: T.Type) -> Bool { 179 | popTo(where: { $0.screen is T }) 180 | } 181 | } 182 | 183 | // MARK: - Dismiss 184 | 185 | public extension FlowPath { 186 | /// Dismisses a given number of presentation layers off the stack. Only screens that have been presented will 187 | /// be included in the count. 188 | /// - Parameter count: The number of presentation layers to go back. Defaults to 1. 189 | mutating func dismiss(count: Int = 1) { 190 | routes.dismiss(count: count) 191 | } 192 | 193 | /// Dismisses all presented sheets and modals, without popping any pushed screens in the bottommost 194 | /// presentation layer. 195 | mutating func dismissAll() { 196 | routes.dismissAll() 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FlowStacks 2 | 3 | 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. 4 | 5 | You might like this library if: 6 | 7 | ✅ You want to support deeplinks into deeply nested navigation routes in your app.
8 | ✅ You want to easily re-use views within different navigation contexts.
9 | ✅ You want to easily go back to the root screen or a specific screen in the navigation stack.
10 | ✅ You want to use the coordinator pattern to keep navigation logic in a single place.
11 | ✅ You want to break an app's navigation into multiple reusable coordinators and compose them together.
12 | 13 | ### Familiar APIs 14 | 15 | If you already know SwiftUI's `NavigationStack` APIs, `FlowStacks` should feel familiar and intuitive. Just replace 'Navigation' with 'Flow' in type and function names: 16 | 17 | ✅ `NavigationStack` -> `FlowStack` 18 | 19 | ✅ `NavigationLink` -> `FlowLink` 20 | 21 | ✅ `NavigationPath` -> `FlowPath` 22 | 23 | ✅ `navigationDestination` -> `flowDestination` 24 | 25 | `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]`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. 26 | 27 | ## Example 28 | 29 |

30 | Click to expand an example 31 | 32 | ```swift 33 | import FlowStacks 34 | import SwiftUI 35 | 36 | struct ContentView: View { 37 | @State var path = FlowPath() 38 | @State var isShowingWelcome = false 39 | 40 | var body: some View { 41 | FlowStack($path, withNavigation: true) { 42 | HomeView() 43 | .flowDestination(for: Int.self, destination: { number in 44 | NumberView(number: number) 45 | }) 46 | .flowDestination(for: String.self, destination: { text in 47 | Text(text) 48 | }) 49 | .flowDestination(isPresented: $isShowingWelcome, style: .sheet) { 50 | Text("Welcome to FlowStacks!") 51 | } 52 | } 53 | } 54 | } 55 | 56 | struct HomeView: View { 57 | @EnvironmentObject var navigator: FlowPathNavigator 58 | 59 | var body: some View { 60 | List { 61 | ForEach(0 ..< 10, id: \.self) { number in 62 | FlowLink(value: number, style: .sheet(withNavigation: true), label: { Text("Show \(number)") }) 63 | } 64 | Button("Show 'hello'") { 65 | navigator.push("Hello") 66 | } 67 | } 68 | .navigationTitle("Home") 69 | } 70 | } 71 | 72 | struct NumberView: View { 73 | @EnvironmentObject var navigator: FlowPathNavigator 74 | let number: Int 75 | 76 | var body: some View { 77 | VStack(spacing: 8) { 78 | Text("\(number)") 79 | FlowLink( 80 | value: number + 1, 81 | style: .push, 82 | label: { Text("Show next number") } 83 | ) 84 | Button("Go back to root") { 85 | navigator.goBackToRoot() 86 | } 87 | } 88 | .navigationTitle("\(number)") 89 | } 90 | } 91 | ``` 92 | 93 |
94 | 95 | ## Additional features 96 | 97 | As well as replicating the standard features of the new `NavigationStack` APIs, some helpful utilities have also been added. 98 | 99 | ### FlowNavigator 100 | 101 | 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: 102 | 103 | ```swift 104 | @EnvironmentObject var navigator: FlowPathNavigator 105 | ``` 106 | 107 | Or for a FlowStack backed by a routes array, e.g. `[Route]`: 108 | 109 | ```swift 110 | @EnvironmentObject var navigator: FlowNavigator 111 | ``` 112 | 113 | Here's an example of a `FlowNavigator` in use: 114 | 115 | ```swift 116 | @EnvironmentObject var navigator: FlowNavigator 117 | 118 | var body: some View { 119 | VStack { 120 | Button("View detail") { 121 | navigator.push(.detail) 122 | } 123 | Button("Go back to profile") { 124 | navigator.goBackTo(.profile) 125 | } 126 | Button("Go back to root") { 127 | navigator.goBackToRoot() 128 | } 129 | } 130 | } 131 | ``` 132 | 133 | ### Convenience methods 134 | 135 | When interacting with a `FlowNavigator` (and also the original `FlowPath` or routes array), a number of convenience methods are available for easier navigation, including: 136 | 137 | | Method | Effect | 138 | |--------------|---------------------------------------------------| 139 | | push | Pushes a new screen onto the stack. | 140 | | presentSheet | Presents a new screen as a sheet.† | 141 | | presentCover | Presents a new screen as a full-screen cover.† | 142 | | goBack | Goes back one screen in the stack. | 143 | | goBackToRoot | Goes back to the very first screen in the stack. | 144 | | goBackTo | Goes back to a specific screen in the stack. | 145 | | pop | Pops the current screen if it was pushed. | 146 | | dismiss | Dismisses the most recently presented screen. | 147 | 148 | _† Pass `embedInNavigationView: true` if you want to be able to push screens from the presented screen._ 149 | 150 | ### Deep-linking 151 | 152 | 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. 153 | 154 | ### Bindings 155 | 156 | 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.: 157 | 158 | ```swift 159 | import SwiftUINavigation 160 | 161 | struct BindingExampleCoordinator: View { 162 | @State var path = FlowPath() 163 | 164 | var body: some View { 165 | FlowStack($path, withNavigation: true) { 166 | FlowLink(value: 1, style: .push, label: { Text("Push '1'") }) 167 | .flowDestination(for: Int.self) { $number in 168 | EditNumberScreen(number: $number) // This screen can now change the number stored in the path. 169 | } 170 | } 171 | } 172 | ``` 173 | 174 | 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](https://github.com/pointfreeco/swiftui-navigation) library, which includes a number of helpful Binding transformations for optional and enum state, e.g.: 175 | 176 | 177 | 178 |
179 | Click to expand an example of using a Binding to a value in a typed Array of enum-based routes 180 | 181 | ```swift 182 | import FlowStacks 183 | import SwiftUI 184 | import SwiftUINavigation 185 | 186 | enum Screen: Hashable { 187 | case number(Int) 188 | case greeting(String) 189 | } 190 | 191 | struct BindingExampleCoordinator: View { 192 | @State var routes: Routes = [] 193 | 194 | var body: some View { 195 | FlowStack($routes, withNavigation: true) { 196 | HomeView() 197 | .flowDestination(for: Screen.self) { $screen in 198 | if let number = Binding(unwrapping: $screen, case: /Screen.number) { 199 | // Here `number` is a `Binding`, so `EditNumberScreen` can change its 200 | // value in the routes array. 201 | EditNumberScreen(number: number) 202 | } else if case let .greeting(greetingText) = screen { 203 | // Here `greetingText` is a plain `String`, as a binding is not needed. 204 | Text(greetingText) 205 | } 206 | } 207 | } 208 | } 209 | } 210 | 211 | struct HomeView: View { 212 | @EnvironmentObject var navigator: FlowPathNavigator 213 | 214 | var body: some View { 215 | VStack { 216 | FlowLink(value: Screen.number(42), style: .push, label: { Text("Show Number") }) 217 | FlowLink(value: Screen.greeting("Hello world"), style: .push, label: { Text("Show Greeting") }) 218 | } 219 | } 220 | } 221 | 222 | struct EditNumberScreen: View { 223 | @Binding var number: Int 224 | 225 | var body: some View { 226 | Stepper( 227 | label: { Text("\(number)") }, 228 | onIncrement: { number += 1 }, 229 | onDecrement: { number -= 1 } 230 | ) 231 | } 232 | } 233 | 234 | ``` 235 |
236 | 237 | ### Child flow coordinators 238 | 239 | `FlowStack`s 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](Docs/Nesting%20FlowStacks.md) for more info. 240 | 241 | ## How does it work? 242 | 243 | 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](https://github.com/johnpatrickmorgan/NavigationBackport). 244 | 245 | ## Migrating from earlier versions 246 | 247 | Please see the [migration docs](Docs/Migration/Migrating%20to%201.0.md). 248 | 249 | ------- 250 | 251 | [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/T6T114GWOT) 252 | 253 | -------------------------------------------------------------------------------- /Sources/FlowStacks/Convenience methods/FlowNavigator+convenienceMethods.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension FlowNavigator { 4 | /// Whether the Array of Routes is able to push new screens. If it is not possible to determine, 5 | /// `nil` will be returned, e.g. if there is no `NavigationView` in this routes stack but it's possible 6 | /// a `NavigationView` has been added outside the FlowStack.. 7 | var canPush: Bool? { 8 | routes.canPush 9 | } 10 | 11 | /// Pushes a new screen via a push navigation. 12 | /// This should only be called if the most recently presented screen is embedded in a `NavigationView`. 13 | /// - Parameter screen: The screen to push. 14 | func push(_ screen: Screen) { 15 | routes.push(screen) 16 | } 17 | 18 | /// Presents a new screen via a sheet presentation. 19 | /// - Parameter screen: The screen to push. 20 | func presentSheet(_ screen: Screen, withNavigation: Bool = false) { 21 | routes.presentSheet(screen, withNavigation: withNavigation) 22 | } 23 | 24 | #if os(macOS) 25 | #else 26 | /// Presents a new screen via a full-screen cover presentation. 27 | /// - Parameter screen: The screen to push. 28 | @available(OSX, unavailable, message: "Not available on OS X.") 29 | func presentCover(_ screen: Screen, withNavigation: Bool = false) { 30 | routes.presentCover(screen, withNavigation: withNavigation) 31 | } 32 | #endif 33 | } 34 | 35 | // MARK: - Go back 36 | 37 | public extension FlowNavigator { 38 | /// Returns true if it's possible to go back the given number of screens. 39 | /// - Parameter count: The number of screens to go back. Defaults to 1. 40 | func canGoBack(_: Int = 1) -> Bool { 41 | routes.canGoBack() 42 | } 43 | 44 | /// Goes back a given number of screens off the stack 45 | /// - Parameter count: The number of screens to go back. Defaults to 1. 46 | func goBack(_ count: Int = 1) { 47 | routes.goBack(count) 48 | } 49 | 50 | /// Goes back to a given index in the array of screens. The resulting screen count 51 | /// will be index + 1. 52 | /// - Parameter index: The index that should become top of the stack. 53 | func goBackTo(index: Int) { 54 | routes.goBackTo(index: index) 55 | } 56 | 57 | /// Goes back to the root screen (index -1). The resulting screen count 58 | /// will be 0. 59 | func goBackToRoot() { 60 | routes.goBackToRoot() 61 | } 62 | 63 | /// Goes back to the topmost (most recently shown) screen in the stack 64 | /// that satisfies the given condition. If no screens satisfy the condition, 65 | /// the routes array will be unchanged. 66 | /// - Parameter condition: The predicate indicating which screen to go back to. 67 | /// - Returns: A `Bool` indicating whether a screen was found. 68 | @discardableResult 69 | func goBackTo(where condition: (Route) -> Bool) -> Bool { 70 | routes.goBackTo(where: condition) 71 | } 72 | 73 | /// Goes back to the topmost (most recently shown) screen in the stack 74 | /// that satisfies the given condition. If no screens satisfy the condition, 75 | /// the routes array will be unchanged. 76 | /// - Parameter condition: The predicate indicating which screen to go back to. 77 | /// - Returns: A `Bool` indicating whether a screen was found. 78 | @discardableResult 79 | func goBackTo(where condition: (Screen) -> Bool) -> Bool { 80 | routes.goBackTo(where: condition) 81 | } 82 | } 83 | 84 | public extension FlowNavigator where Screen == AnyHashable { 85 | /// Goes back to the topmost (most recently shown) screen in the stack 86 | /// whose type matches the given type. If no screens satisfy the condition, 87 | /// the routes array will be unchanged. 88 | /// - Parameter type: The type of the screen to go back to. 89 | /// - Returns: A `Bool` indicating whether a screen was found. 90 | @discardableResult 91 | func goBackTo(type: T.Type) -> Bool { 92 | goBackTo(where: { $0.screen is T }) 93 | } 94 | } 95 | 96 | public extension FlowNavigator where Screen: Equatable { 97 | /// Goes back to the topmost (most recently shown) screen in the stack 98 | /// equal to the given screen. If no screens are found, 99 | /// the routes array will be unchanged. 100 | /// - Parameter screen: The predicate indicating which screen to go back to. 101 | /// - Returns: A `Bool` indicating whether a matching screen was found. 102 | @discardableResult 103 | func goBackTo(_ screen: Screen) -> Bool { 104 | routes.goBackTo(screen) 105 | } 106 | } 107 | 108 | public extension FlowNavigator where Screen: Identifiable { 109 | /// Goes back to the topmost (most recently shown) identifiable screen in the stack 110 | /// with the given ID. If no screens are found, the routes array will be unchanged. 111 | /// - Parameter id: The id of the screen to goBack to. 112 | /// - Returns: A `Bool` indicating whether a matching screen was found. 113 | @discardableResult 114 | func goBackTo(id: Screen.ID) -> Bool { 115 | routes.goBackTo(id: id) 116 | } 117 | 118 | /// Goes back to the topmost (most recently shown) identifiable screen in the stack 119 | /// matching the given screen. If no screens are found, the routes array 120 | /// will be unchanged. 121 | /// - Parameter screen: The screen to goBack to. 122 | /// - Returns: A `Bool` indicating whether a matching screen was found. 123 | @discardableResult 124 | func goBackTo(_ screen: Screen) -> Bool { 125 | routes.goBackTo(screen) 126 | } 127 | } 128 | 129 | /// Avoids an ambiguity when `Screen` is both `Identifiable` and `Equatable`. 130 | public extension FlowNavigator where Screen: Identifiable & Equatable { 131 | /// Goes back to the topmost (most recently shown) identifiable screen in the stack 132 | /// matching the given screen. If no screens are found, the routes array 133 | /// will be unchanged. 134 | /// - Parameter screen: The screen to goBack to. 135 | /// - Returns: A `Bool` indicating whether a matching screen was found. 136 | @discardableResult 137 | func goBackTo(_ screen: Screen) -> Bool { 138 | routes.goBackTo(screen) 139 | } 140 | } 141 | 142 | // MARK: - Pop 143 | 144 | public extension FlowNavigator { 145 | /// Pops a given number of screens off the stack. Only screens that have been pushed will 146 | /// be popped. 147 | /// - Parameter count: The number of screens to go back. Defaults to 1. 148 | func pop(_ count: Int = 1) { 149 | routes.pop(count) 150 | } 151 | 152 | /// Pops to a given index in the array of screens. The resulting screen count 153 | /// will be index + 1. Only screens that have been pushed will 154 | /// be popped. 155 | /// - Parameter index: The index that should become top of the stack. 156 | func popTo(index: Int) { 157 | routes.popTo(index: index) 158 | } 159 | 160 | /// Pops to the root screen (index -1). The resulting screen count 161 | /// will be 0. Only screens that have been pushed will 162 | /// be popped. 163 | func popToRoot() { 164 | routes.popToRoot() 165 | } 166 | 167 | /// Pops all screens in the current navigation stack only, without dismissing any screens. 168 | func popToCurrentNavigationRoot() { 169 | routes.popToCurrentNavigationRoot() 170 | } 171 | 172 | /// Pops to the topmost (most recently pushed) screen in the stack 173 | /// that satisfies the given condition. If no screens satisfy the condition, 174 | /// the routes array will be unchanged. Only screens that have been pushed will 175 | /// be popped. 176 | /// - Parameter condition: The predicate indicating which screen to pop to. 177 | /// - Returns: A `Bool` indicating whether a screen was found. 178 | @discardableResult 179 | func popTo(where condition: (Route) -> Bool) -> Bool { 180 | routes.popTo(where: condition) 181 | } 182 | 183 | /// Pops to the topmost (most recently pushed) screen in the stack 184 | /// that satisfies the given condition. If no screens satisfy the condition, 185 | /// the routes array will be unchanged. Only screens that have been pushed will 186 | /// be popped. 187 | /// - Parameter condition: The predicate indicating which screen to pop to. 188 | /// - Returns: A `Bool` indicating whether a screen was found. 189 | @discardableResult 190 | func popTo(where condition: (Screen) -> Bool) -> Bool { 191 | routes.popTo(where: condition) 192 | } 193 | } 194 | 195 | public extension FlowNavigator where Screen: Equatable { 196 | /// Pops to the topmost (most recently pushed) screen in the stack 197 | /// equal to the given screen. If no screens are found, 198 | /// the routes array will be unchanged. Only screens that have been pushed will 199 | /// be popped. 200 | /// - Parameter screen: The predicate indicating which screen to go back to. 201 | /// - Returns: A `Bool` indicating whether a matching screen was found. 202 | @discardableResult 203 | func popTo(_ screen: Screen) -> Bool { 204 | routes.popTo(screen) 205 | } 206 | } 207 | 208 | public extension FlowNavigator where Screen: Identifiable { 209 | /// Pops to the topmost (most recently pushed) identifiable screen in the stack 210 | /// with the given ID. If no screens are found, the routes array will be unchanged. 211 | /// Only screens that have been pushed will 212 | /// be popped. 213 | /// - Parameter id: The id of the screen to goBack to. 214 | /// - Returns: A `Bool` indicating whether a matching screen was found. 215 | @discardableResult 216 | func popTo(id: Screen.ID) -> Bool { 217 | routes.popTo(id: id) 218 | } 219 | 220 | /// Pops to the topmost (most recently pushed) identifiable screen in the stack 221 | /// matching the given screen. If no screens are found, the routes array 222 | /// will be unchanged. Only screens that have been pushed will 223 | /// be popped. 224 | /// - Parameter screen: The screen to goBack to. 225 | /// - Returns: A `Bool` indicating whether a matching screen was found. 226 | @discardableResult 227 | func popTo(_ screen: Screen) -> Bool { 228 | routes.popTo(screen) 229 | } 230 | } 231 | 232 | /// Avoids an ambiguity when `Screen` is both `Identifiable` and `Equatable`. 233 | public extension FlowNavigator where Screen: Identifiable & Equatable { 234 | /// Pops to the topmost (most recently pushed) identifiable screen in the stack 235 | /// matching the given screen. If no screens are found, the routes array 236 | /// will be unchanged. Only screens that have been pushed will 237 | /// be popped. 238 | /// - Parameter screen: The screen to pop to. 239 | /// - Returns: A `Bool` indicating whether a matching screen was found. 240 | @discardableResult 241 | func popTo(_ screen: Screen) -> Bool { 242 | routes.popTo(screen) 243 | } 244 | } 245 | 246 | public extension FlowNavigator where Screen == AnyHashable { 247 | /// Pops to the topmost (most recently shown) screen in the stack 248 | /// whose type matches the given type. If no screens satisfy the condition, 249 | /// the routes array will be unchanged. Only screens that have been pushed will 250 | /// be popped. 251 | /// - Parameter type: The type of the screen to go back to. 252 | /// - Returns: A `Bool` indicating whether a screen was found. 253 | @discardableResult 254 | func popTo(type: T.Type) -> Bool { 255 | popTo(where: { $0.screen is T }) 256 | } 257 | } 258 | 259 | // MARK: - Dismiss 260 | 261 | public extension FlowNavigator { 262 | /// Dismisses a given number of presentation layers off the stack. Only screens that have been presented will 263 | /// be included in the count. 264 | /// - Parameter count: The number of presentation layers to go back. Defaults to 1. 265 | func dismiss(count: Int = 1) { 266 | routes.dismiss(count: count) 267 | } 268 | 269 | /// Dismisses all presented sheets and modals, without popping any pushed screens in the bottommost 270 | /// presentation layer. 271 | func dismissAll() { 272 | routes.dismissAll() 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /Sources/FlowStacks/Convenience methods/Array+convenienceMethods.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // TCACoordinators sets this to true. 4 | @_spi(Private) public var isWithinTCACoordinators = false 5 | private var rootIndex: Int { isWithinTCACoordinators ? 0 : -1 } 6 | 7 | public extension Array where Element: RouteProtocol { 8 | /// Whether the Array of Routes is able to push new screens. If it is not possible to determine, 9 | /// `nil` will be returned, e.g. if there is no `NavigationView` in this routes stack but it's possible 10 | /// a `NavigationView` has been added outside the FlowStack.. 11 | var canPush: Bool? { 12 | for route in reversed() { 13 | switch route.style { 14 | case .push: 15 | continue 16 | case let .cover(withNavigation), let .sheet(withNavigation): 17 | if isWithinTCACoordinators { 18 | // NOTE: TCACoordinators includes the root screen in its Array, which may have `withNavigation` set to false. 19 | // However, in nested coordinators, it's possible that the parent includes a navigation wrapper, and pushing 20 | // is possible even if `withNavigation` is false. 21 | return withNavigation ? true : nil 22 | } 23 | return withNavigation 24 | } 25 | } 26 | return nil 27 | } 28 | 29 | /// Pushes a new screen via a push navigation. 30 | /// This should only be called if the most recently presented screen is embedded in a `NavigationView`. 31 | /// - Parameter screen: The screen to push. 32 | mutating func push(_ screen: Element.Screen) { 33 | assert( 34 | canPush != false, 35 | """ 36 | Attempting to push a screen, but the most recently presented screen is not \ 37 | embedded in a `NavigationView`. Please ensure the root or most recently presented \ 38 | route has `withNavigation` set to `true`. 39 | """ 40 | ) 41 | append(.push(screen)) 42 | } 43 | 44 | /// Presents a new screen via a sheet presentation. 45 | /// - Parameter screen: The screen to push. 46 | mutating func presentSheet(_ screen: Element.Screen, withNavigation: Bool = false) { 47 | append(.sheet(screen, withNavigation: withNavigation)) 48 | } 49 | 50 | #if os(macOS) 51 | #else 52 | /// Presents a new screen via a full-screen cover presentation. 53 | /// - Parameter screen: The screen to push. 54 | @available(OSX, unavailable, message: "Not available on OS X.") 55 | mutating func presentCover(_ screen: Element.Screen, withNavigation: Bool = false) { 56 | append(.cover(screen, withNavigation: withNavigation)) 57 | } 58 | #endif 59 | } 60 | 61 | // MARK: - Go back 62 | 63 | public extension Array where Element: RouteProtocol { 64 | /// Returns true if it's possible to go back the given number of screens. 65 | /// - Parameter count: The number of screens to go back. Defaults to 1. 66 | func canGoBack(_ count: Int = 1) -> Bool { 67 | self.count - count > rootIndex && count >= 0 68 | } 69 | 70 | /// Goes back a given number of screens off the stack 71 | /// - Parameter count: The number of screens to go back. Defaults to 1. 72 | mutating func goBack(_ count: Int = 1) { 73 | assert( 74 | self.count - count > rootIndex, 75 | "Can't go back\(count == 1 ? "" : " \(count) screens") - the screen count is \(self.count)" 76 | ) 77 | assert( 78 | count >= 0, 79 | "Can't go back \(count) screens - count must be positive" 80 | ) 81 | guard self.count - count > rootIndex, count >= 0 else { return } 82 | removeLast(count) 83 | } 84 | 85 | /// Goes back to a given index in the array of screens. The resulting array count 86 | /// will be index + 1. 87 | /// - Parameter index: The index that should become top of the stack, e.g. 0 for the root screen. 88 | mutating func goBackTo(index: Int) { 89 | goBack(count - index - 1) 90 | } 91 | 92 | /// Goes back to the root screen (index 0). The resulting array's count will be 0. 93 | mutating func goBackToRoot() { 94 | guard !isEmpty else { return } 95 | goBackTo(index: rootIndex) 96 | } 97 | 98 | /// Goes back to the topmost (most recently shown) screen in the stack 99 | /// that satisfies the given condition. If no screens satisfy the condition, 100 | /// the routes array will be unchanged. 101 | /// - Parameter condition: The predicate indicating which screen to go back to. 102 | /// - Returns: A `Bool` indicating whether a screen was found. 103 | @discardableResult 104 | mutating func goBackTo(where condition: (Element) -> Bool) -> Bool { 105 | guard let index = lastIndex(where: condition) else { 106 | return false 107 | } 108 | goBackTo(index: index) 109 | return true 110 | } 111 | 112 | /// Goes back to the topmost (most recently shown) screen in the stack 113 | /// that satisfies the given condition. If no screens satisfy the condition, 114 | /// the routes array will be unchanged. 115 | /// - Parameter condition: The predicate indicating which screen to go back to. 116 | /// - Returns: A `Bool` indicating whether a screen was found. 117 | @discardableResult 118 | mutating func goBackTo(where condition: (Element.Screen) -> Bool) -> Bool { 119 | goBackTo(where: { condition($0.screen) }) 120 | } 121 | } 122 | 123 | public extension Array where Element: RouteProtocol, Element.Screen: Equatable { 124 | /// Goes back to the topmost (most recently shown) screen in the stack 125 | /// equal to the given screen. If no screens are found, 126 | /// the routes array will be unchanged. 127 | /// - Parameter screen: The predicate indicating which screen to go back to. 128 | /// - Returns: A `Bool` indicating whether a matching screen was found. 129 | @discardableResult 130 | mutating func goBackTo(_ screen: Element.Screen) -> Bool { 131 | goBackTo(where: { $0.screen == screen }) 132 | } 133 | } 134 | 135 | public extension Array where Element: RouteProtocol, Element.Screen: Identifiable { 136 | /// Goes back to the topmost (most recently shown) identifiable screen in the stack 137 | /// with the given ID. If no screens are found, the routes array will be unchanged. 138 | /// - Parameter id: The id of the screen to goBack to. 139 | /// - Returns: A `Bool` indicating whether a matching screen was found. 140 | @discardableResult 141 | mutating func goBackTo(id: Element.Screen.ID) -> Bool { 142 | goBackTo(where: { $0.screen.id == id }) 143 | } 144 | 145 | /// Goes back to the topmost (most recently shown) identifiable screen in the stack 146 | /// matching the given screen. If no screens are found, the routes array 147 | /// will be unchanged. 148 | /// - Parameter screen: The screen to goBack to. 149 | /// - Returns: A `Bool` indicating whether a matching screen was found. 150 | @discardableResult 151 | mutating func goBackTo(_ screen: Element.Screen) -> Bool { 152 | goBackTo(id: screen.id) 153 | } 154 | } 155 | 156 | /// Avoids an ambiguity when `Screen` is both `Identifiable` and `Equatable`. 157 | public extension Array where Element: RouteProtocol, Element.Screen: Identifiable & Equatable { 158 | /// Goes back to the topmost (most recently shown) identifiable screen in the stack 159 | /// matching the given screen. If no screens are found, the routes array 160 | /// will be unchanged. 161 | /// - Parameter screen: The screen to goBack to. 162 | /// - Returns: A `Bool` indicating whether a matching screen was found. 163 | @discardableResult 164 | mutating func goBackTo(_ screen: Element.Screen) -> Bool { 165 | goBackTo(id: screen.id) 166 | } 167 | } 168 | 169 | // MARK: - Pop 170 | 171 | public extension Array where Element: RouteProtocol { 172 | /// Pops a given number of screens off the stack. Only screens that have been pushed will 173 | /// be popped. 174 | /// - Parameter count: The number of screens to go back. Defaults to 1. 175 | mutating func pop(_ count: Int = 1) { 176 | assert(self.count - count > rootIndex) 177 | assert(suffix(count).allSatisfy { $0.style == .push }) 178 | goBack(count) 179 | } 180 | 181 | /// Pops to a given index in the array of screens. The resulting screen count 182 | /// will be equal to index + 1. Only screens that have been pushed will 183 | /// be popped. 184 | /// - Parameter index: The index that should become top of the stack, e.g. 0 for the root. 185 | mutating func popTo(index: Int) { 186 | let popCount = count - index - 1 187 | pop(popCount) 188 | } 189 | 190 | /// Pops to the root screen (index -1). The resulting screen count 191 | /// will be 0. Only screens that have been pushed will 192 | /// be popped. 193 | mutating func popToRoot() { 194 | popTo(index: rootIndex) 195 | } 196 | 197 | /// Pops all pushed screens in the current navigation stack only, without dismissing any screens. 198 | mutating func popToCurrentNavigationRoot() { 199 | let index = lastIndex(where: { !$0.style.isPush }) ?? -1 200 | goBackTo(index: index) 201 | } 202 | 203 | /// Pops to the topmost (most recently pushed) screen in the stack 204 | /// that satisfies the given condition. If no screens satisfy the condition, 205 | /// the routes array will be unchanged. Only screens that have been pushed will 206 | /// be popped. 207 | /// - Parameter condition: The predicate indicating which screen to pop to. 208 | /// - Returns: A `Bool` indicating whether a screen was found. 209 | @discardableResult 210 | mutating func popTo(where condition: (Element) -> Bool) -> Bool { 211 | guard let index = lastIndex(where: condition) else { 212 | return false 213 | } 214 | popTo(index: index) 215 | return true 216 | } 217 | 218 | /// Pops to the topmost (most recently pushed) screen in the stack 219 | /// that satisfies the given condition. If no screens satisfy the condition, 220 | /// the routes array will be unchanged. Only screens that have been pushed will 221 | /// be popped. 222 | /// - Parameter condition: The predicate indicating which screen to pop to. 223 | /// - Returns: A `Bool` indicating whether a screen was found. 224 | @discardableResult 225 | mutating func popTo(where condition: (Element.Screen) -> Bool) -> Bool { 226 | popTo(where: { condition($0.screen) }) 227 | } 228 | } 229 | 230 | public extension Array where Element: RouteProtocol, Element.Screen: Equatable { 231 | /// Pops to the topmost (most recently pushed) screen in the stack 232 | /// equal to the given screen. If no screens are found, 233 | /// the routes array will be unchanged. Only screens that have been pushed will 234 | /// be popped. 235 | /// - Parameter screen: The predicate indicating which screen to go back to. 236 | /// - Returns: A `Bool` indicating whether a matching screen was found. 237 | @discardableResult 238 | mutating func popTo(_ screen: Element.Screen) -> Bool { 239 | popTo(where: { $0 == screen }) 240 | } 241 | } 242 | 243 | public extension Array where Element: RouteProtocol, Element.Screen: Identifiable { 244 | /// Pops to the topmost (most recently pushed) identifiable screen in the stack 245 | /// with the given ID. If no screens are found, the routes array will be unchanged. 246 | /// Only screens that have been pushed will 247 | /// be popped. 248 | /// - Parameter id: The id of the screen to goBack to. 249 | /// - Returns: A `Bool` indicating whether a matching screen was found. 250 | @discardableResult 251 | mutating func popTo(id: Element.Screen.ID) -> Bool { 252 | popTo(where: { $0.id == id }) 253 | } 254 | 255 | /// Pops to the topmost (most recently pushed) identifiable screen in the stack 256 | /// matching the given screen. If no screens are found, the routes array 257 | /// will be unchanged. Only screens that have been pushed will 258 | /// be popped. 259 | /// - Parameter screen: The screen to goBack to. 260 | /// - Returns: A `Bool` indicating whether a matching screen was found. 261 | @discardableResult 262 | mutating func popTo(_ screen: Element.Screen) -> Bool { 263 | popTo(id: screen.id) 264 | } 265 | } 266 | 267 | /// Avoids an ambiguity when `Screen` is both `Identifiable` and `Equatable`. 268 | public extension Array where Element: RouteProtocol, Element.Screen: Identifiable & Equatable { 269 | /// Pops to the topmost (most recently pushed) identifiable screen in the stack 270 | /// matching the given screen. If no screens are found, the routes array 271 | /// will be unchanged. Only screens that have been pushed will 272 | /// be popped. 273 | /// - Parameter screen: The screen to pop to. 274 | /// - Returns: A `Bool` indicating whether a matching screen was found. 275 | @discardableResult 276 | mutating func popTo(_ screen: Element.Screen) -> Bool { 277 | popTo(id: screen.id) 278 | } 279 | } 280 | 281 | // MARK: - Dismiss 282 | 283 | public extension Array where Element: RouteProtocol { 284 | /// Dismisses a given number of presentation layers off the stack. Only screens that have been presented will 285 | /// be included in the count. 286 | /// - Parameter count: The number of presentation layers to go back. Defaults to 1. 287 | mutating func dismiss(count: Int = 1) { 288 | assert(count >= 0) 289 | var index = endIndex - 1 290 | var dismissed = 0 291 | while dismissed < count, index + 1 > rootIndex { 292 | if self[index].isPresented { 293 | dismissed += 1 294 | } 295 | index -= 1 296 | } 297 | goBackTo(index: index) 298 | } 299 | 300 | /// Dismisses all presented sheets and modals, without popping any pushed screens in the bottommost 301 | /// presentation layer. 302 | mutating func dismissAll() { 303 | let count = self[(rootIndex + 1)...].filter(\.isPresented).count 304 | guard count > 0 else { return } 305 | dismiss(count: count) 306 | } 307 | } 308 | --------------------------------------------------------------------------------