├── .gitignore ├── .swift-version ├── .swiftformat ├── .swiftpm └── xcode │ ├── package.xcworkspace │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ └── xcschemes │ └── FlowStacks.xcscheme ├── Docs ├── Migration │ └── Migrating to 1.0.md └── Nesting FlowStacks.md ├── FlowStacks.podspec ├── FlowStacksApp.xcodeproj ├── project.pbxproj └── xcshareddata │ └── xcschemes │ ├── FlowStacksApp (iOS).xcscheme │ ├── FlowStacksApp (macOS).xcscheme │ └── FlowStacksApp (tvOS).xcscheme ├── FlowStacksApp ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json ├── Info.plist ├── Package.swift └── Shared │ ├── ArrayBindingView.swift │ ├── Deeplink.swift │ ├── FlowPathView.swift │ ├── FlowStacksApp.swift │ ├── NoBindingView.swift │ ├── NumberCoordinator.swift │ ├── NumberVMFlow.swift │ ├── SimpleStepper.swift │ └── View+indexedA11y.swift ├── FlowStacksAppUITests ├── FlowStacksUITests.swift ├── NestedFlowStacksUITests.swift └── NumbersUITests.swift ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── FlowStacks │ ├── ConditionalViewBuilder.swift │ ├── Convenience methods │ ├── Array+convenienceMethods.swift │ ├── FlowNavigator+convenienceMethods.swift │ └── FlowPath+convenienceMethods.swift │ ├── DestinationBuilderHolder.swift │ ├── DestinationBuilderModifier.swift │ ├── DestinationBuilderView.swift │ ├── EmbedModifier.swift │ ├── EnvironmentValues+keys.swift │ ├── FlowLink.swift │ ├── FlowNavigator.swift │ ├── FlowPath+calculateSteps.swift │ ├── FlowPath.CodableRepresentation.swift │ ├── FlowPath.swift │ ├── FlowStack.swift │ ├── LocalDestinationBuilderModifier.swift │ ├── Node.swift │ ├── NonReactiveState.swift │ ├── Route.swift │ ├── RouteProtocol.swift │ ├── RouteStyle.swift │ ├── Router.swift │ ├── RoutesHolder.swift │ ├── ScreenModifier.swift │ ├── UnchangedViewModifier.swift │ ├── Unobserved.swift │ ├── View+UseNavigationStack.swift │ ├── View+cover.swift │ ├── View+flowDestination.swift │ ├── View+onFirstAppear.swift │ ├── View+push.swift │ ├── View+sheet.swift │ ├── View+show.swift │ ├── apply.swift │ └── withDelaysIfUnsupported │ ├── Binding+withDelaysIfUnsupported.swift │ ├── Navigator+withDelaysIfUnsupported.swift │ └── ObservableObject+withDelaysIfUnsupported.swift └── Tests └── FlowStacksTests ├── CalculateStepsTests.swift └── ConvenienceMethodsTests.swift /.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 | -------------------------------------------------------------------------------- /.swift-version: -------------------------------------------------------------------------------- 1 | 5.10 -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/FlowStacks.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 44 | 50 | 51 | 57 | 58 | 59 | 60 | 62 | 63 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | 20 | 21 | ## Approach 2: Nested FlowStack holds its own state and takes over navigation duties from its parent FlowStack 22 | 23 | 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. 24 | 25 | That means: 26 | 27 | - Only the child can push new routes onto the path: it assumes responsibility for navigation until it is removed from its parent's path. 28 | - Calling `goBackToRoot` from the child will go back to the child's root screen. 29 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /FlowStacksApp/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /FlowStacksApp/Package.swift: -------------------------------------------------------------------------------- 1 | import PackageDescription 2 | 3 | let package = Package( 4 | name: "", 5 | products: [], 6 | dependencies: [], 7 | targets: [] 8 | ) 9 | -------------------------------------------------------------------------------- /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 | } 9 | 10 | struct ArrayBindingView: View { 11 | @State private var savedRoutes: [Route]? 12 | @State private var routes: [Route] = [] 13 | 14 | var body: some View { 15 | VStack { 16 | HStack { 17 | Button("Save", action: saveRoutes) 18 | .disabled(savedRoutes == routes) 19 | Button("Restore", action: restoreRoutes) 20 | .disabled(savedRoutes == nil) 21 | } 22 | FlowStack($routes, withNavigation: true) { 23 | HomeView() 24 | .flowDestination(for: Screen.self, destination: { screen in 25 | switch screen { 26 | case let .numberList(numberList): 27 | NumberListView(numberList: numberList) 28 | case let .number(number): 29 | NumberView(number: number) 30 | case let .visualisation(visualisation): 31 | EmojiView(visualisation: visualisation) 32 | } 33 | }) 34 | } 35 | } 36 | } 37 | 38 | func saveRoutes() { 39 | savedRoutes = routes 40 | } 41 | 42 | func restoreRoutes() { 43 | guard let savedRoutes else { return } 44 | routes = savedRoutes 45 | } 46 | } 47 | 48 | private struct HomeView: View { 49 | @State var isPushing = false 50 | @EnvironmentObject var navigator: FlowNavigator 51 | 52 | var body: some View { 53 | VStack(spacing: 8) { 54 | // Push via FlowLink 55 | FlowLink(value: Screen.numberList(NumberList(range: 0 ..< 10)), style: .sheet(withNavigation: true), label: { Text("Pick a number") }) 56 | .indexedA11y("Pick a number") 57 | // Push via navigator 58 | Button("99 Red balloons", action: show99RedBalloons) 59 | // Push via Bool binding 60 | Button("Push local destination", action: { isPushing = true }).disabled(isPushing) 61 | }.navigationTitle("Home") 62 | .flowDestination(isPresented: $isPushing, style: .push) { 63 | Text("Local destination") 64 | } 65 | } 66 | 67 | func show99RedBalloons() { 68 | navigator.push(.number(99)) 69 | navigator.push(.visualisation(EmojiVisualisation(emoji: "🎈", count: 99))) 70 | } 71 | } 72 | 73 | private struct NumberListView: View { 74 | @EnvironmentObject var navigator: FlowNavigator 75 | let numberList: NumberList 76 | var body: some View { 77 | List { 78 | ForEach(numberList.range, id: \.self) { number in 79 | FlowLink("\(number)", value: Screen.number(number), style: .sheet(withNavigation: true)) 80 | .indexedA11y("Show \(number)") 81 | } 82 | Button("Go back", action: { navigator.goBack() }) 83 | }.navigationTitle("List") 84 | } 85 | } 86 | 87 | private struct NumberView: View { 88 | @EnvironmentObject var navigator: FlowNavigator 89 | @State var number: Int 90 | 91 | var body: some View { 92 | VStack(spacing: 8) { 93 | Text("\(number)").font(.title) 94 | SimpleStepper(number: $number) 95 | FlowLink( 96 | value: Screen.number(number + 1), 97 | style: .push, 98 | label: { Text("Show next number") } 99 | ) 100 | FlowLink( 101 | value: Screen.visualisation(.init(emoji: "🐑", count: number)), 102 | style: .sheet, 103 | label: { Text("Visualise with sheep") } 104 | ) 105 | Button("Go back to root", action: { navigator.goBackToRoot() }) 106 | }.navigationTitle("\(number)") 107 | } 108 | } 109 | 110 | private struct EmojiView: View { 111 | @EnvironmentObject var navigator: FlowNavigator 112 | let visualisation: EmojiVisualisation 113 | 114 | var body: some View { 115 | Text(visualisation.text) 116 | .navigationTitle("Visualise \(visualisation.count)") 117 | Button("Go back", action: { navigator.goBack() }) 118 | } 119 | } 120 | 121 | // MARK: - State 122 | 123 | private struct EmojiVisualisation: Hashable, Codable { 124 | let emoji: String 125 | let count: Int 126 | 127 | var text: String { 128 | Array(repeating: emoji, count: count).joined() 129 | } 130 | } 131 | 132 | private struct NumberList: Hashable, Codable { 133 | let range: Range 134 | } 135 | 136 | private class ClassDestination { 137 | let data: String 138 | 139 | init(data: String) { 140 | self.data = data 141 | } 142 | } 143 | 144 | extension ClassDestination: Hashable { 145 | static func == (lhs: ClassDestination, rhs: ClassDestination) -> Bool { 146 | lhs.data == rhs.data 147 | } 148 | 149 | func hash(into hasher: inout Hasher) { 150 | hasher.combine(data) 151 | } 152 | } 153 | 154 | private class SampleClassDestination: ClassDestination { 155 | init() { super.init(data: "Sample data") } 156 | } 157 | 158 | private struct ChildFlowStack: View { 159 | enum ChildType: Hashable { 160 | case flowPath, noBinding 161 | } 162 | 163 | let childType: ChildType 164 | 165 | var body: some View { 166 | switch childType { 167 | case .flowPath: 168 | FlowPathView() 169 | case .noBinding: 170 | NoBindingView() 171 | } 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | FlowLink(value: ChildFlowStack.ChildType.flowPath, style: .push, label: { Text("FlowPath Child") }) 121 | FlowLink(value: ChildFlowStack.ChildType.noBinding, style: .push, label: { Text("NoBinding Child") }) 122 | Button("Go back to root", action: { navigator.goBackToRoot() }) 123 | }.navigationTitle("\(number)") 124 | } 125 | } 126 | 127 | private struct EmojiView: View { 128 | @EnvironmentObject var navigator: FlowPathNavigator 129 | let visualisation: EmojiVisualisation 130 | 131 | var body: some View { 132 | VStack { 133 | Text(visualisation.text) 134 | .navigationTitle("Visualise \(visualisation.count)") 135 | Button("Go back", action: { navigator.goBack() }) 136 | } 137 | } 138 | } 139 | 140 | private struct ClassDestinationView: View { 141 | @EnvironmentObject var navigator: FlowPathNavigator 142 | let destination: ClassDestination 143 | 144 | var body: some View { 145 | VStack { 146 | Text(destination.data) 147 | .navigationTitle("A ClassDestination") 148 | Button("Go back", action: { navigator.goBack() }) 149 | } 150 | } 151 | } 152 | 153 | // MARK: - State 154 | 155 | private struct EmojiVisualisation: Hashable, Codable { 156 | let emoji: String 157 | let count: Int 158 | 159 | var text: String { 160 | Array(repeating: emoji, count: count).joined() 161 | } 162 | } 163 | 164 | private struct Number: Hashable, Codable { 165 | var value: Int 166 | } 167 | 168 | private struct NumberList: Hashable, Codable { 169 | let range: Range 170 | } 171 | 172 | private class ClassDestination { 173 | let data: String 174 | 175 | init(data: String) { 176 | self.data = data 177 | } 178 | } 179 | 180 | extension ClassDestination: Hashable { 181 | static func == (lhs: ClassDestination, rhs: ClassDestination) -> Bool { 182 | lhs.data == rhs.data 183 | } 184 | 185 | func hash(into hasher: inout Hasher) { 186 | hasher.combine(data) 187 | } 188 | } 189 | 190 | private class SampleClassDestination: ClassDestination { 191 | init() { super.init(data: "Sample data") } 192 | } 193 | 194 | private struct ChildFlowStack: View, Codable { 195 | enum ChildType: Hashable, Codable { 196 | case flowPath, noBinding 197 | } 198 | 199 | let childType: ChildType 200 | 201 | var body: some View { 202 | switch childType { 203 | case .flowPath: 204 | FlowPathView() 205 | case .noBinding: 206 | NoBindingView() 207 | } 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /FlowStacksApp/Shared/FlowStacksApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @main 4 | struct FlowStacksApp: App { 5 | enum Tab: Hashable { 6 | case numberCoordinator 7 | case flowPath 8 | case arrayBinding 9 | case noBinding 10 | case viewModel 11 | } 12 | 13 | @State var selectedTab: Tab = .numberCoordinator 14 | 15 | var body: some Scene { 16 | WindowGroup { 17 | TabView(selection: $selectedTab) { 18 | NumberCoordinator() 19 | .tabItem { Text("Numbers") } 20 | .tag(Tab.numberCoordinator) 21 | FlowPathView() 22 | .tabItem { Text("FlowPath") } 23 | .tag(Tab.flowPath) 24 | ArrayBindingView() 25 | .tabItem { Text("ArrayBinding") } 26 | .tag(Tab.arrayBinding) 27 | NoBindingView() 28 | .tabItem { Text("NoBinding") } 29 | .tag(Tab.noBinding) 30 | NumberVMFlow(viewModel: .init(initialNumber: 64)) 31 | .tabItem { Text("ViewModel") } 32 | .tag(Tab.viewModel) 33 | }.onOpenURL { url in 34 | guard let deeplink = Deeplink(url: url) else { return } 35 | follow(deeplink) 36 | } 37 | } 38 | } 39 | 40 | private func follow(_ deeplink: Deeplink) { 41 | // Test deeplinks from CLI with, e.g.: 42 | // `xcrun simctl openurl booted flowstacksapp://numbers/42/13` 43 | switch deeplink { 44 | case .numberCoordinator: 45 | selectedTab = .numberCoordinator 46 | case .viewModelTab: 47 | selectedTab = .viewModel 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /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 | FlowLink(value: ChildFlowStack.ChildType.flowPath, style: .push, label: { Text("FlowPath Child") }) 94 | FlowLink(value: ChildFlowStack.ChildType.noBinding, style: .push, label: { Text("NoBinding Child") }) 95 | Button("Go back to root") { 96 | navigator.goBackToRoot() 97 | } 98 | }.navigationTitle("\(number)") 99 | } 100 | } 101 | 102 | private struct EmojiView: View { 103 | @EnvironmentObject var navigator: FlowPathNavigator 104 | let visualisation: EmojiVisualisation 105 | 106 | var body: some View { 107 | VStack { 108 | Text(visualisation.text) 109 | .navigationTitle("Visualise \(visualisation.count)") 110 | Button("Go back", action: { navigator.goBack() }) 111 | } 112 | } 113 | } 114 | 115 | private struct ClassDestinationView: View { 116 | @EnvironmentObject var navigator: FlowPathNavigator 117 | let destination: ClassDestination 118 | 119 | var body: some View { 120 | VStack { 121 | Text(destination.data) 122 | .navigationTitle("A ClassDestination") 123 | Button("Go back", action: { navigator.goBack() }) 124 | } 125 | } 126 | } 127 | 128 | // MARK: - State 129 | 130 | private struct EmojiVisualisation: Hashable, Codable { 131 | let emoji: String 132 | let count: Int 133 | 134 | var text: String { 135 | Array(repeating: emoji, count: count).joined() 136 | } 137 | } 138 | 139 | private struct Number: Hashable, Codable { 140 | var value: Int 141 | } 142 | 143 | private struct NumberList: Hashable, Codable { 144 | let range: Range 145 | } 146 | 147 | private class ClassDestination { 148 | let data: String 149 | 150 | init(data: String) { 151 | self.data = data 152 | } 153 | } 154 | 155 | extension ClassDestination: Hashable { 156 | static func == (lhs: ClassDestination, rhs: ClassDestination) -> Bool { 157 | lhs.data == rhs.data 158 | } 159 | 160 | func hash(into hasher: inout Hasher) { 161 | hasher.combine(data) 162 | } 163 | } 164 | 165 | private class SampleClassDestination: ClassDestination { 166 | init() { super.init(data: "Sample data") } 167 | } 168 | 169 | private struct ChildFlowStack: View, Codable { 170 | enum ChildType: Hashable, Codable { 171 | case flowPath, noBinding 172 | } 173 | 174 | let childType: ChildType 175 | 176 | var body: some View { 177 | switch childType { 178 | case .flowPath: 179 | FlowPathView() 180 | case .noBinding: 181 | NoBindingView() 182 | } 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /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) (\(routeIndex))") 86 | .font(.footnote).foregroundColor(.gray) 87 | } 88 | } 89 | .padding() 90 | .flowDestination(item: $colorShown, style: .sheet(withNavigation: true)) { color in 91 | Text(String(describing: color)).foregroundColor(color) 92 | .navigationTitle("Color") 93 | } 94 | .navigationTitle("\(number)") 95 | } 96 | } 97 | 98 | // Included so that the same example code can be used for macOS too. 99 | #if os(macOS) 100 | extension Route { 101 | static func cover(_ screen: Screen, withNavigation: Bool = false) -> Route { 102 | sheet(screen, withNavigation: withNavigation) 103 | } 104 | } 105 | 106 | extension RouteStyle { 107 | static func cover(withNavigation: Bool = false) -> RouteStyle { 108 | .sheet(withNavigation: withNavigation) 109 | } 110 | } 111 | 112 | extension Array where Element: RouteProtocol { 113 | mutating func presentCover(_ screen: Element.Screen, withNavigation: Bool = false) { 114 | presentSheet(screen, withNavigation: withNavigation) 115 | } 116 | } 117 | 118 | extension FlowNavigator { 119 | func presentCover(_ screen: Screen, withNavigation: Bool = false) { 120 | presentSheet(screen, withNavigation: withNavigation) 121 | } 122 | } 123 | #endif 124 | 125 | struct AccentColorModifier: ViewModifier { 126 | let color: Color 127 | 128 | func body(content: Content) -> some View { 129 | if #available(iOS 16.0, macOS 13.0, tvOS 16.0, *) { 130 | content.tint(color) 131 | } else { 132 | content.accentColor(color) 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /FlowStacksAppUITests/FlowStacksUITests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | final class FlowStacksUITests: XCTestCase { 4 | override func setUpWithError() throws { 5 | continueAfterFailure = false 6 | } 7 | 8 | func testNavigationViaPathWithFlowStack() { 9 | launchAndRunNavigationTests(tabTitle: "FlowPath", useNavigationStack: false, app: XCUIApplication()) 10 | } 11 | 12 | func testNavigationViaArrayWithFlowStack() { 13 | launchAndRunNavigationTests(tabTitle: "ArrayBinding", useNavigationStack: false, app: XCUIApplication()) 14 | } 15 | 16 | func testNavigationViaNoneWithFlowStack() { 17 | launchAndRunNavigationTests(tabTitle: "NoBinding", useNavigationStack: false, app: XCUIApplication()) 18 | } 19 | 20 | func launchAndRunNavigationTests(tabTitle: String, useNavigationStack: Bool, app: XCUIApplication) { 21 | if useNavigationStack { 22 | // This currently has no effect, but may do so in future. 23 | app.launchArguments = ["USE_NAVIGATIONSTACK"] 24 | } 25 | app.launch() 26 | 27 | let navigationTimeout = 0.8 28 | 29 | XCTAssertTrue(app.tabBars.buttons[tabTitle].waitForExistence(timeout: 3)) 30 | app.tabBars.buttons[tabTitle].tap() 31 | XCTAssertTrue(app.navigationBars["Home"].waitForExistence(timeout: 2)) 32 | 33 | app.buttons["Pick a number - route 1:-1"].tap() 34 | XCTAssertTrue(app.navigationBars["List"].waitForExistence(timeout: navigationTimeout)) 35 | 36 | app.navigationBars["List"].swipeSheetDown() 37 | XCTAssertTrue(app.navigationBars["Home"].waitForExistence(timeout: navigationTimeout)) 38 | 39 | app.buttons["99 Red balloons"].tap() 40 | XCTAssertTrue(app.navigationBars["Visualise 99"].waitForExistence(timeout: 2 * navigationTimeout)) 41 | 42 | app.navigationBars.buttons.element(boundBy: 0).tap() 43 | app.navigationBars.buttons.element(boundBy: 0).tap() 44 | XCTAssertTrue(app.navigationBars["Home"].waitForExistence(timeout: navigationTimeout)) 45 | 46 | app.buttons["Pick a number - route 1:-1"].tap() 47 | XCTAssertTrue(app.navigationBars["List"].waitForExistence(timeout: navigationTimeout)) 48 | 49 | app.buttons["Show 1 - route 1:0"].tap() 50 | XCTAssertTrue(app.navigationBars["1"].waitForExistence(timeout: navigationTimeout)) 51 | 52 | app.buttons["Show next number"].tap() 53 | XCTAssertTrue(app.navigationBars["2"].waitForExistence(timeout: navigationTimeout)) 54 | 55 | app.buttons["Show next number"].tap() 56 | XCTAssertTrue(app.navigationBars["3"].waitForExistence(timeout: navigationTimeout)) 57 | 58 | app.buttons["Show next number"].tap() 59 | XCTAssertTrue(app.navigationBars["4"].waitForExistence(timeout: navigationTimeout)) 60 | 61 | app.buttons["Go back to root"].tap() 62 | XCTAssertTrue(app.navigationBars["Home"].waitForExistence(timeout: navigationTimeout)) 63 | 64 | if #available(iOS 15.0, *) { 65 | // This test fails on iOS 14, despite working in real use. 66 | app.buttons["Push local destination"].tap() 67 | XCTAssertTrue(app.staticTexts["Local destination"].waitForExistence(timeout: navigationTimeout * 2)) 68 | 69 | app.navigationBars.buttons.element(boundBy: 0).tap() 70 | XCTAssertTrue(app.navigationBars["Home"].waitForExistence(timeout: navigationTimeout)) 71 | XCTAssertTrue(app.buttons["Push local destination"].isEnabled) 72 | } 73 | 74 | if tabTitle != "ArrayBinding" { 75 | app.buttons["Show Class Destination"].tap() 76 | XCTAssertTrue(app.staticTexts["Sample data"].waitForExistence(timeout: navigationTimeout)) 77 | 78 | app.navigationBars.buttons.element(boundBy: 0).tap() 79 | XCTAssertTrue(app.navigationBars["Home"].waitForExistence(timeout: navigationTimeout)) 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /FlowStacksAppUITests/NestedFlowStacksUITests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | final class NestedFlowStacksUITests: XCTestCase { 4 | override func setUpWithError() throws { 5 | continueAfterFailure = false 6 | } 7 | 8 | func testNestedNavigationViaPathWithFlowStack() { 9 | launchAndRunNestedNavigationTests(tabTitle: "FlowPath", useNavigationStack: false, app: XCUIApplication()) 10 | } 11 | 12 | func testNestedNavigationViaNoneWithFlowStack() { 13 | launchAndRunNestedNavigationTests(tabTitle: "NoBinding", useNavigationStack: false, app: XCUIApplication()) 14 | } 15 | 16 | func launchAndRunNestedNavigationTests(tabTitle: String, useNavigationStack: Bool, app: XCUIApplication) { 17 | if useNavigationStack { 18 | // This currently has no effect, but may do so in future. 19 | app.launchArguments = ["USE_NAVIGATIONSTACK"] 20 | } 21 | app.launch() 22 | 23 | let navigationTimeout = 0.8 24 | 25 | XCTAssertTrue(app.tabBars.buttons[tabTitle].waitForExistence(timeout: 3)) 26 | app.tabBars.buttons[tabTitle].tap() 27 | XCTAssertTrue(app.navigationBars["Home"].waitForExistence(timeout: 2)) 28 | 29 | app.buttons["Pick a number - route 1:-1"].tap() 30 | XCTAssertTrue(app.navigationBars["List"].waitForExistence(timeout: navigationTimeout)) 31 | 32 | app.buttons["Show 1 - route 1:0"].tap() 33 | XCTAssertTrue(app.navigationBars["1"].waitForExistence(timeout: navigationTimeout)) 34 | 35 | app.buttons["FlowPath Child"].tap() 36 | XCTAssertTrue(app.navigationBars["Home"].waitForExistence(timeout: navigationTimeout)) 37 | 38 | app.buttons["Pick a number - route 2:-1"].firstMatch.tap() 39 | XCTAssertTrue(app.navigationBars["List"].waitForExistence(timeout: navigationTimeout)) 40 | 41 | app.buttons["Show 1 - route 2:0"].tap() 42 | XCTAssertTrue(app.navigationBars["1"].waitForExistence(timeout: navigationTimeout)) 43 | 44 | app.buttons["NoBinding Child"].tap() 45 | XCTAssertTrue(app.navigationBars["Home"].waitForExistence(timeout: navigationTimeout)) 46 | 47 | app.buttons["Pick a number - route 2:2"].firstMatch.tap() 48 | XCTAssertTrue(app.navigationBars["List"].waitForExistence(timeout: navigationTimeout)) 49 | 50 | app.buttons["Show 1 - route 2:3"].tap() 51 | XCTAssertTrue(app.navigationBars["1"].waitForExistence(timeout: navigationTimeout)) 52 | 53 | app.buttons["Go back to root"].tap() 54 | XCTAssertTrue(app.navigationBars["Home"].waitForExistence(timeout: navigationTimeout)) 55 | 56 | // Goes back to root of FlowPath child. 57 | XCTAssertTrue(app.buttons["Pick a number - route 2:-1"].exists) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /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 testNumbersTab() { 11 | XCUIDevice.shared.orientation = .portrait 12 | let app = XCUIApplication() 13 | app.launch() 14 | 15 | XCTAssertTrue(app.tabBars.buttons["Numbers"].waitForExistence(timeout: 3)) 16 | app.tabBars.buttons["Numbers"].tap() 17 | XCTAssertTrue(app.navigationBars["0"].waitForExistence(timeout: navigationTimeout)) 18 | 19 | app.buttons["Push next"].firstMatch.tap() 20 | XCTAssertTrue(app.navigationBars["1"].waitForExistence(timeout: navigationTimeout)) 21 | XCTAssertTrue(app.staticTexts["push (0)"].exists) 22 | 23 | app.buttons["Present Double (cover) from 1"].firstMatch.tap() 24 | XCTAssertTrue(app.navigationBars["2"].waitForExistence(timeout: navigationTimeout)) 25 | XCTAssertTrue(app.staticTexts["cover(withNavigation: true) (1)"].exists) 26 | 27 | app.buttons["Present Double (cover) from 2"].firstMatch.tap() 28 | XCTAssertTrue(app.navigationBars["4"].waitForExistence(timeout: navigationTimeout)) 29 | XCTAssertTrue(app.staticTexts["cover(withNavigation: true) (2)"].exists) 30 | 31 | app.buttons["Present Double (sheet) from 4"].tap() 32 | XCTAssertTrue(app.navigationBars["8"].waitForExistence(timeout: navigationTimeout)) 33 | XCTAssertTrue(app.staticTexts["sheet(withNavigation: true) (3)"].exists) 34 | 35 | app.buttons["Present Double (sheet) from 8"].firstMatch.tap() 36 | XCTAssertTrue(app.navigationBars["16"].waitForExistence(timeout: navigationTimeout)) 37 | XCTAssertTrue(app.staticTexts["sheet(withNavigation: true) (4)"].exists) 38 | 39 | app.buttons["Push next from 16"].firstMatch.tap() 40 | XCTAssertTrue(app.navigationBars["17"].waitForExistence(timeout: navigationTimeout)) 41 | XCTAssertTrue(app.staticTexts["push (5)"].exists) 42 | 43 | app.buttons["Push next from 17"].firstMatch.tap() 44 | XCTAssertTrue(app.navigationBars["18"].waitForExistence(timeout: navigationTimeout)) 45 | XCTAssertTrue(app.staticTexts["push (6)"].exists) 46 | 47 | app.buttons["Present Double (sheet) from 18"].firstMatch.tap() 48 | XCTAssertTrue(app.navigationBars["36"].waitForExistence(timeout: navigationTimeout)) 49 | XCTAssertTrue(app.staticTexts["sheet(withNavigation: true) (7)"].exists) 50 | 51 | app.buttons["Push next from 36"].firstMatch.tap() 52 | XCTAssertTrue(app.navigationBars["37"].waitForExistence(timeout: navigationTimeout)) 53 | XCTAssertTrue(app.staticTexts["push (8)"].exists) 54 | 55 | app.buttons["Push next from 37"].firstMatch.tap() 56 | XCTAssertTrue(app.navigationBars["38"].waitForExistence(timeout: navigationTimeout)) 57 | XCTAssertTrue(app.staticTexts["push (9)"].exists) 58 | 59 | app.navigationBars.buttons["37"].tap() 60 | XCTAssertTrue(app.navigationBars["37"].waitForExistence(timeout: navigationTimeout)) 61 | XCTAssertTrue(app.staticTexts["push (8)"].exists) 62 | 63 | app.buttons["Go back from 37"].firstMatch.tap() 64 | XCTAssertTrue(app.navigationBars["36"].waitForExistence(timeout: navigationTimeout)) 65 | XCTAssertTrue(app.staticTexts["sheet(withNavigation: true) (7)"].exists) 66 | 67 | app.buttons["Go back from 36"].firstMatch.tap() 68 | XCTAssertTrue(app.navigationBars["18"].waitForExistence(timeout: navigationTimeout)) 69 | XCTAssertTrue(app.staticTexts["push (6)"].exists) 70 | 71 | app.buttons["Go back from 18"].firstMatch.tap() 72 | XCTAssertTrue(app.navigationBars["17"].waitForExistence(timeout: navigationTimeout)) 73 | XCTAssertTrue(app.staticTexts["push (5)"].exists) 74 | 75 | app.buttons["Present Double (sheet) from 17"].firstMatch.tap() 76 | XCTAssertTrue(app.navigationBars["34"].waitForExistence(timeout: navigationTimeout)) 77 | XCTAssertTrue(app.staticTexts["sheet(withNavigation: true) (6)"].exists) 78 | 79 | app.navigationBars["34"].swipeSheetDown() 80 | XCTAssertTrue(app.navigationBars["17"].waitForExistence(timeout: navigationTimeout)) 81 | XCTAssertTrue(app.staticTexts["push (5)"].exists) 82 | 83 | app.buttons["Show red from 17"].firstMatch.tap() 84 | XCTAssertTrue(app.navigationBars["Color"].waitForExistence(timeout: navigationTimeout)) 85 | XCTAssertTrue(app.staticTexts["red"].exists) 86 | app.navigationBars["Color"].swipeSheetDown() 87 | 88 | app.buttons["Go back to root from 17"].firstMatch.tap() 89 | XCTAssertTrue(app.navigationBars["0"].waitForExistence(timeout: navigationTimeout * 5)) 90 | } 91 | } 92 | 93 | extension XCUIElement { 94 | func swipeSheetDown() { 95 | if #available(iOS 17.0, *) { 96 | // This doesn't work in iOS 16 97 | self.swipeDown(velocity: .fast) 98 | } else { 99 | let start = coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)) 100 | let end = coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 5)) 101 | start.press(forDuration: 0.05, thenDragTo: end, withVelocity: .fast, thenHoldForDuration: 0.0) 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /Sources/FlowStacks/Convenience methods/Array+convenienceMethods.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension Array where Element: RouteProtocol { 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 | for route in reversed() { 9 | switch route.style { 10 | case .push: 11 | continue 12 | case let .cover(withNavigation), let .sheet(withNavigation): 13 | return withNavigation 14 | } 15 | } 16 | return nil 17 | } 18 | 19 | /// Pushes a new screen via a push navigation. 20 | /// This should only be called if the most recently presented screen is embedded in a `NavigationView`. 21 | /// - Parameter screen: The screen to push. 22 | mutating func push(_ screen: Element.Screen) { 23 | assert( 24 | canPush != false, 25 | """ 26 | Attempting to push a screen, but the most recently presented screen is not \ 27 | embedded in a `NavigationView`. Please ensure the root or most recently presented \ 28 | route has `withNavigation` set to `true`. 29 | """ 30 | ) 31 | append(.push(screen)) 32 | } 33 | 34 | /// Presents a new screen via a sheet presentation. 35 | /// - Parameter screen: The screen to push. 36 | /// - Parameter onDismiss: A closure to be invoked when the screen is dismissed. 37 | mutating func presentSheet(_ screen: Element.Screen, withNavigation: Bool = false) { 38 | append(.sheet(screen, withNavigation: withNavigation)) 39 | } 40 | 41 | #if os(macOS) 42 | #else 43 | /// Presents a new screen via a full-screen cover presentation. 44 | /// - Parameter screen: The screen to push. 45 | /// - Parameter onDismiss: A closure to be invoked when the screen is dismissed. 46 | @available(OSX, unavailable, message: "Not available on OS X.") 47 | mutating func presentCover(_ screen: Element.Screen, withNavigation: Bool = false) { 48 | append(.cover(screen, withNavigation: withNavigation)) 49 | } 50 | #endif 51 | } 52 | 53 | // MARK: - Go back 54 | 55 | public extension Array where Element: RouteProtocol { 56 | /// Returns true if it's possible to go back the given number of screens. 57 | /// - Parameter count: The number of screens to go back. Defaults to 1. 58 | func canGoBack(_ count: Int = 1) -> Bool { 59 | self.count - count >= 0 && count >= 0 60 | } 61 | 62 | /// Goes back a given number of screens off the stack 63 | /// - Parameter count: The number of screens to go back. Defaults to 1. 64 | mutating func goBack(_ count: Int = 1) { 65 | assert( 66 | self.count - count >= 0, 67 | "Can't go back\(count == 1 ? "" : " \(count) screens") - the screen count is \(self.count)" 68 | ) 69 | assert( 70 | count >= 0, 71 | "Can't go back \(count) screens - count must be positive" 72 | ) 73 | guard self.count - count >= 0, count >= 0 else { return } 74 | removeLast(count) 75 | } 76 | 77 | /// Goes back to a given index in the array of screens. The resulting array count 78 | /// will be index + 1. 79 | /// - Parameter index: The index that should become top of the stack, e.g. 0 for the root screen. 80 | mutating func goBackTo(index: Int) { 81 | goBack(count - index - 1) 82 | } 83 | 84 | /// Goes back to the root screen (index 0). The resulting array's count will be 0. 85 | mutating func goBackToRoot() { 86 | guard !isEmpty else { return } 87 | goBackTo(index: -1) 88 | } 89 | 90 | /// Goes back to the topmost (most recently shown) screen in the stack 91 | /// that satisfies the given condition. If no screens satisfy the condition, 92 | /// the routes array will be unchanged. 93 | /// - Parameter condition: The predicate indicating which screen to go back to. 94 | /// - Returns: A `Bool` indicating whether a screen was found. 95 | @discardableResult 96 | mutating func goBackTo(where condition: (Element) -> Bool) -> Bool { 97 | guard let index = lastIndex(where: condition) else { 98 | return false 99 | } 100 | goBackTo(index: index) 101 | return true 102 | } 103 | 104 | /// Goes back to the topmost (most recently shown) screen in the stack 105 | /// that satisfies the given condition. If no screens satisfy the condition, 106 | /// the routes array will be unchanged. 107 | /// - Parameter condition: The predicate indicating which screen to go back to. 108 | /// - Returns: A `Bool` indicating whether a screen was found. 109 | @discardableResult 110 | mutating func goBackTo(where condition: (Element.Screen) -> Bool) -> Bool { 111 | goBackTo(where: { condition($0.screen) }) 112 | } 113 | } 114 | 115 | public extension Array where Element: RouteProtocol, Element.Screen: Equatable { 116 | /// Goes back to the topmost (most recently shown) screen in the stack 117 | /// equal to the given screen. If no screens are found, 118 | /// the routes array will be unchanged. 119 | /// - Parameter screen: The predicate indicating which screen to go back to. 120 | /// - Returns: A `Bool` indicating whether a matching screen was found. 121 | @discardableResult 122 | mutating func goBackTo(_ screen: Element.Screen) -> Bool { 123 | goBackTo(where: { $0.screen == screen }) 124 | } 125 | } 126 | 127 | public extension Array where Element: RouteProtocol, Element.Screen: Identifiable { 128 | /// Goes back to the topmost (most recently shown) identifiable screen in the stack 129 | /// with the given ID. If no screens are found, the routes array will be unchanged. 130 | /// - Parameter id: The id of the screen to goBack to. 131 | /// - Returns: A `Bool` indicating whether a matching screen was found. 132 | @discardableResult 133 | mutating func goBackTo(id: Element.Screen.ID) -> Bool { 134 | goBackTo(where: { $0.screen.id == id }) 135 | } 136 | 137 | /// Goes back to the topmost (most recently shown) identifiable screen in the stack 138 | /// matching the given screen. If no screens are found, the routes array 139 | /// will be unchanged. 140 | /// - Parameter screen: The screen to goBack to. 141 | /// - Returns: A `Bool` indicating whether a matching screen was found. 142 | @discardableResult 143 | mutating func goBackTo(_ screen: Element.Screen) -> Bool { 144 | goBackTo(id: screen.id) 145 | } 146 | } 147 | 148 | /// Avoids an ambiguity when `Screen` is both `Identifiable` and `Equatable`. 149 | public extension Array where Element: RouteProtocol, Element.Screen: Identifiable & Equatable { 150 | /// Goes back to the topmost (most recently shown) identifiable screen in the stack 151 | /// matching the given screen. If no screens are found, the routes array 152 | /// will be unchanged. 153 | /// - Parameter screen: The screen to goBack to. 154 | /// - Returns: A `Bool` indicating whether a matching screen was found. 155 | @discardableResult 156 | mutating func goBackTo(_ screen: Element.Screen) -> Bool { 157 | goBackTo(id: screen.id) 158 | } 159 | } 160 | 161 | // MARK: - Pop 162 | 163 | public extension Array where Element: RouteProtocol { 164 | /// Pops a given number of screens off the stack. Only screens that have been pushed will 165 | /// be popped. 166 | /// - Parameter count: The number of screens to go back. Defaults to 1. 167 | mutating func pop(_ count: Int = 1) { 168 | assert(count <= self.count) 169 | assert(suffix(count).allSatisfy { $0.style == .push }) 170 | goBack(count) 171 | } 172 | 173 | /// Pops to a given index in the array of screens. The resulting screen count 174 | /// will be equal to index + 1. Only screens that have been pushed will 175 | /// be popped. 176 | /// - Parameter index: The index that should become top of the stack, e.g. 0 for the root. 177 | mutating func popTo(index: Int) { 178 | let popCount = count - index - 1 179 | pop(popCount) 180 | } 181 | 182 | /// Pops to the root screen (index -1). The resulting screen count 183 | /// will be 0. Only screens that have been pushed will 184 | /// be popped. 185 | mutating func popToRoot() { 186 | popTo(index: -1) 187 | } 188 | 189 | /// Pops all pushed screens in the current navigation stack only, without dismissing any screens. 190 | mutating func popToCurrentNavigationRoot() { 191 | let index = lastIndex(where: { !$0.style.isPush }) ?? -1 192 | goBackTo(index: index) 193 | } 194 | 195 | /// Pops to the topmost (most recently pushed) screen in the stack 196 | /// that satisfies the given condition. If no screens satisfy the condition, 197 | /// the routes array will be unchanged. Only screens that have been pushed will 198 | /// be popped. 199 | /// - Parameter condition: The predicate indicating which screen to pop to. 200 | /// - Returns: A `Bool` indicating whether a screen was found. 201 | @discardableResult 202 | mutating func popTo(where condition: (Element) -> Bool) -> Bool { 203 | guard let index = lastIndex(where: condition) else { 204 | return false 205 | } 206 | popTo(index: index) 207 | return true 208 | } 209 | 210 | /// Pops to the topmost (most recently pushed) screen in the stack 211 | /// that satisfies the given condition. If no screens satisfy the condition, 212 | /// the routes array will be unchanged. Only screens that have been pushed will 213 | /// be popped. 214 | /// - Parameter condition: The predicate indicating which screen to pop to. 215 | /// - Returns: A `Bool` indicating whether a screen was found. 216 | @discardableResult 217 | mutating func popTo(where condition: (Element.Screen) -> Bool) -> Bool { 218 | popTo(where: { condition($0.screen) }) 219 | } 220 | } 221 | 222 | public extension Array where Element: RouteProtocol, Element.Screen: Equatable { 223 | /// Pops to the topmost (most recently pushed) screen in the stack 224 | /// equal to the given screen. If no screens are found, 225 | /// the routes array will be unchanged. Only screens that have been pushed will 226 | /// be popped. 227 | /// - Parameter screen: The predicate indicating which screen to go back to. 228 | /// - Returns: A `Bool` indicating whether a matching screen was found. 229 | @discardableResult 230 | mutating func popTo(_ screen: Element.Screen) -> Bool { 231 | popTo(where: { $0 == screen }) 232 | } 233 | } 234 | 235 | public extension Array where Element: RouteProtocol, Element.Screen: Identifiable { 236 | /// Pops to the topmost (most recently pushed) identifiable screen in the stack 237 | /// with the given ID. If no screens are found, the routes array will be unchanged. 238 | /// Only screens that have been pushed will 239 | /// be popped. 240 | /// - Parameter id: The id of the screen to goBack to. 241 | /// - Returns: A `Bool` indicating whether a matching screen was found. 242 | @discardableResult 243 | mutating func popTo(id: Element.Screen.ID) -> Bool { 244 | popTo(where: { $0.id == id }) 245 | } 246 | 247 | /// Pops to the topmost (most recently pushed) identifiable screen in the stack 248 | /// matching the given screen. If no screens are found, the routes array 249 | /// will be unchanged. Only screens that have been pushed will 250 | /// be popped. 251 | /// - Parameter screen: The screen to goBack to. 252 | /// - Returns: A `Bool` indicating whether a matching screen was found. 253 | @discardableResult 254 | mutating func popTo(_ screen: Element.Screen) -> Bool { 255 | popTo(id: screen.id) 256 | } 257 | } 258 | 259 | /// Avoids an ambiguity when `Screen` is both `Identifiable` and `Equatable`. 260 | public extension Array where Element: RouteProtocol, Element.Screen: Identifiable & Equatable { 261 | /// Pops to the topmost (most recently pushed) identifiable screen in the stack 262 | /// matching the given screen. If no screens are found, the routes array 263 | /// will be unchanged. Only screens that have been pushed will 264 | /// be popped. 265 | /// - Parameter screen: The screen to pop to. 266 | /// - Returns: A `Bool` indicating whether a matching screen was found. 267 | @discardableResult 268 | mutating func popTo(_ screen: Element.Screen) -> Bool { 269 | popTo(id: screen.id) 270 | } 271 | } 272 | 273 | // MARK: - Dismiss 274 | 275 | public extension Array where Element: RouteProtocol { 276 | /// Dismisses a given number of presentation layers off the stack. Only screens that have been presented will 277 | /// be included in the count. 278 | /// - Parameter count: The number of presentation layers to go back. Defaults to 1. 279 | mutating func dismiss(count: Int = 1) { 280 | assert(count >= 0) 281 | var index = endIndex - 1 282 | var dismissed = 0 283 | while dismissed < count, indices.contains(index) { 284 | assert( 285 | index >= 0, 286 | "Can't dismiss\(count == 1 ? "" : " \(count) screens") - the number of presented screens is \(dismissed)" 287 | ) 288 | guard index >= 0 else { return } 289 | 290 | if self[index].isPresented { 291 | dismissed += 1 292 | } 293 | index -= 1 294 | } 295 | goBackTo(index: index) 296 | } 297 | 298 | /// Dismisses all presented sheets and modals, without popping any pushed screens in the bottommost 299 | /// presentation layer. 300 | mutating func dismissAll() { 301 | let count = filter(\.isPresented).count 302 | guard count > 0 else { return } 303 | dismiss(count: count) 304 | } 305 | } 306 | -------------------------------------------------------------------------------- /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 | /// - Parameter onDismiss: A closure to be invoked when the screen is dismissed. 21 | func presentSheet(_ screen: Screen, withNavigation: Bool = false) { 22 | routes.presentSheet(screen, withNavigation: withNavigation) 23 | } 24 | 25 | #if os(macOS) 26 | #else 27 | /// Presents a new screen via a full-screen cover presentation. 28 | /// - Parameter screen: The screen to push. 29 | /// - Parameter onDismiss: A closure to be invoked when the screen is dismissed. 30 | @available(OSX, unavailable, message: "Not available on OS X.") 31 | func presentCover(_ screen: Screen, withNavigation: Bool = false) { 32 | routes.presentCover(screen, withNavigation: withNavigation) 33 | } 34 | #endif 35 | } 36 | 37 | // MARK: - Go back 38 | 39 | public extension FlowNavigator { 40 | /// Returns true if it's possible to go back the given number of screens. 41 | /// - Parameter count: The number of screens to go back. Defaults to 1. 42 | func canGoBack(_: Int = 1) -> Bool { 43 | routes.canGoBack() 44 | } 45 | 46 | /// Goes back a given number of screens off the stack 47 | /// - Parameter count: The number of screens to go back. Defaults to 1. 48 | func goBack(_ count: Int = 1) { 49 | routes.goBack(count) 50 | } 51 | 52 | /// Goes back to a given index in the array of screens. The resulting screen count 53 | /// will be index + 1. 54 | /// - Parameter index: The index that should become top of the stack. 55 | func goBackTo(index: Int) { 56 | routes.goBackTo(index: index) 57 | } 58 | 59 | /// Goes back to the root screen (index -1). The resulting screen count 60 | /// will be 0. 61 | func goBackToRoot() { 62 | routes.goBackToRoot() 63 | } 64 | 65 | /// Goes back to the topmost (most recently shown) screen in the stack 66 | /// that satisfies the given condition. If no screens satisfy the condition, 67 | /// the routes array will be unchanged. 68 | /// - Parameter condition: The predicate indicating which screen to go back to. 69 | /// - Returns: A `Bool` indicating whether a screen was found. 70 | @discardableResult 71 | func goBackTo(where condition: (Route) -> Bool) -> Bool { 72 | routes.goBackTo(where: condition) 73 | } 74 | 75 | /// Goes back to the topmost (most recently shown) screen in the stack 76 | /// that satisfies the given condition. If no screens satisfy the condition, 77 | /// the routes array will be unchanged. 78 | /// - Parameter condition: The predicate indicating which screen to go back to. 79 | /// - Returns: A `Bool` indicating whether a screen was found. 80 | @discardableResult 81 | func goBackTo(where condition: (Screen) -> Bool) -> Bool { 82 | routes.goBackTo(where: condition) 83 | } 84 | } 85 | 86 | public extension FlowNavigator where Screen == AnyHashable { 87 | /// Goes back to the topmost (most recently shown) screen in the stack 88 | /// whose type matches the given type. If no screens satisfy the condition, 89 | /// the routes array will be unchanged. 90 | /// - Parameter type: The type of the screen to go back to. 91 | /// - Returns: A `Bool` indicating whether a screen was found. 92 | @discardableResult 93 | func goBackTo(type: T.Type) -> Bool { 94 | goBackTo(where: { $0.screen is T }) 95 | } 96 | } 97 | 98 | public extension FlowNavigator where Screen: Equatable { 99 | /// Goes back to the topmost (most recently shown) screen in the stack 100 | /// equal to the given screen. If no screens are found, 101 | /// the routes array will be unchanged. 102 | /// - Parameter screen: The predicate indicating which screen to go back to. 103 | /// - Returns: A `Bool` indicating whether a matching screen was found. 104 | @discardableResult 105 | func goBackTo(_ screen: Screen) -> Bool { 106 | routes.goBackTo(screen) 107 | } 108 | } 109 | 110 | public extension FlowNavigator where Screen: Identifiable { 111 | /// Goes back to the topmost (most recently shown) identifiable screen in the stack 112 | /// with the given ID. If no screens are found, the routes array will be unchanged. 113 | /// - Parameter id: The id of the screen to goBack to. 114 | /// - Returns: A `Bool` indicating whether a matching screen was found. 115 | @discardableResult 116 | func goBackTo(id: Screen.ID) -> Bool { 117 | routes.goBackTo(id: id) 118 | } 119 | 120 | /// Goes back to the topmost (most recently shown) identifiable screen in the stack 121 | /// matching the given screen. If no screens are found, the routes array 122 | /// will be unchanged. 123 | /// - Parameter screen: The screen to goBack to. 124 | /// - Returns: A `Bool` indicating whether a matching screen was found. 125 | @discardableResult 126 | func goBackTo(_ screen: Screen) -> Bool { 127 | routes.goBackTo(screen) 128 | } 129 | } 130 | 131 | /// Avoids an ambiguity when `Screen` is both `Identifiable` and `Equatable`. 132 | public extension FlowNavigator where Screen: Identifiable & Equatable { 133 | /// Goes back to the topmost (most recently shown) identifiable screen in the stack 134 | /// matching the given screen. If no screens are found, the routes array 135 | /// will be unchanged. 136 | /// - Parameter screen: The screen to goBack to. 137 | /// - Returns: A `Bool` indicating whether a matching screen was found. 138 | @discardableResult 139 | func goBackTo(_ screen: Screen) -> Bool { 140 | routes.goBackTo(screen) 141 | } 142 | } 143 | 144 | // MARK: - Pop 145 | 146 | public extension FlowNavigator { 147 | /// Pops a given number of screens off the stack. Only screens that have been pushed will 148 | /// be popped. 149 | /// - Parameter count: The number of screens to go back. Defaults to 1. 150 | func pop(_ count: Int = 1) { 151 | routes.pop(count) 152 | } 153 | 154 | /// Pops to a given index in the array of screens. The resulting screen count 155 | /// will be index + 1. Only screens that have been pushed will 156 | /// be popped. 157 | /// - Parameter index: The index that should become top of the stack. 158 | func popTo(index: Int) { 159 | routes.popTo(index: index) 160 | } 161 | 162 | /// Pops to the root screen (index -1). The resulting screen count 163 | /// will be 0. Only screens that have been pushed will 164 | /// be popped. 165 | func popToRoot() { 166 | routes.popToRoot() 167 | } 168 | 169 | /// Pops all screens in the current navigation stack only, without dismissing any screens. 170 | func popToCurrentNavigationRoot() { 171 | routes.popToCurrentNavigationRoot() 172 | } 173 | 174 | /// Pops to the topmost (most recently pushed) screen in the stack 175 | /// that satisfies the given condition. If no screens satisfy the condition, 176 | /// the routes array will be unchanged. Only screens that have been pushed will 177 | /// be popped. 178 | /// - Parameter condition: The predicate indicating which screen to pop to. 179 | /// - Returns: A `Bool` indicating whether a screen was found. 180 | @discardableResult 181 | func popTo(where condition: (Route) -> Bool) -> Bool { 182 | routes.popTo(where: condition) 183 | } 184 | 185 | /// Pops to the topmost (most recently pushed) screen in the stack 186 | /// that satisfies the given condition. If no screens satisfy the condition, 187 | /// the routes array will be unchanged. Only screens that have been pushed will 188 | /// be popped. 189 | /// - Parameter condition: The predicate indicating which screen to pop to. 190 | /// - Returns: A `Bool` indicating whether a screen was found. 191 | @discardableResult 192 | func popTo(where condition: (Screen) -> Bool) -> Bool { 193 | routes.popTo(where: condition) 194 | } 195 | } 196 | 197 | public extension FlowNavigator where Screen: Equatable { 198 | /// Pops to the topmost (most recently pushed) screen in the stack 199 | /// equal to the given screen. If no screens are found, 200 | /// the routes array will be unchanged. Only screens that have been pushed will 201 | /// be popped. 202 | /// - Parameter screen: The predicate indicating which screen to go back to. 203 | /// - Returns: A `Bool` indicating whether a matching screen was found. 204 | @discardableResult 205 | func popTo(_ screen: Screen) -> Bool { 206 | routes.popTo(screen) 207 | } 208 | } 209 | 210 | public extension FlowNavigator where Screen: Identifiable { 211 | /// Pops to the topmost (most recently pushed) identifiable screen in the stack 212 | /// with the given ID. If no screens are found, the routes array will be unchanged. 213 | /// Only screens that have been pushed will 214 | /// be popped. 215 | /// - Parameter id: The id of the screen to goBack to. 216 | /// - Returns: A `Bool` indicating whether a matching screen was found. 217 | @discardableResult 218 | func popTo(id: Screen.ID) -> Bool { 219 | routes.popTo(id: id) 220 | } 221 | 222 | /// Pops to the topmost (most recently pushed) identifiable screen in the stack 223 | /// matching the given screen. If no screens are found, the routes array 224 | /// will be unchanged. Only screens that have been pushed will 225 | /// be popped. 226 | /// - Parameter screen: The screen to goBack to. 227 | /// - Returns: A `Bool` indicating whether a matching screen was found. 228 | @discardableResult 229 | func popTo(_ screen: Screen) -> Bool { 230 | routes.popTo(screen) 231 | } 232 | } 233 | 234 | /// Avoids an ambiguity when `Screen` is both `Identifiable` and `Equatable`. 235 | public extension FlowNavigator where Screen: Identifiable & Equatable { 236 | /// Pops to the topmost (most recently pushed) identifiable screen in the stack 237 | /// matching the given screen. If no screens are found, the routes array 238 | /// will be unchanged. Only screens that have been pushed will 239 | /// be popped. 240 | /// - Parameter screen: The screen to pop to. 241 | /// - Returns: A `Bool` indicating whether a matching screen was found. 242 | @discardableResult 243 | func popTo(_ screen: Screen) -> Bool { 244 | routes.popTo(screen) 245 | } 246 | } 247 | 248 | public extension FlowNavigator where Screen == AnyHashable { 249 | /// Pops to the topmost (most recently shown) screen in the stack 250 | /// whose type matches the given type. If no screens satisfy the condition, 251 | /// the routes array will be unchanged. Only screens that have been pushed will 252 | /// be popped. 253 | /// - Parameter type: The type of the screen to go back to. 254 | /// - Returns: A `Bool` indicating whether a screen was found. 255 | @discardableResult 256 | func popTo(type: T.Type) -> Bool { 257 | popTo(where: { $0.screen is T }) 258 | } 259 | } 260 | 261 | // MARK: - Dismiss 262 | 263 | public extension FlowNavigator { 264 | /// Dismisses a given number of presentation layers off the stack. Only screens that have been presented will 265 | /// be included in the count. 266 | /// - Parameter count: The number of presentation layers to go back. Defaults to 1. 267 | func dismiss(count: Int = 1) { 268 | routes.dismiss(count: count) 269 | } 270 | 271 | /// Dismisses all presented sheets and modals, without popping any pushed screens in the bottommost 272 | /// presentation layer. 273 | func dismissAll() { 274 | routes.dismissAll() 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /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 | /// - Parameter onDismiss: A closure to be invoked when the screen is dismissed. 21 | mutating func presentSheet(_ screen: AnyHashable, withNavigation: Bool = false) { 22 | routes.presentSheet(screen, withNavigation: withNavigation) 23 | } 24 | 25 | #if os(macOS) 26 | #else 27 | /// Presents a new screen via a full-screen cover presentation. 28 | /// - Parameter screen: The screen to push. 29 | /// - Parameter onDismiss: A closure to be invoked when the screen is dismissed. 30 | @available(OSX, unavailable, message: "Not available on OS X.") 31 | mutating func presentCover(_ screen: AnyHashable, withNavigation: Bool = false) { 32 | routes.presentCover(screen, withNavigation: withNavigation) 33 | } 34 | #endif 35 | } 36 | 37 | // MARK: - Go back 38 | 39 | public extension FlowPath { 40 | /// Returns true if it's possible to go back the given number of screens. 41 | /// - Parameter count: The number of screens to go back. Defaults to 1. 42 | func canGoBack(_: Int = 1) -> Bool { 43 | routes.canGoBack() 44 | } 45 | 46 | /// Goes back a given number of screens off the stack 47 | /// - Parameter count: The number of screens to go back. Defaults to 1. 48 | mutating func goBack(_ count: Int = 1) { 49 | routes.goBack(count) 50 | } 51 | 52 | /// Goes back to a given index in the array of screens. The resulting screen count 53 | /// will be index + 1. 54 | /// - Parameter index: The index that should become top of the stack. 55 | mutating func goBackTo(index: Int) { 56 | routes.goBackTo(index: index) 57 | } 58 | 59 | /// Goes back to the root screen (index -1). The resulting screen count 60 | /// will be 0. 61 | mutating func goBackToRoot() { 62 | routes.goBackToRoot() 63 | } 64 | 65 | /// Goes back to the topmost (most recently shown) screen in the stack 66 | /// that satisfies the given condition. If no screens satisfy the condition, 67 | /// the routes array will be unchanged. 68 | /// - Parameter condition: The predicate indicating which screen to go back to. 69 | /// - Returns: A `Bool` indicating whether a screen was found. 70 | @discardableResult 71 | mutating func goBackTo(where condition: (Route) -> Bool) -> Bool { 72 | routes.goBackTo(where: condition) 73 | } 74 | 75 | /// Goes back to the topmost (most recently shown) screen in the stack 76 | /// that satisfies the given condition. If no screens satisfy the condition, 77 | /// the routes array will be unchanged. 78 | /// - Parameter condition: The predicate indicating which screen to go back to. 79 | /// - Returns: A `Bool` indicating whether a screen was found. 80 | @discardableResult 81 | mutating func goBackTo(where condition: (AnyHashable) -> Bool) -> Bool { 82 | routes.goBackTo(where: condition) 83 | } 84 | } 85 | 86 | public extension FlowPath { 87 | /// Goes back to the topmost (most recently shown) screen in the stack 88 | /// equal to the given screen. If no screens are found, 89 | /// the routes array will be unchanged. 90 | /// - Parameter screen: The predicate indicating which screen to go back to. 91 | /// - Returns: A `Bool` indicating whether a matching screen was found. 92 | @discardableResult 93 | mutating func goBackTo(_ screen: AnyHashable) -> Bool { 94 | routes.goBackTo(screen) 95 | } 96 | 97 | /// Goes back to the topmost (most recently shown) screen in the stack 98 | /// whose type matches the given type. If no screens satisfy the condition, 99 | /// the routes array will be unchanged. 100 | /// - Parameter type: The type of the screen to go back to. 101 | /// - Returns: A `Bool` indicating whether a screen was found. 102 | @discardableResult 103 | mutating func goBackTo(type _: T.Type) -> Bool { 104 | goBackTo(where: { $0.screen is T }) 105 | } 106 | } 107 | 108 | // MARK: - Pop 109 | 110 | public extension FlowPath { 111 | /// Pops a given number of screens off the stack. Only screens that have been pushed will 112 | /// be popped. 113 | /// - Parameter count: The number of screens to go back. Defaults to 1. 114 | mutating func pop(_ count: Int = 1) { 115 | routes.pop(count) 116 | } 117 | 118 | /// Pops to a given index in the array of screens. The resulting screen count 119 | /// will be index + 1. Only screens that have been pushed will 120 | /// be popped. 121 | /// - Parameter index: The index that should become top of the stack. 122 | mutating func popTo(index: Int) { 123 | routes.popTo(index: index) 124 | } 125 | 126 | /// Pops to the root screen (index -1). The resulting screen count 127 | /// will be 0. Only screens that have been pushed will 128 | /// be popped. 129 | mutating func popToRoot() { 130 | routes.popToRoot() 131 | } 132 | 133 | /// Pops all screens in the current navigation stack only, without dismissing any screens. 134 | mutating func popToCurrentNavigationRoot() { 135 | routes.popToCurrentNavigationRoot() 136 | } 137 | 138 | /// Pops to the topmost (most recently pushed) screen in the stack 139 | /// that satisfies the given condition. If no screens satisfy the condition, 140 | /// the routes array will be unchanged. Only screens that have been pushed will 141 | /// be popped. 142 | /// - Parameter condition: The predicate indicating which screen to pop to. 143 | /// - Returns: A `Bool` indicating whether a screen was found. 144 | @discardableResult 145 | mutating func popTo(where condition: (Route) -> Bool) -> Bool { 146 | routes.popTo(where: condition) 147 | } 148 | 149 | /// Pops to the topmost (most recently pushed) screen in the stack 150 | /// that satisfies the given condition. If no screens satisfy the condition, 151 | /// the routes array will be unchanged. Only screens that have been pushed will 152 | /// be popped. 153 | /// - Parameter condition: The predicate indicating which screen to pop to. 154 | /// - Returns: A `Bool` indicating whether a screen was found. 155 | @discardableResult 156 | mutating func popTo(where condition: (AnyHashable) -> Bool) -> Bool { 157 | routes.popTo(where: condition) 158 | } 159 | } 160 | 161 | public extension FlowPath { 162 | /// Pops to the topmost (most recently pushed) screen in the stack 163 | /// equal to the given screen. If no screens are found, 164 | /// the routes array will be unchanged. Only screens that have been pushed will 165 | /// be popped. 166 | /// - Parameter screen: The predicate indicating which screen to go back to. 167 | /// - Returns: A `Bool` indicating whether a matching screen was found. 168 | @discardableResult 169 | mutating func popTo(_ screen: AnyHashable) -> Bool { 170 | routes.popTo(screen) 171 | } 172 | 173 | /// Pops to the topmost (most recently shown) screen in the stack 174 | /// whose type matches the given type. If no screens satisfy the condition, 175 | /// the routes array will be unchanged. Only screens that have been pushed will 176 | /// be popped. 177 | /// - Parameter type: The type of the screen to go back to. 178 | /// - Returns: A `Bool` indicating whether a screen was found. 179 | @discardableResult 180 | mutating func popTo(type: T.Type) -> Bool { 181 | popTo(where: { $0.screen is T }) 182 | } 183 | } 184 | 185 | // MARK: - Dismiss 186 | 187 | public extension FlowPath { 188 | /// Dismisses a given number of presentation layers off the stack. Only screens that have been presented will 189 | /// be included in the count. 190 | /// - Parameter count: The number of presentation layers to go back. Defaults to 1. 191 | mutating func dismiss(count: Int = 1) { 192 | routes.dismiss(count: count) 193 | } 194 | 195 | /// Dismisses all presented sheets and modals, without popping any pushed screens in the bottommost 196 | /// presentation layer. 197 | mutating func dismissAll() { 198 | routes.dismissAll() 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | @Environment(\.useNavigationStack) var useNavigationStack 8 | 9 | @ViewBuilder 10 | func wrapped(content: Content) -> some View { 11 | if #available(iOS 16.0, *, macOS 13.0, *, watchOS 9.0, *, tvOS 16.0, *), useNavigationStack == .whenAvailable { 12 | NavigationStack { content } 13 | .modifier(navigationViewModifier) 14 | .environment(\.parentNavigationStackType, .navigationStack) 15 | } else { 16 | NavigationView { content } 17 | .modifier(navigationViewModifier) 18 | .navigationViewStyle(supportedNavigationViewStyle) 19 | .environment(\.parentNavigationStackType, .navigationView) 20 | } 21 | } 22 | 23 | func body(content: Content) -> some View { 24 | if withNavigation { 25 | wrapped(content: content) 26 | } else { 27 | content 28 | } 29 | } 30 | } 31 | 32 | /// There are spurious state updates when using the `column` navigation view style, so 33 | /// the navigation view style is forced to `stack` where possible. 34 | private var supportedNavigationViewStyle: some NavigationViewStyle { 35 | #if os(macOS) 36 | .automatic 37 | #else 38 | .stack 39 | #endif 40 | } 41 | -------------------------------------------------------------------------------- /Sources/FlowStacks/EnvironmentValues+keys.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | enum UseNavigationStackPolicy { 4 | case whenAvailable 5 | case never 6 | } 7 | 8 | struct UseNavigationStackPolicyKey: EnvironmentKey { 9 | static let defaultValue = UseNavigationStackPolicy.never 10 | } 11 | 12 | enum ParentNavigationStackType { 13 | case navigationView, navigationStack 14 | } 15 | 16 | struct ParentNavigationStackKey: EnvironmentKey { 17 | static let defaultValue: ParentNavigationStackType? = nil 18 | } 19 | 20 | enum FlowStackDataType { 21 | case typedArray, flowPath, noBinding 22 | } 23 | 24 | struct FlowStackDataTypeKey: EnvironmentKey { 25 | static let defaultValue: FlowStackDataType? = nil 26 | } 27 | 28 | extension EnvironmentValues { 29 | var useNavigationStack: UseNavigationStackPolicy { 30 | get { self[UseNavigationStackPolicyKey.self] } 31 | set { self[UseNavigationStackPolicyKey.self] = newValue } 32 | } 33 | 34 | var parentNavigationStackType: ParentNavigationStackType? { 35 | get { self[ParentNavigationStackKey.self] } 36 | set { self[ParentNavigationStackKey.self] = newValue } 37 | } 38 | 39 | var flowStackDataType: FlowStackDataType? { 40 | get { self[FlowStackDataTypeKey.self] } 41 | set { self[FlowStackDataTypeKey.self] = newValue } 42 | } 43 | } 44 | 45 | struct RouteStyleKey: EnvironmentKey { 46 | static let defaultValue: RouteStyle? = nil 47 | } 48 | 49 | public extension EnvironmentValues { 50 | /// If the view is part of a route within a FlowStack, this denotes the presentation style of the route within the stack. 51 | internal(set) var routeStyle: RouteStyle? { 52 | get { self[RouteStyleKey.self] } 53 | set { self[RouteStyleKey.self] = newValue } 54 | } 55 | } 56 | 57 | struct RouteIndexKey: EnvironmentKey { 58 | static let defaultValue: Int? = nil 59 | } 60 | 61 | public extension EnvironmentValues { 62 | /// If the view is part of a route within a FlowStack, this denotes the index of the route within the stack. 63 | internal(set) var routeIndex: Int? { 64 | get { self[RouteIndexKey.self] } 65 | set { self[RouteIndexKey.self] = newValue } 66 | } 67 | } 68 | 69 | struct NestingIndexKey: EnvironmentKey { 70 | static let defaultValue: Int? = nil 71 | } 72 | 73 | public extension EnvironmentValues { 74 | /// If the view is part of a route within a FlowStack, this denotes the number of nested FlowStacks above this view in the hierarchy. 75 | internal(set) var nestingIndex: Int? { 76 | get { self[NestingIndexKey.self] } 77 | set { self[NestingIndexKey.self] = newValue } 78 | } 79 | } 80 | 81 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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) -> [[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 | while var popStep = steps.last, popStep.count > firstDivergingIndex { 43 | var popped: Route? = popStep.popLast() 44 | while popped?.style == .push, popStep.count > firstDivergingIndex, popStep.last?.style == .push { 45 | popped = popStep.popLast() 46 | } 47 | steps.append(popStep) 48 | } 49 | 50 | // Push or present each new step. 51 | while var newStep = steps.last, newStep.count < end.count { 52 | newStep.append(end[newStep.count]) 53 | steps.append(newStep) 54 | } 55 | 56 | return steps 57 | } 58 | 59 | /// Calculates the minimal number of steps to update from one routes array to another, within the constraints of SwiftUI. 60 | /// For a given update to an array of routes, returns the minimum intermediate steps. 61 | /// required to ensure each update is supported by SwiftUI. 62 | /// - Parameters: 63 | /// - start: The initial state. 64 | /// - end: The goal state. 65 | /// - Returns: A series of state updates from the start to end. 66 | public static func calculateSteps(from start: [Route], to end: [Route]) -> [[Route]] { 67 | let allowMultipleDismissalsInOne: Bool 68 | if #available(iOS 17.0, *) { 69 | allowMultipleDismissalsInOne = true 70 | } else { 71 | allowMultipleDismissalsInOne = false 72 | } 73 | return calculateSteps(from: start, to: end, allowMultipleDismissalsInOne: allowMultipleDismissalsInOne) 74 | } 75 | 76 | static func canSynchronouslyUpdate(from start: [Route], to end: [Route]) -> Bool { 77 | // If there are less than 3 steps, the transformation can be applied in one update. 78 | let steps = calculateSteps(from: start, to: end) 79 | return steps.count < 3 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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(\.nestingIndex) var nestingIndex 11 | @EnvironmentObject var routesHolder: RoutesHolder 12 | @EnvironmentObject var inheritedDestinationBuilder: DestinationBuilderHolder 13 | @Binding var externalTypedPath: [Route] 14 | @State var internalTypedPath: [Route] = [] 15 | @StateObject var path = RoutesHolder() 16 | @StateObject var destinationBuilder = DestinationBuilderHolder() 17 | var root: Root 18 | var useInternalTypedPath: Bool 19 | 20 | var deferToParentFlowStack: Bool { 21 | (parentFlowStackDataType == .flowPath || parentFlowStackDataType == .noBinding) && dataType == .noBinding 22 | } 23 | 24 | var screenModifier: some ViewModifier { 25 | ScreenModifier( 26 | path: path, 27 | destinationBuilder: parentFlowStackDataType == nil ? destinationBuilder : inheritedDestinationBuilder, 28 | navigator: FlowNavigator(useInternalTypedPath ? $internalTypedPath : $externalTypedPath), 29 | typedPath: useInternalTypedPath ? $internalTypedPath : $externalTypedPath, 30 | nestingIndex: (nestingIndex ?? 0) + 1 31 | ) 32 | } 33 | 34 | public var body: some View { 35 | if deferToParentFlowStack { 36 | root 37 | } else { 38 | Router(rootView: root.environment(\.routeIndex, -1), navigationViewModifier: navigationViewModifier, screenModifier: screenModifier, screens: $path.boundRoutes) 39 | .modifier(EmbedModifier(withNavigation: withNavigation && parentFlowStackDataType == nil, navigationViewModifier: navigationViewModifier)) 40 | .modifier(screenModifier) 41 | .environment(\.flowStackDataType, dataType) 42 | .onFirstAppear { 43 | path.routes = externalTypedPath.map { $0.erased() } 44 | } 45 | } 46 | } 47 | 48 | init(routes: Binding<[Route]>?, withNavigation: Bool = false, navigationViewModifier: NavigationViewModifier, dataType: FlowStackDataType, @ViewBuilder root: () -> Root) { 49 | _externalTypedPath = routes ?? .constant([]) 50 | self.root = root() 51 | self.withNavigation = withNavigation 52 | self.navigationViewModifier = navigationViewModifier 53 | self.dataType = dataType 54 | useInternalTypedPath = routes == nil 55 | } 56 | 57 | /// Initialises a ``FlowStack`` with a binding to an Array of routes. 58 | /// - Parameters: 59 | /// - routes: The array of routes that will manage navigation state. 60 | /// - withNavigation: Whether the root view should be wrapped in a navigation view. 61 | /// - navigationViewModifier: A modifier for styling any navigation views the FlowStack creates. 62 | /// - root: The root view for the ``FlowStack``. 63 | public init(_ routes: Binding<[Route]>, withNavigation: Bool = false, navigationViewModifier: NavigationViewModifier, @ViewBuilder root: () -> Root) { 64 | self.init(routes: routes, withNavigation: withNavigation, navigationViewModifier: navigationViewModifier, dataType: .typedArray, root: root) 65 | } 66 | } 67 | 68 | public extension FlowStack where Data == AnyHashable { 69 | /// Initialises a ``FlowStack`` without any binding - the stack of routes will be managed internally by the ``FlowStack``. 70 | /// - Parameters: 71 | /// - withNavigation: Whether the root view should be wrapped in a navigation view. 72 | /// - navigationViewModifier: A modifier for styling any navigation views the FlowStack creates. 73 | /// - root: The root view for the ``FlowStack``. 74 | init(withNavigation: Bool = false, navigationViewModifier: NavigationViewModifier, @ViewBuilder root: () -> Root) { 75 | self.init(routes: nil, withNavigation: withNavigation, navigationViewModifier: navigationViewModifier, dataType: .noBinding, root: root) 76 | } 77 | 78 | /// Initialises a ``FlowStack`` with a binding to a ``FlowPath``. 79 | /// - Parameters: 80 | /// - path: The FlowPath that will manage navigation state. 81 | /// - withNavigation: Whether the root view should be wrapped in a navigation view. 82 | /// - navigationViewModifier: A modifier for styling any navigation views the FlowStack creates. 83 | /// - root: The root view for the ``FlowStack``. 84 | init(_ path: Binding, withNavigation: Bool = false, navigationViewModifier: NavigationViewModifier, @ViewBuilder root: () -> Root) { 85 | let path = Binding( 86 | get: { path.wrappedValue.routes }, 87 | set: { path.wrappedValue.routes = $0 } 88 | ) 89 | self.init(routes: path, withNavigation: withNavigation, navigationViewModifier: navigationViewModifier, dataType: .flowPath, root: root) 90 | } 91 | } 92 | 93 | public extension FlowStack where NavigationViewModifier == UnchangedViewModifier { 94 | /// Initialises a ``FlowStack`` with a binding to an Array of routes. 95 | /// - Parameters: 96 | /// - routes: The array of routes that will manage navigation state. 97 | /// - withNavigation: Whether the root view should be wrapped in a navigation view. 98 | /// - root: The root view for the ``FlowStack``. 99 | init(_ routes: Binding<[Route]>, withNavigation: Bool = false, @ViewBuilder root: () -> Root) { 100 | self.init(routes: routes, withNavigation: withNavigation, navigationViewModifier: UnchangedViewModifier(), dataType: .typedArray, root: root) 101 | } 102 | } 103 | 104 | public extension FlowStack where NavigationViewModifier == UnchangedViewModifier, Data == AnyHashable { 105 | /// Initialises a ``FlowStack`` without any binding - the stack of routes will be managed internally by the ``FlowStack``. 106 | /// - Parameters: 107 | /// - withNavigation: Whether the root view should be wrapped in a navigation view. 108 | /// - root: The root view for the ``FlowStack``. 109 | init(withNavigation: Bool = false, @ViewBuilder root: () -> Root) { 110 | self.init(withNavigation: withNavigation, navigationViewModifier: UnchangedViewModifier(), root: root) 111 | } 112 | 113 | /// Initialises a ``FlowStack`` with a binding to a ``FlowPath``. 114 | /// - Parameters: 115 | /// - path: The FlowPath that will manage navigation state. 116 | /// - withNavigation: Whether the root view should be wrapped in a navigation view. 117 | /// - root: The root view for the ``FlowStack``. 118 | init(_ path: Binding, withNavigation: Bool = false, @ViewBuilder root: () -> Root) { 119 | self.init(path, withNavigation: withNavigation, navigationViewModifier: UnchangedViewModifier(), root: root) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /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/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 | // NOTE: even though this object is unused, its inclusion avoids a glitch when swiping to dismiss 13 | // a sheet that's been presented from a pushed screen. 14 | @EnvironmentObject var navigator: FlowNavigator 15 | 16 | @State var isAppeared = false 17 | 18 | init(allRoutes: Binding<[Route]>, truncateToIndex: @escaping (Int) -> Void, index: Int, navigationViewModifier: Modifier, screenModifier: ScreenModifier) { 19 | _allRoutes = allRoutes 20 | self.truncateToIndex = truncateToIndex 21 | self.index = index 22 | self.navigationViewModifier = navigationViewModifier 23 | self.screenModifier = screenModifier 24 | route = allRoutes.wrappedValue[safe: index] 25 | } 26 | 27 | private var isActiveBinding: Binding { 28 | Binding( 29 | get: { allRoutes.count > index + 1 }, 30 | set: { isShowing in 31 | guard !isShowing else { return } 32 | guard allRoutes.count > index + 1 else { return } 33 | guard isAppeared else { return } 34 | truncateToIndex(index + 1) 35 | } 36 | ) 37 | } 38 | 39 | var next: some View { 40 | Node(allRoutes: $allRoutes, truncateToIndex: truncateToIndex, index: index + 1, navigationViewModifier: navigationViewModifier, screenModifier: screenModifier) 41 | } 42 | 43 | var nextRouteStyle: RouteStyle? { 44 | allRoutes[safe: index + 1]?.style 45 | } 46 | 47 | var body: some View { 48 | if let route = allRoutes[safe: index] ?? route { 49 | let binding = Binding(get: { 50 | allRoutes[safe: index]?.screen ?? route.screen 51 | }, set: { newValue in 52 | guard let typedData = newValue as? Screen else { return } 53 | allRoutes[index].screen = typedData 54 | }) 55 | 56 | DestinationBuilderView(data: binding) 57 | .modifier(screenModifier) 58 | .environment(\.routeStyle, allRoutes[safe: index]?.style) 59 | .environment(\.routeIndex, index) 60 | .show(isActive: isActiveBinding, routeStyle: nextRouteStyle, destination: next) 61 | .modifier(EmbedModifier(withNavigation: route.withNavigation, navigationViewModifier: navigationViewModifier)) 62 | .onAppear { isAppeared = true } 63 | .onDisappear { isAppeared = false } 64 | } 65 | } 66 | } 67 | 68 | extension Collection { 69 | /// Returns the element at the specified index if it is within bounds, otherwise nil. 70 | subscript(safe index: Index) -> Element? { 71 | indices.contains(index) ? self[index] : nil 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /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/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: Codable where Screen: Codable {} 87 | 88 | extension Route where Screen: Hashable { 89 | func erased() -> Route { 90 | if let anyHashableSelf = self as? Route { 91 | return anyHashableSelf 92 | } 93 | return map { $0 } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /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/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/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 | 10 | @Binding var screens: [Route] 11 | 12 | init(rootView: RootView, navigationViewModifier: NavigationViewModifier, screenModifier: ScreenModifier, screens: Binding<[Route]>) { 13 | self.rootView = rootView 14 | self.navigationViewModifier = navigationViewModifier 15 | self.screenModifier = screenModifier 16 | _screens = screens 17 | } 18 | 19 | var pushedScreens: some View { 20 | Node(allRoutes: $screens, truncateToIndex: { screens = Array(screens.prefix($0)) }, index: 0, navigationViewModifier: navigationViewModifier, screenModifier: screenModifier) 21 | } 22 | 23 | private var isActiveBinding: Binding { 24 | Binding( 25 | get: { !screens.isEmpty }, 26 | set: { isShowing in 27 | guard !isShowing else { return } 28 | guard !screens.isEmpty else { return } 29 | screens = [] 30 | } 31 | ) 32 | } 33 | 34 | var nextRouteStyle: RouteStyle? { 35 | screens.first?.style 36 | } 37 | 38 | var body: some View { 39 | rootView 40 | .modifier(screenModifier) 41 | .show(isActive: isActiveBinding, routeStyle: nextRouteStyle, destination: pushedScreens) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /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 | 9 | @Published var routes: [Route] = [] { 10 | didSet { 11 | task?.cancel() 12 | task = _withDelaysIfUnsupported(\.delayedRoutes, transform: { $0 = routes }) 13 | } 14 | } 15 | @Published var delayedRoutes: [Route] = [] 16 | 17 | var boundRoutes: [Route] { 18 | get { 19 | delayedRoutes 20 | } 21 | set { 22 | routes = newValue 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /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 | .onChange(of: path.routes) { routes in 38 | guard routes != typedPath.map({ $0.erased() }) else { return } 39 | typedPath = routes.compactMap { route in 40 | if let data = route.screen.base as? Data { 41 | return route.map { _ in data } 42 | } else if route.screen.base is LocalDestinationID { 43 | return nil 44 | } 45 | fatalError("Cannot add \(type(of: route.screen.base)) to stack of \(Data.self)") 46 | } 47 | } 48 | #if os(iOS) 49 | .onReceive(NotificationCenter.default.publisher(for: didBecomeActive)) { _ in 50 | appIsActive.value = true 51 | path.routes = typedPath.map { $0.erased() } 52 | } 53 | .onReceive(NotificationCenter.default.publisher(for: willResignActive)) { _ in 54 | appIsActive.value = false 55 | } 56 | #elseif os(tvOS) 57 | .onReceive(NotificationCenter.default.publisher(for: didBecomeActive)) { _ in 58 | appIsActive.value = true 59 | path.routes = typedPath.map { $0.erased() } 60 | } 61 | .onReceive(NotificationCenter.default.publisher(for: willResignActive)) { _ in 62 | appIsActive.value = false 63 | } 64 | #endif 65 | } 66 | } 67 | 68 | #if os(iOS) 69 | private let didBecomeActive = UIApplication.didBecomeActiveNotification 70 | private let willResignActive = UIApplication.willResignActiveNotification 71 | #elseif os(tvOS) 72 | private let didBecomeActive = UIApplication.didBecomeActiveNotification 73 | private let willResignActive = UIApplication.willResignActiveNotification 74 | #endif 75 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /Sources/FlowStacks/View+UseNavigationStack.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // NOTE: This is not yet public, as there are still issues with its use. 4 | extension View { 5 | /// Sets the policy for whether to use SwiftUI's built-in `NavigationStack` when available (i.e. when the SwiftUI 6 | /// version includes it). The default behaviour is to never use `NavigationStack` - instead `NavigationView` 7 | /// will be used on all versions, even when the API is available. 8 | /// - Parameter policy: The policy to use 9 | /// - Returns: A view with the policy set for all child views via a private environment value. 10 | func useNavigationStack(_ policy: UseNavigationStackPolicy) -> some View { 11 | environment(\.useNavigationStack, policy) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | AnyView( 12 | content 13 | .navigationDestination(isPresented: $isActive, destination: { destination }) 14 | ) 15 | } else { 16 | AnyView( 17 | content 18 | .background( 19 | NavigationLink(destination: destination, isActive: $isActive, label: EmptyView.init) 20 | .hidden() 21 | ) 22 | ).onChange(of: isActive) { isActive in 23 | if isActive, parentNavigationStackType == nil { 24 | print( 25 | """ 26 | Attempting to push from a view that is not embedded in a navigation view. \ 27 | Did you mean to pass `withNavigation: true` when creating the FlowStack or \ 28 | presenting the sheet/cover? 29 | """ 30 | ) 31 | } 32 | } 33 | } 34 | } 35 | } 36 | 37 | extension View { 38 | func push(isActive: Binding, destination: some View) -> some View { 39 | modifier(PushModifier(isActive: isActive, destination: destination)) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /Sources/FlowStacks/apply.swift: -------------------------------------------------------------------------------- 1 | /// Utilty for applying a transform to a value. 2 | /// - Parameters: 3 | /// - transform: The transform to apply. 4 | /// - input: The value to be transformed. 5 | /// - Returns: The transformed value. 6 | func apply(_ transform: (inout T) -> Void, to input: T) -> T { 7 | var transformed = input 8 | transform(&transformed) 9 | return transformed 10 | } 11 | -------------------------------------------------------------------------------- /Sources/FlowStacks/withDelaysIfUnsupported/Binding+withDelaysIfUnsupported.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | public extension Binding where Value: Collection { 5 | /// Any changes can be made to the routes array passed to the transform closure. If those 6 | /// changes are not supported within a single update by SwiftUI, the changes will be 7 | /// applied in stages. 8 | @available(*, deprecated, message: "No longer necessary as it is taken care of automatically") 9 | @_disfavoredOverload 10 | @MainActor 11 | func withDelaysIfUnsupported(_ transform: (inout [Route]) -> Void, onCompletion: (() -> Void)? = nil) where Value == [Route] { 12 | let start = wrappedValue 13 | let end = apply(transform, to: start) 14 | 15 | let didUpdateSynchronously = synchronouslyUpdateIfSupported(from: start, to: end) 16 | guard !didUpdateSynchronously else { return } 17 | 18 | Task { @MainActor in 19 | await withDelaysIfUnsupported(from: start, to: end, keyPath: \.self) 20 | onCompletion?() 21 | } 22 | } 23 | 24 | /// Any changes can be made to the routes array passed to the transform closure. If those 25 | /// changes are not supported within a single update by SwiftUI, the changes will be 26 | /// applied in stages. 27 | @available(*, deprecated, message: "No longer necessary as it is taken care of automatically") 28 | @MainActor 29 | func withDelaysIfUnsupported(_ transform: (inout [Route]) -> Void) async where Value == [Route] { 30 | let start = wrappedValue 31 | let end = apply(transform, to: start) 32 | 33 | let didUpdateSynchronously = synchronouslyUpdateIfSupported(from: start, to: end) 34 | guard !didUpdateSynchronously else { return } 35 | 36 | await withDelaysIfUnsupported(from: start, to: end, keyPath: \.self) 37 | } 38 | 39 | fileprivate func synchronouslyUpdateIfSupported(from start: [Route], to end: [Route]) -> Bool where Value == [Route] { 40 | guard FlowPath.canSynchronouslyUpdate(from: start, to: end) else { 41 | return false 42 | } 43 | wrappedValue = end 44 | return true 45 | } 46 | } 47 | 48 | public extension Binding where Value == FlowPath { 49 | /// Any changes can be made to the routes array passed to the transform closure. If those 50 | /// changes are not supported within a single update by SwiftUI, the changes will be 51 | /// applied in stages. 52 | @available(*, deprecated, message: "No longer necessary as it is taken care of automatically") 53 | @_disfavoredOverload 54 | @MainActor 55 | func withDelaysIfUnsupported(_ transform: (inout FlowPath) -> Void, onCompletion: (() -> Void)? = nil) { 56 | let start = wrappedValue 57 | let end = apply(transform, to: start) 58 | 59 | let didUpdateSynchronously = synchronouslyUpdateIfSupported(from: start.routes, to: end.routes) 60 | guard !didUpdateSynchronously else { return } 61 | 62 | Task { @MainActor in 63 | await withDelaysIfUnsupported(from: start.routes, to: end.routes, keyPath: \.routes) 64 | onCompletion?() 65 | } 66 | } 67 | 68 | /// Any changes can be made to the routes array passed to the transform closure. If those 69 | /// changes are not supported within a single update by SwiftUI, the changes will be 70 | /// applied in stages. 71 | @available(*, deprecated, message: "No longer necessary as it is taken care of automatically") 72 | @MainActor 73 | func withDelaysIfUnsupported(_ transform: (inout Value) -> Void) async { 74 | let start = wrappedValue 75 | let end = apply(transform, to: start) 76 | 77 | let didUpdateSynchronously = synchronouslyUpdateIfSupported(from: start.routes, to: end.routes) 78 | guard !didUpdateSynchronously else { return } 79 | 80 | await withDelaysIfUnsupported(from: start.routes, to: end.routes, keyPath: \.routes) 81 | } 82 | 83 | fileprivate func synchronouslyUpdateIfSupported(from start: [Route], to end: [Route]) -> Bool { 84 | guard FlowPath.canSynchronouslyUpdate(from: start, to: end) else { 85 | return false 86 | } 87 | wrappedValue.routes = end 88 | return true 89 | } 90 | } 91 | 92 | extension Binding { 93 | @MainActor 94 | func withDelaysIfUnsupported(from start: [Route], to end: [Route], keyPath: WritableKeyPath]>) async { 95 | let steps = FlowPath.calculateSteps(from: start, to: end) 96 | 97 | wrappedValue[keyPath: keyPath] = steps.first! 98 | await scheduleRemainingSteps(steps: Array(steps.dropFirst()), keyPath: keyPath) 99 | } 100 | 101 | @MainActor 102 | func scheduleRemainingSteps(steps: [[Route]], keyPath: WritableKeyPath]>) async { 103 | guard let firstStep = steps.first else { 104 | return 105 | } 106 | wrappedValue[keyPath: keyPath] = firstStep 107 | do { 108 | try await Task.sleep(nanoseconds: UInt64(0.65 * 1_000_000_000)) 109 | try Task.checkCancellation() 110 | await scheduleRemainingSteps(steps: Array(steps.dropFirst()), keyPath: keyPath) 111 | } catch {} 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /Sources/FlowStacks/withDelaysIfUnsupported/Navigator+withDelaysIfUnsupported.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension FlowNavigator { 4 | /// Any changes can be made to the routes array passed to the transform closure. If those 5 | /// changes are not supported within a single update by SwiftUI, the changes will be 6 | /// applied in stages. 7 | @available(*, deprecated, message: "No longer necessary as it is taken care of automatically") 8 | @_disfavoredOverload 9 | @MainActor 10 | func withDelaysIfUnsupported(transform: (inout [Route]) -> Void, onCompletion: (() -> Void)? = nil) { 11 | let start = routes 12 | let end = apply(transform, to: start) 13 | 14 | let didUpdateSynchronously = synchronouslyUpdateIfSupported(from: start, to: end) 15 | guard !didUpdateSynchronously else { return } 16 | 17 | Task { @MainActor in 18 | await routesBinding.withDelaysIfUnsupported(from: start, to: end, keyPath: \.self) 19 | onCompletion?() 20 | } 21 | } 22 | 23 | /// Any changes can be made to the routes array passed to the transform closure. If those 24 | /// changes are not supported within a single update by SwiftUI, the changes will be 25 | /// applied in stages. 26 | @available(*, deprecated, message: "No longer necessary as it is taken care of automatically") 27 | @MainActor 28 | func withDelaysIfUnsupported(transform: (inout [Route]) -> Void) async { 29 | let start = routes 30 | let end = apply(transform, to: start) 31 | 32 | let didUpdateSynchronously = synchronouslyUpdateIfSupported(from: start, to: end) 33 | guard !didUpdateSynchronously else { return } 34 | 35 | await routesBinding.withDelaysIfUnsupported(transform) 36 | } 37 | 38 | private func synchronouslyUpdateIfSupported(from start: [Route], to end: [Route]) -> Bool { 39 | guard FlowPath.canSynchronouslyUpdate(from: start, to: end) else { 40 | return false 41 | } 42 | routes = end 43 | return true 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/FlowStacks/withDelaysIfUnsupported/ObservableObject+withDelaysIfUnsupported.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | extension ObservableObject { 5 | /// Used internally to ensure any changes made to the path, that are not supported within a single update by SwiftUI, will be 6 | /// applied in stages. 7 | @_disfavoredOverload 8 | @MainActor 9 | @discardableResult 10 | func _withDelaysIfUnsupported(_ keyPath: WritableKeyPath]>, transform: (inout [Route]) -> Void, onCompletion: (() -> Void)? = nil) -> Task? { 11 | let start = self[keyPath: keyPath] 12 | let end = apply(transform, to: start) 13 | 14 | let didUpdateSynchronously = synchronouslyUpdateIfSupported(keyPath, from: start, to: end) 15 | guard !didUpdateSynchronously else { return nil } 16 | 17 | return Task { @MainActor in 18 | await withDelaysIfUnsupported(keyPath, from: start, to: end) 19 | onCompletion?() 20 | } 21 | } 22 | } 23 | 24 | public extension ObservableObject { 25 | /// Any changes can be made to the routes array passed to the transform closure. If those 26 | /// changes are not supported within a single update by SwiftUI, the changes will be 27 | /// applied in stages. An async version of this function is also available. 28 | @available(*, deprecated, message: "No longer necessary as it is taken care of automatically") 29 | @_disfavoredOverload 30 | @MainActor 31 | func withDelaysIfUnsupported(_ keyPath: WritableKeyPath]>, transform: (inout [Route]) -> Void, onCompletion: (() -> Void)? = nil) { 32 | _withDelaysIfUnsupported(keyPath, transform: transform, onCompletion: onCompletion) 33 | } 34 | 35 | /// Any changes can be made to the routes array passed to the transform closure. If those 36 | /// changes are not supported within a single update by SwiftUI, the changes will be 37 | /// applied in stages. 38 | @available(*, deprecated, message: "No longer necessary as it is taken care of automatically") 39 | @MainActor 40 | func withDelaysIfUnsupported(_ keyPath: WritableKeyPath]>, transform: (inout [Route]) -> Void) async { 41 | let start = self[keyPath: keyPath] 42 | let end = apply(transform, to: start) 43 | 44 | let didUpdateSynchronously = synchronouslyUpdateIfSupported(keyPath, from: start, to: end) 45 | guard !didUpdateSynchronously else { return } 46 | 47 | await withDelaysIfUnsupported(keyPath, from: start, to: end) 48 | } 49 | 50 | /// Any changes can be made to the routes array passed to the transform closure. If those 51 | /// changes are not supported within a single update by SwiftUI, the changes will be 52 | /// applied in stages. An async version of this function is also available. 53 | @available(*, deprecated, message: "No longer necessary as it is taken care of automatically") 54 | @_disfavoredOverload 55 | @MainActor 56 | func withDelaysIfUnsupported(_ keyPath: WritableKeyPath, transform: (inout FlowPath) -> Void, onCompletion: (() -> Void)? = nil) { 57 | let start = self[keyPath: keyPath] 58 | let end = apply(transform, to: start) 59 | 60 | let didUpdateSynchronously = synchronouslyUpdateIfSupported(keyPath.appending(path: \.routes), from: start.routes, to: end.routes) 61 | guard !didUpdateSynchronously else { return } 62 | 63 | Task { @MainActor in 64 | await withDelaysIfUnsupported(keyPath.appending(path: \.routes), from: start.routes, to: end.routes) 65 | onCompletion?() 66 | } 67 | } 68 | 69 | /// Any changes can be made to the routes array passed to the transform closure. If those 70 | /// changes are not supported within a single update by SwiftUI, the changes will be 71 | /// applied in stages. 72 | @available(*, deprecated, message: "No longer necessary as it is taken care of automatically") 73 | @MainActor 74 | func withDelaysIfUnsupported(_ keyPath: WritableKeyPath, transform: (inout FlowPath) -> Void) async { 75 | let start = self[keyPath: keyPath] 76 | let end = apply(transform, to: start) 77 | 78 | let didUpdateSynchronously = synchronouslyUpdateIfSupported(keyPath.appending(path: \.routes), from: start.routes, to: end.routes) 79 | guard !didUpdateSynchronously else { return } 80 | 81 | await withDelaysIfUnsupported(keyPath.appending(path: \.routes), from: start.routes, to: end.routes) 82 | } 83 | 84 | @MainActor 85 | private func withDelaysIfUnsupported(_ keyPath: WritableKeyPath]>, from start: [Route], to end: [Route]) async { 86 | let binding = Binding( 87 | get: { [weak self] in self?[keyPath: keyPath] ?? [] }, 88 | set: { [weak self] in self?[keyPath: keyPath] = $0 } 89 | ) 90 | await binding.withDelaysIfUnsupported(from: start, to: end, keyPath: \.self) 91 | } 92 | 93 | private func synchronouslyUpdateIfSupported(_ keyPath: WritableKeyPath]>, from start: [Route], to end: [Route]) -> Bool { 94 | guard FlowPath.canSynchronouslyUpdate(from: start, to: end) else { 95 | return false 96 | } 97 | // Even though self is known to be a class, the compiler complains that self is immutable 98 | // without this indirection. 99 | var copy = self 100 | copy[keyPath: keyPath] = end 101 | return true 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /Tests/FlowStacksTests/CalculateStepsTests.swift: -------------------------------------------------------------------------------- 1 | @testable import FlowStacks 2 | import XCTest 3 | 4 | final class CaluclateStepsTests: 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) 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 testPopAllAtOnce() { 33 | let start: RouterState = [ 34 | .push(2), 35 | .push(3), 36 | .push(4), 37 | ] 38 | let end: RouterState = [ 39 | ] 40 | 41 | let steps = FlowPath.calculateSteps(from: start, to: end) 42 | 43 | let expectedSteps: [RouterState] = [ 44 | [ 45 | .push(2), 46 | .push(3), 47 | .push(4), 48 | ], 49 | end, 50 | ] 51 | XCTAssertEqual(steps, expectedSteps) 52 | } 53 | 54 | func testPresentOneAtATime() { 55 | let start: RouterState = [] 56 | let end: RouterState = [ 57 | .sheet(-2), 58 | .cover(-3), 59 | .sheet(-4), 60 | ] 61 | 62 | let steps = FlowPath.calculateSteps(from: start, to: end) 63 | 64 | let expectedSteps: [RouterState] = [ 65 | [ 66 | ], 67 | [ 68 | .sheet(-2), 69 | ], 70 | [ 71 | .sheet(-2), 72 | .cover(-3), 73 | ], 74 | end, 75 | ] 76 | XCTAssertEqual(steps, expectedSteps) 77 | } 78 | 79 | func testDismissOneAtATime() { 80 | let start: RouterState = [ 81 | .sheet(2), 82 | .cover(3), 83 | .sheet(4), 84 | ] 85 | let end: RouterState = [ 86 | ] 87 | 88 | let steps = FlowPath.calculateSteps(from: start, to: end, allowMultipleDismissalsInOne: false) 89 | 90 | let expectedSteps: [RouterState] = [ 91 | [ 92 | .sheet(2), 93 | .cover(3), 94 | .sheet(4), 95 | ], 96 | [ 97 | .sheet(2), 98 | .cover(3), 99 | ], 100 | [ 101 | .sheet(2), 102 | ], 103 | end, 104 | ] 105 | XCTAssertEqual(steps, expectedSteps) 106 | } 107 | 108 | func testPresentAndPushOneAtATime() { 109 | let start: RouterState = [] 110 | let end: RouterState = [ 111 | .push(-2), 112 | .push(-3), 113 | .sheet(-4), 114 | .sheet(-5), 115 | ] 116 | 117 | let steps = FlowPath.calculateSteps(from: start, to: end) 118 | 119 | let expectedSteps: [RouterState] = [ 120 | [ 121 | ], 122 | [ 123 | .push(-2), 124 | ], 125 | [ 126 | .push(-2), 127 | .push(-3), 128 | ], 129 | [ 130 | .push(-2), 131 | .push(-3), 132 | .sheet(-4), 133 | ], 134 | end, 135 | ] 136 | XCTAssertEqual(steps, expectedSteps) 137 | } 138 | 139 | func testBackToCommonAncestorFirst() { 140 | let start: RouterState = [ 141 | .push(2), 142 | .push(3), 143 | .push(4), 144 | ] 145 | let end: RouterState = [ 146 | .push(-2), 147 | .push(-3), 148 | .sheet(-4), 149 | .sheet(-5), 150 | ] 151 | 152 | let steps = FlowPath.calculateSteps(from: start, to: end) 153 | 154 | let expectedSteps: [RouterState] = [ 155 | [ 156 | .push(-2), 157 | .push(-3), 158 | .push(4), 159 | ], 160 | [ 161 | .push(-2), 162 | .push(-3), 163 | ], 164 | [ 165 | .push(-2), 166 | .push(-3), 167 | .sheet(-4), 168 | ], 169 | end, 170 | ] 171 | XCTAssertEqual(steps, expectedSteps) 172 | } 173 | 174 | func testBackToCommonAncestorFirstWithoutPoppingWithinExtraPresentationLayers() { 175 | let start: RouterState = [ 176 | .sheet(2), 177 | .push(3), 178 | .sheet(4), 179 | .push(5), 180 | ] 181 | let end: RouterState = [ 182 | .push(-2), 183 | ] 184 | 185 | let steps = FlowPath.calculateSteps(from: start, to: end, allowMultipleDismissalsInOne: false) 186 | 187 | let expectedSteps: [RouterState] 188 | 189 | expectedSteps = [ 190 | [ 191 | .sheet(2), 192 | .push(3), 193 | .sheet(4), 194 | .push(5), 195 | ], 196 | [ 197 | .sheet(2), 198 | .push(3), 199 | ], 200 | [ 201 | ], 202 | end, 203 | ] 204 | XCTAssertEqual(steps, expectedSteps) 205 | } 206 | 207 | func testSimultaneousDismissalsWhenSupported() { 208 | let start: RouterState = [ 209 | .sheet(2), 210 | .push(3), 211 | .sheet(4), 212 | .push(5), 213 | ] 214 | let end: RouterState = [ 215 | ] 216 | 217 | let steps = FlowPath.calculateSteps(from: start, to: end, allowMultipleDismissalsInOne: true) 218 | 219 | let expectedSteps: [RouterState] 220 | 221 | expectedSteps = [ 222 | [ 223 | .sheet(2), 224 | .push(3), 225 | .sheet(4), 226 | .push(5), 227 | ], 228 | end, 229 | ] 230 | XCTAssertEqual(steps, expectedSteps) 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------