Skip to content
This repository has been archived by the owner on Oct 10, 2024. It is now read-only.

Turbo navigator without session #74

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 55 additions & 3 deletions Demo/Demo/SceneDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ let baseURL = URL(string: "http://localhost:3000")!
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?

private lazy var turboNavigator = TurboNavigator(delegate: self, pathConfiguration: pathConfiguration)
private lazy var pathConfiguration = PathConfiguration(sources: [
private lazy var turboNavigator = TurboNavigator(pathConfiguration: pathConfiguration)
private let pathConfiguration = PathConfiguration(sources: [
.server(baseURL.appendingPathComponent("/configurations/ios_v1.json"))
])

Expand All @@ -23,4 +23,56 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
}
}

extension SceneDelegate: TurboNavigationDelegate {}
import WebKit

/// This example class shows how one can use more features of Turbo Navigator.
class ComplexSceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?

private lazy var turboNavigator = TurboNavigator(pathConfiguration: pathConfiguration, delegate: self)
private let pathConfiguration = PathConfiguration(sources: [
.server(baseURL.appendingPathComponent("/configurations/ios_v1.json"))
])

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }

TurboConfig.shared.userAgent += " CUSTOM STRADA USER AGENT"
Copy link
Contributor Author

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.

Copy link
Owner

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.

Copy link

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#L49

Copy link
Contributor Author

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.

TurboConfig.shared.makeCustomWebView = { config in
let webView = WKWebView(frame: .zero, configuration: config)
// Bridge.initialize(webView)
return webView
}

self.turboNavigator.webkitUIDelegate = ExampleWKUIController(delegate: self.turboNavigator)

self.window = UIWindow(windowScene: windowScene)
self.window?.makeKeyAndVisible()

self.window?.rootViewController = self.turboNavigator.rootViewController
self.turboNavigator.route(baseURL)
}
}

extension ComplexSceneDelegate: TurboNavigatorDelegate {
func handle(proposal: VisitProposal) -> ProposalResult {
switch proposal.viewController {
case "example": .acceptCustom(ExampleViewController())
default: .accept
}
}
}

class ExampleViewController: UIViewController {}

class ExampleWKUIController: TurboWKUIController {
// Overridden: custom handling of confirm() dialog.
override func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
completionHandler(true)
}

// New function: custom handling of prompt() dialog.
func webView(_ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, defaultText: String?, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (String?) -> Void) {
completionHandler("Hi!")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public class TurboConfig {

// MARK: - Internal

func makeWebView() -> WKWebView {
public func makeWebView() -> WKWebView {
makeCustomWebView(makeWebViewConfiguration())
}

Expand Down
70 changes: 0 additions & 70 deletions Sources/TurboNavigationDelegate.swift

This file was deleted.

209 changes: 209 additions & 0 deletions Sources/TurboNavigationHierarchyController.swift
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
Copy link
Owner

Choose a reason for hiding this comment

The 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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 UINavigationController into this class anymore, so testing was broken. What I did was create this variable and keep it internal so consumers cannot use it but unit tests can reach it.

Copy link
Owner

Choose a reason for hiding this comment

The 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
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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))
}
}
}
11 changes: 11 additions & 0 deletions Sources/TurboNavigationHierarchyControllerDelegate.swift
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)
}
Loading
Loading