{"id":13880613,"url":"https://github.com/joemasilotti/TurboNavigator","last_synced_at":"2025-07-16T17:30:49.533Z","repository":{"id":146320669,"uuid":"608793171","full_name":"joemasilotti/TurboNavigator","owner":"joemasilotti","description":"A drop-in class for Turbo Native apps to handle common navigation flows.","archived":true,"fork":false,"pushed_at":"2024-10-10T20:22:54.000Z","size":1044,"stargazers_count":208,"open_issues_count":2,"forks_count":15,"subscribers_count":11,"default_branch":"main","last_synced_at":"2025-04-05T21:43:30.650Z","etag":null,"topics":["hotwire","ios","turbo-native"],"latest_commit_sha":null,"homepage":"","language":"Swift","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/joemasilotti.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":".github/FUNDING.yml","license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null},"funding":{"github":"joemasilotti"}},"created_at":"2023-03-02T18:45:06.000Z","updated_at":"2025-03-23T00:35:21.000Z","dependencies_parsed_at":null,"dependency_job_id":"73f30470-64d5-49d9-bdf4-f3ccdab24126","html_url":"https://github.com/joemasilotti/TurboNavigator","commit_stats":null,"previous_names":[],"tags_count":2,"template":false,"template_full_name":null,"purl":"pkg:github/joemasilotti/TurboNavigator","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/joemasilotti%2FTurboNavigator","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/joemasilotti%2FTurboNavigator/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/joemasilotti%2FTurboNavigator/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/joemasilotti%2FTurboNavigator/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/joemasilotti","download_url":"https://codeload.github.com/joemasilotti/TurboNavigator/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/joemasilotti%2FTurboNavigator/sbom","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":265527543,"owners_count":23782480,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["hotwire","ios","turbo-native"],"created_at":"2024-08-06T08:03:17.408Z","updated_at":"2025-07-16T17:30:49.137Z","avatar_url":"https://github.com/joemasilotti.png","language":"Swift","funding_links":["https://github.com/sponsors/joemasilotti"],"categories":["Swift"],"sub_categories":[],"readme":"# Turbo Navigator\n\nA drop-in class for [Turbo Native](https://github.com/hotwired/turbo-ios) apps to handle common navigation flows.\n\n\u003e [!CAUTION]\n\u003e Turbo Navigator has been integrated into [Hotwire Native iOS](https://github.com/hotwired/hotwire-native-ios). This repository will no longer be maintained.\n\n![Turbo Navigator screenshot demo](.github/images/demo.png)\n\n## Why use this?\n\nTurbo Native apps require a fair amount of navigation handling to create a decent experience.\n\nUnfortunately, 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.\n\nThis package abstracts that boilerplate into a single class. You can drop it into your app and not worry about handling every flow manually.\n\nI've been using something a version of this on the [dozens of Turbo Native apps](https://masilotti.com/services/) I've built over the years.\n\n## Handled flows\n\nWhen a link is tapped, turbo-ios sends a `VisitProposal` to your application code. Based on the [Path Configuration](https://github.com/hotwired/turbo-ios/blob/main/Docs/PathConfiguration.md), different `PathProperties` will be set.\n\n* **Current context** - What state the app is in.\n    * `modal` - a modal is currently presented\n    * `default` - otherwise\n* **Given context** - Value of `context` on the requested link.\n    * `modal` or `default`/blank\n* **Given presentation** - Value of `presentation` on the proposal.\n    * `replace`, `pop`, `refresh`, `clear_all`, `replace_root`, `none`, `default`/blank\n* **Navigation** - The behavior that the navigation controller provides.\n\n\u003ctable\u003e\n  \u003cthead\u003e\n    \u003ctr\u003e\n      \u003cth\u003eCurrent Context\u003c/th\u003e\n      \u003cth\u003eGiven Context\u003c/th\u003e\n      \u003cth\u003eGiven Presentation\u003c/th\u003e\n      \u003cth\u003eNew Presentation\u003c/th\u003e\n    \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n    \u003ctr\u003e\n      \u003ctd\u003e\u003ccode\u003edefault\u003c/code\u003e\u003c/td\u003e\n      \u003ctd\u003e\u003ccode\u003edefault\u003c/code\u003e\u003c/td\u003e\n      \u003ctd\u003e\u003ccode\u003edefault\u003c/code\u003e\u003c/td\u003e\n      \u003ctd\u003ePush on main stack (or)\u003cbr\u003e\n        Replace if visiting same page (or)\u003cbr\u003e\n        Pop (and visit) if previous controller is same URL\n      \u003c/td\u003e\n    \u003c/tr\u003e\n    \u003ctr\u003e\n      \u003ctd\u003e\u003ccode\u003edefault\u003c/code\u003e\u003c/td\u003e\n      \u003ctd\u003e\u003ccode\u003edefault\u003c/code\u003e\u003c/td\u003e\n      \u003ctd\u003e\u003ccode\u003ereplace\u003c/code\u003e\u003c/td\u003e\n      \u003ctd\u003eReplace controller on main stack\u003c/td\u003e\n    \u003c/tr\u003e\n    \u003ctr\u003e\n      \u003ctd\u003e\u003ccode\u003edefault\u003c/code\u003e\u003c/td\u003e\n      \u003ctd\u003e\u003ccode\u003emodal\u003c/code\u003e\u003c/td\u003e\n      \u003ctd\u003e\u003ccode\u003edefault\u003c/code\u003e\u003c/td\u003e\n      \u003ctd\u003ePresent a modal with only this controller\u003c/td\u003e\n    \u003c/tr\u003e\n    \u003ctr\u003e\n      \u003ctd\u003e\u003ccode\u003edefault\u003c/code\u003e\u003c/td\u003e\n      \u003ctd\u003e\u003ccode\u003emodal\u003c/code\u003e\u003c/td\u003e\n      \u003ctd\u003e\u003ccode\u003ereplace\u003c/code\u003e\u003c/td\u003e\n      \u003ctd\u003ePresent a modal with only this controller\u003c/td\u003e\n    \u003c/tr\u003e\n    \u003ctr\u003e\n      \u003ctd\u003e\u003ccode\u003emodal\u003c/code\u003e\u003c/td\u003e\n      \u003ctd\u003e\u003ccode\u003edefault\u003c/code\u003e\u003c/td\u003e\n      \u003ctd\u003e\u003ccode\u003edefault\u003c/code\u003e\u003c/td\u003e\n      \u003ctd\u003eDismiss then Push on main stack\u003c/td\u003e\n    \u003c/tr\u003e\n    \u003ctr\u003e\n      \u003ctd\u003e\u003ccode\u003emodal\u003c/code\u003e\u003c/td\u003e\n      \u003ctd\u003e\u003ccode\u003edefault\u003c/code\u003e\u003c/td\u003e\n      \u003ctd\u003e\u003ccode\u003ereplace\u003c/code\u003e\u003c/td\u003e\n      \u003ctd\u003eDismiss then Replace on main stack\u003c/td\u003e\n    \u003c/tr\u003e\n    \u003ctr\u003e\n      \u003ctd\u003e\u003ccode\u003emodal\u003c/code\u003e\u003c/td\u003e\n      \u003ctd\u003e\u003ccode\u003emodal\u003c/code\u003e\u003c/td\u003e\n      \u003ctd\u003e\u003ccode\u003edefault\u003c/code\u003e\u003c/td\u003e\n      \u003ctd\u003ePush on the modal stack\u003c/td\u003e\n    \u003c/tr\u003e\n    \u003ctr\u003e\n      \u003ctd\u003e\u003ccode\u003emodal\u003c/code\u003e \u003c/td\u003e\n      \u003ctd\u003e\u003ccode\u003emodal\u003c/code\u003e\u003c/td\u003e\n      \u003ctd\u003e\u003ccode\u003ereplace\u003c/code\u003e\u003c/td\u003e\n      \u003ctd\u003eReplace controller on modal stack\u003c/td\u003e\n    \u003c/tr\u003e\n    \u003ctr\u003e\n      \u003ctd\u003e\u003ccode\u003edefault\u003c/code\u003e\u003c/td\u003e\n      \u003ctd\u003e(any)\u003c/td\u003e\n      \u003ctd\u003e\u003ccode\u003epop\u003c/code\u003e\u003c/td\u003e\n      \u003ctd\u003ePop controller off main stack\u003c/td\u003e\n    \u003c/tr\u003e\n    \u003ctr\u003e\n      \u003ctd\u003e\u003ccode\u003edefault\u003c/code\u003e\u003c/td\u003e\n      \u003ctd\u003e(any)\u003c/td\u003e\n      \u003ctd\u003e\u003ccode\u003erefresh\u003c/code\u003e\u003c/td\u003e\n      \u003ctd\u003ePop on main stack then\u003c/td\u003e\n    \u003c/tr\u003e\n    \u003ctr\u003e\n      \u003ctd\u003e\u003ccode\u003emodal\u003c/code\u003e\u003c/td\u003e\n      \u003ctd\u003e(any)\u003c/td\u003e\n      \u003ctd\u003e\u003ccode\u003epop\u003c/code\u003e\u003c/td\u003e\n      \u003ctd\u003ePop controller off modal stack (or)\u003cbr\u003e\n        Dismiss if one modal controller\n      \u003c/td\u003e\n    \u003c/tr\u003e\n    \u003ctr\u003e\n      \u003ctd\u003e\u003ccode\u003emodal\u003c/code\u003e\u003c/td\u003e\n      \u003ctd\u003e(any)\u003c/td\u003e\n      \u003ctd\u003e\u003ccode\u003erefresh\u003c/code\u003e\u003c/td\u003e\n      \u003ctd\u003ePop controller off modal stack then\u003cbr\u003e\n        Refresh last controller on modal stack\u003cbr\u003e\n        (or)\u003cbr\u003e\n        Dismiss if one modal controller then\u003cbr\u003e\n        Refresh last controller on main stack\n      \u003c/td\u003e\n    \u003c/tr\u003e\n    \u003ctr\u003e\n      \u003ctd\u003e(any)\u003c/td\u003e\n      \u003ctd\u003e(any)\u003c/td\u003e\n      \u003ctd\u003e\u003ccode\u003eclearAll\u003c/code\u003e\u003c/td\u003e\n      \u003ctd\u003eDismiss if modal controller then\u003cbr\u003e\n        Pop to root then\u003cbr\u003e\n        Refresh root controller on main stack\n      \u003c/td\u003e\n    \u003c/tr\u003e\n    \u003ctr\u003e\n      \u003ctd\u003e(any)\u003c/td\u003e\n      \u003ctd\u003e(any)\u003c/td\u003e\n      \u003ctd\u003e\u003ccode\u003ereplaceRoot\u003c/code\u003e\u003c/td\u003e\n      \u003ctd\u003eDismiss if modal controller then\u003cbr\u003e\n        Pop to root then\u003cbr\u003e\n        Replace root controller on main stack\n      \u003c/td\u003e\n    \u003c/tr\u003e\n    \u003ctr\u003e\n      \u003ctd\u003e(any)\u003c/td\u003e\n      \u003ctd\u003e(any)\u003c/td\u003e\n      \u003ctd\u003e\u003ccode\u003enone\u003c/code\u003e\u003c/td\u003e\n      \u003ctd\u003eNothing\u003c/td\u003e\n    \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\n### Examples\n\nTo present forms (URLs ending in `/new` or `/edit`) as a modal, add the following to the `rules` key of your Path Configuration.\n\n```json\n{\n  \"patterns\": [\n    \"/new$\",\n    \"/edit$\"\n  ],\n  \"properties\": {\n    \"context\": \"modal\"\n  }\n}\n```\n\nTo hook into the \"refresh\" [turbo-rails native route](https://github.com/hotwired/turbo-rails/blob/main/app/controllers/turbo/native/navigation.rb), 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.\n\n```json\n{\n  \"patterns\": [\n    \"/refresh_historical_location\"\n  ],\n  \"properties\": {\n    \"presentation\": \"refresh\"\n  }\n}\n```\n\n## Getting started\n\nFirst, create a new Xcode project using the iOS App template.\n\n![New Xcode project](.github/images/new-xcode-project.png)\n\nThen add the Turbo Navigator Swift package.\n\n1. In Xcode, File → Add Packages…\n1. Enter the following URL in the upper right: `https://github.com/joemasilotti/TurboNavigator`\n1. Click Add Package\n1. Click Add Package again\n\n![Add package](.github/images/add-package.png)\n\nReplace `SceneDelegate.swift` with the following.\n\n```swift\nimport TurboNavigator\nimport UIKit\n\nlet rootURL = URL(string: \"http://localhost:3000\")!\n\nclass SceneDelegate: UIResponder, UIWindowSceneDelegate {\n    var window: UIWindow?\n\n    private let navigationController = UINavigationController()\n    private lazy var navigator = TurboNavigator(delegate: self, navigationController: navigationController)\n\n    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {\n        window?.rootViewController = navigationController\n        navigator.route(rootURL)\n    }\n}\n\nextension SceneDelegate: TurboNavigationDelegate {}\n```\n\nStart the [demo Rails server](Demo/Server) then run the iOS app in Xcode via Product → Run.\n\n## Demo project\n\nThe `Demo/` directory includes an iOS app and Ruby on Rails server to demo the package.\n\nIt shows off most of the navigation flows outlined above. There is also an example CRUD resource for more real world applications of each.\n\n## Custom controller and routing overrides\n\nYou can also implement an optional method on the `TurboNavigationDelegate` to handle custom routing.\n\nThis 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.\n\n```json\n{\n  \"patterns\": [\n    \"/numbers$\"\n  ],\n  \"properties\": {\n    \"view-controller\": \"numbers\"\n  }\n}\n```\n\n```swift\nclass MyCustomClass: TurboNavigationDelegate {\n    let navigator = TurboNavigator(delegate: self)\n\n    func handle(proposal: VisitProposal) -\u003e ProposalResult {\n        if proposal.viewController == \"numbers\" {\n            // Let Turbo Navigator route this custom controller.\n            return NumbersViewController()\n        } else if proposal.presentation == .clearAll {\n            // Return nil to tell Turbo Navigator stop processing the request.\n            return nil\n        } else {\n            // Return the given controller to continue with default behavior.\n            // Optionally customize the given controller.\n            controller.view.backgroundColor = .orange\n            return controller\n        }\n    }\n}\n```\n\nIf 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.\n\n```swift\nclass NumbersViewController: UIViewController, PathConfigurationIdentifiable {\n    static var pathConfigurationIdentifier: String { \"numbers\" }\n}\n\nclass MyCustomClass: TurboNavigationDelegate {\n    let navigator = TurboNavigator(delegate: self)\n    \n    func handle(proposal: VisitProposal) -\u003e ProposalResult {\n        if proposal.viewController == NumbersViewController.pathConfigurationIdentifier {\n            // Let Turbo Navigator route this custom controller.\n            return .acceptCustom(NumbersViewController())\n        } else ... \n            ...\n        }\n    }\n}\n``` \n\n## Custom configuration\n\nCustomize the configuration via `TurboConfig`.\n\n### Override the user agent\n\nKeep \"Turbo Native\" to use `turbo_native_app?` on your Rails server.\n\n```swift\nTurboConfig.shared.userAgent = \"Custom (Turbo Native)\"\n```\n\n### Customize the web view and web view configuration\n\nA 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.\n\n```swift\nTurboConfig.shared.makeCustomWebView = { (configuration: WKWebViewConfiguration) in\n    // Customize the WKWebViewConfiguration instance\n    // ...\n\n    return WKWebView(frame: .zero, configuration: configuration)\n}\n```\n\n### Customize behavior for external URLs\n\nTurbo cannot navigate across domains because page visits are done via JavaScript. A tapped link that points to a different domain is considered external.\n\nBy 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.\n\nIf you want to register _additional_ URL schemes you need to follow the [steps outlined here](https://developer.apple.com/documentation/uikit/uiapplication/1622952-canopenurl#discussion) to add an entry in your Info.plist.\n\nThis behavior can be overridden by implementing the following delegate method.\n\n```swift\nclass MyCustomClass: TurboNavigationDelegate {\n    func openExternalURL(_ url: URL, from controller: UIViewController) {\n        // Do something custom with the external URL.\n        // The controller is provided to present on top of.\n    }\n}\n```\n\n### Customized error handling\n\nBy 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.\n\nYou can customize the error handling by overriding the following delegate method.\n\n```swift\nextension MyCustomClass: TurboNavigationDelegate {\n    func visitableDidFailRequest(_ visitable: Visitable, error: Error, retry: @escaping RetryBlock) {\n        if case let TurboError.http(statusCode) = error, statusCode == 401 {\n            // Custom error handling for 401 responses.\n        } else if let errorPresenter = visitable as? ErrorPresenter {\n            errorPresenter.presentError(error) {\n                retry()\n            }\n        }\n    }\n}\n```\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fjoemasilotti%2FTurboNavigator","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fjoemasilotti%2FTurboNavigator","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fjoemasilotti%2FTurboNavigator/lists"}