42 | Hotwire Native provides an answer to all of these drawbacks with its web-first approach. The framework enables you to build your screens once, in HTML and CSS, and reuse them across every platform. If you already have a Hotwire web app, you can use the screens you've already built!
43 |
44 |
45 |
46 | It unlocks bug fixes and new features without having to go through app store review. And you have full access to underlying iOS and Android SDKs and APIs as soon as they are released.
47 |
48 |
49 |
50 | Small teams can build highly functional, beautiful, and sustainable mobile apps. All without the headache and rigmarole of traditional native development.
51 |
52 |
53 |
54 | Hotwire Native is a high-level native framework, available for iOS and Android, that provides you with all the tools you need to leverage your web app and build great mobile apps. It wraps a web view within a native shell and renders HTML from your server. Native navigation and transition animations between screens work automatically out-of-the-box.
55 |
86 |
--------------------------------------------------------------------------------
/_source/_assets/css/components/_landing.scss:
--------------------------------------------------------------------------------
1 | .landing-intro {
2 | position: relative;
3 | margin: 0;
4 | padding: 0;
5 | background-color: $color-tint;
6 | border-top: 0.4rem solid $color-brand;
7 | }
8 |
9 | .landing-intro__text {
10 | position: relative;
11 | z-index: 1;
12 | margin: 1.25em 0 2.25em 0;
13 | text-align: center;
14 | color: $color-black;
15 | font-weight: 800;
16 | line-height: 1.3;
17 |
18 | @include media(medium) {
19 | font-size: $font-xxxx-large;
20 | }
21 | }
22 |
23 | .landing-hero {
24 | position: relative;
25 | margin: -4em 0 0 0;
26 |
27 | &::before {
28 | content: "";
29 | display: block;
30 | width: 110%;
31 | height: 90%;
32 | position: absolute;
33 | z-index: -1;
34 | left: -5%;
35 | top: 2%;
36 | background: url('/assets/waves-pattern-4.svg') center no-repeat;
37 | background-size: 100% 100%;
38 | }
39 |
40 | @include media(medium) {
41 | display: flex;
42 | justify-content: space-between;
43 |
44 | &::before {
45 | width: 124%;
46 | height: 80%;
47 | left: -12%;
48 | top: 12%;
49 | }
50 | }
51 | }
52 |
53 | .landing-hero__step {
54 | overflow: hidden;
55 | aspect-ratio: 2/1;
56 | position: relative;
57 | width: 100%;
58 | margin-bottom: 2em;
59 | background-color: $color-tint;
60 | border: 0.25em solid $color-white;
61 | box-shadow: 0 0 2em rgba(0,0,0, 0.1);
62 |
63 | img {
64 | display: block;
65 | width: 100%;
66 | height: auto;
67 | }
68 | }
69 |
70 | .landing-summary {
71 | text-align: left;
72 | font-size: $font-large;
73 | }
74 |
75 | .landing-versions {
76 | display: flex;
77 | justify-content: space-around;
78 | margin: 0 auto;
79 | }
80 |
81 | .landing-version {
82 | padding: 1.5em 2em;
83 | position: relative;
84 | margin: 0 0 5em 0;
85 | font-weight: 600;
86 | font-style: italic;
87 | font-size: $font-small;
88 |
89 | em {
90 | text-decoration: underline;
91 | }
92 |
93 | &:hover {
94 | em {
95 | text-decoration-color: $color-brand;
96 | }
97 | }
98 |
99 | @include media(medium) {
100 | margin-top: -4em;
101 | }
102 | }
103 |
104 | .landing-actions {
105 | grid-column: 3/4;
106 | margin: 0;
107 |
108 | @include media(medium) {
109 | display: flex;
110 | justify-content: space-between;
111 | align-items: stretch;
112 | }
113 | }
114 |
115 | .landing-actions__item {
116 | display: block;
117 | margin-bottom: 1em;
118 | padding: 1.5em 1em;
119 | font-family: $sans-stack;
120 | font-style: italic;
121 | text-align: center;
122 | line-height: 1.25;
123 | background-color: $color-white;
124 | box-shadow: 0 0 2em rgba($color-black, 0.075);
125 | border-radius: 0.5em;
126 |
127 | @include media(small) {
128 | max-width: 20em;
129 | margin-left: auto;
130 | margin-right: auto;
131 | }
132 |
133 | @include media(medium) {
134 | width: 32%;
135 | margin-bottom: 0;
136 | }
137 | }
138 |
139 | .landing-actions__icon {
140 | display: block;
141 | width: 3.75em;
142 | height: 3.75em;
143 | margin: 0 auto 1em auto;
144 | background-color: $color-brand;
145 |
146 | .landing-actions__item:hover & {
147 | background-color: $color-accent;
148 | }
149 | }
150 |
151 | .landing-actions__icon--guide {
152 | -webkit-mask: url('/assets/icon-guide.svg') center / 100% no-repeat;
153 | mask: url('/assets/icon-guide.svg') center / 100% no-repeat;
154 | }
155 |
156 | .landing-actions__icon--install {
157 | -webkit-mask: url('/assets/icon-install.svg') center / 100% no-repeat;
158 | mask: url('/assets/icon-install.svg') center / 100% no-repeat;
159 | }
160 |
161 | .landing-actions__icon--apple {
162 | -webkit-mask: url('/assets/icon-apple.svg') center / 100% no-repeat;
163 | mask: url('/assets/icon-apple.svg') center / 100% no-repeat;
164 | }
165 |
166 | .landing-actions__icon--android {
167 | -webkit-mask: url('/assets/icon-android.svg') center / 100% no-repeat;
168 | mask: url('/assets/icon-android.svg') center / 100% no-repeat;
169 | }
170 |
--------------------------------------------------------------------------------
/_source/_includes/svg/hotwire-native-logo.svg:
--------------------------------------------------------------------------------
1 |
21 |
--------------------------------------------------------------------------------
/_source/android/01_getting_started.md:
--------------------------------------------------------------------------------
1 | ---
2 | permalink: /android/getting-started.html
3 | order: 01
4 | title: "Getting Started"
5 | description: "How to create a new Hotwire Native app on Android."
6 | ---
7 |
8 | # Getting Started
9 |
10 | Follow these steps to create a minimal Hotwire Native application on Android with support for basic back/forward navigation and error handling.
11 |
12 | ## New Project
13 |
14 | First, download and install [Android Studio](https://developer.android.com/studio).
15 |
16 | Open Android Studio and create a new Android app via File → New → New Project... and choose the "Empty Views Activity" template.
17 |
18 |
19 |
20 | Then select API 28 or higher for the minimum SDK and Kotlin DSL for the build configuration language.
21 |
22 |
23 |
24 | ## Integrate Hotwire Native
25 |
26 | Add the Hotwire Native dependencies to your app's module (not top-level) `build.gradle.kts` file. You can find the latest version number from [github.com/hotwired/hotwire-native-android/releases](https://github.com/hotwired/hotwire-native-android/releases).
27 |
28 | ```kotlin
29 | dependencies {
30 | implementation("dev.hotwire:core:")
31 | implementation("dev.hotwire:navigation-fragments:")
32 | }
33 | ```
34 |
35 | Enable internet access for the app by opening `AndroidManifest.xml` and adding the following above the `` node:
36 |
37 | ```xml
38 |
39 | ```
40 |
41 | Set up the app's layout by opening `activity_main.xml` and replace the entire file with the following:
42 |
43 | ```xml
44 |
45 |
53 | ```
54 |
55 | Finally, open `MainActivity.kt` and replace the class with this code:
56 |
57 | ```kotlin
58 | package com.example.myapplication // update to match your project
59 |
60 | import android.os.Bundle
61 | import android.view.View
62 | import androidx.activity.enableEdgeToEdge
63 | import dev.hotwire.navigation.activities.HotwireActivity
64 | import dev.hotwire.navigation.navigator.NavigatorConfiguration
65 | import dev.hotwire.navigation.util.applyDefaultImeWindowInsets
66 |
67 | class MainActivity : HotwireActivity() {
68 | override fun onCreate(savedInstanceState: Bundle?) {
69 | enableEdgeToEdge()
70 | super.onCreate(savedInstanceState)
71 | setContentView(R.layout.activity_main)
72 | findViewById(R.id.main_nav_host).applyDefaultImeWindowInsets()
73 | }
74 |
75 | override fun navigatorConfigurations() = listOf(
76 | NavigatorConfiguration(
77 | name = "main",
78 | startLocation = "https://hotwire-native-demo.dev",
79 | navigatorHostId = R.id.main_nav_host
80 | )
81 | )
82 | }
83 | ```
84 |
85 | ## Run!
86 |
87 | Click Run → Run 'app' to launch the app in the emulator. You should see the following screen in the emulator:
88 |
89 |
90 |
91 |
92 |
93 | This example only touches on the core requirements of creating a `HotwireActivity` and routing start location. Feel free to change the URL used for the initial visit to point to your web app.
94 |
95 | And note that we are pointing to a demo application server that expects a bit more native functionality. Some of the links, like native controls, won't work out of the box. Check out the [Hotwire Native Android demo app](https://github.com/hotwired/hotwire-native-android/tree/main/demo) for examples on how to add bridge components, native screens, and more.
96 |
--------------------------------------------------------------------------------
/_source/ios/04_native_screens.md:
--------------------------------------------------------------------------------
1 | ---
2 | permalink: /ios/native-screens.html
3 | order: 04
4 | title: "Native Screens"
5 | description: "Integrate fully native Swift screens in your Hotiwre Native app."
6 | ---
7 |
8 | # Native Screens
9 |
10 | If you need to go fully native, we've got you covered: it's easy to integrate native screens to Hotwire Native's navigation flow. Even though you may be tempted to get a reference to Hotwire Native's navigation controller and push/present yourself, we strongly advise against it. It's better to leverage the power of Hotwire Native's [Path Configuration](/ios/path-configuration), even for native screens.
11 |
12 | First, conform your view controller to `PathConfigurationIdentifiable` and provide a matching `pathConfigurationIdentifier`. When Hotwire Native intercepts a link, the identifier is used to resolve that a native view controller was requested.
13 |
14 | ```swift
15 | class NumbersViewController: UITableViewController, PathConfigurationIdentifiable {
16 | static var pathConfigurationIdentifier: String { "numbers" }
17 |
18 | init(url: URL) {
19 | self.url = url
20 | }
21 |
22 | // ...
23 | }
24 | ```
25 |
26 | Next, create a URL path pattern to match against, and set its `view_controller` property. This path configuration routes all URLs ending in `/numbers`:
27 |
28 | ```json
29 | {
30 | "settings": {},
31 | "rules": [
32 | {
33 | "patterns": [
34 | "/numbers$"
35 | ],
36 | "properties": {
37 | "view_controller": "numbers"
38 | }
39 | }
40 | ]
41 | }
42 | ```
43 |
44 | When a link is intercepted by Hotwire Native, it will go through its usual process of matching the link's URL path to all rules in the app's Path Configuration. When it matches the above rule, it will create a `VisitProposal` and will set this `view_controller` property to `"numbers"`.
45 |
46 | You can inspect this property when `handle(proposal:)` is called on `Navigator`'s delegate and instantiate your own view controller there. That's it! Hotwire Native will handle presentation (push/replace and animations) as if it were a web view controller.
47 |
48 | ```swift
49 | class SceneDelegate: UIResponder, UIWindowSceneDelegate {
50 | private lazy var navigator = Navigator(
51 | configuration: .init(name: "main", startLocation: rootURL),
52 | delegate: self
53 | )
54 |
55 | // ...
56 | }
57 |
58 | extension SceneDelegate: NavigatorDelegate {
59 | func handle(proposal: VisitProposal, from navigator: Navigator) -> ProposalResult {
60 | switch proposal.viewController {
61 | case NumbersViewController.pathConfigurationIdentifier:
62 | let numbersViewController = NumbersViewController(url: proposal.url)
63 | return .acceptCustom(numbersViewController)
64 | default:
65 | return .accept
66 | }
67 | }
68 | }
69 | ```
70 |
71 | ## Progressive Rollout
72 |
73 | In a purely native app, if a new screen presented an issue you'd be unable to react immediately. The usual process would be to rush out bug fixes and hope for a quick review. If the bug was severe or your team needed more time to fix a critical issue, you'd have to rollback to a previous app version and submit that to the App Store, probably with an expedited review.
74 |
75 | Since even native screens are routed through Hotwire Native, the Path Configuration is a powerful ally when it comes to rolling out your native screens. If you were to find a critical issue with your native screen, you could easily update your remote Path Configuration and either point to your web content so users don't lose functionality, or immediately disable the screen altogether – no app store review required.
76 |
77 | Simply remove the `"view_controller"` property and Hotwire Native will stop using your native screen, instead presenting a web view controller which loads `"/numbers"`: a web page you fully control.
78 |
79 | ```json
80 | {
81 | "settings": {},
82 | "rules": [
83 | {
84 | "patterns": [
85 | "/numbers$"
86 | ],
87 | "properties": { }
88 | }
89 | ]
90 | }
91 | ```
92 |
93 | Check out the [demo app](https://github.com/hotwired/hotwire-native-ios/tree/main/Demo) to see how everything is wired up and for more complex examples.
94 |
--------------------------------------------------------------------------------
/_source/ios/06_reference.md:
--------------------------------------------------------------------------------
1 | ---
2 | permalink: /ios/reference.html
3 | order: 06
4 | title: "Reference"
5 | description: "An reference guide to the Hotwire Native iOS library."
6 | ---
7 |
8 | # Reference
9 |
10 | There are a few main types in Hotwire Native iOS, most notably `Navigator` and `Visitable`.
11 |
12 | ## Navigator
13 |
14 | The `Navigator` is the central coordinator in a Hotwire Native iOS application. Each `Navigator` manages the stack of screens via a `UINavigationController` with a single, shared `WKWebView` instance. It lets your app choose how to handle link taps, present view controllers, and deal with errors.
15 |
16 | ### Creating a `Navigator`
17 |
18 | Create with no parameters to use the default configuration:
19 |
20 | ```swift
21 | let navigator = Navigator()
22 | ```
23 |
24 | Provide optional [path configuration](path-configuration) to configure settings and path rules:
25 |
26 | ```swift
27 | let navigator = Navigator(pathConfiguration: pathConfiguration)
28 | ```
29 |
30 | Provide an optional [delegate](#navigatordelegate) to configure how different URLs, errors, and external links are handled:
31 |
32 | ```swift
33 | let navigator = Navigator(delegate: delegate)
34 |
35 | extension SceneController: NavigatorDelgate {
36 | // ...
37 | }
38 | ```
39 |
40 | Customize the underlying `WKWebView` and configuration with a block. For example, to use a custom `WKProcessPool` to share cookies from web views outside of Hotwire Native:
41 |
42 | ```swift
43 | Hotwire.config.makeCustomWebView = { config in
44 | config.processPool = processPool
45 | return WKWebView(frame: .zero, configuration: config)
46 | }
47 | ```
48 |
49 | ## `NavigatorDelegate`
50 |
51 | The delegate is an optional interface you can implement to customize behavior of the `Navigator`.
52 |
53 | ### Handling Proposals
54 |
55 | Hotwire Native iOS calls the `handle(proposal:)` method before every visit, such as when you tap a Turbo-enabled link or call `Turbo.visit(...)` in your web application. Implement this function to choose how to handle the specified URL and action. This is called a *proposal* since your application is not required to complete the visit.
56 |
57 | Return one of the following three `ProposalResult` cases:
58 | * `accept`: Proposals are accepted and a new [`HotwireWebViewController`](#hotwirewebviewcontroller) is displayed.
59 | * `acceptCustom(UIViewController)`: Provide a custom view controller to be displayed.
60 | * `reject`: No changes to navigation occur, the visit is effectively cancelled.
61 |
62 | ### Handling External URLs
63 |
64 | Implement `handle(externalURL:)` to customize the behavior when an external URL is visited. URLs are considered "external" if they do not match the same domain as the first visited link. By default, this will present a [`SFSafariViewController`](https://developer.apple.com/documentation/safariservices/sfsafariviewcontroller) modally.
65 |
66 | ### Handling Errors
67 |
68 | Network errors and responses with HTTP status codes outside of the 200 range are considered errors. By default a native screen with the error's localized description and a Retry button is presented.
69 |
70 | Customize this behavior by implementing `visitableDidFailRequest(_:error:retryHandler:)`. Call `retryHandler()` to attempt the network request again.
71 |
72 | ## `HotwireWebViewController`
73 |
74 | A `HotwireWebViewController` is a `UIViewController` that can be visited by a `Navigator`. Each view controller provides a `VisitableView` instance, which acts as a container for the shared `WKWebView`. The `VisitableView` optionally has a pull-to-refresh control and an activity indicator. It also automatically displays a screenshot of its contents when the web view moves to another `VisitableView`.
75 |
76 | Most applications will probably want to subclass `HotwireWebViewController` to customize its layout or add additional views. If your application’s design prevents you from subclassing `HotwireWebViewController`, you can implement the `Visitable` and `BridgeDestination` protocols yourself.
77 |
78 | Note: Custom `Visitable` view controllers must notify their delegate of their `viewWillAppear` and `viewDidAppear` methods through the `VisitableDelegate`'s `visitableViewWillAppear` and `visitableViewDidAppear` methods. The `Navigator` uses these hooks to know when it should move the `WKWebView` from one `VisitableView` to another.
79 |
--------------------------------------------------------------------------------
/_source/_assets/images/logo-hey.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/_source/_assets/css/base/_elements.scss:
--------------------------------------------------------------------------------
1 | * {
2 | box-sizing: border-box;
3 | margin: 0;
4 | padding: 0;
5 | }
6 |
7 | html {
8 | font-size: 10px;
9 | }
10 |
11 | body {
12 | margin: 0;
13 | padding: 0;
14 | position: relative;
15 | overflow-x: hidden;
16 | background-color: $color-white;
17 | color: $color-black;
18 | font-family: $serif-stack;
19 | font-size: $font-base-small;
20 | font-style: normal;
21 | font-weight: 400;
22 | line-height: 1;
23 |
24 | @include media(large) {
25 | font-size: $font-base-medium;
26 | }
27 |
28 | @include media(x-large) {
29 | font-size: $font-base-large;
30 | }
31 | }
32 |
33 | a {
34 | color: $color-black;
35 | font-weight: 550;
36 | margin: -.2rem;
37 | padding: .2rem;
38 | text-decoration: underline;
39 | text-decoration-thickness: 0.1em;
40 | text-decoration-width: 0.1rem;
41 |
42 | &:visited {
43 | color: $color-black;
44 | }
45 |
46 | @media(hover: hover) {
47 |
48 | &:hover {
49 | text-decoration-color: $color-accent;
50 | text-decoration-thickness: 0.2em;
51 | text-decoration-width: 0.2em;
52 | }
53 | }
54 |
55 | &:active {
56 | color: $color-black;
57 | }
58 | }
59 |
60 | h1, h2, h3 {
61 | margin-top: 1.5em;
62 | margin-bottom: 0.75em;
63 | font-family: $sans-stack;
64 | font-weight: 600;
65 | letter-spacing: -.01em;
66 | line-height: 1.1em;
67 | text-align: center;
68 | word-wrap: break-word;
69 | }
70 |
71 | h1 {
72 | margin-top: 2em;
73 | font-size: $font-xxx-large;
74 | font-weight: 700;
75 | }
76 |
77 | h2 {
78 | margin-top: 2.5em;
79 | font-size: $font-xx-large;
80 | text-align: center;
81 | }
82 |
83 | h3 {
84 | margin-top: 2.5em;
85 | font-size: $font-x-large;
86 | font-weight: 500;
87 | letter-spacing: 0.01em;
88 | }
89 |
90 | ul,
91 | ol {
92 | line-height: 1.6;
93 | margin-bottom: 1.5em;
94 | }
95 |
96 | li {
97 | margin-left: 1em;
98 | margin-bottom: 0.5em;
99 |
100 | ul,
101 | ol {
102 | margin-top: 0.5em;
103 | margin-bottom: 0;
104 | }
105 | }
106 |
107 | p {
108 | line-height: 1.6;
109 | margin-bottom: 1.5em;
110 | }
111 |
112 | strong {
113 | font-weight: 600;
114 | }
115 |
116 | blockquote {
117 | position: relative;
118 | margin-bottom: 1.5em;
119 | padding: 0 0 0 2em;
120 | border-left: 2px solid $color-black;
121 | text-align: left;
122 |
123 | &:before {
124 | content: "";
125 | position: absolute;
126 | top: 0;
127 | left: 0;
128 | display: block;
129 | width: 1.5rem;
130 | height: 1.5rem;
131 | background: url('/assets/logo.svg') left top / contain no-repeat;
132 | }
133 |
134 | h1,
135 | h2,
136 | h3 {
137 | margin-top: 2rem;
138 | text-align: left;
139 | }
140 | }
141 |
142 | hr {
143 | display: block;
144 | height: 1px;
145 | margin: 2em 0 3em 0;
146 | padding: 0;
147 | border: none;
148 | border-top: 1px solid #f7f5f2;
149 | }
150 |
151 | code,
152 | pre {
153 | font-family: $mono-stack;
154 | line-height: 1.25;
155 | padding: 0 0.1em;
156 | background-color: $color-tint;
157 | direction: ltr;
158 | text-align: left;
159 | white-space: pre;
160 | word-spacing: normal;
161 | word-break: normal;
162 | -moz-tab-size: 4;
163 | -o-tab-size: 4;
164 | tab-size: 4;
165 | -webkit-hyphens: none;
166 | -moz-hyphens: none;
167 | -ms-hyphens: none;
168 | hyphens: none;
169 | }
170 |
171 | pre {
172 | position: relative;
173 | overflow: auto;
174 | margin: 0 -1em 2em -1em;
175 | margin: calc(min(5vw, 4rem)*-1);
176 | margin-top: 0;
177 | margin-bottom: 2em;
178 | font-weight: 400;
179 | background: $color-tint;
180 |
181 | @include media(medium) {
182 | margin: 0 0 2em 0;
183 | }
184 |
185 | &::after {
186 | display: block;
187 | content: "";
188 | pointer-events: none;
189 | position: absolute;
190 | top: 0;
191 | bottom: 0;
192 | right: 0;
193 | width: 2em;
194 | background: linear-gradient(to right, rgba($color-tint, 0), $color-tint);
195 | }
196 | }
197 |
198 | code {
199 | padding: 0.125em;
200 | word-wrap: break-word;
201 | }
202 |
203 | pre > code {
204 | display: block;
205 | max-width: 100%;
206 | overflow: auto;
207 | padding: 1em;
208 | word-wrap: normal;
209 | }
210 |
211 | table {
212 | width: 100%;
213 | border-collapse: collapse;
214 | border-spacing: 0;
215 | text-align: left;
216 | }
217 |
218 | thead {
219 | background-color: $color-tint;
220 | font-family: $sans-stack;
221 | }
222 |
223 | tr {
224 | border-bottom: 0.125em solid $color-tint;
225 | }
226 |
227 | th,
228 | td {
229 | vertical-align: middle;
230 | padding: 0.5em;
231 | }
232 |
233 | input,
234 | select {
235 | vertical-align: middle;
236 | }
237 |
--------------------------------------------------------------------------------
/_source/_assets/images/logo-basecamp.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/.eleventy.js:
--------------------------------------------------------------------------------
1 | const glob = require('fast-glob');
2 | const syntaxHighlight = require("@11ty/eleventy-plugin-syntaxhighlight");
3 | const markdownIt = require("markdown-it");
4 | const markdownItAnchor = require('markdown-it-anchor');
5 | const markdownItToc = require('markdown-it-toc-done-right');
6 |
7 | module.exports = function(eleventyConfig) {
8 |
9 | /* --------------------------------------------------------------------------
10 | 11ty plugins
11 | -------------------------------------------------------------------------- */
12 | eleventyConfig.addPlugin(syntaxHighlight);
13 |
14 | /* --------------------------------------------------------------------------
15 | filters
16 | -------------------------------------------------------------------------- */
17 | glob.sync(['_source/_filters/*.js']).forEach(file => {
18 | let filters = require('./' + file);
19 | Object.keys(filters).forEach(name => eleventyConfig.addFilter(name, filters[name]));
20 | });
21 |
22 | /* --------------------------------------------------------------------------
23 | BrowserSync settings
24 | -------------------------------------------------------------------------- */
25 | eleventyConfig.setBrowserSyncConfig({
26 | ui: false,
27 | logPrefix: false,
28 | files: [ // watch the files generated elsewhere
29 | '_public/assets/*.css',
30 | '_public/assets/*.js',
31 | '_public/assets',
32 | '!_public/assets/**/**.map'
33 | ],
34 | server: { // make URLs work without a .html extension
35 | baseDir: "_public",
36 | serveStaticOptions: {
37 | extensions: ["html"]
38 | }
39 | },
40 | snippetOptions: {
41 | rule: { // put the snippet in the head for Turbo happiness
42 | match: /<\/head>/i,
43 | fn: function (snippet, match) {
44 | return snippet + match;
45 | }
46 | }
47 | },
48 | });
49 |
50 | /* --------------------------------------------------------------------------
51 | MarkdownIt settings
52 | -------------------------------------------------------------------------- */
53 | const markdownItOptions = {
54 | html: true, // allow HTML markup
55 | typographer: true // fancy quotes
56 | };
57 | const markdownLib = markdownIt(markdownItOptions);
58 | markdownLib.use(markdownItAnchor, { // add anchors to headings
59 | level: '2',
60 | permalink: 'true',
61 | permalinkClass: 'anchor',
62 | permalinkSymbol: '﹟',
63 | permalinkBefore: 'true'
64 | });
65 | markdownLib.use(markdownItToc, { // make a TOC with ${toc}
66 | level: '2',
67 | listType: 'ul'
68 | });
69 |
70 | /* --------------------------------------------------------------------------
71 | LiquidJS settings
72 | -------------------------------------------------------------------------- */
73 |
74 | eleventyConfig.setLiquidOptions({
75 | dynamicPartials: false,
76 | strictFilters: false
77 | });
78 |
79 | /* --------------------------------------------------------------------------
80 | 11ty settings
81 | -------------------------------------------------------------------------- */
82 |
83 | // overview collection - bake in ordering by 'order' front matter value
84 | eleventyConfig.addCollection("overview", function(collectionApi) {
85 | return collectionApi.getFilteredByTag("overview").sort((a, b) => {
86 | return a.data.order - b.data.order;
87 | });
88 | });
89 |
90 | // ios collection - bake in ordering by 'order' front matter value
91 | eleventyConfig.addCollection("ios", function(collectionApi) {
92 | return collectionApi.getFilteredByTag("ios").sort((a, b) => {
93 | return a.data.order - b.data.order;
94 | });
95 | });
96 |
97 | // android collection - bake in ordering by 'order' front matter value
98 | eleventyConfig.addCollection("android", function(collectionApi) {
99 | return collectionApi.getFilteredByTag("android").sort((a, b) => {
100 | return a.data.order - b.data.order;
101 | });
102 | });
103 |
104 | // reference collection - bake in ordering by 'order' front matter value
105 | eleventyConfig.addCollection("reference", function(collectionApi) {
106 | return collectionApi.getFilteredByTag("reference").sort((a, b) => {
107 | return a.data.order - b.data.order;
108 | });
109 | });
110 |
111 | eleventyConfig.setLibrary('md', markdownLib);
112 | eleventyConfig.setDataDeepMerge(true);
113 | eleventyConfig.addPassthroughCopy({ '_source/_assets/fonts': 'assets/fonts' });
114 | eleventyConfig.addPassthroughCopy({ '_source/_assets/images': 'assets' });
115 |
116 | return {
117 | dir: {
118 | input: '_source',
119 | output: '_public',
120 | layouts: '_layouts',
121 | includes: '_includes'
122 | },
123 | templateFormats: ['html', 'md', 'liquid', 'njk'],
124 | htmlTemplateEngine: 'liquid'
125 | };
126 | };
--------------------------------------------------------------------------------
/_source/reference/bridge_components.md:
--------------------------------------------------------------------------------
1 | ---
2 | permalink: /reference/bridge-components.html
3 | order: 04
4 | title: "Bridge Components"
5 | description: "Advanced details for Bridge Components"
6 | ---
7 |
8 | # Bridge Components
9 |
10 | ## Web Components
11 |
12 | The `BridgeComponent` class is an extension of a Stimulus [Controller](https://stimulus.hotwired.dev/reference/controllers). You have everything available in a standard `Controller` in addition to the following Hotwire Native-specific bridge functionality:
13 |
14 | * `static component`: The unique name of the component. This must match the name you use for the corresponding native component.
15 | * `this.platformOptingOut`: Specifies whether the controller is opted out for the current platform using the `data-controller-optout-` attribute.
16 | * `this.enabled`: Specifies whether the component is enabled and supported by the current version of the native app.
17 | * `this.bridgeElement`: Provides `this.element` for the component instance wrapped in a `BridgeElement`.
18 | * `this.send(event, data = {}, callback)`: Sends a message to the native component with the `event` name, optional JSON `data`, and a `callback` to be run when the native component replies to the message.
19 |
20 | For example, to create a `"form"` component that displays a native submit button in your native app, you'd add the following controller, target, and title attributes to your web `
38 | ```
39 |
40 | Next, create a `BridgeComponent` with named `"form"` that sends a message to the native component with `data` that contains the form's `submitTitle`. Provide a callback to run when the native component replies to the message.
41 |
42 | ```javascript
43 | // bridge/form_controller.js
44 |
45 | import { BridgeComponent, BridgeElement } from "@hotwired/hotwire-native-bridge"
46 |
47 | export default class extends BridgeComponent {
48 | static component = "form"
49 | static targets = [ "submit" ]
50 |
51 | submitTargetConnected(target) {
52 | const submitButton = new BridgeElement(target)
53 | const submitTitle = submitButton.title
54 |
55 | this.send("connect", { submitTitle }, () => {
56 | target.click()
57 | })
58 | }
59 | }
60 | ```
61 |
62 | _Note: It's recommended to place your bridge components in a `/bridge` subdirectory where your Stimulus controllers live to make them easily identifiable and isolated from your other Stimulus controllers._
63 |
64 | ## Bridge Elements
65 |
66 | The `BridgeElement` class lets you easily use bridge-specific data and behaviors on elements in your components. You can wrap any element in a `new BridgeElement(myElement)` within your bridge components to access the following:
67 |
68 | * `title`: Returns the title of the element, attempting to use a `data-bridge-title` value first, the `aria-label` value second, then otherwise falling back to the element's `textContent` or `value`.
69 | * `disabled`: Returns whether the element is disabled with the `data-bridge-disabled` attribute.
70 | * `enabled`: Returns the opposite of `disabled`.
71 | * `enableForComponent(component)`: Removes the `data-bridge-disabled` attribute on the element.
72 | * `hasClass(className)`: Returns whether the element has a particular class in its `classList`.
73 | * `attribute(name)`: Returns the value of an attribute on the element.
74 | * `bridgeAttribute(name)`: Returns the value of a `data-bridge-` attribute on the element.
75 | * `setBridgeAttribute(name, value)`: Sets the value of a `data-bridge-` attribute on the element.
76 | * `removeBridgeAttribute(name)`: Removes the `data-bridge-` attribute on the element.
77 | * `click()`: Performs a click on the element.
78 |
79 | ## Data Attributes
80 |
81 | The following data attributes can be applied to any element accessed via the `BridgeElement` class:
82 |
83 | * `data-bridge-title="My Title"`: Specifies a custom bridge title for your element.
84 | * `data-bridge-disabled`: Specifies whether the bridge element should be enabled or disabled for a particular platform. Values must be `"true"`, `"false"`, `"ios"`, or `"android"`.
85 | * `data-bridge-*`: Specifies arbitrary attributes prefixed with `data-bridge-` whose values are accessible from a `BridgeElement`.
86 |
87 | The following data attributes can be applied to elements associated with a `data-controller` and a `BridgeComponent` class:
88 |
89 | * `data-controller-optout-ios`: Opt-out the component for your iOS app using [hotwire-native-ios](https://github.com/hotwired/hotwire-native-ios). Allows you to conditionally disable a component instance for iOS, even if the native app supports the component.
90 | * `data-controller-optout-android`: Opt-out the component for your Android app using [hotwire-native-android](https://github.com/hotwired/hotwire-native-android). Allows you to conditionally disable a component instance for Android, even if the native app supports the component.
91 |
--------------------------------------------------------------------------------
/_source/ios/03_bridge_components.md:
--------------------------------------------------------------------------------
1 | ---
2 | permalink: /ios/bridge-components.html
3 | order: 03
4 | title: "Bridge Components"
5 | description: "Bridge the gap with native bridge components driven by the web on iOS."
6 | ---
7 |
8 | # Bridge Components
9 |
10 | Hotwire Native abstracts the integration with its corresponding web bridge (formerly [Strada](https://dev.37signals.com/announcing-strada/)), making it even faster to get started. This assumes you've already installed the [Hotwire Native Bridge javaScript package](/reference/bridge-installation) on your server.
11 |
12 | Let's walk through how to create a new component on iOS.
13 |
14 | The component will add a native bar button item to the right side of the navigation bar. Tapping it will "click" the associated link in the HTML.
15 |
16 |
17 |
18 | Native button component on iOS
19 |
20 |
21 | Components are made of three parts: the HTML markup, a `BridgeComponent` [Stimulus](https://stimulus.hotwired.dev) controller, and the native code. The HTML configures Stimulus which passes messages to Swift.
22 |
23 | ## Stimulus Controller
24 |
25 | On your server, add the data attributes needed to wire up the Stimulus controller.
26 |
27 | ```html
28 |
29 | View profile
30 |
31 | ```
32 |
33 | Then, create a new JavaScript `BridgeComponent` controller with the following.
34 |
35 | ```javascript
36 | import { BridgeComponent } from "@hotwired/hotwire-native-bridge"
37 |
38 | export default class extends BridgeComponent {
39 | static component = "button"
40 |
41 | connect() {
42 | super.connect()
43 |
44 | const element = this.bridgeElement
45 | const title = element.bridgeAttribute("title")
46 | this.send("connect", {title}, () => {
47 | this.element.click()
48 | })
49 | }
50 | }
51 | ```
52 |
53 | This component identifies itself as `"button"` via the static `component` property. It will pass all messages to a native bridge component identified with the same name.
54 |
55 | When `data-controller="button"` is found in the DOM then `connect()` is fired. This function calls `send()` which passes the `title` of the button in JSON to its native bridge component counterpart.
56 |
57 | The third parameter of send, the callback block, is executed when the bridge component replies back to the message, which is explained below. Here, the button is clicked.
58 |
59 | ## Swift Component
60 |
61 | In Xcode, create a new Swift file with the following.
62 |
63 | ```swift
64 | import HotwireNative
65 | import UIKit
66 |
67 | final class ButtonComponent: BridgeComponent {
68 | override class var name: String { "button" }
69 |
70 | override func onReceive(message: Message) {
71 | guard let viewController else { return }
72 | addButton(via: message, to: viewController)
73 | }
74 |
75 | private var viewController: UIViewController? {
76 | delegate?.destination as? UIViewController
77 | }
78 |
79 | private func addButton(via message: Message, to viewController: UIViewController) {
80 | guard let data: MessageData = message.data() else { return }
81 |
82 | let action = UIAction { [unowned self] _ in
83 | self.reply(to: "connect")
84 | }
85 | let item = UIBarButtonItem(title: data.title, primaryAction: action)
86 | viewController.navigationItem.rightBarButtonItem = item
87 | }
88 | }
89 |
90 | private extension ButtonComponent {
91 | struct MessageData: Decodable {
92 | let title: String
93 | }
94 | }
95 | ```
96 |
97 | First, the component identifies itself as `"button"` via `name` to match the Stimulus controller.
98 |
99 | `onReceive(message:)` is called when a message is received from Stimulus. Here, the `{title}` object is unpacked to add a native button to the right side of the screen. When it's tapped, the `UIAction` is fired, replying to the message and calling the callback block, clicking the button.
100 |
101 | Finally, register the component in `AppDelegate.swift`. This ensures that Hotwire is configured before the first URL is routed.
102 |
103 | ```swift
104 | import HotwireNative
105 | import UIKit
106 |
107 | @main
108 | class AppDelegate: UIResponder, UIApplicationDelegate {
109 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
110 | Hotwire.registerBridgeComponents([
111 | ButtonComponent.self
112 | ])
113 | return true
114 | }
115 | }
116 | ```
117 |
118 | ## Add CSS to Hide Bridged Elements
119 |
120 | We've now set up `"button"` components in the web and native apps. Whenever a native app supports the `"button"` component, it'll receive a message from the web component and display its native button.
121 |
122 | There's one final piece to finish. We want to hide the web button when a native button is being displayed in the native app. It's easy to write scoped css that is only applied if:
123 | - A particular version of the native app supports the `"button"` component
124 | - A particular element in your app is connected to a `"button"` component
125 |
126 | ```css
127 | [data-bridge-components~="button"]
128 | [data-controller~="button"] {
129 | display: none;
130 | }
131 | ```
132 |
--------------------------------------------------------------------------------
/_source/android/03_bridge_components.md:
--------------------------------------------------------------------------------
1 | ---
2 | permalink: /android/bridge-components.html
3 | order: 03
4 | title: "Bridge Components"
5 | description: "Bridge the gap with native bridge components driven by the web on Android."
6 | ---
7 |
8 | # Bridge Components
9 |
10 | Hotwire Native abstracts the integration with its corresponding web bridge (formerly [Strada](https://dev.37signals.com/announcing-strada/)), making it even faster to get started. This assumes you've already installed the [Hotwire Native Bridge javaScript package](/reference/bridge-installation) on your server.
11 |
12 | Let's walk through how to create a new component on Android.
13 |
14 | The component will add a native button item to the right side of the toolbar. Tapping it will "click" the associated link in the HTML.
15 |
16 |
17 |
18 | Native button component on Android
19 |
20 |
21 | Components are made of three parts: the HTML markup, a `BridgeComponent` [Stimulus](https://stimulus.hotwired.dev) controller, and the native code. The HTML configures Stimulus which passes messages to Kotlin.
22 |
23 | ## Stimulus Controller
24 |
25 | On your server, add the data attributes needed to wire up the Stimulus controller.
26 |
27 | ```html
28 |
29 | View profile
30 |
31 | ```
32 |
33 | Then, create a new JavaScript `BridgeComponent` controller with the following.
34 |
35 | ```javascript
36 | import { BridgeComponent } from "@hotwired/hotwire-native-bridge"
37 |
38 | export default class extends BridgeComponent {
39 | static component = "button"
40 |
41 | connect() {
42 | super.connect()
43 |
44 | const element = this.bridgeElement
45 | const title = element.bridgeAttribute("title")
46 | this.send("connect", {title}, () => {
47 | this.element.click()
48 | })
49 | }
50 | }
51 | ```
52 |
53 | This component identifies itself as `"button"` via the static `component` property. It will pass all messages to a native bridge component identified with the same name.
54 |
55 | When `data-controller="button"` is found in the DOM then `connect()` is fired. This function calls `send()` which passes the `title` of the button in JSON to its native bridge component counterpart.
56 |
57 | The third parameter of send, the callback block, is executed when the bridge component replies back to the message, which is explained below. Here, the button is clicked.
58 |
59 | ## Kotlin Component
60 |
61 | In Android Studio, create a new Kotlin file with the following.
62 |
63 | ```kotlin
64 | class ButtonComponent(
65 | name: String,
66 | private val delegate: BridgeDelegate
67 | ) : BridgeComponent(name, delegate) {
68 |
69 | override fun onReceive(message: Message) {
70 | // Handle incoming messages based on the message `event`.
71 | when (message.event) {
72 | "connect" -> handleConnectEvent(message)
73 | else -> Log.w("ButtonComponent", "Unknown event for message: $message")
74 | }
75 | }
76 |
77 | private fun handleConnectEvent(message: Message) {
78 | val data = message.data() ?: return
79 |
80 | // Write native code to display a native submit button in the
81 | // toolbar displayed in the delegate.destination. Use the
82 | // incoming data.title to set the button title.
83 | }
84 |
85 | private fun performButtonClick(): Boolean {
86 | return replyTo("connect")
87 | }
88 |
89 | // Use kotlinx.serialization annotations to define a serializable
90 | // data class that represents the incoming message.data json.
91 | @Serializable
92 | data class MessageData(
93 | @SerialName("title") val title: String
94 | )
95 | }
96 | ```
97 |
98 | This component subclasses the `BridgeComponent` available in the Android library.
99 |
100 | `onReceive(message)` is called when a message is received from Stimulus. Here, the `{title}` object is unpacked to add a native button to the right side of the screen. When it's tapped, the `performButtonClick()` is fired, replying to the message and calling the callback block, clicking the button.
101 |
102 | Finally, register the component with the matching `"button"` name as the Stimulus controller. The best place for this is in an `Application` [subclass for your app](/android/configuration#create-an-application-instance), which runs immediately at app startup:
103 |
104 | ```kotlin
105 | Hotwire.registerBridgeComponents(
106 | BridgeComponentFactory("button", ::ButtonComponent)
107 | )
108 | ```
109 |
110 | See the [Hotwire Native Android demo app](https://github.com/hotwired/hotwire-native-android/blob/main/demo/README.md) for a full implementation.
111 |
112 | ## Add CSS to Hide Bridged Elements
113 |
114 | We've now set up `"button"` components in the web and native apps. Whenever a native app supports the `"button"` component, it'll receive a message from the web component and display its native button.
115 |
116 | There's one final piece to finish. We want to hide the web button when a native button is being displayed in the native app. It's easy to write scoped css that is only applied if:
117 | - A particular version of the native app supports the `"button"` component
118 | - A particular element in your app is connected to a `"button"` component
119 |
120 | ```css
121 | [data-bridge-components~="button"]
122 | [data-controller~="button"] {
123 | display: none;
124 | }
125 | ```
126 |
127 |
--------------------------------------------------------------------------------
/_source/_assets/css/components/_nav.scss:
--------------------------------------------------------------------------------
1 | .nav-skip {
2 | clip: rect(1px, 1px, 1px, 1px);
3 | position: absolute !important;
4 | height: 1px;
5 | width: 1px;
6 | overflow: hidden;
7 | background-color: $color-white;
8 |
9 | &:hover,
10 | &:active,
11 | &:focus {
12 | clip: auto !important;
13 | top: 1rem;
14 | left: 1rem;
15 | width: auto;
16 | height: auto;
17 | z-index: 100000;
18 | }
19 | }
20 |
21 | .nav-logo {
22 | display: block;
23 | height: 2em;
24 | margin: 0;
25 | max-height: 100%;
26 | text-decoration: none;
27 |
28 | .logo {
29 | height: 100%;
30 | width: auto;
31 | }
32 |
33 | .logo__icon {
34 | fill: $color-brand;
35 | }
36 |
37 | path {
38 | transition: fill 0.2s ease;
39 | }
40 |
41 | @media(hover: hover) {
42 |
43 | &:hover path,
44 | &:hover rect {
45 | fill: $color-accent;
46 | }
47 |
48 | &:hover span {
49 | color: $color-accent;
50 | }
51 | }
52 |
53 | span {
54 | font-family: $mono-stack;
55 | font-size: $font-xxx-large;
56 | font-weight: 900;
57 | text-transform: uppercase;
58 | font-style: italic;
59 | }
60 | }
61 |
62 | .nav {
63 |
64 | @include media(small) {
65 | @include transform(translate(-100%, 0));
66 | @include transition(transform 0.3s ease-in-out);
67 | opacity: 0;
68 | position: fixed;
69 | overflow-y: auto;
70 | top: 0;
71 | left: 0;
72 | display: block;
73 | margin: 0;
74 | padding: 0 1.5em;
75 | width: 100%;
76 | height: 100%;
77 | z-index: 3000;
78 | text-align: right;
79 | background-color: $color-brand;
80 | }
81 |
82 | @include media(medium) {
83 | @include transform(translate(0, 0));
84 | @include transition(transform 0s ease-in-out);
85 | margin: 0;
86 | padding: 0;
87 | opacity: 1;
88 | background-color: $color-white;
89 | }
90 | }
91 |
92 | .nav__list {
93 | margin: 1em 0 0 0;
94 | text-align: right;
95 | list-style-type: none;
96 | border-top: 0.2rem solid $color-brand;
97 |
98 | @include media(medium) {
99 | text-align: left;
100 | }
101 |
102 | &.active {
103 | display: block;
104 | }
105 |
106 | li {
107 | margin: 0;
108 | padding: 0;
109 | }
110 | }
111 |
112 | .nav__list--horizontal {
113 |
114 | @include media(small) {
115 | font-size: $font-x-large;
116 | }
117 |
118 | @include media(medium) {
119 | display: flex;
120 | margin: 0;
121 | border: none;
122 |
123 | li {
124 | margin: 0 0.25em;
125 | }
126 | }
127 |
128 | }
129 |
130 | .nav__list-link {
131 | display: block;
132 | margin: 0.5em 0;
133 | padding: 0.25em 0;
134 | font-size: $font-x-large;
135 | font-family: $sans-stack;
136 | font-weight: 600;
137 | font-style: italic;
138 | line-height: 1.25;
139 |
140 | &.active {
141 | text-decoration: none;
142 | pointer-events: none;
143 | font-weight: 800;
144 | }
145 |
146 | @include media(medium) {
147 | line-height: 1.4;
148 | font-size: $font-medium;
149 |
150 | .nav__list--horizontal & {
151 | padding: 0;
152 | margin: 0;
153 | }
154 |
155 | .nav__list--horizontal li:not(:last-child) &::after {
156 | display: inline-block;
157 | margin-left: 0.5rem;
158 | margin-right: -0.5rem;
159 | content: "/";
160 | }
161 | }
162 | }
163 |
164 | .nav__sublist {
165 | display: none;
166 | list-style-type: none;
167 | margin: 0;
168 | border-right: 0.2rem solid $color-black;
169 |
170 | @include media(medium) {
171 | border-right: 0;
172 | border-left: 0.2rem solid $color-black;
173 | }
174 |
175 | &.active {
176 | display: block;
177 | }
178 |
179 | li {
180 | margin: 0;
181 | padding: 0 0.25em;
182 | }
183 | }
184 |
185 | .nav__sublist-link {
186 | display: block;
187 | margin: 0;
188 | padding: 0.5em;
189 | font-size: $font-large;
190 | font-family: $sans-stack;
191 | font-weight: 400;
192 | line-height: 1.25;
193 |
194 | &.active {
195 | text-decoration: none;
196 | pointer-events: none;
197 | font-weight: 800;
198 | }
199 |
200 | @include media(medium) {
201 | text-align: left;
202 | margin-bottom: 0.5em;
203 | padding: 0.25em;
204 | font-size: $font-small;
205 | line-height: 1.4;
206 |
207 | &.active {
208 | color: $color-black;
209 | background-color: $color-tint;
210 | border-radius: 0.125em;
211 | }
212 | }
213 | }
214 |
215 | .nav-checkbox {
216 | display: none;
217 | }
218 |
219 | .nav-checkbox:checked ~ .nav {
220 | @include transform(translate(0, 0));
221 | opacity: 1;
222 | }
223 |
224 | .nav-mobile-button {
225 | display: block;
226 | margin: 0;
227 | padding: 0.5em 1em;
228 | cursor: pointer;
229 | z-index: 2000;
230 | font-family: $sans-stack;
231 | font-size: $font-large;
232 | font-weight: 800;
233 | text-transform: uppercase;
234 | background-color: $color-brand;
235 | border-radius: 0.2rem;
236 |
237 | @include media(medium) {
238 | display: none;
239 | }
240 |
241 | span {
242 | position: relative;
243 | display: inline-flex;
244 | top: -0.5rem;
245 | width: 1.25em;
246 | height: 0.2rem;
247 | background-color: $color-white;
248 |
249 | &::after,
250 | &::before {
251 | content: '';
252 | position: absolute;
253 | display: block;
254 | width: 1.25em;
255 | height: 0.2rem;
256 | background-color: $color-white;
257 | }
258 |
259 | &::before {
260 | margin-top: -0.3em;
261 | }
262 |
263 | &::after {
264 | margin-top: 0.3em;
265 | }
266 | }
267 | }
268 |
269 | .nav-mobile-button--close {
270 | margin: 1.5em auto 1.5em auto;
271 | padding: 0;
272 | background-color: $color-brand;
273 |
274 | span {
275 | background-color: rgba(0, 0, 0, 0);
276 |
277 | &::before,
278 | &::after {
279 | background-color: $color-black;
280 | margin-top: 0;
281 | }
282 |
283 | &::before {
284 | @include transform(rotate(45deg));
285 | }
286 |
287 | &::after {
288 | @include transform(rotate(-45deg));
289 | }
290 | }
291 | }
292 |
293 | .nav__github-corner {
294 |
295 | @include media(small) {
296 | display: none;
297 | }
298 |
299 | position: absolute;
300 | right: 0px;
301 | z-index: 1;
302 |
303 | .github-corner:hover .octo-arm {
304 | animation: octocat-wave 560ms ease-in-out
305 | }
306 |
307 | @keyframes octocat-wave {
308 | 0%, 100% {
309 | transform: rotate(0)
310 | }
311 |
312 | 20%, 60% {
313 | transform: rotate(-25deg)
314 | }
315 |
316 | 40%, 80% {
317 | transform: rotate(10deg)
318 | }
319 | }
320 |
321 | @media(max-width: 500px) {
322 | .github-corner:hover .octo-arm {
323 | animation: none
324 | }
325 |
326 | .github-corner .octo-arm {
327 | animation: octocat-wave 560ms ease-in-out
328 | }
329 | }
330 |
331 | @namespace svg url(github-corner);
332 |
333 | svg|a:link, svg|a:visited {
334 | cursor: pointer;
335 | }
336 |
337 | svg|a text,
338 | text svg|a {
339 | fill: blue;
340 | text-decoration: underline;
341 | }
342 |
343 | svg|a:hover, svg|a:active {
344 | outline: dotted 1px blue;
345 | }
346 |
347 | }
348 |
--------------------------------------------------------------------------------
/_source/reference/path_configuration.md:
--------------------------------------------------------------------------------
1 | ---
2 | permalink: /reference/path-configuration.html
3 | order: 02
4 | title: "Path Configuration"
5 | description: "Advanced navigation via the path configuration."
6 | ---
7 |
8 | # Path Configuration
9 |
10 | The basics of *Path Configuration* are explained in the [overview](/overview/path-configuration). If you're ready to build your first Path Configuration, keep reading.
11 |
12 | ## Settings
13 |
14 | `settings` is your sandbox for App-level configuration. As explained in the overview, common use cases include feature flags or any additional information that your app may use to configure itself. Feel free to add or modify any objects or arrays here, always remembering to [version](/overview/path-configuration#versioning) your path configuration if you make breaking changes.
15 |
16 | ```json
17 | {
18 | "settings": {
19 | "use_local_db": true,
20 | "cable": {
21 | "script_url": "https://hotwire-native-demo.dev/configurations/action_cable.js"
22 | },
23 | "feature_flags": [
24 | {
25 | "name": "new_onboarding_flow",
26 | "enabled": true
27 | }
28 | ]
29 | }
30 | "rules": []
31 | }
32 | ```
33 |
34 | ## Rules
35 |
36 | `rules` contains individual entries that define how different URL path patterns should behave. Each rule consists of the `patterns` to match and the `properties` to apply.
37 |
38 | ```json
39 | {
40 | "settings": {},
41 | "rules": [
42 | {
43 | "patterns": "",
44 | "properties": {}
45 | }
46 | ]
47 | }
48 | ```
49 |
50 | Entries in `rules` are read sequentially and are applied to the caught URL (via path pattern regex matching) as they are read. This means that rules earlier in the array can be overwritten by rules further down the array. It's recommended that the first rule should establish the default behavior for all patterns and subsequent rules can override this for specific behavior.
51 |
52 | The following *Path Configuration* shows how a rule further down the array can override properties set by previous rules. Notice property `pull_to_refresh_enabled` is `true` for all URLs but `false` for URL path patterns matching `"/new$"`.
53 |
54 | ```json
55 | {
56 | "settings": {},
57 | "rules": [
58 | {
59 | "patterns": [
60 | ".*"
61 | ],
62 | "properties": {
63 | "context": "default",
64 | "pull_to_refresh_enabled": true
65 | }
66 | },
67 | {
68 | "patterns": [
69 | "/new$"
70 | ],
71 | "properties": {
72 | "context": "modal",
73 | "pull_to_refresh_enabled": false
74 | }
75 | }
76 | ]
77 | }
78 | ```
79 |
80 | Using the above Path Configuration, when a navigation is requested to `"/"`:
81 | 1. *Path Configuration* matches `"/"` to the first rule `".*"`
82 | 2. *Path Configuration* sets `pull_to_refresh_enabled = true`
83 | 3. Since the second rule does not match the URL path pattern, it is ignored.
84 |
85 | However, when navigation is requested to `"/new"`:
86 | 1. *Path Configuration* matches `"/new"` to the first rule `".*"`
87 | 2. *Path Configuration* sets `pull_to_refresh_enabled = true`
88 | 3. *Path Configuration* matches `"/new"` to the second rule `"/new$"`
89 | 4. *Path Configuration* sets `pull_to_refresh_enabled = false`
90 |
91 | A rule earlier in the array can be overwritten by rules further down the array.
92 |
93 |
94 | ### Patterns
95 |
96 | The `patterns` array defines regular expression patterns that will be used to match URL paths.
97 |
98 | ### Properties
99 |
100 | The `properties` hash contains a handful of key/value pairs that Hotwire Native supports out of the box.
101 |
102 | * `context` — Specifies the presentation context in which the view should be displayed. Hotwire Native will determine what the navigation behavior should be based on this value and `presentation`.
103 | * Optional.
104 | * Possible values: `default` or `modal`. Defaults to `default`.
105 | * `presentation` — Specifies what style to use when presenting the given destination. Hotwire Native will determine what the navigation behavior should be based on this value and `context`.
106 | * Optional.
107 | * Possible values: `default`, `push`, `pop`, `replace`, `replace_root`, `clear_all`, `refresh`, `none`. Defaults to `default`.
108 | * `pull_to_refresh_enabled` — Whether or not pull-to-refresh should be enabled.
109 | * Optional.
110 | * Possible values: `true`, `false`. Defaults to `false` on Android and `true` on iOS.
111 | * `animated` — Specifies whether the navigation should be animated when pushing, popping, or presenting.
112 | * Optional.
113 | * Possible values: `true`, `false`. Defaults to `true`.
114 |
115 | You are free to add more properties as your app needs, but these are the ones the framework is aware of and will handle automatically.
116 |
117 | ### Android-specific properties
118 |
119 | * `uri` — The target destination URI to navigate to. Must map to an Activity or Fragment that has implemented the [`HotwireDestinationDeepLink`](https://github.com/hotwired/hotwire-native-android/blob/main/navigation-fragments/src/main/java/dev/hotwire/navigation/destinations/HotwireDestinationDeepLink.kt) annotation with a matching `uri` value.
120 | * **Required**.
121 | * No explicit value options. No default value.
122 | * `fallback_uri` — Provides a fallback URI in case a destination cannot be found that maps to the `uri`. Can be useful in cases when pointing to a new `uri` that may not be available yet in older versions of the app.
123 | * Optional.
124 | * No explicit value options. No default value.
125 | * `title` — Specifies a default title that will be displayed in the toolbar for the destination. This is most useful for native destinations, since web destinations will render their title from the web view page's `` tag.
126 | * Optional.
127 | * No explicit value options. No default value.
128 |
129 | ### iOS-specific properties
130 |
131 | * `view_controller` — The identifier for a native `UIViewController` to navigate to. Conform your custom controller to `PathConfigurationIdentifiable` to it to this identifier.
132 | * Optional.
133 | * No explicit value options. No default value.
134 | * `modal_style` — Specifies how a modal should be presented. Make sure to set `context` to `modal`, too.
135 | * Optional.
136 | * Possible values (defaults to `large`):
137 | * `large` — The default system presentation style, [*automatic*](https://developer.apple.com/documentation/uikit/uimodalpresentationstyle/automatic).
138 | * `medium` — A half-sheet modal that can be expanded to full screen when dragged up, via [detents](https://developer.apple.com/documentation/uikit/uisheetpresentationcontroller/detents).
139 | * [`full`](https://developer.apple.com/documentation/uikit/uimodalpresentationstyle/fullscreen) — A full-screen modal, covering everything but the status bar.
140 | * [`page_sheet`](https://developer.apple.com/documentation/uikit/uimodalpresentationstyle/pagesheet) — On iPads, presents a modal that partially covers the underlying content. On iPhones, uses the default system presentation style.
141 | * [`form_sheet`](https://developer.apple.com/documentation/uikit/uimodalpresentationstyle/formsheet) — On iPads, presents a modal centered in the screen. On iPhones, uses the default system presentation style.
142 | * `modal_dismiss_gesture_enabled` — Whether or not swiping down (or tapping outside the content on iPads) on a modal will dismiss it.
143 | * Optional.
144 | * Possible values: `true`, `false`. Defaults to `true`.
145 |
--------------------------------------------------------------------------------
/_source/reference/navigation.md:
--------------------------------------------------------------------------------
1 | ---
2 | permalink: /reference/navigation.html
3 | order: 01
4 | title: "Navigation"
5 | description: "How to navigate between screens with Hotwire Native."
6 | ---
7 |
8 | # Navigation
9 |
10 | Navigating between screens is a core concept of building Hotwire Native apps. By default, all screens will be pushed onto the main navigation stack with animation. You can customize the navigation behavior by providing path configuration rules or manually routing in Swift or Kotlin.
11 |
12 | ## Routing
13 |
14 | Set `context` or `presentation` to a [path configuration](/reference/path-configuration) rule to apply the logic in the following table.
15 |
16 | * **State** describes what state the app is currently in: `modal` if a modal is presented, `default` otherwise.
17 | * **Context** is the value of the `context` property on the tapped link: `modal` or `default`. No value defaults to `default`.
18 | * **Presentation** is the value of the `presentation` property on the tapped link: `replace`, `pop`, `refresh`, `clear_all`, `replace_root`, `none`, or `default`. No value defaults to `default`.
19 |
20 |
21 |
22 |
23 |
State
24 |
Context
25 |
Presentation
26 |
Behavior
27 |
28 |
29 |
30 |
31 |
default
32 |
default
33 |
default
34 |
35 | Push on main stack (or)
36 | Replace if visiting same page (or)
37 | Pop then visit if previous screen is same URL
38 |
39 |
40 |
41 |
default
42 |
default
43 |
replace
44 |
Replace screen on main stack
45 |
46 |
47 |
default
48 |
modal
49 |
default
50 |
Present a modal with only this screen
51 |
52 |
53 |
default
54 |
modal
55 |
replace
56 |
Present a modal with only this screen
57 |
58 |
59 |
modal
60 |
default
61 |
default
62 |
Dismiss then Push on main stack
63 |
64 |
65 |
modal
66 |
default
67 |
replace
68 |
Dismiss then Replace on main stack
69 |
70 |
71 |
modal
72 |
modal
73 |
default
74 |
Push on the modal stack
75 |
76 |
77 |
modal
78 |
modal
79 |
replace
80 |
Replace screen on modal stack
81 |
82 |
83 |
default
84 |
(any)
85 |
pop
86 |
Pop screen off main stack
87 |
88 |
89 |
default
90 |
(any)
91 |
refresh
92 |
Pop on main stack then
93 |
94 |
95 |
modal
96 |
(any)
97 |
pop
98 |
99 | Pop screen off modal stack (or)
100 | Dismiss if one modal screen
101 |
102 |
103 |
104 |
modal
105 |
(any)
106 |
refresh
107 |
108 | Pop screen off modal stack then
109 | Refresh last screen on modal stack
110 | (or)
111 | Dismiss if one modal screen then
112 | Refresh last screen on main stack
113 |
114 |
115 |
116 |
(any)
117 |
(any)
118 |
clear_all
119 |
120 | Dismiss if modal screen then
121 | Pop to root then
122 | Refresh root screen on main stack
123 |
124 |
125 |
126 |
(any)
127 |
(any)
128 |
replace_root
129 |
130 | Dismiss if modal screen then
131 | Pop to root then
132 | Replace root screen on main stack
133 |
134 |
135 |
136 |
(any)
137 |
(any)
138 |
none
139 |
Nothing
140 |
141 |
142 |
143 |
144 | ### Server-Driven Routing in Rails
145 |
146 | If you're using Ruby on Rails, the [turbo-rails](https://github.com/hotwired/turbo-rails) gem provides the following additional historical location routes. Use these to manipulate the navigation stack for Hotwire Native apps, falling back to redirecting elsewhere.
147 |
148 | * `recede_or_redirect_to(url, **options)` - First, pops any modal screen (if present) off the navigation stack. Then, pops the visible screen off of the navigation stack.
149 | * `refresh_or_redirect_to(url, **options)` - First, pops any modal screen (if present) off the navigation stack. Then, reloads the visible screen by performing a new web request and invalidating the cache.
150 | * `resume_or_redirect_to(url **options)` - Pops any modal screen (if present) off the navigation stack. No further action is taken.
151 |
152 | The iOS and Android frameworks (starting in version `1.2.0`) automatically support these historical location urls.
153 |
154 | ## Route Decision Handlers
155 |
156 | By default, all external urls outside of your app's domain open externally. The specific behavior can be customized, though. Out-of-the-box, Hotwire Native registers these route decision handlers to control how urls are routed:
157 | - `AppNavigationRouteDecisionHandler`: Routes all internal urls on your app's domain through your app.
158 | - `SafariViewControllerRouteDecisionHandler`: **(iOS Only)** Routes all external `http`/`https` urls to a [SFSafariViewController](https://developer.apple.com/documentation/safariservices/sfsafariviewcontroller) in your app.
159 | - `BrowserTabRouteDecisionHandler`: **(Android Only)** Routes all external `http`/`https` urls to a [Custom Tab](https://developer.chrome.com/docs/android/custom-tabs) in your app.
160 | - `SystemNavigationRouteDecisionHandler`: Routes all remaining external urls (such as `sms:` or `mailto:`) through device's system navigation.
161 |
162 | If you'd like to customize this behavior you can subclass the `RouteDecisionHandler` class in your app to provide your own implementation(s). Register your app's decision handlers in order of importance. To decide how a url should be routed, the registered `RouteDecisionHandler` instances are called in order. When a matching `RouteDecisionHandler` is found for a given url, its `handle()` function is called and no other `RouteDecisionHandler` instances will be subsequently called.
163 |
164 | **Example for iOS:**
165 | ```swift
166 | Hotwire.registerRouteDecisionHandlers([
167 | AppNavigationRouteDecisionHandler(),
168 | MyCustomExternalRouteDecisionHandler()
169 | ])
170 | ```
171 |
172 | **Example for Android:**
173 | ```kotlin
174 | Hotwire.registerRouteDecisionHandlers(
175 | AppNavigationRouteDecisionHandler(),
176 | MyCustomExternalRouteDecisionHandler()
177 | )
178 | ```
179 |
180 | ## Manual Navigation
181 |
182 | `Navigator` can be used to navigate from a [native screen](/overview/native-screens) to another native screen or back to a web context.
183 |
184 | ### iOS
185 |
186 | ```swift
187 | let rootURL = URL(string: "...")!
188 | let navigator = Navigator()
189 |
190 | // Visit a new page.
191 | navigator.route(rootURL.appending(path: "foo"))
192 |
193 | // Pop the top controller off the stack.
194 | navigator.pop()
195 |
196 | // Pop the entire stack of controllers.
197 | navigator.clearAll()
198 | ```
199 |
200 | Disable the animation via the optional `animated` parameter.
201 |
202 | ```swift
203 | navigator.route(rootURL.appending(path: "foo"), animated: false)
204 | navigator.pop(animated: false)
205 | navigator.clearAll(animated: false)
206 | ```
207 |
208 | ### Android
209 |
210 | Inside of a `HotwireActivity` class:
211 |
212 | ```kotlin
213 | val location = "https://..."
214 | val navigator = delegate.currentNavigator
215 |
216 | // Visit a new page.
217 | navigator?.route("$location/foo")
218 |
219 | // Pop the backstack to the previous destination.
220 | navigator?.pop()
221 |
222 | // Clear the navigation backstack to the start destination.
223 | navigator?.clearAll()
224 | ```
225 |
226 | Inside of a `HotwireFragment` class:
227 |
228 | ```kotlin
229 | val location = "https://..."
230 |
231 | // Visit a new page.
232 | navigator.route("$location/foo")
233 |
234 | // Pop the backstack to the previous destination.
235 | navigator.pop()
236 |
237 | // Clear the navigation backstack to the start destination.
238 | navigator.clearAll()
239 | ```
240 |
--------------------------------------------------------------------------------
/_source/_assets/images/logo-once.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/_source/_assets/images/waves-pattern-4.svg:
--------------------------------------------------------------------------------
1 |
226 |
--------------------------------------------------------------------------------