Skip to content

Commit

Permalink
Adds FlowNavigator environment object
Browse files Browse the repository at this point in the history
Adds a FlowNavigator environment object for easier navigation from deeply
nested views.
  • Loading branch information
johnpatrickmorgan committed Jun 5, 2023
1 parent 88a9913 commit c20db17
Show file tree
Hide file tree
Showing 8 changed files with 364 additions and 88 deletions.
77 changes: 34 additions & 43 deletions FlowStacksApp/Shared/NumberCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,38 +15,23 @@ extension Screen: ExpressibleByIntegerLiteral, Hashable {

struct NumberCoordinator: View {
@State var routes: Routes<Screen> = [.root(0, embedInNavigationView: true)]

var randomRoutes: [Route<Screen>] {
let options: [[Route<Screen>]] = [
[.root(0, embedInNavigationView: true)],
[.root(0, embedInNavigationView: true), .push(1), .push(2), .push(3), .sheet(4, embedInNavigationView: true), .push(5)],
[.root(0, embedInNavigationView: true), .push(1), .push(2), .push(3)],
[.root(0, embedInNavigationView: true), .push(1), .sheet(2, embedInNavigationView: true), .push(3), .sheet(4, embedInNavigationView: true), .push(5)],
[.root(0, embedInNavigationView: true), .sheet(1, embedInNavigationView: true), .cover(2, embedInNavigationView: true), .push(3), .sheet(4, embedInNavigationView: true), .push(5)]
[.root(0, embedInNavigationView: true), .sheet(1, embedInNavigationView: true), .cover(2, embedInNavigationView: true), .push(3), .sheet(4, embedInNavigationView: true), .push(5)],
]
return options.randomElement()!
}

var body: some View {
Router($routes) { $screen, index in
Router($routes) { $screen, _ in
if let number = Binding(unwrapping: $screen, case: /Screen.number) {
NumberView(
number: number,
presentDoubleCover: { number in
routes.presentCover(.number(number * 2), embedInNavigationView: true)
},
presentDoubleSheet: { number in
routes.presentSheet(.number(number * 2), embedInNavigationView: true)
},
pushNext: { number in
routes.push(.number(number + 1))
},
goBack: index != 0 ? { routes.goBack() } : nil,
goBackToRoot: {
$routes.withDelaysIfUnsupported {
$0.goBackToRoot()
}
},
goRandom: {
$routes.withDelaysIfUnsupported {
$0 = randomRoutes
Expand All @@ -62,7 +47,7 @@ struct NumberCoordinator: View {
follow(deeplink)
}
}

private func follow(_ deeplink: Deeplink) {
guard case .numberCoordinator(let link) = deeplink else {
return
Expand All @@ -80,27 +65,29 @@ struct NumberCoordinator: View {

struct NumberView: View {
@Binding var number: Int

let presentDoubleCover: (Int) -> Void
let presentDoubleSheet: (Int) -> Void
let pushNext: (Int) -> Void
let goBack: (() -> Void)?
let goBackToRoot: () -> Void
@EnvironmentObject var navigator: FlowNavigator<Screen>

let goRandom: (() -> Void)?

var body: some View {
VStack(spacing: 8) {
Stepper("\(number)", value: $number)
Button("Present Double (cover)") { presentDoubleCover(number) }
Button("Present Double (sheet)") { presentDoubleSheet(number) }
Button("Push next") { pushNext(number) }
Button("Present Double (cover)") {
navigator.presentCover(.number(number * 2), embedInNavigationView: true)
}
Button("Present Double (sheet)") {
navigator.presentSheet(.number(number * 2), embedInNavigationView: true)
}
Button("Push next") {
navigator.push(.number(number + 1))
}
if let goRandom = goRandom {
Button("Go random", action: goRandom)
}
if let goBack = goBack {
Button("Go back", action: goBack)
if navigator.routes.count > 1 {
Button("Go back") { navigator.goBack() }
Button("Go back to root") { navigator.goBackToRoot() }
}
Button("Go back to root", action: goBackToRoot)
}
.padding()
.navigationTitle("\(number)")
Expand All @@ -109,17 +96,21 @@ struct NumberView: View {

// Included so that the same example code can be used for macOS too.
#if os(macOS)
extension Route {

static func cover(_ screen: Screen, embedInNavigationView: Bool = false) -> Route {
sheet(screen, embedInNavigationView: embedInNavigationView)
extension Route {
static func cover(_ screen: Screen, embedInNavigationView: Bool = false) -> Route {
sheet(screen, embedInNavigationView: embedInNavigationView)
}
}

extension Array where Element: RouteProtocol {
mutating func presentCover(_ screen: Element.Screen, embedInNavigationView: Bool = false) {
presentSheet(screen, embedInNavigationView: embedInNavigationView)
}
}
}

extension Array where Element: RouteProtocol {

mutating func presentCover(_ screen: Element.Screen, embedInNavigationView: Bool = false) {
presentSheet(screen, embedInNavigationView: embedInNavigationView)
extension FlowNavigator {
func presentCover(_ screen: Screen, embedInNavigationView: Bool = false) {
presentSheet(screen, embedInNavigationView: embedInNavigationView)
}
}
}
#endif
49 changes: 28 additions & 21 deletions FlowStacksApp/Shared/ShowingCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,36 @@ import SwiftUINavigation

struct ShowingCoordinator: View {
@State var routes: Routes<Int> = []

var body: some View {
Button("Show 42", action: { routes.push(42) })
.showing($routes, embedInNavigationView: true) { $number, index in
NumberView(
number: $number,
presentDoubleCover: { number in
routes.presentCover(number * 2 , embedInNavigationView: true)
},
presentDoubleSheet: { number in
routes.presentSheet(number * 2 , embedInNavigationView: true)
},
pushNext: { number in
routes.push(number + 1)
},
goBack: { routes.goBack() },
goBackToRoot: {
$routes.withDelaysIfUnsupported {
$0 = []
}
},
goRandom: nil
)
.showing($routes, embedInNavigationView: true) { $number, _ in
ShownNumberView(number: $number)
}
}
}

struct ShownNumberView: View {
@Binding var number: Int
@EnvironmentObject var navigator: FlowNavigator<Int>

var body: some View {
VStack(spacing: 8) {
Stepper("\(number)", value: $number)
Button("Present Double (cover)") {
navigator.presentCover(number * 2, embedInNavigationView: true)
}
Button("Present Double (sheet)") {
navigator.presentSheet(number * 2, embedInNavigationView: true)
}
Button("Push next") {
navigator.push(number + 1)
}
if !navigator.routes.isEmpty {
Button("Go back") { navigator.goBack() }
}
}
.padding()
.navigationTitle("\(number)")
}
}
36 changes: 18 additions & 18 deletions FlowStacksApp/Shared/VMCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,25 @@ class VMCoordinatorViewModel: ObservableObject {
case numberList(NumberListView.ViewModel)
case numberDetail(NumberDetailView.ViewModel)
}

@Published var routes: Routes<Screen> = []

init() {
routes.presentSheet(.home(.init(pickANumberSelected: showNumberList)))
}

func showNumberList() {
routes.presentSheet(.numberList(.init(numberSelected: showNumber, cancel: dismiss)))
}

func showNumber(_ number: Int) {
routes.presentSheet(.numberDetail(.init(number: number, cancel: goBackToRoot)))
}

func dismiss() {
routes.goBack()
}

func goBackToRoot() {
RouteSteps.withDelaysIfUnsupported(self, \.routes) {
$0.goBackToRoot()
Expand All @@ -35,7 +35,7 @@ class VMCoordinatorViewModel: ObservableObject {

struct VMCoordinator: View {
@ObservedObject var viewModel = VMCoordinatorViewModel()

var body: some View {
Router($viewModel.routes) { screen, _ in
switch screen {
Expand All @@ -55,14 +55,14 @@ struct VMCoordinator: View {
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)
Expand All @@ -76,15 +76,15 @@ struct NumberListView: View {
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
Expand All @@ -100,21 +100,21 @@ struct NumberDetailView: View {
class ViewModel: ObservableObject {
let number: Int
let cancel: () -> Void

init(number: Int, cancel: @escaping () -> Void) {
self.number = number
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("Go back to root", action: viewModel.cancel)
Button("PresentationMode Dismiss") {
presentationMode.wrappedValue.dismiss()
}
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,25 @@ The routes array can be managed using normal Array methods, but a number of conv

If the user taps the back button, the routes array will be automatically updated to reflect the new navigation state. Navigating back with an edge swipe gesture or via a long-press gesture on the back button will also update the routes array automatically, as will swiping to dismiss a sheet.

### FlowNavigator

The example above passes closures to screen views for presenting new screens and going back. However, passing closures can soon become unwieldy if you need to pass them down through multiple layers of views. Instead, a `FlowNavigator` object is available through the environment, giving access to the current routes array and the ability to update it via all its convenience methods. It can be accessed via the environment from any view within the router, e.g.:

```swift
@EnvironmentObject var navigator: FlowNavigator<ScreenType>

var body: some View {
VStack {
Button("View detail") {
navigator.push(.detail)
}
Button("Go back to root") {
navigator.goBackToRoot()
}
}
}
```

### Bindings

The Router can be configured to work with a binding to the screen state, rather than just a read-only value - just add `$` before the screen argument in the view-builder closure. The screen itself can then be responsible for updating its state within the routes array. Normally an enum is used to represent the screen, so it might be necessary to further extract the associated value for a particular screen as a binding. You can do that using the [SwiftUINavigation](https://github.com/pointfreeco/swiftui-navigation) library, which includes a number of helpful Binding transformations for optional and enum state, e.g.:
Expand Down
Loading

0 comments on commit c20db17

Please sign in to comment.