Skip to content

Commit

Permalink
Merge pull request #3 from davidscheutz/swiftui-ios16
Browse files Browse the repository at this point in the history
Pure SwiftUI Implementation for iOS16
  • Loading branch information
davidscheutz authored Jun 18, 2023
2 parents 4b1d98a + bd6f280 commit 6cddf63
Show file tree
Hide file tree
Showing 8 changed files with 320 additions and 147 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Foundation

enum MyDestination {
enum MyDestination: Hashable {
case detail
case sheet
}
18 changes: 12 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
...
Expand Down Expand Up @@ -116,23 +120,25 @@ It's recommended to use a dedicated object that encapsulates your app navigation
InfiniteNavigation.create(
initialStack: Array<YOUR_VIEW_TYPE>,
navAction: AnyPublisher<NavAction<YOUR_VIEW_TYPE>>,
environment: YOUR_ENVIRONMENT,
environments: [YOUR_ENVIRONMENT_OBJECTS],
viewBuilder: (YOUR_VIEW_TYPE) -> AnyView,
homeView: () -> some View
)
```

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
Expand Down
14 changes: 14 additions & 0 deletions Sources/InfiniteNavigation/Helper/Collection+Safe.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
18 changes: 18 additions & 0 deletions Sources/InfiniteNavigation/Helper/NavigationPath+.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import SwiftUI

@available(iOS 16.0, *)
extension NavigationPath {
mutating func append<T: Hashable>(contentsOf paths: any Sequence<T>) {
paths.forEach { append($0) }
}

mutating func removeLastSafely() {
guard !isEmpty else { return }
removeLast()
}

mutating func removeAll() {
guard !isEmpty else { return }
removeLast(count)
}
}
9 changes: 9 additions & 0 deletions Sources/InfiniteNavigation/Helper/View+Environments.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
216 changes: 97 additions & 119 deletions Sources/InfiniteNavigation/InfiniteNavContainer.swift
Original file line number Diff line number Diff line change
@@ -1,145 +1,123 @@
import Combine
import SwiftUI
import UIKit
import Combine

public struct InfiniteNavContainer<Root: SwiftUI.View, View>: UIViewControllerRepresentable {

public typealias UIViewControllerType = UINavigationController
public typealias NavDestinationPublisher = AnyPublisher<NavAction<View>, 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<Destination: Hashable, Root: View>: View {

public typealias NavDestinationPublisher = AnyPublisher<NavAction<Destination>, 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<T: SwiftUI.View>(_ view: T) -> UIHostingController<AnyView> {
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<Sheet>) -> AnyView {
NavigationStack(path: sheet.path) {
wrap(sheet.wrappedValue.source())
.navigationDestination(for: Destination.self) { wrap(viewBuilder($0)) }
.fullScreenCover(item: Binding<Sheet?>(
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
}
}
Loading

0 comments on commit 6cddf63

Please sign in to comment.