diff --git a/InfiniteNavigationDemo/InfiniteNavigationDemo/Navigation/MyDestination.swift b/InfiniteNavigationDemo/InfiniteNavigationDemo/Navigation/MyDestination.swift index ccbe6b9..669b6c5 100644 --- a/InfiniteNavigationDemo/InfiniteNavigationDemo/Navigation/MyDestination.swift +++ b/InfiniteNavigationDemo/InfiniteNavigationDemo/Navigation/MyDestination.swift @@ -1,6 +1,6 @@ import Foundation -enum MyDestination { +enum MyDestination: Hashable { case detail case sheet } diff --git a/README.md b/README.md index b61d161..cf83341 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,19 @@ # SwiftUI InfiniteNavigation -InfiniteNavigation is a Swift Package for SwiftUI that provides a programmatic navigation framework for unlimited nested navigations using the native push and present transitions. +InfiniteNavigation is a navigation framework for programmatic, unlimited navigation using the native push and present transitions and interactive gestures. Without reinventing the wheel, it leverages existing native iOS features to get the best out of the platform. + +**Native SwiftUI implementation for iOS 16!** + +Additionally, it maintains backward compatibility until iOS 14 by utilizing UIKit. ![demo-gif](https://media.giphy.com/media/v1.Y2lkPTc5MGI3NjExZTQ5ZWNkMDRmMDg0NDkzZDVhZjFkNDhmNjE2ZmU2OTYxODlhYzJjOSZjdD1n/vsAs2ngVs4sdQ01PQ4/giphy.gif) ## Why was InfiniteNavigation created? -The standard navigation framework including `NavigationLink` and `.sheet()` provided by SwiftUI is limited in its nesting capabilities, which can make it difficult to create complex navigation structures in your app. Additionally, existing third-party solutions often come with their own limitations, such as no support for the native iOS swipe back gesture when pushing a view. +In SwiftUI, NavigationStack offers a partial solution for programmatic navigation, but it lacks support for sheets and does not cover iOS 14 or 15. Moreover, the responsibility of navigation should not reside within the UI itself. Views should be free from concerns about which views will be presented next or how they will be presented. -InfiniteNavigation was created to provide a solution that is easy to use and understand, and that leverages the native iOS push and present transitions. With InfiniteNavigation, you can create unlimited nested navigations and navigate programmatically without worrying about limitations or the complexity of other solutions. +Furthermore, existing third-party solutions often come with their own limitations, such as the absence of support for the native iOS swipe back gesture for dismissing views. ## Features @@ -78,7 +82,7 @@ Let's put it all together. 1. Create an `enum` that represents all your view destinations, e.g.: ```swift -enum MyDestination { +enum MyDestination: Hashable { case view1 case view2 ... @@ -116,7 +120,7 @@ It's recommended to use a dedicated object that encapsulates your app navigation InfiniteNavigation.create( initialStack: Array, navAction: AnyPublisher>, - environment: YOUR_ENVIRONMENT, + environments: [YOUR_ENVIRONMENT_OBJECTS], viewBuilder: (YOUR_VIEW_TYPE) -> AnyView, homeView: () -> some View ) @@ -124,15 +128,17 @@ InfiniteNavigation.create( Optional: the `initialStack` will setup the initial view state without an animation, on top of the `homeView`. -Optional: Providing an `environment` to make it available to all views on the stack. +Optional: Providing `environments` to make them available to all views within the navigation stack. 5. Use the `PassthroughSubject` to manipulate the navigation stack. ```swift +// presentation navigateTo.send(.show(.sheet(.view1))) // present View1 as full screen sheet navigateTo.send(.show(.detail(.view2))) // push View2 as detail navigateTo.send(.setStack([.view1, .view2])) // push an entire stack of views +// dismissal navigateTo.send(.dismiss) navigateTo.send(.pop) navigateTo.send(.popToCurrentRoot) // pop to root of current stack, each sheet has it's own stack diff --git a/Sources/InfiniteNavigation/Helper/Collection+Safe.swift b/Sources/InfiniteNavigation/Helper/Collection+Safe.swift new file mode 100644 index 0000000..b904054 --- /dev/null +++ b/Sources/InfiniteNavigation/Helper/Collection+Safe.swift @@ -0,0 +1,14 @@ +import Foundation + +extension Collection { + subscript (safe index: Index) -> Element? { + indices.contains(index) ? self[index] : nil + } +} + +extension Array { + mutating func removeLastSafely() { + guard !isEmpty else { return } + removeLast() + } +} diff --git a/Sources/InfiniteNavigation/Helper/NavigationPath+.swift b/Sources/InfiniteNavigation/Helper/NavigationPath+.swift new file mode 100644 index 0000000..5879e38 --- /dev/null +++ b/Sources/InfiniteNavigation/Helper/NavigationPath+.swift @@ -0,0 +1,18 @@ +import SwiftUI + +@available(iOS 16.0, *) +extension NavigationPath { + mutating func append(contentsOf paths: any Sequence) { + paths.forEach { append($0) } + } + + mutating func removeLastSafely() { + guard !isEmpty else { return } + removeLast() + } + + mutating func removeAll() { + guard !isEmpty else { return } + removeLast(count) + } +} diff --git a/Sources/InfiniteNavigation/Helper/View+Environments.swift b/Sources/InfiniteNavigation/Helper/View+Environments.swift new file mode 100644 index 0000000..d359a62 --- /dev/null +++ b/Sources/InfiniteNavigation/Helper/View+Environments.swift @@ -0,0 +1,9 @@ +import SwiftUI + +extension View { + func apply(environments: Environments) -> AnyView { + var result: any View = self + environments.forEach { result = (result.environmentObject($0) as any View) } + return result.toAnyView() + } +} diff --git a/Sources/InfiniteNavigation/InfiniteNavContainer.swift b/Sources/InfiniteNavigation/InfiniteNavContainer.swift index eddc248..95b8595 100644 --- a/Sources/InfiniteNavigation/InfiniteNavContainer.swift +++ b/Sources/InfiniteNavigation/InfiniteNavContainer.swift @@ -1,145 +1,123 @@ -import Combine import SwiftUI -import UIKit +import Combine -public struct InfiniteNavContainer: UIViewControllerRepresentable { - - public typealias UIViewControllerType = UINavigationController - public typealias NavDestinationPublisher = AnyPublisher, Never> - public typealias NavDestinationBuilder = (View) -> AnyView +@available(iOS 16.0, *) +internal struct Sheet: Identifiable { + let id = UUID().uuidString + var path = NavigationPath() + let source: () -> AnyView +} + +public typealias Environments = [any ObservableObject] + +@available(iOS 16.0, *) +public struct InfiniteNavContainer: View { + + public typealias NavDestinationPublisher = AnyPublisher, Never> + public typealias NavDestinationBuilder = (Destination) -> AnyView - private let coordinator: Coordinator - private let rootResolver: () -> Root - private let root: Root - private let initialStack: [View] + private let navAction: NavDestinationPublisher private let viewBuilder: NavDestinationBuilder + private let environments: Environments + + @State private var stack: [Sheet] - internal init( - initialStack: [View] = [], + init( + initialStack: [Destination] = [], navAction: NavDestinationPublisher, - environments: [any ObservableObject] = [], + environments: Environments = [], viewBuilder: @escaping NavDestinationBuilder, root: @escaping () -> Root ) { - self.initialStack = initialStack - self.rootResolver = root - self.root = root() + _stack = .init(initialValue: [ + .init(path: NavigationPath(initialStack), source: { root().toAnyView() }) + ]) + + self.navAction = navAction self.viewBuilder = viewBuilder - coordinator = .init(navAction: navAction, environments: environments, viewBuilder: viewBuilder) + self.environments = environments } - public func makeUIViewController(context: Context) -> UIViewControllerType { - let vc = UIViewControllerType() - context.coordinator.resolver = { vc } - vc.navigationBar.isHidden = true - vc.viewControllers = [context.coordinator.wrap(root)] + initialStack.map { context.coordinator.wrap(viewBuilder($0)) } - return vc - } - - public func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { - // TODO: figure out if it's still needed... + public var body: some View { + root + .onReceive(navAction.receiveOnMain()) { + switch $0 { + case .show(let action): + switch action { + case .sheet(let destination): stack.append(.init { viewBuilder(destination) }) + case .detail(let destination): mutateCurrentPath { $0.append(destination) } + } + case .setStack(let destinations): mutateCurrentPath { $0.append(contentsOf: destinations) } + case .dismiss: dismiss() + case .pop: mutateCurrentPath { $0.removeLastSafely() } + case .popToCurrentRoot: mutateCurrentPath { $0.removeAll() } + } + } } +} + +@available(iOS 16.0, *) +extension InfiniteNavContainer { - public func makeCoordinator() -> Coordinator { - coordinator + private var root: some View { + guard let root = $stack.first else { + fatalError("Root view unexpectedly missing.") + } + return render(sheet: root) } - public final class Coordinator: NSObject { - - typealias Resolver = () -> UINavigationController - - var resolver: Resolver? - - private let environments: [any ObservableObject] - private let viewBuilder: NavDestinationBuilder - private var navSubscription: AnyCancellable? - - init(navAction: NavDestinationPublisher, environments: [any ObservableObject], viewBuilder: @escaping NavDestinationBuilder) { - self.environments = environments - self.viewBuilder = viewBuilder - - super.init() - - navSubscription = subscribe(to: navAction) - } - - func wrap(_ view: T) -> UIHostingController { - UIHostingController(rootView: view.apply(environments: environments).toAnyView()) - } - - // MARK: Helper - - private func subscribe(to navAction: NavDestinationPublisher) -> AnyCancellable { - navAction - .receiveOnMain() - .sink { [weak self] navAction in - guard let self = self else { return } - switch navAction { - case .show(let destination): - switch destination { - case .detail(let detail): - let vc = self.wrap(self.viewBuilder(detail)) - self.navigationController?.pushViewController(vc, animated: true) - case .sheet(let sheet): - let vc = self.wrap(self.viewBuilder(sheet)) - let navVc = UINavigationController(rootViewController: vc) - navVc.navigationBar.isHidden = true - navVc.modalPresentationStyle = .fullScreen - self.navigationController?.present(navVc, animated: true) - } - case .setStack(let stack): - let vcs = stack.map { self.wrap(self.viewBuilder($0)) } - self.navigationController?.setViewControllers(vcs, animated: true) - case .dismiss: - if let currentSheet = self.currentSheet { - currentSheet.dismiss(animated: true) - } else { - print("⚠️ No sheet exists to dismiss.") - } - case .pop: - // avoid the app from crashing - if self.navigationController?.viewControllers.isEmpty == false { - self.navigationController?.popViewController(animated: true) - } else { - print("⚠️ No detail exists to pop.") - } - case .popToCurrentRoot: - self.navigationController?.popToRootViewController(animated: true) - } + private func render(sheet: Binding) -> AnyView { + NavigationStack(path: sheet.path) { + wrap(sheet.wrappedValue.source()) + .navigationDestination(for: Destination.self) { wrap(viewBuilder($0)) } + .fullScreenCover(item: Binding( + get: { next(after: sheet.wrappedValue) }, + set: { if $0 == nil && stack.last?.id == next(after: sheet.wrappedValue)?.id { dismiss() } } + )) { sheet in + render(sheet: .init( + get: { sheet }, + set: { if $0.id == sheet.id { update(sheet: $0) } } + )) } } - - private var currentSheet: UIViewController? { - var sheet = resolver?().presentedViewController - - while sheet?.presentedViewController != nil { - sheet = sheet?.presentedViewController - } - - return sheet - } - - private var navigationController: UINavigationController? { - currentSheet as? UINavigationController ?? resolver?() + .toAnyView() + } + + private func wrap(_ view: some View) -> some View { + view + .apply(environments: environments) + .navigationBarHidden(true) + } + + private func mutateCurrentPath(_ mutate: (inout NavigationPath) -> Void) { + mutate(&stack[stack.count - 1].path) + } + + private func update(sheet: Sheet) { + guard let index = stack.firstIndex(where: { $0.id == sheet.id }) else { + return } + stack[index] = sheet + } + + private func next(after sheet: Sheet) -> Sheet? { + guard let index = stack.firstIndex(where: { $0.id == sheet.id }) else { return nil } + return stack[safe: index + 1] + } + + private func dismiss() { + stack.removeLastSafely() } } -extension InfiniteNavContainer { - func barTint(color: Color) -> some SwiftUI.View { - if #available(iOS 15.0, *) { - return tint(color).toAnyView() - } else { - // TODO: Fallback on earlier versions - return EmptyView().toAnyView() - } +// enable swipe back gesture when navigation bar is hidden +extension UINavigationController: UIGestureRecognizerDelegate { + override open func viewDidLoad() { + super.viewDidLoad() + interactivePopGestureRecognizer?.delegate = self } -} -extension View { - func apply(environments: [any ObservableObject]) -> AnyView { - var result: any SwiftUI.View = self - environments.forEach { result = (result.environmentObject($0) as any SwiftUI.View) } - return result.toAnyView() + public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + viewControllers.count > 1 } } diff --git a/Sources/InfiniteNavigation/InfiniteNavigation.swift b/Sources/InfiniteNavigation/InfiniteNavigation.swift index 5739185..1b990d3 100644 --- a/Sources/InfiniteNavigation/InfiniteNavigation.swift +++ b/Sources/InfiniteNavigation/InfiniteNavigation.swift @@ -3,37 +3,48 @@ import Combine public struct InfiniteNavigation { - /// Creates an instance with an enviroment object - public static func create( - initialStack: [View] = [], - navAction: AnyPublisher, Never>, + @ViewBuilder + public static func create( + initialStack: [Destination] = [], + navAction: AnyPublisher, Never>, environments: any ObservableObject..., - viewBuilder: @escaping (View) -> AnyView, + viewBuilder: @escaping (Destination) -> AnyView, root: @escaping () -> Root - ) -> some SwiftUI.View { - InfiniteNavContainer( + ) -> some View { + create( initialStack: initialStack, navAction: navAction, environments: environments, viewBuilder: viewBuilder, root: root ) - .ignoresSafeArea() } - /// Creates an instance with an empty enviroment object - public static func create( - initialStack: [View] = [], - navAction: AnyPublisher, Never>, - viewBuilder: @escaping (View) -> AnyView, + @ViewBuilder + public static func create( + initialStack: [Destination] = [], + navAction: AnyPublisher, Never>, + environments: Environments = [], + viewBuilder: @escaping (Destination) -> AnyView, root: @escaping () -> Root - ) -> some SwiftUI.View { - InfiniteNavContainer( - initialStack: initialStack, - navAction: navAction, - viewBuilder: viewBuilder, - root: root - ) - .ignoresSafeArea() + ) -> some View { + if #available(iOS 16.0, *) { + InfiniteNavContainer( + initialStack: initialStack, + navAction: navAction, + environments: environments, + viewBuilder: viewBuilder, + root: root + ) + } else { + LegacyInfiniteNavContainer( + initialStack: initialStack, + navAction: navAction, + environments: environments, + viewBuilder: viewBuilder, + root: root + ) + .ignoresSafeArea() + } } } diff --git a/Sources/InfiniteNavigation/LegacyInfiniteNavContainer.swift b/Sources/InfiniteNavigation/LegacyInfiniteNavContainer.swift new file mode 100644 index 0000000..91f2bee --- /dev/null +++ b/Sources/InfiniteNavigation/LegacyInfiniteNavContainer.swift @@ -0,0 +1,137 @@ +import Combine +import SwiftUI +import UIKit + +public struct LegacyInfiniteNavContainer: UIViewControllerRepresentable { + + public typealias UIViewControllerType = UINavigationController + public typealias NavDestinationPublisher = AnyPublisher, Never> + public typealias NavDestinationBuilder = (View) -> AnyView + + private let coordinator: Coordinator + private let rootResolver: () -> Root + private let root: Root + private let initialStack: [View] + private let viewBuilder: NavDestinationBuilder + + internal init( + initialStack: [View] = [], + navAction: NavDestinationPublisher, + environments: Environments = [], + viewBuilder: @escaping NavDestinationBuilder, + root: @escaping () -> Root + ) { + self.initialStack = initialStack + self.rootResolver = root + self.root = root() + self.viewBuilder = viewBuilder + coordinator = .init(navAction: navAction, environments: environments, viewBuilder: viewBuilder) + } + + public func makeUIViewController(context: Context) -> UIViewControllerType { + let vc = UIViewControllerType() + context.coordinator.resolver = { vc } + vc.navigationBar.isHidden = true + vc.viewControllers = [context.coordinator.wrap(root)] + initialStack.map { context.coordinator.wrap(viewBuilder($0)) } + return vc + } + + public func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { + // TODO: figure out if it's still needed... + } + + public func makeCoordinator() -> Coordinator { + coordinator + } + + public final class Coordinator: NSObject { + + typealias Resolver = () -> UINavigationController + + var resolver: Resolver? + + private let environments: Environments + private let viewBuilder: NavDestinationBuilder + private var navSubscription: AnyCancellable? + + init(navAction: NavDestinationPublisher, environments: Environments, viewBuilder: @escaping NavDestinationBuilder) { + self.environments = environments + self.viewBuilder = viewBuilder + + super.init() + + navSubscription = subscribe(to: navAction) + } + + func wrap(_ view: T) -> UIHostingController { + UIHostingController(rootView: view.apply(environments: environments)) + } + + // MARK: Helper + + private func subscribe(to navAction: NavDestinationPublisher) -> AnyCancellable { + navAction + .receiveOnMain() + .sink { [weak self] navAction in + guard let self = self else { return } + switch navAction { + case .show(let destination): + switch destination { + case .detail(let detail): + let vc = self.wrap(self.viewBuilder(detail)) + self.navigationController?.pushViewController(vc, animated: true) + case .sheet(let sheet): + let vc = self.wrap(self.viewBuilder(sheet)) + let navVc = UINavigationController(rootViewController: vc) + navVc.navigationBar.isHidden = true + navVc.modalPresentationStyle = .fullScreen + self.navigationController?.present(navVc, animated: true) + } + case .setStack(let stack): + let vcs = stack.map { self.wrap(self.viewBuilder($0)) } + self.navigationController?.setViewControllers(vcs, animated: true) + case .dismiss: + if let currentSheet = self.currentSheet { + currentSheet.dismiss(animated: true) + } else { + print("⚠️ No sheet exists to dismiss.") + } + case .pop: + // avoid the app from crashing + if self.navigationController?.viewControllers.isEmpty == false { + self.navigationController?.popViewController(animated: true) + } else { + print("⚠️ No detail exists to pop.") + } + case .popToCurrentRoot: + self.navigationController?.popToRootViewController(animated: true) + } + } + } + + private var currentSheet: UIViewController? { + var sheet = resolver?().presentedViewController + + while sheet?.presentedViewController != nil { + sheet = sheet?.presentedViewController + } + + return sheet + } + + private var navigationController: UINavigationController? { + currentSheet as? UINavigationController ?? resolver?() + } + } +} + +extension LegacyInfiniteNavContainer { + func barTint(color: Color) -> some SwiftUI.View { + if #available(iOS 15.0, *) { + return tint(color).toAnyView() + } else { + // TODO: Fallback on earlier versions + return EmptyView().toAnyView() + } + } +}