Skip to content

Commit

Permalink
push_screens_v1 (#29)
Browse files Browse the repository at this point in the history
Co-authored-by: Nick <[email protected]>
  • Loading branch information
SwiftfulThinking and swiftfulthinking-llc committed Sep 4, 2023
1 parent 59c2752 commit 7de396a
Show file tree
Hide file tree
Showing 5 changed files with 335 additions and 43 deletions.
69 changes: 58 additions & 11 deletions Sources/SwiftfulRouting/Core/AnyRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,34 +8,81 @@
import Foundation
import SwiftUI

// Note (possible SwiftUI bug?):
// Do not conform to Equatable here. It causes the @State property wrapper to monitor Equatable value instead of Hashable value
// so didSegue changing value does not update the View (I think)
public struct AnyRoute: Identifiable {
public let id = UUID().uuidString
let segue: SegueOption
let destination: (AnyRouter) -> any View

public init(_ segue: SegueOption, destination: @escaping (AnyRouter) -> any View) {
self.segue = segue
self.destination = destination
}

static var root: AnyRoute = {
var route = AnyRoute(.push) { router in
AnyView(Text("Root"))
}
return route
}()
}

/// Type-erased Router with convenience methods.
public struct AnyRouter: Router {
private let object: any Router

public init(object: any Router) {
self.object = object
}

public var screens: [AnyDestination] {
object.screens
}

/// Show any screen via Push (NavigationLink), Sheet, or FullScreenCover.
public func showScreen<T>(_ option: SegueOption, @ViewBuilder destination: @escaping (AnyRouter) -> T) where T : View {
object.showScreen(option, destination: destination)
object.showScreens([AnyRoute(option, destination: destination)])
}

/// Show any screen via Push (NavigationLink), Sheet, or FullScreenCover.
public func showScreen(_ route: AnyRoute) {
object.showScreens([route])
}

/// Show a flow of screens, segueing to the first route immediately. The following routes can be accessed via 'showNextScreen()'.
public func showScreens(_ routes: [AnyRoute]) {
object.showScreens(routes)
}

/// Shows the next screen set in the current screen flow. This would have been set previously via showScreens().
public func showNextScreen() throws {
try object.showNextScreen()
}

/// If there is a next screen in the current screen flow, go to it. Otherwise, flow is complete and dismiss the environment.
public func showNextScreenOrDismissEnvironment() {
do {
try showNextScreen()
} catch {
dismissEnvironment()
}
}

/// Dismiss the top-most presented environment (this would be the top-most sheet or fullScreenCover).
public func dismissEnvironment() {
object.dismissEnvironment()
}

/// Dismiss the top-most presented screen in the current Environment. Same as calling presentationMode.wrappedValue.dismiss().
public func dismissScreen() {
object.dismissScreen()
}

/// Dismiss all NavigationLinks in NavigationStack heirarchy.
/// Push a stack of screens and show the last one immediately.
@available(iOS 16, *)
public func pushScreens(destinations: [(AnyRouter) -> any View]) {
object.pushScreens(destinations: destinations)
public func pushScreenStack(destinations: [(AnyRouter) -> any View]) {
object.pushScreenStack(destinations: destinations)
}

/// Show a resizeable sheet on top of the current context.
@available(iOS 16, *)
public func showResizableSheet<V>(sheetDetents: Set<PresentationDetentTransformable>, selection: Binding<PresentationDetentTransformable>?, showDragIndicator: Bool, destination: @escaping (AnyRouter) -> V) where V : View {
object.showResizableSheet(sheetDetents: sheetDetents, selection: selection, showDragIndicator: showDragIndicator, destination: destination)
Expand All @@ -45,8 +92,8 @@ public struct AnyRouter: Router {
///
/// WARNING: Does not dismiss Sheet or FullScreenCover.
@available(iOS 16, *)
public func popToRoot() {
object.popToRoot()
public func dismissScreenStack() {
object.dismissScreenStack()
}

/// Show any Alert or ConfirmationDialog.
Expand Down
11 changes: 6 additions & 5 deletions Sources/SwiftfulRouting/Core/Router.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,16 @@ import SwiftUI
import Combine

public protocol Router {
var screens: [AnyDestination] { get }
func showScreen<V:View>(_ option: SegueOption, @ViewBuilder destination: @escaping (AnyRouter) -> V)
func showScreens(_ routes: [AnyRoute])
func showNextScreen() throws
func dismissScreen()
func dismissEnvironment()
@available(iOS 16, *)
func dismissScreenStack()

@available(iOS 16, *)
func pushScreens(destinations: [(AnyRouter) -> any View])
func pushScreenStack(destinations: [(AnyRouter) -> any View])

@available(iOS 16, *)
func popToRoot()

@available(iOS 16, *)
func showResizableSheet<V:View>(sheetDetents: Set<PresentationDetentTransformable>, selection: Binding<PresentationDetentTransformable>?, showDragIndicator: Bool, @ViewBuilder destination: @escaping (AnyRouter) -> V)
Expand Down
141 changes: 115 additions & 26 deletions Sources/SwiftfulRouting/Core/RouterView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,20 @@ public struct RouterView<T:View>: View, Router {
let addNavigationView: Bool
let content: (AnyRouter) -> T

// Routable methods
@State private var route: AnyRoute

// Segues
@State private var segueOption: SegueOption = .push
@State public var screens: [AnyDestination] = []
@State private var previousScreens: [AnyDestination] = []

/// routes are all routes set on heirarchy, included ones that are in front of current screen
@State private var routes: [[AnyRoute]]
@State private var environmentRouter: Router?

// Binding to view stack from previous RouterViews
@Binding private var screenStack: [AnyDestination]
@State private var screenStackCount: Int = 0

// Configuration for resizable sheet on iOS 16+
// TODO: Move resizable sheet modifiers into a struct "SheetConfiguration"
Expand All @@ -61,11 +68,21 @@ public struct RouterView<T:View>: View, Router {
@State private var modalConfiguration: ModalConfiguration = .default
@State private var modal: AnyDestination? = nil

public init(addNavigationView: Bool = true, screens: (Binding<[AnyDestination]>)? = nil, @ViewBuilder content: @escaping (AnyRouter) -> T) {
public init(addNavigationView: Bool = true, screens: (Binding<[AnyDestination]>)? = nil, route: AnyRoute? = nil, routes: [[AnyRoute]]? = nil, environmentRouter: Router? = nil, @ViewBuilder content: @escaping (AnyRouter) -> T) {
self.addNavigationView = addNavigationView
self._screenStack = screens ?? .constant([])
self._screenStackCount = State(wrappedValue: (screens?.wrappedValue.count ?? 0))

if let route {
self._route = State(wrappedValue: route)
self._routes = State(wrappedValue: routes ?? [])
} else {
let root = AnyRoute.root
self._route = State(wrappedValue: root)
self._routes = State(wrappedValue: [[root]])
}
self._environmentRouter = State(wrappedValue: environmentRouter)
self.content = content

}

public var body: some View {
Expand All @@ -78,21 +95,92 @@ public struct RouterView<T:View>: View, Router {
sheetDetents: sheetDetents,
sheetSelection: sheetSelection,
sheetSelectionEnabled: sheetSelectionEnabled,
showDragIndicator: showDragIndicator)
showDragIndicator: showDragIndicator
)
.onFirstAppear(perform: setEnvironmentRouterIfNeeded)
}
.showingAlert(option: alertOption, item: $alert)
.showingModal(configuration: modalConfiguration, item: $modal)
}

public func showScreen<V:View>(_ option: SegueOption, @ViewBuilder destination: @escaping (AnyRouter) -> V) {
self.segueOption = option
private func setEnvironmentRouterIfNeeded() {
// If this is a new environnent (ie. .sheet or .fullScreenCover) then no previous environmentRouter will be passed in
// Therefore, this is the start of a new environment and this router will be the environmentRouter
// The first screen should not have one
if environmentRouter == nil {
environmentRouter = self
}
}

/// Show a flow of screens, segueing to the first route immediately. The following routes can be accessed via 'showNextScreen()'.
public func showScreens(_ newRoutes: [AnyRoute]) {
guard let route = newRoutes.first else {
assertionFailure("SwiftfulRouting: No routes found.")
return
}

routes.append(newRoutes)

let destination = { router in
AnyView(route.destination(router))
}

showScreen(route, destination: destination)
}

public func dismissEnvironment() {
if let environmentRouter {
environmentRouter.dismissScreen()
} else {
dismissScreen()
}
}

private enum RoutableError: LocalizedError {
case noNextScreenSet
}

public func showNextScreen() throws {
guard
let currentFlow = routes.last(where: { flow in
return flow.contains(where: { $0.id == route.id })
}),
let nextRoute = currentFlow.firstAfter(route)
else {
throw RoutableError.noNextScreenSet
}

let destination = { router in
AnyView(nextRoute.destination(router))
}

showScreen(nextRoute, destination: destination)
}

private func removeRoutes(route: AnyRoute) {
// After segueing, remove that flow from local routes
// Loop backwards, if have not yet found the current flow...
// it's a future flow or the current flow and should be removed now
for (index, item) in routes.enumerated().reversed() {
routes.remove(at: index)

if item.contains(where: { $0.id == route.id }) {
return
}
}
}

// if isEnvironmentRouter & screens no longer includes this screen, then environment did dismiss?

private func showScreen<V:View>(_ route: AnyRoute, @ViewBuilder destination: @escaping (AnyRouter) -> V) {
self.segueOption = route.segue

if option != .push {
if route.segue != .push {
// Add new Navigation
// Sheet and FullScreenCover enter new Environments and require a new Navigation to be added.
// Sheet and FullScreenCover enter new Environments and require a new Navigation to be added, and don't need an environmentRouter because they will host the environment.
self.sheetDetents = [.large]
self.sheetSelectionEnabled = false
self.screens.append(AnyDestination(RouterView<V>(addNavigationView: true, screens: nil, content: destination)))
self.screens.append(AnyDestination(RouterView<V>(addNavigationView: true, screens: nil, route: route, routes: routes, environmentRouter: nil, content: destination)))
} else {
// Using existing Navigation
// Push continues in the existing Environment and uses the existing Navigation
Expand All @@ -102,22 +190,24 @@ public struct RouterView<T:View>: View, Router {
if #available(iOS 16, *) {
if screenStack.isEmpty {
// We are in the root Router and should start building on $screens
self.screens.append(AnyDestination(RouterView<V>(addNavigationView: false, screens: $screens, content: destination)))
self.screens.append(AnyDestination(RouterView<V>(addNavigationView: false, screens: $screens, route: route, routes: routes, environmentRouter: environmentRouter, content: destination)))
} else {
// We are not in the root Router and should continue off of $screenStack
self.screenStack.append(AnyDestination(RouterView<V>(addNavigationView: false, screens: $screenStack, content: destination)))
self.screenStack.append(AnyDestination(RouterView<V>(addNavigationView: false, screens: $screenStack, route: route, routes: routes, environmentRouter: environmentRouter, content: destination)))
}

// iOS 14/15 uses NavigationView and can only push 1 view at a time
} else {
// Push a new screen and don't pass view stack to child view (screens == nil)
self.screens.append(AnyDestination(RouterView<V>(addNavigationView: false, screens: nil, content: destination)))
self.screens.append(AnyDestination(RouterView<V>(addNavigationView: false, screens: nil, route: route, routes: routes, environmentRouter: environmentRouter, content: destination)))
}
}

removeRoutes(route: route)
}

@available(iOS 16, *)
public func pushScreens(destinations: [(AnyRouter) -> any View]) {
public func pushScreenStack(destinations: [(AnyRouter) -> any View]) {
// iOS 16 supports NavigationStack, which can push a stack of views and increment an existing view stack
self.segueOption = .push

Expand All @@ -127,14 +217,20 @@ public struct RouterView<T:View>: View, Router {

var localStack: [AnyDestination] = []
let bindingStack = screenStack.isEmpty ? $screens : $screenStack
var localRoutes: [AnyRoute] = []

destinations.forEach { destination in
let view = AnyDestination(RouterView<AnyView>(addNavigationView: false, screens: bindingStack, content: { router in
let route = AnyRoute(.push, destination: destination)
localRoutes.append(route)

let allRoutes: [[AnyRoute]] = routes + [localRoutes]

let view = AnyDestination(RouterView<AnyView>(addNavigationView: false, screens: bindingStack, route: route, routes: allRoutes, environmentRouter: environmentRouter, content: { router in
AnyView(destination(router))
}))
localStack.append(view)
}

if screenStack.isEmpty {
self.screens.append(contentsOf: localStack)
} else {
Expand All @@ -156,15 +252,15 @@ public struct RouterView<T:View>: View, Router {
self.sheetSelectionEnabled = false
}

self.screens.append(AnyDestination(RouterView<V>(addNavigationView: true, screens: nil, content: destination)))
self.screens.append(AnyDestination(RouterView<V>(addNavigationView: true, screens: nil, route: route, routes: routes, environmentRouter: environmentRouter, content: destination)))
}

public func dismissScreen() {
self.presentationMode.wrappedValue.dismiss()
}

@available(iOS 16, *)
public func popToRoot() {
public func dismissScreenStack() {
self.screens = []
self.screenStack = []
}
Expand Down Expand Up @@ -231,7 +327,8 @@ extension View {
sheetDetents: Set<PresentationDetentTransformable>,
sheetSelection: Binding<PresentationDetentTransformable>,
sheetSelectionEnabled: Bool,
showDragIndicator: Bool) -> some View {
showDragIndicator: Bool
) -> some View {
if #available(iOS 14, *) {
self
.modifier(NavigationLinkViewModifier(
Expand Down Expand Up @@ -279,12 +376,4 @@ extension View {
modifier(ModalViewModifier(configuration: configuration, item: item))
}

@ViewBuilder func onChangeIfiOS15<E:Equatable>(of value: E, perform: @escaping (E) -> Void) -> some View {
if #available(iOS 15, *) {
self
.onChange(of: value, perform: perform)
} else {
self
}
}
}
Loading

0 comments on commit 7de396a

Please sign in to comment.