From 8f0bff8c220dd27aab68f52714564ca9b5b97cf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Tue, 26 Nov 2024 15:29:46 +0100 Subject: [PATCH 01/12] Implement a custom navigation controller to track the `appear` reason of a visitable view controller. --- .../Visitable/VisitableViewController.swift | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/Source/Turbo/Visitable/VisitableViewController.swift b/Source/Turbo/Visitable/VisitableViewController.swift index 6efb7b1..b1b5aea 100644 --- a/Source/Turbo/Visitable/VisitableViewController.swift +++ b/Source/Turbo/Visitable/VisitableViewController.swift @@ -4,6 +4,14 @@ import WebKit open class VisitableViewController: UIViewController, Visitable { open weak var visitableDelegate: VisitableDelegate? open var visitableURL: URL! + var appearReason: AppearReason = .default + + enum AppearReason { + case `default` + case pushed + case poped + case tabSwitched + } public convenience init(url: URL) { self.init() @@ -36,6 +44,7 @@ open class VisitableViewController: UIViewController, Visitable { override open func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) visitableDelegate?.visitableViewDidDisappear(self) + appearReason = .default } // MARK: Visitable @@ -78,3 +87,67 @@ open class VisitableViewController: UIViewController, Visitable { ]) } } + +open class HotwireNavigationController: UINavigationController { + open override func viewDidLoad() { + super.viewDidLoad() + + super.delegate = delegateProxy + } + + open override var delegate: UINavigationControllerDelegate? { + get { + return delegateProxy.originalDelegate + } + set { + // Update the original delegate in the proxy. + delegateProxy.setDelegate(newValue) + } + } + + open override func pushViewController(_ viewController: UIViewController, animated: Bool) { + if let visitableViewController = viewController as? VisitableViewController { + visitableViewController.appearReason = .pushed + } + + super.pushViewController(viewController, animated: animated) + } + + open override func popViewController(animated: Bool) -> UIViewController? { + let poppedViewController = super.popViewController(animated: animated) + if let visitableViewController = topViewController as? VisitableViewController { + visitableViewController.appearReason = .poped + } + + return poppedViewController + } + + // MARK: Private + private let delegateProxy = HotwireNavigationControllerDelegateProxy() +} + +final class HotwireNavigationControllerDelegateProxy: NSObject, UINavigationControllerDelegate { + weak var originalDelegate: UINavigationControllerDelegate? + + func setDelegate(_ delegate: UINavigationControllerDelegate?) { + self.originalDelegate = delegate + } + + func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) { + if navigationController.tabBarController != nil, + let visitableViewController = viewController as? VisitableViewController, + visitableViewController.appearReason == .default { + visitableViewController.appearReason = .tabSwitched + } + + // Forward to the original delegate. + originalDelegate?.navigationController?(navigationController, willShow: viewController, animated: animated) + } + + func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) { + // Forward to the original delegate. + originalDelegate?.navigationController?(navigationController, didShow: viewController, animated: animated) + } + + // TODO: Add other delegate methods +} From a09aff037702d9111936422a0c031630a0f39687 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Tue, 26 Nov 2024 15:30:17 +0100 Subject: [PATCH 02/12] Set`HotwireNavigationController` as default navigation controller. --- Source/HotwireConfig.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/HotwireConfig.swift b/Source/HotwireConfig.swift index f0360f0..7bbd0c1 100644 --- a/Source/HotwireConfig.swift +++ b/Source/HotwireConfig.swift @@ -38,7 +38,7 @@ public struct HotwireConfig { /// The navigation controller used in `Navigator` for the main and modal stacks. /// Must be a `UINavigationController` or subclass. public var defaultNavigationController: () -> UINavigationController = { - UINavigationController() + HotwireNavigationController() } /// Optionally customize the web views used by each Turbo Session. From f85d94e1933a62f657d10bed3d3d3ac3f3d890eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Tue, 26 Nov 2024 15:30:43 +0100 Subject: [PATCH 03/12] Temporarily configure app to have a tab bar as the root view. --- Demo/SceneController.swift | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/Demo/SceneController.swift b/Demo/SceneController.swift index 5395504..3432a1c 100644 --- a/Demo/SceneController.swift +++ b/Demo/SceneController.swift @@ -24,7 +24,19 @@ final class SceneController: UIResponder { fatalError() } - window.rootViewController = navigator.rootViewController + let tabBarController = UITabBarController() + let secondViewController = SecondViewController() + secondViewController.title = "Second" + + tabBarController.viewControllers = [ + navigator.rootViewController, + secondViewController + ] + window.rootViewController = tabBarController + +// navigator.route(baseURL) + +// window.rootViewController = navigator.rootViewController } // MARK: - Authentication @@ -85,3 +97,10 @@ extension SceneController: NavigatorDelegate { } } } + +class SecondViewController: UIViewController { + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemGreen + } +} From e51b0549faafb0ec5563053b6a7bdae43ffc554e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Tue, 26 Nov 2024 15:41:15 +0100 Subject: [PATCH 04/12] Make AppearReason a property on the Visitable protocol. --- Source/Turbo/Visitable/Visitable.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Source/Turbo/Visitable/Visitable.swift b/Source/Turbo/Visitable/Visitable.swift index ec27bde..5bfda42 100644 --- a/Source/Turbo/Visitable/Visitable.swift +++ b/Source/Turbo/Visitable/Visitable.swift @@ -10,11 +10,19 @@ public protocol VisitableDelegate: AnyObject { func visitableDidRequestRefresh(_ visitable: Visitable) } +public enum AppearReason { + case `default` + case pushed + case poped + case tabSwitched +} + public protocol Visitable: AnyObject { var visitableViewController: UIViewController { get } var visitableDelegate: VisitableDelegate? { get set } var visitableView: VisitableView! { get } var visitableURL: URL! { get } + var appearReason: AppearReason { get } func visitableDidRender() func showVisitableActivityIndicator() From 0e86ff7299680394f955e3b200f9d2ffe6e48feb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Tue, 26 Nov 2024 15:42:50 +0100 Subject: [PATCH 05/12] Use `viewIsAppearing` instead of `viewWillAppear` to have the correct call sequence. --- .../Visitable/VisitableViewController.swift | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/Source/Turbo/Visitable/VisitableViewController.swift b/Source/Turbo/Visitable/VisitableViewController.swift index b1b5aea..a36b970 100644 --- a/Source/Turbo/Visitable/VisitableViewController.swift +++ b/Source/Turbo/Visitable/VisitableViewController.swift @@ -4,14 +4,7 @@ import WebKit open class VisitableViewController: UIViewController, Visitable { open weak var visitableDelegate: VisitableDelegate? open var visitableURL: URL! - var appearReason: AppearReason = .default - - enum AppearReason { - case `default` - case pushed - case poped - case tabSwitched - } + public var appearReason: AppearReason = .default public convenience init(url: URL) { self.init() @@ -26,7 +19,12 @@ open class VisitableViewController: UIViewController, Visitable { installVisitableView() } - override open func viewWillAppear(_ animated: Bool) { +// override open func viewWillAppear(_ animated: Bool) { +// super.viewWillAppear(animated) +// visitableDelegate?.visitableViewWillAppear(self) +// } + + open override func viewIsAppearing(_ animated: Bool) { super.viewWillAppear(animated) visitableDelegate?.visitableViewWillAppear(self) } From 884a3cda511f6e7fb0a473df90bff3fd2e15f149 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Tue, 26 Nov 2024 15:43:17 +0100 Subject: [PATCH 06/12] Handle tab switched in session. --- Source/Turbo/Session/Session.swift | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/Source/Turbo/Session/Session.swift b/Source/Turbo/Session/Session.swift index 98f5172..e6f49f2 100644 --- a/Source/Turbo/Session/Session.swift +++ b/Source/Turbo/Session/Session.swift @@ -234,7 +234,7 @@ extension Session: VisitableDelegate { previousVisit = nil } - guard let topmostVisit = topmostVisit, let currentVisit = currentVisit else { return } + guard let topmostVisit, let currentVisit else { return } if isSnapshotCacheStale { clearSnapshotCache() @@ -244,20 +244,39 @@ extension Session: VisitableDelegate { if isShowingStaleContent { reload() isShowingStaleContent = false - } else if visitable === topmostVisit.visitable && visitable.visitableViewController.isMovingToParent { + return + } + + if visitable === topmostVisit.visitable && visitable.visitableViewController.isMovingToParent { // Back swipe gesture canceled if topmostVisit.state == .completed { currentVisit.cancel() } else { visit(visitable, action: .advance) } - } else if visitable === currentVisit.visitable && currentVisit.state == .started { + return + } + + if visitable === currentVisit.visitable && currentVisit.state == .started { // Navigating forward - complete navigation early completeNavigationForCurrentVisit() - } else if visitable !== topmostVisit.visitable { + return + } + + if visitable !== topmostVisit.visitable { // Navigating backward from a web view screen to a web view screen. visit(visitable, action: .restore) - } else if visitable === previousVisit?.visitable { + return + } + + // Switching back to a tab. + if visitable === previousVisit?.visitable && + visitable.appearReason == .tabSwitched { + completeNavigationForCurrentVisit() + return + } + + if visitable === previousVisit?.visitable { // Navigating backward from a native to a web view screen. visit(visitable, action: .restore) } From 6f093d91299063938666c3726b810182436c9245 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Thu, 28 Nov 2024 14:27:26 +0100 Subject: [PATCH 07/12] Extract `HotwireNavigationController` in its own file. --- Source/HotwireNavigationController.swift | 45 ++++++++++ Source/Turbo/Visitable/Visitable.swift | 8 -- .../Visitable/VisitableViewController.swift | 83 ++++--------------- 3 files changed, 61 insertions(+), 75 deletions(-) create mode 100644 Source/HotwireNavigationController.swift diff --git a/Source/HotwireNavigationController.swift b/Source/HotwireNavigationController.swift new file mode 100644 index 0000000..bebe702 --- /dev/null +++ b/Source/HotwireNavigationController.swift @@ -0,0 +1,45 @@ +import UIKit + +open class HotwireNavigationController: UINavigationController { + open override func pushViewController(_ viewController: UIViewController, animated: Bool) { + if let visitableViewController = viewController as? VisitableViewController { + visitableViewController.appearReason = .pushedOntoNavigationStack + } + + if let topVisitableViewController = topViewController as? VisitableViewController { + topVisitableViewController.disappearReason = .coveredByPush + } + + super.pushViewController(viewController, animated: animated) + } + + open override func popViewController(animated: Bool) -> UIViewController? { + let poppedViewController = super.popViewController(animated: animated) + if let poppedVisitableViewController = poppedViewController as? VisitableViewController { + poppedVisitableViewController.disappearReason = .poppedFromNavigationStack + } + + if let topVisitableViewController = topViewController as? VisitableViewController { + topVisitableViewController.appearReason = .revealedByPop + } + + return poppedViewController + } + + open override func viewWillAppear(_ animated: Bool) { + if let topVisitableViewController = topViewController as? VisitableViewController, + topVisitableViewController.disappearReason == .tabDeselected { + topVisitableViewController.appearReason = .tabSelected + } + super.viewWillAppear(animated) + } + + open override func viewWillDisappear(_ animated: Bool) { + if tabBarController != nil, + let topVisitableViewController = topViewController as? VisitableViewController { + topVisitableViewController.disappearReason = .tabDeselected + } + + super.viewWillDisappear(animated) + } +} diff --git a/Source/Turbo/Visitable/Visitable.swift b/Source/Turbo/Visitable/Visitable.swift index 5bfda42..ec27bde 100644 --- a/Source/Turbo/Visitable/Visitable.swift +++ b/Source/Turbo/Visitable/Visitable.swift @@ -10,19 +10,11 @@ public protocol VisitableDelegate: AnyObject { func visitableDidRequestRefresh(_ visitable: Visitable) } -public enum AppearReason { - case `default` - case pushed - case poped - case tabSwitched -} - public protocol Visitable: AnyObject { var visitableViewController: UIViewController { get } var visitableDelegate: VisitableDelegate? { get set } var visitableView: VisitableView! { get } var visitableURL: URL! { get } - var appearReason: AppearReason { get } func visitableDidRender() func showVisitableActivityIndicator() diff --git a/Source/Turbo/Visitable/VisitableViewController.swift b/Source/Turbo/Visitable/VisitableViewController.swift index a36b970..7c5c0f6 100644 --- a/Source/Turbo/Visitable/VisitableViewController.swift +++ b/Source/Turbo/Visitable/VisitableViewController.swift @@ -4,7 +4,8 @@ import WebKit open class VisitableViewController: UIViewController, Visitable { open weak var visitableDelegate: VisitableDelegate? open var visitableURL: URL! - public var appearReason: AppearReason = .default + var appearReason: AppearReason = .pushedOntoNavigationStack + var disappearReason: DisappearReason = .poppedFromNavigationStack public convenience init(url: URL) { self.init() @@ -19,30 +20,28 @@ open class VisitableViewController: UIViewController, Visitable { installVisitableView() } -// override open func viewWillAppear(_ animated: Bool) { -// super.viewWillAppear(animated) -// visitableDelegate?.visitableViewWillAppear(self) -// } - - open override func viewIsAppearing(_ animated: Bool) { + override open func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + if appearReason == .tabSelected { return } visitableDelegate?.visitableViewWillAppear(self) } override open func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) + if appearReason == .tabSelected { return } visitableDelegate?.visitableViewDidAppear(self) } override open func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) + if disappearReason == .tabDeselected { return } visitableDelegate?.visitableViewWillDisappear(self) } override open func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) + if disappearReason == .tabDeselected { return } visitableDelegate?.visitableViewDidDisappear(self) - appearReason = .default } // MARK: Visitable @@ -86,66 +85,16 @@ open class VisitableViewController: UIViewController, Visitable { } } -open class HotwireNavigationController: UINavigationController { - open override func viewDidLoad() { - super.viewDidLoad() - - super.delegate = delegateProxy - } - - open override var delegate: UINavigationControllerDelegate? { - get { - return delegateProxy.originalDelegate - } - set { - // Update the original delegate in the proxy. - delegateProxy.setDelegate(newValue) - } - } - - open override func pushViewController(_ viewController: UIViewController, animated: Bool) { - if let visitableViewController = viewController as? VisitableViewController { - visitableViewController.appearReason = .pushed - } - - super.pushViewController(viewController, animated: animated) - } - - open override func popViewController(animated: Bool) -> UIViewController? { - let poppedViewController = super.popViewController(animated: animated) - if let visitableViewController = topViewController as? VisitableViewController { - visitableViewController.appearReason = .poped - } - - return poppedViewController - } - - // MARK: Private - private let delegateProxy = HotwireNavigationControllerDelegateProxy() -} - -final class HotwireNavigationControllerDelegateProxy: NSObject, UINavigationControllerDelegate { - weak var originalDelegate: UINavigationControllerDelegate? - - func setDelegate(_ delegate: UINavigationControllerDelegate?) { - self.originalDelegate = delegate +extension VisitableViewController { + public enum AppearReason { + case pushedOntoNavigationStack + case revealedByPop + case tabSelected } - func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) { - if navigationController.tabBarController != nil, - let visitableViewController = viewController as? VisitableViewController, - visitableViewController.appearReason == .default { - visitableViewController.appearReason = .tabSwitched - } - - // Forward to the original delegate. - originalDelegate?.navigationController?(navigationController, willShow: viewController, animated: animated) + public enum DisappearReason { + case coveredByPush + case poppedFromNavigationStack + case tabDeselected } - - func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) { - // Forward to the original delegate. - originalDelegate?.navigationController?(navigationController, didShow: viewController, animated: animated) - } - - // TODO: Add other delegate methods } From 487831f6d5406387b3a10e7193cf23f81a85096d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Thu, 28 Nov 2024 14:27:56 +0100 Subject: [PATCH 08/12] Remove tab switched check from session. --- Source/Turbo/Session/Session.swift | 7 ------- 1 file changed, 7 deletions(-) diff --git a/Source/Turbo/Session/Session.swift b/Source/Turbo/Session/Session.swift index e6f49f2..6b05587 100644 --- a/Source/Turbo/Session/Session.swift +++ b/Source/Turbo/Session/Session.swift @@ -269,13 +269,6 @@ extension Session: VisitableDelegate { return } - // Switching back to a tab. - if visitable === previousVisit?.visitable && - visitable.appearReason == .tabSwitched { - completeNavigationForCurrentVisit() - return - } - if visitable === previousVisit?.visitable { // Navigating backward from a native to a web view screen. visit(visitable, action: .restore) From a6e6e04cd242ca0001a2f17f7bd014fb76441f9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Thu, 28 Nov 2024 14:28:52 +0100 Subject: [PATCH 09/12] Revert "Temporarily configure app to have a tab bar as the root view." This reverts commit f85d94e1933a62f657d10bed3d3d3ac3f3d890eb. --- Demo/SceneController.swift | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/Demo/SceneController.swift b/Demo/SceneController.swift index 3432a1c..5395504 100644 --- a/Demo/SceneController.swift +++ b/Demo/SceneController.swift @@ -24,19 +24,7 @@ final class SceneController: UIResponder { fatalError() } - let tabBarController = UITabBarController() - let secondViewController = SecondViewController() - secondViewController.title = "Second" - - tabBarController.viewControllers = [ - navigator.rootViewController, - secondViewController - ] - window.rootViewController = tabBarController - -// navigator.route(baseURL) - -// window.rootViewController = navigator.rootViewController + window.rootViewController = navigator.rootViewController } // MARK: - Authentication @@ -97,10 +85,3 @@ extension SceneController: NavigatorDelegate { } } } - -class SecondViewController: UIViewController { - override func viewDidLoad() { - super.viewDidLoad() - view.backgroundColor = .systemGreen - } -} From ddae7938907726bb917a0823e9a63a07962e86e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Tue, 3 Dec 2024 14:28:07 +0100 Subject: [PATCH 10/12] Document `HotwireNavigationController`. --- Source/HotwireNavigationController.swift | 25 ++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/Source/HotwireNavigationController.swift b/Source/HotwireNavigationController.swift index bebe702..685aa1d 100644 --- a/Source/HotwireNavigationController.swift +++ b/Source/HotwireNavigationController.swift @@ -1,5 +1,30 @@ import UIKit +/// The `HotwireNavigationController` is a custom subclass of `UINavigationController` designed to enhance the management of `VisitableViewController` instances within a navigation stack. +/// It tracks the reasons why a view controller appears or disappears, which is crucial for handling navigation in Hotwire-powered applications. +/// - Important: If you are using a custom or third-party navigation controller, subclass `HotwireNavigationController` to integrate its behavior. +/// +/// ## Usage Notes +/// +/// - **Integrating with Custom Navigation Controllers:** +/// If you're using a custom or third-party navigation controller, subclass `HotwireNavigationController` to incorporate the necessary behavior. +/// +/// ```swift +/// open class YourCustomNavigationController: HotwireNavigationController { +/// // Make sure to always call super when overriding functions from `HotwireNavigationController`. +/// } +/// ``` +/// +/// - **Extensibility:** +/// The class is marked as `open`, allowing you to subclass and extend its functionality to suit your specific needs. +/// +/// ## Limitations +/// +/// - **Other Container Controllers:** +/// The current implementation focuses on `UINavigationController` and includes handling for `UITabBarController`. It does not provide out-of-the-box support for other container controllers like `UISplitViewController`. +/// +/// - **Custom Navigation Setups:** +/// For completely custom navigation setups or container controllers, you will need to implement similar logic to manage the `appearReason` and `disappearReason` of `VisitableViewController` instances. open class HotwireNavigationController: UINavigationController { open override func pushViewController(_ viewController: UIViewController, animated: Bool) { if let visitableViewController = viewController as? VisitableViewController { From 69124416a43dfc7593345f169ed1366ac42d643b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Tue, 3 Dec 2024 14:30:30 +0100 Subject: [PATCH 11/12] Update documentation for the default navigation controller. --- Source/HotwireConfig.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/HotwireConfig.swift b/Source/HotwireConfig.swift index 7bbd0c1..b7ea101 100644 --- a/Source/HotwireConfig.swift +++ b/Source/HotwireConfig.swift @@ -36,7 +36,7 @@ public struct HotwireConfig { } /// The navigation controller used in `Navigator` for the main and modal stacks. - /// Must be a `UINavigationController` or subclass. + /// Must be a `HotwireNavigationController` or subclass. public var defaultNavigationController: () -> UINavigationController = { HotwireNavigationController() } From f9603b12b7854acc4af5362923ec4275e47b52f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Tue, 3 Dec 2024 16:03:12 +0100 Subject: [PATCH 12/12] Move comment out of if statements. --- Source/Turbo/Session/Session.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Source/Turbo/Session/Session.swift b/Source/Turbo/Session/Session.swift index 6b05587..677332e 100644 --- a/Source/Turbo/Session/Session.swift +++ b/Source/Turbo/Session/Session.swift @@ -247,8 +247,8 @@ extension Session: VisitableDelegate { return } + // Back swipe gesture canceled. if visitable === topmostVisit.visitable && visitable.visitableViewController.isMovingToParent { - // Back swipe gesture canceled if topmostVisit.state == .completed { currentVisit.cancel() } else { @@ -257,20 +257,20 @@ extension Session: VisitableDelegate { return } + // Navigating forward - complete navigation early. if visitable === currentVisit.visitable && currentVisit.state == .started { - // Navigating forward - complete navigation early completeNavigationForCurrentVisit() return } + // Navigating backward from a web view screen to a web view screen. if visitable !== topmostVisit.visitable { - // Navigating backward from a web view screen to a web view screen. visit(visitable, action: .restore) return } + // Navigating backward from a native to a web view screen. if visitable === previousVisit?.visitable { - // Navigating backward from a native to a web view screen. visit(visitable, action: .restore) } }