-
-
Notifications
You must be signed in to change notification settings - Fork 14
Turbo navigator without session #74
Changes from all commits
a71d2d3
e3790d0
6dc761d
93685d7
5275fd1
979400e
438f8e7
10f5e5d
7dd6260
ad0bdda
dd662ee
ba44036
9c82925
2e250ce
657078a
74124a0
ee81553
f753dff
eb1b4ea
ded3633
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,209 @@ | ||
import SafariServices | ||
import Turbo | ||
import UIKit | ||
import WebKit | ||
|
||
/// Handles navigation to new URLs using the following rules: | ||
/// https://github.com/joemasilotti/TurboNavigator#handled-flows | ||
class TurboNavigationHierarchyController { | ||
let navigationController: UINavigationController | ||
let modalNavigationController: UINavigationController | ||
|
||
var rootViewController: UIViewController { navigationController } | ||
var activeNavigationController: UINavigationController { | ||
navigationController.presentedViewController != nil ? modalNavigationController : navigationController | ||
} | ||
|
||
var animationsEnabled: Bool = true | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why does this exist? I don't see a way to set it publicly as a consumer of the library. If anything, I'd prefer to see this live in the path configuration somehow so individual links can be customized instead of a global option. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ha. That was a hack. There's no way to pass along a type of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah, nice catch! What about exposing the navigation controllers in the initializer but defaulted like we had before? init(delegate: TurboNavigationHierarchyControllerDelegate, navigationController: UINavigationController = UINavigationController(), modalNavigationController: UINavigationController = UINavigationController()) {
self.delegate = delegate
self.navigationController = navigationController
self.modalNavigationController = modalNavigationController
} There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, I think that's the way forward. I originally didn't go with this because I didn't want to expose them, but since this class is now internal, I don't see any issues at all. |
||
|
||
enum NavigationStackType { | ||
case main | ||
case modal | ||
} | ||
|
||
func navController(for navigationType: NavigationStackType) -> UINavigationController { | ||
switch navigationType { | ||
case .main: navigationController | ||
case .modal: modalNavigationController | ||
} | ||
} | ||
|
||
/// Default initializer. | ||
/// | ||
/// - Parameters: | ||
/// - delegate: handles visits and refresh | ||
init(delegate: TurboNavigationHierarchyControllerDelegate) { | ||
self.delegate = delegate | ||
self.navigationController = UINavigationController() | ||
self.modalNavigationController = UINavigationController() | ||
} | ||
|
||
func route(controller: UIViewController, proposal: VisitProposal) { | ||
if let alert = controller as? UIAlertController { | ||
presentAlert(alert) | ||
} else { | ||
switch proposal.presentation { | ||
case .default: | ||
navigate(with: controller, via: proposal) | ||
case .pop: | ||
pop() | ||
case .replace: | ||
replace(with: controller, via: proposal) | ||
case .refresh: | ||
refresh() | ||
case .clearAll: | ||
clearAll() | ||
case .replaceRoot: | ||
replaceRoot(with: controller) | ||
case .none: | ||
break // Do nothing. | ||
} | ||
} | ||
} | ||
|
||
func openExternal(url: URL, navigationType: NavigationStackType) { | ||
if ["http", "https"].contains(url.scheme) { | ||
let safariViewController = SFSafariViewController(url: url) | ||
safariViewController.modalPresentationStyle = .pageSheet | ||
if #available(iOS 15.0, *) { | ||
safariViewController.preferredControlTintColor = .tintColor | ||
} | ||
let navController = navController(for: navigationType) | ||
navController.present(safariViewController, animated: animationsEnabled) | ||
} else if UIApplication.shared.canOpenURL(url) { | ||
UIApplication.shared.open(url) | ||
} | ||
} | ||
|
||
// MARK: Private | ||
|
||
@available(*, unavailable) | ||
required init?(coder aDecoder: NSCoder) { | ||
fatalError("init(coder:) has not been implemented") | ||
} | ||
|
||
private unowned let delegate: TurboNavigationHierarchyControllerDelegate | ||
|
||
private func presentAlert(_ alert: UIAlertController) { | ||
if navigationController.presentedViewController != nil { | ||
modalNavigationController.present(alert, animated: animationsEnabled) | ||
} else { | ||
navigationController.present(alert, animated: animationsEnabled) | ||
} | ||
} | ||
|
||
private func navigate(with controller: UIViewController, via proposal: VisitProposal) { | ||
switch proposal.context { | ||
case .default: | ||
navigationController.dismiss(animated: animationsEnabled) | ||
pushOrReplace(on: navigationController, with: controller, via: proposal) | ||
if let visitable = controller as? Visitable { | ||
delegate.visit(visitable, on: .main, with: proposal.options) | ||
} | ||
case .modal: | ||
if navigationController.presentedViewController != nil { | ||
pushOrReplace(on: modalNavigationController, with: controller, via: proposal) | ||
} else { | ||
modalNavigationController.setViewControllers([controller], animated: animationsEnabled) | ||
navigationController.present(modalNavigationController, animated: animationsEnabled) | ||
} | ||
if let visitable = controller as? Visitable { | ||
delegate.visit(visitable, on: .modal, with: proposal.options) | ||
} | ||
} | ||
} | ||
|
||
private func pushOrReplace(on navigationController: UINavigationController, with controller: UIViewController, via proposal: VisitProposal) { | ||
if visitingSamePage(on: navigationController, with: controller, via: proposal.url) { | ||
navigationController.replaceLastViewController(with: controller) | ||
} else if visitingPreviousPage(on: navigationController, with: controller, via: proposal.url) { | ||
navigationController.popViewController(animated: animationsEnabled) | ||
} else if proposal.options.action == .advance { | ||
navigationController.pushViewController(controller, animated: animationsEnabled) | ||
} else { | ||
navigationController.replaceLastViewController(with: controller) | ||
} | ||
} | ||
|
||
private func visitingSamePage(on navigationController: UINavigationController, with controller: UIViewController, via url: URL) -> Bool { | ||
if let visitable = navigationController.topViewController as? Visitable { | ||
return visitable.visitableURL == url | ||
} else if let topViewController = navigationController.topViewController { | ||
return topViewController.isMember(of: type(of: controller)) | ||
} | ||
return false | ||
} | ||
|
||
private func visitingPreviousPage(on navigationController: UINavigationController, with controller: UIViewController, via url: URL) -> Bool { | ||
guard navigationController.viewControllers.count >= 2 else { return false } | ||
|
||
let previousController = navigationController.viewControllers[navigationController.viewControllers.count - 2] | ||
if let previousVisitable = previousController as? VisitableViewController { | ||
return previousVisitable.visitableURL == url | ||
} | ||
return type(of: previousController) == type(of: controller) | ||
} | ||
|
||
private func pop() { | ||
if navigationController.presentedViewController != nil { | ||
if modalNavigationController.viewControllers.count == 1 { | ||
navigationController.dismiss(animated: animationsEnabled) | ||
} else { | ||
modalNavigationController.popViewController(animated: animationsEnabled) | ||
} | ||
} else { | ||
navigationController.popViewController(animated: animationsEnabled) | ||
} | ||
} | ||
|
||
private func replace(with controller: UIViewController, via proposal: VisitProposal) { | ||
switch proposal.context { | ||
case .default: | ||
navigationController.dismiss(animated: animationsEnabled) | ||
navigationController.replaceLastViewController(with: controller) | ||
if let visitable = controller as? Visitable { | ||
delegate.visit(visitable, on: .main, with: proposal.options) | ||
} | ||
case .modal: | ||
if navigationController.presentedViewController != nil { | ||
modalNavigationController.replaceLastViewController(with: controller) | ||
} else { | ||
modalNavigationController.setViewControllers([controller], animated: false) | ||
navigationController.present(modalNavigationController, animated: animationsEnabled) | ||
} | ||
if let visitable = controller as? Visitable { | ||
delegate.visit(visitable, on: .modal, with: proposal.options) | ||
} | ||
} | ||
} | ||
|
||
private func refresh() { | ||
if navigationController.presentedViewController != nil { | ||
if modalNavigationController.viewControllers.count == 1 { | ||
navigationController.dismiss(animated: animationsEnabled) | ||
delegate.refresh(navigationStack: .main) | ||
} else { | ||
modalNavigationController.popViewController(animated: animationsEnabled) | ||
delegate.refresh(navigationStack: .modal) | ||
} | ||
} else { | ||
navigationController.popViewController(animated: animationsEnabled) | ||
delegate.refresh(navigationStack: .main) | ||
} | ||
} | ||
|
||
private func clearAll() { | ||
navigationController.dismiss(animated: animationsEnabled) | ||
navigationController.popToRootViewController(animated: animationsEnabled) | ||
delegate.refresh(navigationStack: .main) | ||
} | ||
|
||
private func replaceRoot(with controller: UIViewController) { | ||
navigationController.dismiss(animated: animationsEnabled) | ||
navigationController.setViewControllers([controller], animated: animationsEnabled) | ||
|
||
if let visitable = controller as? Visitable { | ||
delegate.visit(visitable, on: .main, with: .init(action: .replace)) | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
import SafariServices | ||
import Turbo | ||
import WebKit | ||
|
||
/// Implement to be notified when certain navigations are performed | ||
/// or to render a native controller instead of a Turbo web visit. | ||
protocol TurboNavigationHierarchyControllerDelegate: AnyObject { | ||
func visit(_ : Visitable, on: TurboNavigationHierarchyController.NavigationStackType, with: VisitOptions) | ||
|
||
func refresh(navigationStack: TurboNavigationHierarchyController.NavigationStackType) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Definitely something to consider later, but I think this is the last piece of the puzzle that I can't make fit. I don't like the static configuration. I know Android has a WebView subclass due to technical constraints. Maybe we can make it work somehow.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed. This static configuration made more sense (to me) before Strada existed and I was only setting the user agent. Honestly, we could even rip this out of turbo-ios (i.e. never upstream it) and force folks to use the
Session
initializer if they want to customize the web view. But I'll see how that plays out in the upstream PR.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note that we have an instance in HEY where our composer's WebView is separate from the rest of the app and does not support the same Strada components as the rest of the app, so the user-agent is actually different for that webview. So we should support continue to support situations where the apps can set the user agent per
WebView/Session
— but that doesn't need to be the default path for simpler apps.On the Android side, we just notify the app see whenever the session is (re)created and can update the user agent directly for the
webView
: https://github.com/hotwired/turbo-android/blob/main/demo/src/main/kotlin/dev/hotwire/turbo/demo/main/MainSessionNavHostFragment.kt#L49There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed. Both the user agent and the web view subclass should be supported. I was just saying there should be a better way of achieving both goals than a static configuration.