diff --git a/FlowStacks.podspec b/FlowStacks.podspec new file mode 100644 index 0000000..3ce41e6 --- /dev/null +++ b/FlowStacks.podspec @@ -0,0 +1,31 @@ +Pod::Spec.new do |s| + + s.name = 'FlowStacks' + s.version = '0.0.7' + s.summary = 'Hoist navigation state into a coordinator in SwiftUI.' + + s.description = <<-DESC +FlowStacks allows you to hoist SwiftUI navigation or presentation state into a +higher-level coordinator view. The coordinator pattern allows you to write isolated views +that have zero knowledge of their context within an app. + DESC + + s.homepage = 'https://github.com/johnpatrickmorgan/FlowStacks' + s.license = { :type => 'MIT', :file => 'LICENSE' } + s.author = { 'johnpatrickmorgan' => 'johnpatrickmorganuk@gmail.com' } + s.source = { :git => 'https://github.com/johnpatrickmorgan/FlowStacks.git', :tag => s.version.to_s } + s.social_media_url = 'https://twitter.com/jpmmusic' + + + s.ios.deployment_target = '13.0' + s.osx.deployment_target = '11.0' + s.watchos.deployment_target = '7.0' + s.tvos.deployment_target = '13.0' + + s.swift_version = '5.4' + + s.source_files = 'Sources/**/*' + + s.frameworks = 'Foundation', 'SwiftUI' + +end diff --git a/Package.swift b/FlowStacks/Package.swift similarity index 55% rename from Package.swift rename to FlowStacks/Package.swift index 17a1f22..9f4b110 100644 --- a/Package.swift +++ b/FlowStacks/Package.swift @@ -1,24 +1,24 @@ -// swift-tools-version:5.3 +// swift-tools-version:5.5 import PackageDescription let package = Package( - name: "NStack", + name: "FlowStacks", platforms: [ .iOS(.v13), .watchOS(.v7), .macOS(.v11), .tvOS(.v13), ], products: [ .library( - name: "NStack", - targets: ["NStack"]), + name: "FlowStacks", + targets: ["FlowStacks"]), ], dependencies: [], targets: [ .target( - name: "NStack", + name: "FlowStacks", dependencies: []), .testTarget( - name: "NStackTests", - dependencies: ["NStack"]), + name: "FlowStacksTests", + dependencies: ["FlowStacks"]), ] ) diff --git a/README.md b/FlowStacks/README.md similarity index 59% rename from README.md rename to FlowStacks/README.md index 09fbaec..8541a15 100644 --- a/README.md +++ b/FlowStacks/README.md @@ -1,6 +1,6 @@ -# NStack +# FlowStacks -An NStack allows you to manage SwiftUI navigation state with a single stack property. This makes it easy to hoist that state into a high-level view, such as a coordinator. The coordinator pattern allows you to write isolated views that have zero knowledge of their context within the navigation flow of an app. +*FlowStacks* allow you to manage complex SwiftUI navigation and presentation state with a single piece of state. This makes it easy to hoist that state into a high-level coordinator view. The coordinator pattern allows you to write isolated views that have zero knowledge of their context within the navigation flow of an app. ## Usage @@ -14,11 +14,11 @@ enum Screen { } ``` -You can then add a stack of these screens as a single property in a coordinator view. In the body of the coordinator view, return a `NavigationView` containing an `NStack`. The `NStack` should be initialized with a binding to the stack, and a `ViewBuilder` closure. The closure builds a view from a given screen, e.g.: +You can then add a flow representing a stack of these screens (`NFlow` for navigation, or `PFlow` for presentation) as a single property in a coordinator view. In the body of the coordinator view, initialize an `NStack` (or `PStack` for presentation) with a binding to the flow, and a `ViewBuilder` closure. The closure builds a view for a given screen, e.g.: ```swift struct AppCoordinator: View { - @State var stack = Stack(root: .home) + @State var flow = NFlow(root: .home) var body: some View { NavigationView { @@ -36,24 +36,24 @@ struct AppCoordinator: View { } private func showNumberList() { - stack.push(.numberList) + flow.push(.numberList) } private func showNumber(_ number: Int) { - stack.push(.number(number)) + flow.push(.number(number)) } private func pop() { - stack.pop() + flow.pop() } private func popToRoot() { - stack.popToRoot() + flow.popToRoot() } } ``` -As you can see, pushing a new view is as easy as `stack.push(...)` and popping can be achieved with `stack.pop()`. There are convenience methods for popping to the root and popping to a specific screen in the stack. +As you can see, pushing a new view is as easy as `stack.push(...)` and popping can be achieved with `stack.pop()`. There are convenience methods for popping to the root or popping to a specific screen in the stack. If the user taps the back button, the stack will be automatically updated to reflect its new state. Navigating back with an edge swipe gesture or long-press gesture on the back button will also update the stack. @@ -77,35 +77,35 @@ enum Screen { } class AppCoordinatorViewModel: ObservableObject { - @Published var stack = Stack() + @Published var flow = NFlow() init() { - stack.push(.home(.init(onGoTapped: showNumberList))) + flow.push(.home(.init(onGoTapped: showNumberList))) } func showNumberList() { - stack.push(.numberList(.init(onNumberSelected: showNumber, cancel: pop))) + flow.push(.numberList(.init(onNumberSelected: showNumber, cancel: pop))) } func showNumber(_ number: Int) { - stack.push(.numberDetail(.init(number: number, cancel: popToRoot))) + flow.push(.numberDetail(.init(number: number, cancel: popToRoot))) } func pop() { - stack.pop() + flow.pop() } func popToRoot() { - stack.popToRoot() + flow.popToRoot() } } struct AppCoordinator: View { - @ObservedObject var viewModel = AppCoordinatorViewModel() + @ObservedObject var viewModel: AppCoordinatorViewModel var body: some View { NavigationView { - NStack($viewModel.stack) { screen in + NStack($viewModel.flow) { screen in switch screen { case .home(let viewModel): HomeView(viewModel: viewModel) @@ -120,10 +120,20 @@ struct AppCoordinator: View { } ``` -## Limitations +## Presentation + +In order to use presentation instead of navigation for showing and unshowing screens, the examples above can be re-written using a `PStack` instead of an `NStack`, and a `PFlow` instead of an `NFlow`. The `push` methods become `present` and the `pop` methods become `dismiss`. The presnt method allows you to customize the presentation style and add a callback on dismissal: -Currently, SwiftUI does not support increasing the navigation stack by more than one in a single update. The `Stack` object will throw an assertion failure if you try to do so. +```swift +flow.present(detailView, style: .fullScreenCover) { + print("Detail dismissed") +} +``` ## How does it work? -This [blog post](https://johnpatrickmorgan.github.io/2021/07/03/NStack/) outlines how NStack translates the stack of screens into a hierarchy of views and `NavigationLink`s. +This [blog post](https://johnpatrickmorgan.github.io/2021/07/03/NStack/) outlines how `NStack` translates the stack of screens into a hierarchy of views and `NavigationLink`s. `PStack` uses a similar approach. + +## Limitations + +SwiftUI does not allow more than one screen to be pushed, presented or dismissed in one update, though it is possible to pop any number of views in one update. `NFlow` and `PFlow` only expose methods to make updates that are supported in SwiftUI. diff --git a/Sources/NStack/Stack.swift b/FlowStacks/Sources/FlowStacks/Navigation/NFlow.swift similarity index 94% rename from Sources/NStack/Stack.swift rename to FlowStacks/Sources/FlowStacks/Navigation/NFlow.swift index 702828f..5349f3e 100644 --- a/Sources/NStack/Stack.swift +++ b/FlowStacks/Sources/FlowStacks/Navigation/NFlow.swift @@ -1,9 +1,9 @@ import Foundation -/// A thin wrapper around an array. Stack provides some convenience methods for pushing +/// 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. -public struct Stack { +public struct NFlow { /// The underlying array of screens. public internal(set) var array: [Screen] @@ -61,7 +61,7 @@ public struct Stack { /// 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 replaceStack(with newArray: [Screen]) { + public mutating func replaceNFlow(with newArray: [Screen]) { assert( newArray.count <= array.count + 1, """ @@ -77,7 +77,7 @@ public struct Stack { } } -extension Stack where Screen: Equatable { +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, @@ -90,7 +90,7 @@ extension Stack where Screen: Equatable { } } -extension Stack where Screen: Identifiable { +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. diff --git a/Sources/NStack/NStack.swift b/FlowStacks/Sources/FlowStacks/Navigation/NStack.swift similarity index 96% rename from Sources/NStack/NStack.swift rename to FlowStacks/Sources/FlowStacks/Navigation/NStack.swift index a1b68c3..dd23320 100644 --- a/Sources/NStack/NStack.swift +++ b/FlowStacks/Sources/FlowStacks/Navigation/NStack.swift @@ -42,7 +42,7 @@ public extension NStack { /// - Parameters: /// - stack: A binding to a stack of screens. /// - buildView: A closure that builds a `ScreenView` from a `Screen`. - init(_ stack: Binding>, @ViewBuilder buildView: @escaping (Screen) -> ScreenView) { + init(_ stack: Binding>, @ViewBuilder buildView: @escaping (Screen) -> ScreenView) { self._stack = Binding( get: { stack.wrappedValue.array }, set: { stack.wrappedValue.array = $0 } diff --git a/Sources/NStack/NavigationNode.swift b/FlowStacks/Sources/FlowStacks/Navigation/NavigationNode.swift similarity index 95% rename from Sources/NStack/NavigationNode.swift rename to FlowStacks/Sources/FlowStacks/Navigation/NavigationNode.swift index d946273..0f1f566 100644 --- a/Sources/NStack/NavigationNode.swift +++ b/FlowStacks/Sources/FlowStacks/Navigation/NavigationNode.swift @@ -19,6 +19,7 @@ indirect enum NavigationNode: View { }, set: { isPushed in guard !isPushed else { return } + guard stack.wrappedValue.count > index + 1 else { return } stack.wrappedValue = Array(stack.wrappedValue.prefix(index + 1)) } ) diff --git a/FlowStacks/Sources/FlowStacks/Presentation/PFlow.swift b/FlowStacks/Sources/FlowStacks/Presentation/PFlow.swift new file mode 100644 index 0000000..5b06055 --- /dev/null +++ b/FlowStacks/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. +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/FlowStacks/Sources/FlowStacks/Presentation/PStack.swift b/FlowStacks/Sources/FlowStacks/Presentation/PStack.swift new file mode 100644 index 0000000..cfcdfd3 --- /dev/null +++ b/FlowStacks/Sources/FlowStacks/Presentation/PStack.swift @@ -0,0 +1,53 @@ +import Foundation +import SwiftUI + +/// PStack maintains a stack of presented views for use within a `PresentationView`. +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 `Stack` + /// of screens. + /// - Parameters: + /// - stack: A binding to a stack 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 + } +} diff --git a/FlowStacks/Sources/FlowStacks/Presentation/PresentationNode.swift b/FlowStacks/Sources/FlowStacks/Presentation/PresentationNode.swift new file mode 100644 index 0000000..0771826 --- /dev/null +++ b/FlowStacks/Sources/FlowStacks/Presentation/PresentationNode.swift @@ -0,0 +1,80 @@ +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: { + return 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 #available(iOS 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 } + ) + } + } +} diff --git a/FlowStacks/Sources/FlowStacks/Presentation/PresentationOptions.swift b/FlowStacks/Sources/FlowStacks/Presentation/PresentationOptions.swift new file mode 100644 index 0000000..7ca4c13 --- /dev/null +++ b/FlowStacks/Sources/FlowStacks/Presentation/PresentationOptions.swift @@ -0,0 +1,23 @@ +import Foundation + +/// A struct representing the options for how to present a view. +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. +public enum PresentationStyle { + + @available(iOS 14.0, *) + case fullScreenCover + case sheet + + public static let `default`: PresentationStyle = .sheet +} diff --git a/FlowStacks/Tests/FlowStacksTests/StacksTests.swift b/FlowStacks/Tests/FlowStacksTests/StacksTests.swift new file mode 100644 index 0000000..b578edf --- /dev/null +++ b/FlowStacks/Tests/FlowStacksTests/StacksTests.swift @@ -0,0 +1,6 @@ +import XCTest +@testable import FlowStacks + +final class StacksTests: XCTestCase { + +} diff --git a/FlowStacksApp/Assets.xcassets/AccentColor.colorset/Contents.json b/FlowStacksApp/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/FlowStacksApp/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FlowStacksApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/FlowStacksApp/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..c136eaf --- /dev/null +++ b/FlowStacksApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,148 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FlowStacksApp/Assets.xcassets/Contents.json b/FlowStacksApp/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/FlowStacksApp/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FlowStacksApp/Shared/FlowStacksApp.swift b/FlowStacksApp/Shared/FlowStacksApp.swift new file mode 100644 index 0000000..4f06a9c --- /dev/null +++ b/FlowStacksApp/Shared/FlowStacksApp.swift @@ -0,0 +1,16 @@ +import SwiftUI + +@main +struct FlowStacksApp: App { + var body: some Scene { + WindowGroup { + NumberPCoordinator() +// MixedCoordinator() +// VMCoordinator(viewModel: .init()) +// VMPCoordinator(viewModel: .init()) +// NavigationView { +// NumberNCoordinator() +// } + } + } +} diff --git a/FlowStacksApp/Shared/MixedCoordinator.swift b/FlowStacksApp/Shared/MixedCoordinator.swift new file mode 100644 index 0000000..050da80 --- /dev/null +++ b/FlowStacksApp/Shared/MixedCoordinator.swift @@ -0,0 +1,26 @@ +import SwiftUI +import FlowStacks + +struct MixedCoordinator: View { + enum Screen { + case launch + case vmCoordinator(VMNCoordinatorViewModel) + } + + @State var flow = PFlow(root: .launch) + + var body: some View { + PStack($flow) { screen in + switch screen { + case .launch: + Button("Go", action: showVMCoordinator) + case .vmCoordinator(let vm): + VMNCoordinator(viewModel: vm) + } + } + } + + private func showVMCoordinator() { + flow.present(.vmCoordinator(.init(showMore: showVMCoordinator))) + } +} diff --git a/FlowStacksApp/Shared/NumberNCoordinator.swift b/FlowStacksApp/Shared/NumberNCoordinator.swift new file mode 100644 index 0000000..fd18efc --- /dev/null +++ b/FlowStacksApp/Shared/NumberNCoordinator.swift @@ -0,0 +1,33 @@ +import SwiftUI +import FlowStacks + +struct NumberNCoordinator: View { + @State var flow = NFlow(root: 0) + + var body: some View { + NStack($flow) { screen in + VStack(spacing: 8) { + Text("Screen \(screen)") + HStack(spacing: 8) { + Button("<-", action: showPrevious) + Button("->", action: showNext) + } + } + } + } + + private func showPrevious() { + flow.pop() + } + + private func showNext() { + let index = flow.array.count + flow.push(index) + } +} + +struct NumberNCoordinator_Previews: PreviewProvider { + static var previews: some View { + NumberNCoordinator() + } +} diff --git a/FlowStacksApp/Shared/NumberPCoordinator.swift b/FlowStacksApp/Shared/NumberPCoordinator.swift new file mode 100644 index 0000000..8db5bfa --- /dev/null +++ b/FlowStacksApp/Shared/NumberPCoordinator.swift @@ -0,0 +1,40 @@ +import SwiftUI +import FlowStacks + +struct NumberPCoordinator: View { + @State var flow = PFlow(root: 0) + + var body: some View { + PStack($flow) { screen in + VStack(spacing: 8) { + Text("Screen \(screen)") + HStack(spacing: 8) { + Button("<-", action: showPrevious) + Button("->", action: showNext) + } + } + } + } + + private func showPrevious() { + flow.dismiss() + } + + private func showNext() { + flow.present(flow.array.count) + } + + private func dismiss(count: Int = 1) { + guard count > 0 else { return } + flow.dismiss() + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) { + dismiss(count: count - 1) + } + } +} + +struct NumberPCoordinator_Previews: PreviewProvider { + static var previews: some View { + NumberPCoordinator() + } +} diff --git a/FlowStacksApp/Shared/VMNCoordinator.swift b/FlowStacksApp/Shared/VMNCoordinator.swift new file mode 100644 index 0000000..6f19b0c --- /dev/null +++ b/FlowStacksApp/Shared/VMNCoordinator.swift @@ -0,0 +1,132 @@ +import SwiftUI +import FlowStacks + +class VMNCoordinatorViewModel: ObservableObject { + enum Screen { + case home(HomeView.ViewModel) + case numberList(NumberListView.ViewModel) + case numberDetail(NumberDetailView.ViewModel) + } + + @Published var flow = NFlow() + let showMore: () -> Void + + init(showMore: @escaping () -> Void = {}) { + self.showMore = showMore + + flow.push(.home(.init(pickANumberSelected: showNumberList))) + } + + func showNumberList() { + flow.push(.numberList(.init(numberSelected: showNumber, cancel: pop))) + } + + func showNumber(_ number: Int) { + flow.push(.numberDetail(.init(number: number, showMore: showMore, cancel: popToRoot))) + } + + func pop() { + flow.pop() + } + + func popToRoot() { + flow.popToRoot() + } +} + +struct VMNCoordinator: View { + + @ObservedObject var viewModel = VMNCoordinatorViewModel() + + var body: some View { + NavigationView { + NStack($viewModel.flow) { screen in + switch screen { + case .home(let viewModel): + HomeView(viewModel: viewModel) + case .numberList(let viewModel): + NumberListView(viewModel: viewModel) + case .numberDetail(let viewModel): + NumberDetailView(viewModel: viewModel) + } + } + } + } +} + +struct HomeView: View { + + class ViewModel: ObservableObject { + let pickANumberSelected: () -> Void + + init(pickANumberSelected: @escaping () -> Void) { + self.pickANumberSelected = pickANumberSelected + } + } + + @ObservedObject var viewModel: ViewModel + + var body: some View { + VStack { + Button("Pick a number", action: viewModel.pickANumberSelected) + } + .navigationTitle("Home") + } +} + +struct NumberListView: View { + + class ViewModel: ObservableObject { + let numbers = 1...100 + let numberSelected: (Int) -> Void + let cancel: () -> Void + + init(numberSelected: @escaping (Int) -> Void, cancel: @escaping () -> Void) { + self.numberSelected = numberSelected + self.cancel = cancel + } + } + + @ObservedObject var viewModel: ViewModel + + var body: some View { + VStack(spacing: 12) { + List(viewModel.numbers, id: \.self) { number in + Button("\(number)", action: { viewModel.numberSelected(number) }) + } + Button("Go back", action: viewModel.cancel) + } + .navigationTitle("Numbers") + } +} + +struct NumberDetailView: View { + + class ViewModel: ObservableObject { + let number: Int + let showMore: () -> Void + let cancel: () -> Void + + init(number: Int, showMore: @escaping () -> Void = {}, cancel: @escaping () -> Void) { + self.number = number + self.showMore = showMore + self.cancel = cancel + } + } + + @ObservedObject var viewModel: ViewModel + + @Environment(\.presentationMode) var presentationMode + + var body: some View { + VStack { + Text("\(viewModel.number)") + Button("Go back", action: viewModel.cancel) + Button("Show more", action: viewModel.showMore) + Button("Dismiss") { + presentationMode.wrappedValue.dismiss() + } + } + .navigationTitle("Number \(viewModel.number)") + } +} diff --git a/FlowStacksApp/Shared/VMPCoordinator.swift b/FlowStacksApp/Shared/VMPCoordinator.swift new file mode 100644 index 0000000..0806207 --- /dev/null +++ b/FlowStacksApp/Shared/VMPCoordinator.swift @@ -0,0 +1,49 @@ +import SwiftUI +import FlowStacks + +class VMPCoordinatorViewModel: ObservableObject { + enum Screen { + case home(HomeView.ViewModel) + case numberList(NumberListView.ViewModel) + case numberDetail(NumberDetailView.ViewModel) + } + + @Published var flow = PFlow() + let showMore: () -> Void + + init(showMore: @escaping () -> Void = {}) { + self.showMore = showMore + + flow.present(.home(.init(pickANumberSelected: showNumberList))) + } + + func showNumberList() { + flow.present(.numberList(.init(numberSelected: showNumber, cancel: dismiss))) + } + + func showNumber(_ number: Int) { + flow.present(.numberDetail(.init(number: number, showMore: showMore, cancel: dismissToRoot))) + } + + func dismiss() { + flow.dismiss() + } +} + +struct VMPCoordinator: View { + + @ObservedObject var viewModel = VMPCoordinatorViewModel() + + var body: some View { + PStack($viewModel.flow) { screen in + switch screen { + case .home(let viewModel): + HomeView(viewModel: viewModel) + case .numberList(let viewModel): + NumberListView(viewModel: viewModel) + case .numberDetail(let viewModel): + NumberDetailView(viewModel: viewModel) + } + } + } +} diff --git a/Tests/NStackTests/NStackTests.swift b/Tests/NStackTests/NStackTests.swift deleted file mode 100644 index 1b6fd44..0000000 --- a/Tests/NStackTests/NStackTests.swift +++ /dev/null @@ -1,6 +0,0 @@ -import XCTest -@testable import NStack - -final class NStackTests: XCTestCase { - -}