From 0e5668765a8c309e14eb2a211447eba6598bb5ed Mon Sep 17 00:00:00 2001 From: Jimmy S Date: Sat, 15 Oct 2022 09:35:20 +0530 Subject: [PATCH 1/9] Use UINavigationController under the hood --- Sources/UIPilot/UIPilot.swift | 210 +++++++++++++--------------------- 1 file changed, 80 insertions(+), 130 deletions(-) diff --git a/Sources/UIPilot/UIPilot.swift b/Sources/UIPilot/UIPilot.swift index 2910b94..488cace 100644 --- a/Sources/UIPilot/UIPilot.swift +++ b/Sources/UIPilot/UIPilot.swift @@ -1,44 +1,53 @@ import SwiftUI import Combine -public class UIPilot: ObservableObject { +public class UIPilot: ObservableObject { private let logger: Logger + + private var _routes: [T] = [] - @Published var paths: [UIPilotPath] = [] - - public var stack: [T] { - return paths.map { $0.route } + var routes: [T] { + return _routes } + + var onPush: ((T) -> Void)? + var onPopLast: ((Int, Bool) -> Void)? + - public init(initial: T, debug: Bool = false) { + public init(initial: T? = nil, debug: Bool = false) { logger = debug ? DebugLog() : EmptyLog() logger.log("UIPilot - Pilot Initialized.") - push(initial) + + if let initial = initial { + push(initial) + } } public func push(_ route: T) { logger.log("UIPilot - Pushing \(route) route.") - self.paths.append(UIPilotPath(route: route)) + self._routes.append(route) + self.onPush?(route) } - public func pop() { - if !self.paths.isEmpty { - logger.log("UIPilot - Route popped.") - self.paths.removeLast() + public func pop(animated: Bool = true) { + if !self._routes.isEmpty { + let popped = self._routes.removeLast() + logger.log("UIPilot - \(popped) route popped.") + onPopLast?(1, animated) } } - public func popTo(_ route: T, inclusive: Bool = false) { + public func popTo(_ route: T, inclusive: Bool = false, animated: Bool = true) { logger.log("UIPilot: Popping route \(route).") - if paths.isEmpty { + if _routes.isEmpty { logger.log("UIPilot - Path is empty.") return } - guard var found = paths.firstIndex(where: { $0.route == route }) else { + guard var found = _routes.lastIndex(where: { $0 == route }) else { logger.log("UIPilot - Route not found.") return } @@ -47,146 +56,87 @@ public class UIPilot: ObservableObject { found += 1 } - let numToPop = (found..) { - if paths.count > 1 - && path.id == self.paths[self.paths.count - 2].id { - self.pop() - } - } - -} - -struct UIPilotPath: Equatable, Hashable { - let route: T - let id: String = UUID().uuidString - - static func == (lhs: UIPilotPath, rhs: UIPilotPath) -> Bool { - return lhs.route == rhs.route && lhs.id == rhs.id - } - - func hash(into hasher: inout Hasher) { - hasher.combine(id) - } -} - -struct PathView: View { - private let content: Screen - @ObservedObject var state: PathViewState - - public init(_ content: Screen, state: PathViewState) { - self.content = content - self.state = state - } - - var body: some View { - VStack { - NavigationLink(destination: self.state.next, isActive: self.$state.isActive) { - EmptyView() - } -#if os(iOS) - .isDetailLink(false) -#endif - content - } - } -} - -class PathViewState: ObservableObject { - @Published - var isActive: Bool = false { - didSet { - if !isActive && next != nil { - onPop() - } - } - } - - @Published - var next: PathView? { - didSet { - isActive = next != nil + public func onSystemPop() { + if !self._routes.isEmpty { + let popped = self._routes.removeLast() + logger.log("UIPilot - \(popped) route popped by system") } } - var onPop: () -> Void - - init(next: PathView? = nil, onPop: @escaping () -> Void = {}) { - self.next = next - self.onPop = onPop - } } -public struct UIPilotHost: View { +public struct UIPilotHost: View { @ObservedObject - private var pilot: UIPilot + var pilot: UIPilot @ViewBuilder - private let routeMap: (T) -> Screen + var routeMap: (T) -> Screen - @State - private var viewGenerator = ViewGenerator() - public init(_ pilot: UIPilot, @ViewBuilder _ routeMap: @escaping (T) -> Screen) { self.pilot = pilot self.routeMap = routeMap - self.viewGenerator.onPop = { path in - pilot.systemPop(path: path) - } } public var body: some View { - NavigationView { - viewGenerator.build(pilot.paths, routeMap) - } -#if !os(macOS) - .navigationViewStyle(.stack) -#endif - .environmentObject(pilot) + NavigationControllerHost(uipilot: pilot, routeMap: routeMap) + .environmentObject(pilot) } } -class ViewGenerator: ObservableObject { - var onPop: ((UIPilotPath) -> Void)? = nil - - private var pathViews = [UIPilotPath: Screen]() - - func build( - _ paths: [UIPilotPath], - @ViewBuilder _ routeMap: (T) -> Screen) -> PathView? { - - recycleViews(paths) - - var current: PathView? - for path in paths.reversed() { - let view = pathViews[path] ?? routeMap(path.route) - pathViews[path] = view - - let content = PathView(view, state: PathViewState()) - - content.state.next = current - content.state.onPop = current == nil ? {} : { [weak self] in - if let self = self { - self.onPop?(path) - } +struct NavigationControllerHost: UIViewControllerRepresentable { + let uipilot: UIPilot + @ViewBuilder + let routeMap: (T) -> Screen + + func makeUIViewController(context: Context) -> UINavigationController { + let navigation = PopAwareUINavigationController() + navigation.popHandler = { + uipilot.onSystemPop() + } + + for path in uipilot.routes { + navigation.pushViewController( + UIHostingController(rootView: routeMap(path)), animated: false) + } + + uipilot.onPush = { route in + navigation.pushViewController( + UIHostingController(rootView: routeMap(route)), animated: true) + } + + uipilot.onPopLast = { numToPop, animated in + if numToPop == navigation.viewControllers.count { + navigation.viewControllers = [] + } else { + let popTo = navigation.viewControllers[navigation.viewControllers.count - numToPop - 1] + navigation.popToViewController(popTo, animated: animated) } - current = content } - return current + + return navigation } + + func updateUIViewController(_ uiViewController: UINavigationController, context: Context) { + + } + + typealias UIViewControllerType = UINavigationController +} - private func recycleViews(_ paths: [UIPilotPath]){ - var pathViews = self.pathViews - for key in pathViews.keys { - if !paths.contains(key) { - pathViews.removeValue(forKey: key) - } - } - self.pathViews = pathViews +class PopAwareUINavigationController: UINavigationController +{ + var popHandler: (() -> Void)? + + override func popViewController(animated: Bool) -> UIViewController? + { + popHandler?() + return super.popViewController(animated: animated) } } From 7baaffefe1113f448981d171797c04b35a040acf Mon Sep 17 00:00:00 2001 From: Jimmy S Date: Sat, 15 Oct 2022 09:43:03 +0530 Subject: [PATCH 2/9] Remove Hashable requirement --- Sources/UIPilot/UIPilot.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/UIPilot/UIPilot.swift b/Sources/UIPilot/UIPilot.swift index 488cace..9cbf711 100644 --- a/Sources/UIPilot/UIPilot.swift +++ b/Sources/UIPilot/UIPilot.swift @@ -1,7 +1,7 @@ import SwiftUI import Combine -public class UIPilot: ObservableObject { +public class UIPilot: ObservableObject { private let logger: Logger @@ -71,7 +71,7 @@ public class UIPilot: ObservableObject { } -public struct UIPilotHost: View { +public struct UIPilotHost: View { @ObservedObject var pilot: UIPilot @@ -89,7 +89,7 @@ public struct UIPilotHost: View { } } -struct NavigationControllerHost: UIViewControllerRepresentable { +struct NavigationControllerHost: UIViewControllerRepresentable { let uipilot: UIPilot @ViewBuilder let routeMap: (T) -> Screen From 48157b0eeffdf30b9d99f8a82ff8f61cf5757eb4 Mon Sep 17 00:00:00 2001 From: Jimmy S Date: Mon, 17 Oct 2022 12:27:00 +0530 Subject: [PATCH 3/9] Add modifiers for title and isHidden --- Sources/UIPilot/UIPilot.swift | 200 ++++++++++++++++++++++++++++++---- 1 file changed, 181 insertions(+), 19 deletions(-) diff --git a/Sources/UIPilot/UIPilot.swift b/Sources/UIPilot/UIPilot.swift index 9cbf711..03631a8 100644 --- a/Sources/UIPilot/UIPilot.swift +++ b/Sources/UIPilot/UIPilot.swift @@ -14,7 +14,6 @@ public class UIPilot: ObservableObject { var onPush: ((T) -> Void)? var onPopLast: ((Int, Bool) -> Void)? - public init(initial: T? = nil, debug: Bool = false) { logger = debug ? DebugLog() : EmptyLog() logger.log("UIPilot - Pilot Initialized.") @@ -68,15 +67,16 @@ public class UIPilot: ObservableObject { logger.log("UIPilot - \(popped) route popped by system") } } - } public struct UIPilotHost: View { - - @ObservedObject - var pilot: UIPilot + + @StateObject + var navigationStyle = NavigationStyle() + + let pilot: UIPilot @ViewBuilder - var routeMap: (T) -> Screen + let routeMap: (T) -> Screen public init(_ pilot: UIPilot, @ViewBuilder _ routeMap: @escaping (T) -> Screen) { self.pilot = pilot @@ -84,30 +84,45 @@ public struct UIPilotHost: View { } public var body: some View { - NavigationControllerHost(uipilot: pilot, routeMap: routeMap) - .environmentObject(pilot) + NavigationControllerHost( + navTitle: navigationStyle.title, + navHidden: navigationStyle.isHidden, + uipilot: pilot, + routeMap: routeMap + ) + .environmentObject(pilot) + .environment(\.uipNavigationStyle, navigationStyle) + } } struct NavigationControllerHost: UIViewControllerRepresentable { + + let navTitle: String + let navHidden: Bool + let uipilot: UIPilot + @ViewBuilder - let routeMap: (T) -> Screen + var routeMap: (T) -> Screen func makeUIViewController(context: Context) -> UINavigationController { let navigation = PopAwareUINavigationController() + navigation.popHandler = { uipilot.onSystemPop() } for path in uipilot.routes { navigation.pushViewController( - UIHostingController(rootView: routeMap(path)), animated: false) + UIHostingController(rootView: routeMap(path)), animated: true + ) } uipilot.onPush = { route in navigation.pushViewController( - UIHostingController(rootView: routeMap(route)), animated: true) + UIHostingController(rootView: routeMap(route)), animated: true + ) } uipilot.onPopLast = { numToPop, animated in @@ -118,25 +133,172 @@ struct NavigationControllerHost: UIViewControllerRep navigation.popToViewController(popTo, animated: animated) } } - + return navigation } - func updateUIViewController(_ uiViewController: UINavigationController, context: Context) { - + func updateUIViewController(_ navigation: UINavigationController, context: Context) { + navigation.topViewController?.navigationItem.title = navTitle + navigation.navigationBar.isHidden = navHidden + } + + static func dismantleUIViewController(_ navigation: UINavigationController, coordinator: ()) { + navigation.viewControllers = [] + (navigation as! PopAwareUINavigationController).popHandler = nil } typealias UIViewControllerType = UINavigationController } -class PopAwareUINavigationController: UINavigationController +class PopAwareUINavigationController: UINavigationController, UINavigationControllerDelegate { var popHandler: (() -> Void)? + + var popGestureBeganController: UIViewController? + + override func viewDidLoad() { + super.viewDidLoad() + self.delegate = self + } + + func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) { + + if let coordinator = viewController.transitionCoordinator { + coordinator.notifyWhenInteractionChanges { [weak self] (context) in + if !context.isCancelled { + self?.popHandler?() + } + } + } + } +} + + +extension View { + public func uipNavigationBarHidden(_ hidden: Bool) -> some View { + return modifier(NavHiddenModifier(isHidden: hidden)) + } + + public func uipNavigationTitle(_ title: String) -> some View { + return modifier(NavTitleModifier(title: title)) + } + +} + +private struct NavigationTitleKey: EnvironmentKey { + static let defaultValue: Binding = .constant("") +} + +private struct NavigationHiddenKey: EnvironmentKey { + static let defaultValue: Binding = .constant(false) +} + +private struct NavigationStyleKey: EnvironmentKey { + static let defaultValue: NavigationStyle = NavigationStyle() +} - override func popViewController(animated: Bool) -> UIViewController? - { - popHandler?() - return super.popViewController(animated: animated) + +extension EnvironmentValues { + + var uipNavigationStyle: NavigationStyle { + get { self[NavigationStyleKey.self] } + set { + self[NavigationStyleKey.self] = newValue + } + } + + var upNavigationHidden: Binding { + get { self[NavigationHiddenKey.self] } + set { + self[NavigationHiddenKey.self] = newValue + } + } + + var upNavigationTitle: Binding { + get { self[NavigationTitleKey.self] } + set { + self[NavigationTitleKey.self] = newValue + } + } +} + +class NavigationStyle: ObservableObject { + @Published + var isHidden = false + var isHiddenOwner: String = "" + + @Published + var title = "" + var titleOwner: String = "" +} + +struct NavTitleModifier: ViewModifier { + let title: String + + @State var id = UUID().uuidString + @State var initialValue: String = "" + + @Environment(\.uipNavigationStyle) var navStyle + + init(title: String) { + self.title = title + } + + func body(content: Content) -> some View { + + // In case where title change after onAppear + if navStyle.titleOwner == id && navStyle.title != title { + DispatchQueue.main.async { + navStyle.title = title + } + } + + return content + .onAppear { + initialValue = navStyle.title + + navStyle.title = title + navStyle.titleOwner = id + } + .onDisappear { + if navStyle.titleOwner == id { + navStyle.title = initialValue + navStyle.titleOwner = "" + } + } + } +} + +struct NavHiddenModifier: ViewModifier { + let isHidden: Bool + + @State var id = UUID().uuidString + @State var initialValue: Bool = false + + @Environment(\.uipNavigationStyle) var navStyle + + func body(content: Content) -> some View { + + // In case where isHidden change after onAppear + if navStyle.isHiddenOwner == id && navStyle.isHidden != isHidden { + DispatchQueue.main.async { + navStyle.isHidden = isHidden + } + } + + return content + .onAppear { + initialValue = navStyle.isHidden + + navStyle.isHidden = isHidden + navStyle.isHiddenOwner = id + } + .onDisappear { + if navStyle.isHiddenOwner == id { + navStyle.isHidden = initialValue + navStyle.isHiddenOwner = "" + } + } } } From c013511cde162280c2ef0be20eb49d15e323a163 Mon Sep 17 00:00:00 2001 From: Jimmy S Date: Mon, 17 Oct 2022 13:37:35 +0530 Subject: [PATCH 4/9] Make routes public --- Sources/UIPilot/UIPilot.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/UIPilot/UIPilot.swift b/Sources/UIPilot/UIPilot.swift index 03631a8..fbdb251 100644 --- a/Sources/UIPilot/UIPilot.swift +++ b/Sources/UIPilot/UIPilot.swift @@ -7,7 +7,7 @@ public class UIPilot: ObservableObject { private var _routes: [T] = [] - var routes: [T] { + public var routes: [T] { return _routes } From 7ca90a348e653a07393e9e7d067631279819e0f0 Mon Sep 17 00:00:00 2001 From: Jimmy S Date: Mon, 17 Oct 2022 13:40:06 +0530 Subject: [PATCH 5/9] Bump version on pods --- UIPilot.podspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UIPilot.podspec b/UIPilot.podspec index a9333e4..5a9512a 100644 --- a/UIPilot.podspec +++ b/UIPilot.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "UIPilot" - s.version = "1.3.1" + s.version = "2.0.0" s.summary = "The missing type-safe, SwiftUI navigation library." s.description = <<-DESC From b510b1bec1d4ba2a459c0777e5aba8cef12baefc Mon Sep 17 00:00:00 2001 From: Jimmy S Date: Tue, 15 Nov 2022 11:52:47 +0530 Subject: [PATCH 6/9] Fix navigation pop issue --- Sources/UIPilot/UIPilot.swift | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/Sources/UIPilot/UIPilot.swift b/Sources/UIPilot/UIPilot.swift index fbdb251..992ad2e 100644 --- a/Sources/UIPilot/UIPilot.swift +++ b/Sources/UIPilot/UIPilot.swift @@ -161,13 +161,11 @@ class PopAwareUINavigationController: UINavigationController, UINavigationContro self.delegate = self } - func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) { - + func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) { if let coordinator = viewController.transitionCoordinator { - coordinator.notifyWhenInteractionChanges { [weak self] (context) in - if !context.isCancelled { - self?.popHandler?() - } + if let dismissedViewController = coordinator.viewController(forKey: .from), + !navigationController.viewControllers.contains(dismissedViewController) { + self.popHandler?() } } } From a79c30b9e5286dfc2d8129275b18a68ce44f43dc Mon Sep 17 00:00:00 2001 From: Jimmy Sanghani Date: Tue, 15 Nov 2022 12:07:10 +0530 Subject: [PATCH 7/9] Prepare Readme for 2.0.0 --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 74b57ed..7f0c8c5 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,10 @@ Please have a look at the [article](https://blog.canopas.com/swiftui-complex-nav ## Installation +Version 1.x - Uses SwiftUI `NavigationView` underneath. + +Version 2.x - Uses UIKit `UINavigationController` underneath (Recommended). + ### Swift Package Manager The [Swift Package Manager](https://swift.org/package-manager/) is a tool for automating the distribution of Swift code and is integrated into the `swift` compiler. @@ -38,7 +42,7 @@ Once you have your Swift package set up, adding UIPilot as a dependency is as ea ```swift dependencies: [ - .package(url: "https://github.com/canopas/UIPilot.git", .upToNextMajor(from: "1.3.1")) + .package(url: "https://github.com/canopas/UIPilot.git", .upToNextMajor(from: "2.0.0")) ] ``` @@ -47,7 +51,7 @@ dependencies: [ [CocoaPods][] is a dependency manager for Cocoa projects. For usage and installation instructions, visit their website. To integrate UIPilot into your Xcode project using CocoaPods, specify it in your Podfile: target 'YourAppName' do - pod 'UIPilot', '~> 1.3.1' + pod 'UIPilot', '~> 2.0.0' end [CocoaPods]: https://cocoapods.org From 0933620c457957bf3f36fa57092f3ea409540e9b Mon Sep 17 00:00:00 2001 From: Jimmy Sanghani Date: Tue, 15 Nov 2022 12:07:52 +0530 Subject: [PATCH 8/9] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7f0c8c5..4fb787c 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Please have a look at the [article](https://blog.canopas.com/swiftui-complex-nav Version 1.x - Uses SwiftUI `NavigationView` underneath. -Version 2.x - Uses UIKit `UINavigationController` underneath (Recommended). +Version 2.x - Uses UIKit `UINavigationController` underneath (recommended). ### Swift Package Manager From 475489b02196ae91fda2e8441c9350def92efa21 Mon Sep 17 00:00:00 2001 From: Jimmy Sanghani Date: Tue, 15 Nov 2022 12:11:29 +0530 Subject: [PATCH 9/9] Prepare website for 2.x --- docs/index.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/index.md b/docs/index.md index b57367d..2170ce6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -309,6 +309,11 @@ Please have a look at the [article](https://blog.canopas.com/swiftui-complex-nav ## Installation +Version 1.x - Uses SwiftUI NavigationView underneath. + +Version 2.x - Uses UIKit UINavigationController underneath (recommended). + + ### Swift Package Manager The [Swift Package Manager](https://swift.org/package-manager/) is a tool for automating the distribution of Swift code and is integrated into the `swift` compiler.