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

Conversation

olivaresf
Copy link
Contributor

Experimenting with breaking apart TurboNavigator into two classes: TurboNavigator (which handles session logic) and TurboNavigationHierarchyController (which handles UIKit nav stack manipulation).

@joemasilotti
Copy link
Owner

Exciting! Is the goal to figure out an approach to #69 or code quality?

@olivaresf
Copy link
Contributor Author

This is an approach to #69!

@joemasilotti
Copy link
Owner

joemasilotti commented Nov 7, 2023

This is looking great! I just pushed some changes that:

  • Ensures presenting modals on alerts work by exposing TurboNavigationHierarchyController .activeNavigationController
  • Reworks TurboNavigatorDelegate to provide a default implementation with implemented functions in an extension. This also removes the need to check for nil.
  • Ensures WKUIDelegate is set up instantiation. But I left a comment because I don't love this approach.
  • Adds a convenience initializer to pass in a PathConfiguration (only) and have TurboNavigator set up the Session instances
  • Copies over cookies after each web request completes

public var rootViewController: UIViewController { navigationController }
public let navigationController: UINavigationController
public let modalNavigationController: UINavigationController
defer { self.webkitUIDelegate = TurboWKUIController(delegate: self) }
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

defer is needed to trigger the didSet callback above. I don't like this but can't think of another way without duplicating the logic in that callback.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't mind duplicating the logic. It really should just be about setting the delegate to both sessions and that's what we're doing here already. I don't feel strongly about it, though.

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.

Comment on lines +12 to +16
public var webkitUIDelegate: TurboWKUIController? {
didSet {
session.webView.uiDelegate = webkitUIDelegate
modalSession.webView.uiDelegate = webkitUIDelegate
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh should we make this public var webkitUIDelegate: WKUIDelegate? instead here? Otherwise I think we run into the issue @joemasilotti raised here about having to keep our TurboWKUIController in step with the WKUIDelegate protocol that we don't control, because subclasses can't implement protocol methods that aren't in the superclass.

And it'd be nice for users to be able to opt out of the alerting behaviour if they want, by supplying their own WKUIDelegate.

Perhaps we could set the default TurboWKUIController up as part of the convenience init on TurboNavigator, if the intention there is to provide the easiest route for set up 🤔?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great catch! One caveat is that TurboWKUIController is a class, not a protocol. So a developer can subclass it to customize alert/confirm behavior or add new functionality.

But doing so is a little awkward because it needs a reference back to TurboNavigator:

self.turboNavigator = TurboNavigator(pathConfiguration: self.pathConfiguration)
self.turboNavigator.webkitUIDelegate = WKUIController(delegate: self.turboNavigator)

class WKUIController: TurboWKUIController {
    // Override functions to customize behavior, presenting alerts on `delegate`.
}

If we expose a generic WKUIDelegate then I don't see how a developer can grab a reference back to the TurboWKUIDelegate needed to actually do the alert presenting!

Here's the diff I'm working with to allow subclassing:

diff --git a/Sources/TurboWKUIDelegate.swift b/Sources/TurboWKUIDelegate.swift
index 94eb1d4..f14ebba 100644
--- a/Sources/TurboWKUIDelegate.swift
+++ b/Sources/TurboWKUIDelegate.swift
@@ -5,9 +5,10 @@ public protocol TurboWKUIDelegate : AnyObject {
     func present(_ alert: UIAlertController, animated: Bool)
 }

-public class TurboWKUIController : NSObject, WKUIDelegate {
-    weak var delegate: TurboWKUIDelegate?
-    init(delegate: TurboWKUIDelegate!) {
+open class TurboWKUIController: NSObject, WKUIDelegate {
+    public private(set) weak var delegate: TurboWKUIDelegate?
+
+    public init(delegate: TurboWKUIDelegate!) {
         self.delegate = delegate
     }

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ohh that's a good point about the delegate. Would wrapping like this work do you reckon? So you'd be able to pass in either a subclassed TurboWKUIController or your own WKUIDelegate and have either be accessible from the outside?

    public enum WKUIDelegateType {
        case standardBehaviour(TurboWKUIController)
        case custom(WKUIDelegate)
        
        var delegate: WKUIDelegate {
            switch self {
            case .custom(let delegate): return delegate
            case .standardBehaviour(let controller): return controller
            }
        }
    }
 ...

    public var webkitUIDelegate: WKUIDelegateType? {
        didSet {
            session.webView.uiDelegate = webkitUIDelegate?.delegate
            modalSession.webView.uiDelegate = webkitUIDelegate?.delegate
        }
    }


... // then in the init method: 

        defer { self.webkitUIDelegate = .standardBehaviour(TurboWKUIController(delegate: self)) }

Copy link
Contributor Author

@olivaresf olivaresf Nov 8, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh should we make this public var webkitUIDelegate: WKUIDelegate? instead here?

I went with a class implementing WKUIDelegate so users could subclass it and override any protocol function in WKUIDelegate. If we explosed WKUIDelegate we leave all the responsibility of catching and building alerts to the consumer. We should definitely allow that flexibility, just not as a default. TurboWKUIController is the default.

Having said that...

subclasses can't implement protocol methods that aren't in the superclass.

This is an interesting idea! I see WKUIDelegate is an Obj-C protocol, so your observation is great: what happens if TurboWKUIDelegate does not conform to an optional method? Can users override it? I actually didn't know if this was true or not so I just tested it in a Playground and I don't think it's entirely true.

import UIKit

@objc protocol TestProtocol {
    @objc optional func doSomething()
    @objc optional func saySomething()
}

class MyClassA : TestProtocol {
    func doSomething() { 
        print("Do A")
    }
}

class MyClassB : MyClassA {
    override func doSomething() {
        print("Do B")
    }
    
    func saySomething() {
        print("Say")
    }
}

let classA = MyClassA()
let classB = MyClassB()

classA.doSomething()
classA.saySomething?() // ❌ Value of type 'MyClassA' has no member 'saySomething'

classB.saySomething()
classB.doSomething()

*Note that MyClassB.doSomething is overridden but MyClassB.saySomething is not.

If you comment out classA.saySomething?() the code compiles and behaves as expected. This is good news as it means we don't actually need to implement all methods in WKUIDelegate. We can conform to however many we want, and users could just conform to functions that we don't implement.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ohh that's a good point about the delegate. Would wrapping like this work do you reckon? So you'd be able to pass in either a subclassed TurboWKUIController or your own WKUIDelegate and have either be accessible from the outside?

I don't think I'm understanding the idea here. Why are we adding another layer of indirection via the enum? In my mind, a user would just subclass TurboWKUIDelegate and just pass their instance as the controller. As soon as it's passed, it's set as the WKUI delegate.

If the issue is the default, we can just have TurboNavigator build its own TurboWKUIDelegate.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just pushed two commits that refactor both delegates a bit. I think this should get us everything we need. Check out the ComplexSceneDelegate at the bottom of SceneDelegate.swift.

The changes move the default implementation of TurboNavigatorDelegate to a protocol extension. Which means developers only have to implement the functions they want.

And makes TurboWKUIController open for subclassing, allowing a developer to customize only what they need.

I'm pretty happy with this! And I think ComplexSceneDelegate shows how it all works together. Let me know if I missed something or drifted off target.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@olivaresf Ohhhh you're totally right, apologies for the detour - I'd got mixed up with another subclassing/protocol scenario which I'll no doubt bore you with over coffee sometime 🙇‍♀️

@joemasilotti The new commits look great – I'm with Fernando, let's start to upstream and get real 😃

/// - Parameters:
/// - pathConfiguration:
/// - delegate: an optional delegate to handle custom view controllers
public convenience init(pathConfiguration: PathConfiguration, delegate: TurboNavigatorDelegate? = nil) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really, really like this initializer. I think it's now very clear that the only required dependency for TurboNavigator is the path configuration.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Building on that… I don't think it is unreasonable for us to provide a default path configuration if one isn't provided. One that handles the required routing, only.

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:/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.

@olivaresf
Copy link
Contributor Author

I think we're in a great spot to get this upstreamed to Turbo. I think it was a great idea to have a SceneDelegate and a ComplexSceneDelegate to clearly see how the minimum implementation looks like and how a custom implementation looks like. Really dig it.

I'm happy we could make this architecture work. It feels very flexible! 🙌🏻

@joemasilotti
Copy link
Owner

Exciting! And I agree. This feels like the last bit of unknowns.

@joemasilotti
Copy link
Owner

This PR provided enough guidance to start upstreaming Turbo Navigator into turbo-ios.

Follow progress on #158!

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants