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

A drop-in class for Turbo Native apps to handle common navigation flows.

License

Notifications You must be signed in to change notification settings

joemasilotti/TurboNavigator

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

46 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Turbo Navigator

A drop-in class for Turbo Native apps to handle common navigation flows.

Caution

Turbo Navigator has been integrated into Hotwire Native iOS. This repository will no longer be maintained.

Turbo Navigator screenshot demo

Why use this?

Turbo Native apps require a fair amount of navigation handling to create a decent experience.

Unfortunately, not much of this is built into turbo-ios. A lot of boilerplate is required to have anything more than basic pushing/popping of screens.

This package abstracts that boilerplate into a single class. You can drop it into your app and not worry about handling every flow manually.

I've been using something a version of this on the dozens of Turbo Native apps I've built over the years.

Handled flows

When a link is tapped, turbo-ios sends a VisitProposal to your application code. Based on the Path Configuration, different PathProperties will be set.

  • Current context - What state the app is in.
    • modal - a modal is currently presented
    • default - otherwise
  • Given context - Value of context on the requested link.
    • modal or default/blank
  • Given presentation - Value of presentation on the proposal.
    • replace, pop, refresh, clear_all, replace_root, none, default/blank
  • Navigation - The behavior that the navigation controller provides.
Current Context Given Context Given Presentation New Presentation
default default default Push on main stack (or)
Replace if visiting same page (or)
Pop (and visit) if previous controller is same URL
default default replace Replace controller on main stack
default modal default Present a modal with only this controller
default modal replace Present a modal with only this controller
modal default default Dismiss then Push on main stack
modal default replace Dismiss then Replace on main stack
modal modal default Push on the modal stack
modal modal replace Replace controller on modal stack
default (any) pop Pop controller off main stack
default (any) refresh Pop on main stack then
modal (any) pop Pop controller off modal stack (or)
Dismiss if one modal controller
modal (any) refresh Pop controller off modal stack then
Refresh last controller on modal stack
(or)
Dismiss if one modal controller then
Refresh last controller on main stack
(any) (any) clearAll Dismiss if modal controller then
Pop to root then
Refresh root controller on main stack
(any) (any) replaceRoot Dismiss if modal controller then
Pop to root then
Replace root controller on main stack
(any) (any) none Nothing

Examples

To present forms (URLs ending in /new or /edit) as a modal, add the following to the rules key of your Path Configuration.

{
  "patterns": [
    "/new$",
    "/edit$"
  ],
  "properties": {
    "context": "modal"
  }
}

To hook into the "refresh" turbo-rails native route, add the following to the rules key of your Path Configuration. You can then call refresh_or_redirect_to in your controller to handle Turbo Native and web-based navigation.

{
  "patterns": [
    "/refresh_historical_location"
  ],
  "properties": {
    "presentation": "refresh"
  }
}

Getting started

First, create a new Xcode project using the iOS App template.

New Xcode project

Then add the Turbo Navigator Swift package.

  1. In Xcode, File → Add Packages…
  2. Enter the following URL in the upper right: https://github.com/joemasilotti/TurboNavigator
  3. Click Add Package
  4. Click Add Package again

Add package

Replace SceneDelegate.swift with the following.

import TurboNavigator
import UIKit

let rootURL = URL(string: "http://localhost:3000")!

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?

    private let navigationController = UINavigationController()
    private lazy var navigator = TurboNavigator(delegate: self, navigationController: navigationController)

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        window?.rootViewController = navigationController
        navigator.route(rootURL)
    }
}

extension SceneDelegate: TurboNavigationDelegate {}

Start the demo Rails server then run the iOS app in Xcode via Product → Run.

Demo project

The Demo/ directory includes an iOS app and Ruby on Rails server to demo the package.

It shows off most of the navigation flows outlined above. There is also an example CRUD resource for more real world applications of each.

Custom controller and routing overrides

You can also implement an optional method on the TurboNavigationDelegate to handle custom routing.

This is useful to break out of the default behavior and/or render a native screen. You may inspect the provided proposal and decide routing based on any of its properties. For custom native screens, you may also include a "view-controller" property that will be passed along.

{
  "patterns": [
    "/numbers$"
  ],
  "properties": {
    "view-controller": "numbers"
  }
}
class MyCustomClass: TurboNavigationDelegate {
    let navigator = TurboNavigator(delegate: self)

    func handle(proposal: VisitProposal) -> ProposalResult {
        if proposal.viewController == "numbers" {
            // Let Turbo Navigator route this custom controller.
            return NumbersViewController()
        } else if proposal.presentation == .clearAll {
            // Return nil to tell Turbo Navigator stop processing the request.
            return nil
        } else {
            // Return the given controller to continue with default behavior.
            // Optionally customize the given controller.
            controller.view.backgroundColor = .orange
            return controller
        }
    }
}

If you're relying on the "view-controller" property, we recommend your view controllers conform to PathConfigurationIdentifiable. You should also avoid using the class name as identifier, as you might rename your controller in the future.

class NumbersViewController: UIViewController, PathConfigurationIdentifiable {
    static var pathConfigurationIdentifier: String { "numbers" }
}

class MyCustomClass: TurboNavigationDelegate {
    let navigator = TurboNavigator(delegate: self)
    
    func handle(proposal: VisitProposal) -> ProposalResult {
        if proposal.viewController == NumbersViewController.pathConfigurationIdentifier {
            // Let Turbo Navigator route this custom controller.
            return .acceptCustom(NumbersViewController())
        } else ... 
            ...
        }
    }
}

Custom configuration

Customize the configuration via TurboConfig.

Override the user agent

Keep "Turbo Native" to use turbo_native_app? on your Rails server.

TurboConfig.shared.userAgent = "Custom (Turbo Native)"

Customize the web view and web view configuration

A closure is used because a new instance is needed for each web view. The closure has a WKWebViewConfiguration argument that's pre-built and ready to be customized and assigned to a new web view.

TurboConfig.shared.makeCustomWebView = { (configuration: WKWebViewConfiguration) in
    // Customize the WKWebViewConfiguration instance
    // ...

    return WKWebView(frame: .zero, configuration: configuration)
}

Customize behavior for external URLs

Turbo cannot navigate across domains because page visits are done via JavaScript. A tapped link that points to a different domain is considered external.

By default, web URLs (http:// and https://) are presented via the in-app browser, SFSafariViewController. Everything else is opened natively. For example, tel: and sms: links are opened, when possible, in Phone.app or Messages.app.

If you want to register additional URL schemes you need to follow the steps outlined here to add an entry in your Info.plist.

This behavior can be overridden by implementing the following delegate method.

class MyCustomClass: TurboNavigationDelegate {
    func openExternalURL(_ url: URL, from controller: UIViewController) {
        // Do something custom with the external URL.
        // The controller is provided to present on top of.
    }
}

Customized error handling

By default, Turbo Navigator will automatically handle any errors that occur when performing visits. The error's localized description and a button to retry the request is displayed.

You can customize the error handling by overriding the following delegate method.

extension MyCustomClass: TurboNavigationDelegate {
    func visitableDidFailRequest(_ visitable: Visitable, error: Error, retry: @escaping RetryBlock) {
        if case let TurboError.http(statusCode) = error, statusCode == 401 {
            // Custom error handling for 401 responses.
        } else if let errorPresenter = visitable as? ErrorPresenter {
            errorPresenter.presentError(error) {
                retry()
            }
        }
    }
}

About

A drop-in class for Turbo Native apps to handle common navigation flows.

Topics

Resources

License

Stars

Watchers

Forks

Sponsor this project

 

Packages

No packages published

Languages