From 0c2a79106b11189dd84ece76d566c8d864c7a53d Mon Sep 17 00:00:00 2001 From: johnpatrickmorgan Date: Thu, 14 Apr 2022 22:58:19 +0100 Subject: [PATCH] Reinstates NStack and PStack (#20) They will instead be removed as part of a minor version bump. --- Sources/FlowStacks/Navigation/NFlow.swift | 124 ++++++++++++++++++ Sources/FlowStacks/Navigation/NStack.swift | 74 +++++++++++ .../Navigation/NavigationNode.swift | 68 ++++++++++ Sources/FlowStacks/Presentation/PFlow.swift | 34 +++++ Sources/FlowStacks/Presentation/PStack.swift | 67 ++++++++++ .../Presentation/PresentationNode.swift | 88 +++++++++++++ .../Presentation/PresentationOptions.swift | 27 ++++ 7 files changed, 482 insertions(+) create mode 100644 Sources/FlowStacks/Navigation/NFlow.swift create mode 100644 Sources/FlowStacks/Navigation/NStack.swift create mode 100644 Sources/FlowStacks/Navigation/NavigationNode.swift create mode 100644 Sources/FlowStacks/Presentation/PFlow.swift create mode 100644 Sources/FlowStacks/Presentation/PStack.swift create mode 100644 Sources/FlowStacks/Presentation/PresentationNode.swift create mode 100644 Sources/FlowStacks/Presentation/PresentationOptions.swift diff --git a/Sources/FlowStacks/Navigation/NFlow.swift b/Sources/FlowStacks/Navigation/NFlow.swift new file mode 100644 index 0000000..850017f --- /dev/null +++ b/Sources/FlowStacks/Navigation/NFlow.swift @@ -0,0 +1,124 @@ +import Foundation + +/// A thin wrapper around an array. NFlow provides some convenience methods for pushing +/// and popping, and makes it harder to perform navigation operations that SwiftUI does +/// not support. +@available(*, deprecated, message: "Use [Route] instead") +public struct NFlow { + /// The underlying array of screens. + public internal(set) var array: [Screen] + + /// Initializes the stack with an empty array of screens. + public init() { + self.array = [] + } + + /// Initializes the stack with a single root screen. + /// - Parameter root: The root screen. + public init(root: Screen) { + self.array = [root] + } + + /// Pushes a new screen onto the stack. + /// - Parameter screen: The screen to push. + public mutating func push(_ screen: Screen) { + array.append(screen) + } + + /// Pops a given number of screens off the stack + /// - Parameter count: The number of screens to pop. Defaults to 1. + public mutating func pop(count: Int = 1) { + array = array.dropLast(count) + } + + /// Pops to a given index in the array of screens. The resulting screen count + /// will be index + 1. + /// - Parameter index: The index that should become top of the stack. + public mutating func popTo(index: Int) { + array = Array(array.prefix(index + 1)) + } + + /// Pops to the root screen (index 0). The resulting screen count + /// will be 1. + public mutating func popToRoot() { + popTo(index: 0) + } + + /// Pops to the topmost (most recently pushed) screen in the stack + /// that satisfies the given condition. If no screens satisfy the condition, + /// the screens array will be unchanged. + /// - Parameter condition: The predicate indicating which screen to pop to. + /// - Returns: A `Bool` indicating whether a screen was found. + @discardableResult + public mutating func popTo(where condition: (Screen) -> Bool) -> Bool { + guard let index = array.lastIndex(where: condition) else { + return false + } + popTo(index: index) + return true + } + + /// Replaces the current screen array with a new array. The count of the new + /// array should be no more than the previous stack's count plus one. + /// - Parameter newArray: The new screens array. + public mutating func replaceNFlow(with newArray: [Screen]) { + assert( + newArray.count <= array.count + 1, + """ + ERROR: SwiftUI does not support increasing the navigation stack + by more than one in a single update. (FB9200490) + OLD STACK: + \(array) + NEW STACK: + \(newArray) + """ + ) + array = newArray + } +} + +public extension NFlow where Screen: Equatable { + /// Pops to the topmost (most recently pushed) screen in the stack + /// equal to the given screen. If no screens are found, + /// the screens array will be unchanged. + /// - Parameter screen: The predicate indicating which screen to pop to. + /// - Returns: A `Bool` indicating whether a matching screen was found. + @discardableResult + mutating func popTo(_ screen: Screen) -> Bool { + popTo(where: { $0 == screen }) + } +} + +public extension NFlow where Screen: Identifiable { + /// Pops to the topmost (most recently pushed) identifiable screen in the stack + /// with the given ID. If no screens are found, the screens array will be unchanged. + /// - Parameter id: The id of the screen to pop to. + /// - Returns: A `Bool` indicating whether a matching screen was found. + @discardableResult + mutating func popTo(id: Screen.ID) -> Bool { + popTo(where: { $0.id == id }) + } + + /// Pops to the topmost (most recently pushed) identifiable screen in the stack + /// matching the given screen. If no screens are found, the screens array + /// will be unchanged. + /// - Parameter screen: The screen to pop to. + /// - Returns: A `Bool` indicating whether a matching screen was found. + @discardableResult + mutating func popTo(_ screen: Screen) -> Bool { + popTo(id: screen.id) + } +} + +/// Avoids an ambiguity for `popTo` when `Screen` is both `Identifiable` and `Equatable`. +public extension NFlow where Screen: Identifiable & Equatable { + /// Pops to the topmost (most recently pushed) identifiable screen in the stack + /// matching the given screen. If no screens are found, the screens array + /// will be unchanged. + /// - Parameter screen: The screen to pop to. + /// - Returns: A `Bool` indicating whether a matching screen was found. + @discardableResult + mutating func popTo(_ screen: Screen) -> Bool { + popTo(id: screen.id) + } +} diff --git a/Sources/FlowStacks/Navigation/NStack.swift b/Sources/FlowStacks/Navigation/NStack.swift new file mode 100644 index 0000000..16ab121 --- /dev/null +++ b/Sources/FlowStacks/Navigation/NStack.swift @@ -0,0 +1,74 @@ +import Foundation +import SwiftUI + +/// NStack maintains a stack of pushed views for use within a `NavigationView`. +@available(*, deprecated, message: "Use Router instead. It is capable of both navigation and presentation.") +public struct NStack: View { + /// The array of screens that represents the navigation stack. + @Binding var stack: [Screen] + + /// A closure that builds a `ScreenView` from a `Screen`and its index. + @ViewBuilder var buildView: (Screen, Int) -> ScreenView + + /// Initializer for creating an NStack using a binding to an array of screens. + /// - Parameters: + /// - stack: A binding to an array of screens. + /// - buildView: A closure that builds a `ScreenView` from a `Screen` and its index. + public init(_ stack: Binding<[Screen]>, @ViewBuilder buildView: @escaping (Screen, Int) -> ScreenView) { + self._stack = stack + self.buildView = buildView + } + + public var body: some View { + stack + .enumerated() + .reversed() + .reduce(NavigationNode.end) { pushedNode, new in + let (index, screen) = new + return NavigationNode.view( + buildView(screen, index), + pushing: pushedNode, + stack: $stack, + index: index + ) + } + } +} + +public extension NStack { + /// Convenience initializer for creating an NStack using a binding to a `NFlow` + /// of screens. + /// - Parameters: + /// - stack: A binding to a stack of screens. + /// - buildView: A closure that builds a `ScreenView` from a `Screen`. + init(_ nFlow: Binding>, @ViewBuilder buildView: @escaping (Screen) -> ScreenView) { + self._stack = Binding( + get: { nFlow.wrappedValue.array }, + set: { nFlow.wrappedValue.array = $0 } + ) + self.buildView = { screen, _ in buildView(screen) } + } + + /// Convenience initializer for creating an NStack using a binding to a `NFlow` + /// of screens. + /// - Parameters: + /// - stack: A binding to a stack of screens. + /// - buildView: A closure that builds a `ScreenView` from a `Screen` and its index. + init(_ nFlow: Binding>, @ViewBuilder buildView: @escaping (Screen, Int) -> ScreenView) { + self._stack = Binding( + get: { nFlow.wrappedValue.array }, + set: { nFlow.wrappedValue.array = $0 } + ) + self.buildView = buildView + } + + /// Convenience initializer for creating an NStack without using an index in the + /// `buildView` closure. + /// - Parameters: + /// - stack: A binding to a stack of screens. + /// - buildView: A closure that builds a `ScreenView` from a `Screen`. + init(_ stack: Binding<[Screen]>, @ViewBuilder buildView: @escaping (Screen) -> ScreenView) { + self._stack = stack + self.buildView = { screen, _ in buildView(screen) } + } +} diff --git a/Sources/FlowStacks/Navigation/NavigationNode.swift b/Sources/FlowStacks/Navigation/NavigationNode.swift new file mode 100644 index 0000000..4995643 --- /dev/null +++ b/Sources/FlowStacks/Navigation/NavigationNode.swift @@ -0,0 +1,68 @@ +import Foundation +import SwiftUI + +/// A view that represents a linked list of views, each pushing the next in +/// a navigation stack. +indirect enum NavigationNode: View { + case view(V, pushing: NavigationNode, stack: Binding<[Screen]>, index: Int) + case end + + private var isActiveBinding: Binding { + switch self { + case .end, .view(_, .end, _, _): + return .constant(false) + case .view(_, .view, let stack, let index): + return Binding( + get: { + stack.wrappedValue.count > index + 1 + }, + set: { isPushed in + guard !isPushed else { return } + guard stack.wrappedValue.count > index + 1 else { return } + stack.wrappedValue = Array(stack.wrappedValue.prefix(index + 1)) + } + ) + } + } + + @ViewBuilder + private var pushingView: some View { + switch self { + case .end: + EmptyView() + case .view(let view, _, _, _): + view + } + } + + @ViewBuilder + private var pushedView: some View { + switch self { + case .end: + EmptyView() + case .view(_, let node, _, _): + node + } + } + + var body: some View { + pushingView + .background( + NavigationLink(destination: pushedView, isActive: isActiveBinding, label: EmptyView.init) + // NOTE: If this is set to true, there are some unexpected + // pops when pushing more than 3 screens. + .isDetailLinkiOS() + .hidden() + ) + } +} + +extension NavigationLink { + func isDetailLinkiOS() -> some View { + #if os(iOS) + isDetailLink(false) + #else + self + #endif + } +} diff --git a/Sources/FlowStacks/Presentation/PFlow.swift b/Sources/FlowStacks/Presentation/PFlow.swift new file mode 100644 index 0000000..68afb11 --- /dev/null +++ b/Sources/FlowStacks/Presentation/PFlow.swift @@ -0,0 +1,34 @@ +import Foundation + +/// A thin wrapper around an array. PFlow provides some convenience methods for presenting and dismissing. +@available(*, deprecated, message: "Use [Route] instead") +public struct PFlow { + /// The underlying array of screens. + public internal(set) var array: [(Screen, PresentationOptions)] + + /// Initializes the stack with an empty array of screens. + public init() { + self.array = [] + } + + /// Initializes the stack with a single root screen. + /// - Parameter root: The root screen. + public init(root: Screen) { + self.array = [(root, .init(style: .default))] + } + + /// Pushes a new screen onto the stack. + /// - Parameter screen: The screen to present. + /// - Parameter style: How to present the screen. + /// - Parameter onDismiss: Called when the presented view is later + /// dismissed. + public mutating func present(_ screen: Screen, style: PresentationStyle = .default, onDismiss: (() -> Void)? = nil) { + let options = PresentationOptions(style: style, onDismiss: onDismiss) + array.append((screen, options)) + } + + /// Dismisses the top screen off the stack. + public mutating func dismiss() { + array = array.dropLast() + } +} diff --git a/Sources/FlowStacks/Presentation/PStack.swift b/Sources/FlowStacks/Presentation/PStack.swift new file mode 100644 index 0000000..9b17fe2 --- /dev/null +++ b/Sources/FlowStacks/Presentation/PStack.swift @@ -0,0 +1,67 @@ +import Foundation +import SwiftUI + +/// PStack maintains a stack of presented views for use within a `PresentationView`. +@available(*, deprecated, message: "Use Router instead. It is capable of both navigation and presentation.") +public struct PStack: View { + /// The array of screens that represents the presentation stack. + @Binding var stack: [(Screen, PresentationOptions)] + + /// A closure that builds a `ScreenView` from a `Screen`. + @ViewBuilder var buildView: (Screen) -> ScreenView + + /// Initializer for creating an PStack using a binding to an array of screens. + /// - Parameters: + /// - stack: A binding to an array of screens. + /// - buildView: A closure that builds a `ScreenView` from a `Screen`. + public init(_ stack: Binding<[(Screen, PresentationOptions)]>, @ViewBuilder buildView: @escaping (Screen) -> ScreenView) { + self._stack = stack + self.buildView = buildView + } + + public var body: some View { + stack + .enumerated() + .reversed() + .reduce(PresentationNode.end) { presentedNode, new in + let (index, (screen, options)) = new + return PresentationNode.view( + buildView(screen), + presenting: presentedNode, + stack: $stack, + index: index, + options: options + ) + } + } +} + +public extension PStack { + /// Convenience initializer for creating an PStack using a binding to a `PFlow` + /// of screens. + /// - Parameters: + /// - stack: A binding to a PFlow of screens. + /// - buildView: A closure that builds a `ScreenView` from a `Screen`. + init(_ stack: Binding>, @ViewBuilder buildView: @escaping (Screen) -> ScreenView) { + self._stack = Binding( + get: { stack.wrappedValue.array }, + set: { stack.wrappedValue.array = $0 } + ) + self.buildView = buildView + } +} + +public extension PStack { + /// Convenience initializer for creating an PStack using a binding to an array + /// of screens, using the default presentation style. + /// - Parameters: + /// - stack: A binding to an array of screens. + /// - buildView: A closure that builds a `ScreenView` from a `Screen`. + init(_ stack: Binding<[Screen]>, @ViewBuilder buildView: @escaping (Screen) -> ScreenView) { + self._stack = Binding( + get: { stack.wrappedValue.map { ($0, .init(style: .default, onDismiss: nil)) } }, + set: { stack.wrappedValue = $0.map { $0.0 } } + ) + self.buildView = buildView + } +} diff --git a/Sources/FlowStacks/Presentation/PresentationNode.swift b/Sources/FlowStacks/Presentation/PresentationNode.swift new file mode 100644 index 0000000..531cb3b --- /dev/null +++ b/Sources/FlowStacks/Presentation/PresentationNode.swift @@ -0,0 +1,88 @@ +import Foundation +import SwiftUI + +/// A view that represents a linked list of views, each presenting the next in +/// a presentation stack. +indirect enum PresentationNode: View { + case view(V, presenting: PresentationNode, stack: Binding<[(Screen, PresentationOptions)]>, index: Int, options: PresentationOptions) + case end + + private var isActiveBinding: Binding { + switch self { + case .end, .view(_, .end, _, _, _): + return .constant(false) + case .view(_, .view, let stack, let index, _): + return Binding( + get: { + stack.wrappedValue.count > index + 1 + }, + set: { isPresented in + guard !isPresented else { return } + guard stack.wrappedValue.count > index + 1 else { return } + stack.wrappedValue = Array(stack.wrappedValue.prefix(index + 1)) + } + ) + } + } + + @ViewBuilder + private var presentingView: some View { + switch self { + case .end: + EmptyView() + case .view(let view, _, _, _, _): + view + } + } + + @ViewBuilder + private var presentedView: some View { + switch self { + case .end: + EmptyView() + case .view(_, let node, _, _, _): + node + } + } + + private var presentedOptions: PresentationOptions? { + switch self { + case .end, .view(_, .end, _, _, _): + return nil + case .view(_, .view(_, _, _, _, let options), _, _, _): + return options + } + } + + var body: some View { +#if os(macOS) + presentingView + .sheet( + isPresented: isActiveBinding, + onDismiss: nil, + content: { presentedView } + ) +#else + if #available(iOS 14.0, tvOS 14.0, *) { + presentingView + .fullScreenCover( + isPresented: presentedOptions?.style == .fullScreenCover ? isActiveBinding : .constant(false), + onDismiss: presentedOptions?.onDismiss, + content: { presentedView } + ) + .sheet( + isPresented: presentedOptions?.style == .sheet ? isActiveBinding : .constant(false), + onDismiss: presentedOptions?.onDismiss, + content: { presentedView } + ) + } else { + presentingView + .sheet( + isPresented: isActiveBinding, + onDismiss: nil, + content: { presentedView } + ) + } +#endif + } +} diff --git a/Sources/FlowStacks/Presentation/PresentationOptions.swift b/Sources/FlowStacks/Presentation/PresentationOptions.swift new file mode 100644 index 0000000..f52a78d --- /dev/null +++ b/Sources/FlowStacks/Presentation/PresentationOptions.swift @@ -0,0 +1,27 @@ +import Foundation + +/// A struct representing the options for how to present a view. +@available(*, deprecated, message: "No longer needed for Router") +public struct PresentationOptions { + public let style: PresentationStyle + public var onDismiss: (() -> Void)? + + public init(style: PresentationStyle, onDismiss: (() -> Void)? = nil) { + self.style = style + self.onDismiss = onDismiss + } +} + +/// Represents a style for how a view should be presented. +@available(*, deprecated, message: "No longer needed for Router") +public enum PresentationStyle { + #if os(macOS) + case sheet + #else + @available(iOS 14.0, tvOS 14.0, *) + case fullScreenCover + case sheet + #endif + + public static let `default`: PresentationStyle = .sheet +}