Skip to content

Commit

Permalink
Merge branch 'screen-modifier-nested-syncing'
Browse files Browse the repository at this point in the history
  • Loading branch information
johnpatrickmorgan committed Aug 12, 2024
2 parents da647db + 3f81f7e commit 8f93cc8
Show file tree
Hide file tree
Showing 17 changed files with 198 additions and 107 deletions.
28 changes: 18 additions & 10 deletions Docs/Nesting FlowStacks.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,28 @@
# Nesting FlowStacks

Sometimes, it can be useful to break your app's screen flows into several distinct flows of related screens. FlowStacks supports nesting coordinators ('coordinator' is used to describe a view that contains a `FlowStack` to manage a flow of screens).
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.

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.
'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.

The best approach to nesting coordinators will depend on how you are instantiating your `FlowStack`. A `FlowStack` can be instantiated with either:
The approach to nesting coordinators will depend on how you are instantiating your `FlowStack`s. A `FlowStack` can be instantiated with either:

1. a binding to a `FlowPath`,
1. no binding at all, or
1. a binding to a routes array, e.g. `[Route<MyScreen>]`.
1. a binding to a `FlowPath`, which can support any `Hashable` data,
1. a binding to a typed routes array, e.g. `[Route<MyScreen>]`, or
1. no binding at all.

## Approach 1: Nested FlowStack inherits its parent FlowStack's state

## Nesting FlowStacks using FlowPaths
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:

In the first two cases, any type can be pushed onto the path, and as long as somewhere in the stack you have declared how to build a flow 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. See the [example app](FlowStacksApp/Shared/FlowPathView) for how this might work.
- Both parent and child can push new routes onto the path, and the parent's path will include the ones its child has pushed.
- Calling `goBackToRoot` from the child will go all the way back to the parent's root screen.

## Nesting FlowStacks using Routes Arrays

'If using a binding to a routes array, e.g. `[Route<MyScreen>]`, 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.
## Approach 2: Nested FlowStack holds its own state and takes over navigation duties from its parent FlowStack

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.

That means:

- Only the child can push new routes onto the path: it assumes responsibility for navigation until it is removed from its parent's path.
- Calling `goBackToRoot` from the child will go back to the child's root screen.
6 changes: 6 additions & 0 deletions FlowStacksApp.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
528AE0F92BF377C400E143C5 /* NumberVMFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528AE0F22BF377C300E143C5 /* NumberVMFlow.swift */; };
528AE0FA2BF377C400E143C5 /* NumberVMFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528AE0F22BF377C300E143C5 /* NumberVMFlow.swift */; };
528AE0FF2BF7662200E143C5 /* FlowStacksUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528AE0FE2BF7662200E143C5 /* FlowStacksUITests.swift */; };
529CB4442C6ABC7B00B0AFE9 /* View+indexedA11y.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529CB4432C6ABC7B00B0AFE9 /* View+indexedA11y.swift */; };
529CB4452C6ABC7B00B0AFE9 /* View+indexedA11y.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529CB4432C6ABC7B00B0AFE9 /* View+indexedA11y.swift */; };
529D5CCE2BFE44A200C50A7C /* NestedFlowStacksUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529D5CCD2BFE44A200C50A7C /* NestedFlowStacksUITests.swift */; };
52C5CCDE286EE92B0075ABA7 /* Deeplink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52C5CCDD286EE92B0075ABA7 /* Deeplink.swift */; };
52C5CCDF286EE92B0075ABA7 /* Deeplink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52C5CCDD286EE92B0075ABA7 /* Deeplink.swift */; };
Expand Down Expand Up @@ -54,6 +56,7 @@
528AE0F12BF377C300E143C5 /* FlowPathView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FlowPathView.swift; sourceTree = "<group>"; };
528AE0F22BF377C300E143C5 /* NumberVMFlow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NumberVMFlow.swift; sourceTree = "<group>"; };
528AE0FE2BF7662200E143C5 /* FlowStacksUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowStacksUITests.swift; sourceTree = "<group>"; };
529CB4432C6ABC7B00B0AFE9 /* View+indexedA11y.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+indexedA11y.swift"; sourceTree = "<group>"; };
529D5CCD2BFE44A200C50A7C /* NestedFlowStacksUITests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NestedFlowStacksUITests.swift; sourceTree = "<group>"; };
52C5CCDB286E5D780075ABA7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
52C5CCDD286EE92B0075ABA7 /* Deeplink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Deeplink.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -138,6 +141,7 @@
isa = PBXGroup;
children = (
528AE0EF2BF377C300E143C5 /* ArrayBindingView.swift */,
529CB4432C6ABC7B00B0AFE9 /* View+indexedA11y.swift */,
528AE0F12BF377C300E143C5 /* FlowPathView.swift */,
528AE0F02BF377C300E143C5 /* NoBindingView.swift */,
528AE0F22BF377C300E143C5 /* NumberVMFlow.swift */,
Expand Down Expand Up @@ -300,6 +304,7 @@
528AE0F72BF377C400E143C5 /* FlowPathView.swift in Sources */,
52C5CCDE286EE92B0075ABA7 /* Deeplink.swift in Sources */,
528AE0F32BF377C400E143C5 /* ArrayBindingView.swift in Sources */,
529CB4442C6ABC7B00B0AFE9 /* View+indexedA11y.swift in Sources */,
528AE0F92BF377C400E143C5 /* NumberVMFlow.swift in Sources */,
528AE0F52BF377C400E143C5 /* NoBindingView.swift in Sources */,
525C73372774BA6B009CBD67 /* NumberCoordinator.swift in Sources */,
Expand All @@ -314,6 +319,7 @@
528AE0F82BF377C400E143C5 /* FlowPathView.swift in Sources */,
52C5CCDF286EE92B0075ABA7 /* Deeplink.swift in Sources */,
528AE0F42BF377C400E143C5 /* ArrayBindingView.swift in Sources */,
529CB4452C6ABC7B00B0AFE9 /* View+indexedA11y.swift in Sources */,
528AE0FA2BF377C400E143C5 /* NumberVMFlow.swift in Sources */,
528AE0F62BF377C400E143C5 /* NoBindingView.swift in Sources */,
525C73382774BA6B009CBD67 /* NumberCoordinator.swift in Sources */,
Expand Down
2 changes: 2 additions & 0 deletions FlowStacksApp/Shared/ArrayBindingView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ private struct HomeView: View {
VStack(spacing: 8) {
// Push via FlowLink
FlowLink(value: Screen.numberList(NumberList(range: 0 ..< 10)), style: .sheet(withNavigation: true), label: { Text("Pick a number") })
.indexedA11y("Pick a number")
// Push via navigator
Button("99 Red balloons", action: show99RedBalloons)
// Push via Bool binding
Expand All @@ -76,6 +77,7 @@ private struct NumberListView: View {
List {
ForEach(numberList.range, id: \.self) { number in
FlowLink("\(number)", value: Screen.number(number), style: .sheet(withNavigation: true))
.indexedA11y("Show \(number)")
}
Button("Go back", action: { navigator.goBack() })
}.navigationTitle("List")
Expand Down
6 changes: 2 additions & 4 deletions FlowStacksApp/Shared/FlowPathView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ struct FlowPathView: View {
}

private struct HomeView: View {
@Environment(\.routeIndex) var routeIndex
@EnvironmentObject var navigator: FlowPathNavigator
@State var isPushing = false

Expand All @@ -62,7 +61,7 @@ private struct HomeView: View {
value: NumberList(range: 0 ..< 10),
style: .sheet(withNavigation: true),
label: { Text("Pick a number") }
).accessibilityIdentifier("Pick a number from index \(routeIndex ?? -1)")
).indexedA11y("Pick a number")
// Push via navigator
Button("99 Red balloons", action: show99RedBalloons)
// Push child class via navigator
Expand All @@ -87,14 +86,13 @@ private struct HomeView: View {
}

private struct NumberListView: View {
@Environment(\.routeIndex) var routeIndex
@EnvironmentObject var navigator: FlowPathNavigator
let numberList: NumberList
var body: some View {
List {
ForEach(numberList.range, id: \.self) { number in
FlowLink("\(number)", value: Number(value: number), style: .push)
.accessibilityIdentifier("Show \(number) from index \(routeIndex ?? -1)")
.indexedA11y("Show \(number)")
}
Button("Go back", action: { navigator.goBack() })
}.navigationTitle("List")
Expand Down
6 changes: 2 additions & 4 deletions FlowStacksApp/Shared/NoBindingView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ struct NoBindingView: View {
}

private struct HomeView: View {
@Environment(\.routeIndex) var routeIndex
@EnvironmentObject var navigator: FlowPathNavigator
@State var isPushing = false

Expand All @@ -36,7 +35,7 @@ private struct HomeView: View {
value: NumberList(range: 0 ..< 10),
style: .sheet(withNavigation: true),
label: { Text("Pick a number") }
).accessibilityIdentifier("Pick a number from index \(routeIndex ?? -1)")
).indexedA11y("Pick a number")
// Push via navigator
Button("99 Red balloons", action: show99RedBalloons)
// Push child class via navigator
Expand All @@ -60,14 +59,13 @@ private struct HomeView: View {
}

private struct NumberListView: View {
@Environment(\.routeIndex) var routeIndex
@EnvironmentObject var navigator: FlowPathNavigator
let numberList: NumberList
var body: some View {
List {
ForEach(numberList.range, id: \.self) { number in
FlowLink("\(number)", value: Number(value: number), style: .push)
.accessibilityIdentifier("Show \(number) from index \(routeIndex ?? -1)")
.indexedA11y("Show \(number)")
}
Button("Go back", action: { navigator.goBack() })
}.navigationTitle("List")
Expand Down
4 changes: 2 additions & 2 deletions FlowStacksApp/Shared/NumberCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ struct NumberCoordinator: View {

private struct NumberView: View {
@EnvironmentObject var navigator: FlowNavigator<Int>
@Environment(\.routeStyle) var routeStyle: RouteStyle?
@Environment(\.routeIndex) var routeIndex: Int?
@Environment(\.routeStyle) var routeStyle
@Environment(\.routeIndex) var routeIndex

@State private var colorShown: Color?
@Binding var number: Int
Expand Down
17 changes: 17 additions & 0 deletions FlowStacksApp/Shared/View+indexedA11y.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import SwiftUI

extension View {
func indexedA11y(_ id: String) -> some View {
modifier(IndexedA11yIdModifier(id: id))
}
}

struct IndexedA11yIdModifier: ViewModifier {
@Environment(\.routeIndex) var routeIndex
@Environment(\.nestingIndex) var nestingIndex
var id: String

func body(content: Content) -> some View {
content.accessibilityIdentifier("\(id) - route \(nestingIndex ?? -1):\(routeIndex ?? -1)")
}
}
6 changes: 3 additions & 3 deletions FlowStacksAppUITests/FlowStacksUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ final class FlowStacksUITests: XCTestCase {
app.tabBars.buttons[tabTitle].tap()
XCTAssertTrue(app.navigationBars["Home"].waitForExistence(timeout: 2))

app.buttons["Pick a number"].tap()
app.buttons["Pick a number - route 1:-1"].tap()
XCTAssertTrue(app.navigationBars["List"].waitForExistence(timeout: navigationTimeout))

app.navigationBars["List"].swipeSheetDown()
Expand All @@ -43,10 +43,10 @@ final class FlowStacksUITests: XCTestCase {
app.navigationBars.buttons.element(boundBy: 0).tap()
XCTAssertTrue(app.navigationBars["Home"].waitForExistence(timeout: navigationTimeout))

app.buttons["Pick a number"].tap()
app.buttons["Pick a number - route 1:-1"].tap()
XCTAssertTrue(app.navigationBars["List"].waitForExistence(timeout: navigationTimeout))

app.buttons["1"].tap()
app.buttons["Show 1 - route 1:0"].tap()
XCTAssertTrue(app.navigationBars["1"].waitForExistence(timeout: navigationTimeout))

app.buttons["Show next number"].tap()
Expand Down
15 changes: 8 additions & 7 deletions FlowStacksAppUITests/NestedFlowStacksUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,33 +26,34 @@ final class NestedFlowStacksUITests: XCTestCase {
app.tabBars.buttons[tabTitle].tap()
XCTAssertTrue(app.navigationBars["Home"].waitForExistence(timeout: 2))

app.buttons["Pick a number from index -1"].tap()
app.buttons["Pick a number - route 1:-1"].tap()
XCTAssertTrue(app.navigationBars["List"].waitForExistence(timeout: navigationTimeout))

app.buttons["Show 1 from index 0"].tap()
app.buttons["Show 1 - route 1:0"].tap()
XCTAssertTrue(app.navigationBars["1"].waitForExistence(timeout: navigationTimeout))

app.buttons["FlowPath Child"].tap()
XCTAssertTrue(app.navigationBars["Home"].waitForExistence(timeout: navigationTimeout))

app.buttons["Pick a number from index 2"].firstMatch.tap()
app.buttons["Pick a number - route 2:-1"].firstMatch.tap()
XCTAssertTrue(app.navigationBars["List"].waitForExistence(timeout: navigationTimeout))

app.buttons["Show 1 from index 3"].tap()
app.buttons["Show 1 - route 2:0"].tap()
XCTAssertTrue(app.navigationBars["1"].waitForExistence(timeout: navigationTimeout))

app.buttons["NoBinding Child"].tap()
XCTAssertTrue(app.navigationBars["Home"].waitForExistence(timeout: navigationTimeout))

app.buttons["Pick a number from index 5"].firstMatch.tap()
app.buttons["Pick a number - route 2:2"].firstMatch.tap()
XCTAssertTrue(app.navigationBars["List"].waitForExistence(timeout: navigationTimeout))

app.buttons["Show 1 from index 6"].tap()
app.buttons["Show 1 - route 2:3"].tap()
XCTAssertTrue(app.navigationBars["1"].waitForExistence(timeout: navigationTimeout))

app.buttons["Go back to root"].tap()
XCTAssertTrue(app.navigationBars["Home"].waitForExistence(timeout: navigationTimeout))

XCTAssertTrue(app.buttons["Pick a number from index -1"].exists)
// Goes back to root of FlowPath child.
XCTAssertTrue(app.buttons["Pick a number - route 2:-1"].exists)
}
}
15 changes: 14 additions & 1 deletion Sources/FlowStacks/EnvironmentValues+keys.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ struct ParentNavigationStackKey: EnvironmentKey {
}

enum FlowStackDataType {
case typedArray, flowPath
case typedArray, flowPath, noBinding
}

struct FlowStackDataTypeKey: EnvironmentKey {
Expand Down Expand Up @@ -65,3 +65,16 @@ public extension EnvironmentValues {
set { self[RouteIndexKey.self] = newValue }
}
}

struct NestingIndexKey: EnvironmentKey {
static let defaultValue: Int? = nil
}

public extension EnvironmentValues {
/// If the view is part of a route within a FlowStack, this denotes the number of nested FlowStacks above this view in the hierarchy.
internal(set) var nestingIndex: Int? {
get { self[NestingIndexKey.self] }
set { self[NestingIndexKey.self] = newValue }
}
}

40 changes: 33 additions & 7 deletions Sources/FlowStacks/FlowPath+calculateSteps.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@ import Foundation
import SwiftUI

extension FlowPath {

/// Calculates the minimal number of steps to update from one routes array to another, within the constraints of SwiftUI.
/// For a given update to an array of routes, returns the minimum intermediate steps.
/// required to ensure each update is supported by SwiftUI.
/// - Parameters:
/// - start: The initial state.
/// - end: The goal state.
/// - allowMultipleDismissalsInOneStep: Whether the platform allows multiple layers of presented screens to be dismissed in one update.
/// - Returns: A series of state updates from the start to end.
public static func calculateSteps<Screen>(from start: [Route<Screen>], to end: [Route<Screen>]) -> [[Route<Screen>]] {
static func calculateSteps<Screen>(from start: [Route<Screen>], to end: [Route<Screen>], allowMultipleDismissalsInOne: Bool) -> [[Route<Screen>]] {
let pairs = Array(zip(start, end))
let firstDivergingIndex = pairs
.firstIndex(where: { $0.style != $1.style }) ?? pairs.endIndex
Expand All @@ -21,13 +23,20 @@ extension FlowPath {
var steps = [initialStep]

// Dismiss extraneous presented stacks.
while var dismissStep = steps.last, dismissStep.count > firstDivergingPresentationIndex {
var dismissed: Route<Screen>? = dismissStep.popLast()
// Ignore pushed screens as they can be dismissed en masse.
while dismissed?.isPresented == false, dismissStep.count > firstDivergingPresentationIndex {
dismissed = dismissStep.popLast()
if allowMultipleDismissalsInOne {
if let dismissStep = steps.last, dismissStep.count > firstDivergingPresentationIndex {
// On iOS 17, this can be performed in one step.
steps.append(Array(end[..<firstDivergingIndex]))
}
} else {
while var dismissStep = steps.last, dismissStep.count > firstDivergingPresentationIndex {
var dismissed: Route<Screen>? = dismissStep.popLast()
// Ignore pushed screens as they can be dismissed en masse.
while dismissed?.isPresented == false, dismissStep.count > firstDivergingPresentationIndex {
dismissed = dismissStep.popLast()
}
steps.append(dismissStep)
}
steps.append(dismissStep)
}

// Pop extraneous pushed screens.
Expand All @@ -47,6 +56,23 @@ extension FlowPath {

return steps
}

/// Calculates the minimal number of steps to update from one routes array to another, within the constraints of SwiftUI.
/// For a given update to an array of routes, returns the minimum intermediate steps.
/// required to ensure each update is supported by SwiftUI.
/// - Parameters:
/// - start: The initial state.
/// - end: The goal state.
/// - Returns: A series of state updates from the start to end.
public static func calculateSteps<Screen>(from start: [Route<Screen>], to end: [Route<Screen>]) -> [[Route<Screen>]] {
let allowMultipleDismissalsInOne: Bool
if #available(iOS 17.0, *) {
allowMultipleDismissalsInOne = true
} else {
allowMultipleDismissalsInOne = false
}
return calculateSteps(from: start, to: end, allowMultipleDismissalsInOne: allowMultipleDismissalsInOne)
}

static func canSynchronouslyUpdate<Screen>(from start: [Route<Screen>], to end: [Route<Screen>]) -> Bool {
// If there are less than 3 steps, the transformation can be applied in one update.
Expand Down
Loading

0 comments on commit 8f93cc8

Please sign in to comment.