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