But getting started isn’t easy. You need to know a bit of Swift and Kotlin to build the initial version of the apps. And the official documentation is… a little sparse.
This guide hopes to fix that by providing a step-by-step tutorial to build basic iOS and Android Turbo Native apps from scratch. You won’t need any mobile experience, just a macOS device with Xcode and Android Studio installed.
Think of this guide as dipping your toes into the waters of Turbo Native development. The goal is to get you started and get a feel for what mobile development with Rails is like.
By the end of this guide you will have an iOS app and Android app both powered by Turbo Native. They will point to the Turbo Native Demo server, so you won’t need to do any backend coding. The source code for that server can be found on GitHub.
These apps will implement a basic Turbo Native integration: pushing new screens with animation, popping screens (navigating back), loading indicators, and error handling. But they won’t include Strada or any native integrations. Find links at the end for where to go next to further upgrade your apps.
Without further ado, let’s dive in! We’ll start with iOS.
First, download the latest version of Xcode from the App Store. This guide and the screenshots reference Xcode 15.3.
Once downloaded and installed, open Xcode and wait for any iOS SDKs to finish downloading.
In Xcode click File → New → Project…
Select the iOS tab at the top then App from the Application section. Click Next.
This template generates a barebones iOS app with a single screen. Perfect for building the Turbo Native app on top of.
On the next screen of the wizard, enter or select the following details:
Product Name is the name of the app user’s see when they install it on their device.
You can select a Team to automatically sign your code before releasing the app to the App Store.
Organization Identifier is how the app is uniquely identified in the App Store but won’t be shown to users.
For Interface we are using the traditional Storyboard option instead of SwiftUI. There are still some limitations in SwiftUI navigation that make getting it to work with Turbo Native a little clunky.
Finally, we want to use Swift for the Language and don’t care about persisting anything to the device (select None for Storage).
On the next screen select a location to store your project then click Create.
Alright, you’ve got a brand new iOS app! Now let’s add the Turbo Native dependency.
Swift packages are a lot like gems in Ruby. But instead of bundler we can use the Swift Package Manager built into Xcode to manage our dependencies.
In Xcode click File → Add Package Dependencies…
In the search bar in the upper right enter https://github.com/hotwired/turbo-ios
.
Switch the Dependency Rule option to Branch and enter turbo-navigator
.
Click Add Package in the bottom right then Add Package again on the next screen.
We point to the turbo-navigator branch instead of an official release because Turbo Native iOS is going through a big upgrade. The code in this branch drastically simplifies the usage of the library and makes it easier for new developers to get started. Myself and the other maintainers recommend starting new projects with this branch - moving forward it will be the default way of using the library.
Up next we want to kick off the Turbo Native integration and visit the homepage of the demo server.
Double-click SceneDelegate from the left pane to open the file. Here is where we will integrate Turbo Native with the app.
Delete all the comments from the file. Then, delete all the functions inside of this class except the first one. You’ll be left with the following:
import UIKit
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let _ = (scene as? UIWindowScene) else { return }
}
}
This remaining function will be called when the app launches - the perfect place to kick off our Turbo Native integration.
Before we do that we need access to the Turbo Native code. At the top of the file import the Turbo framework.
import Turbo // <---
import UIKit
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let _ = (scene as? UIWindowScene) else { return }
}
}
Unlike Ruby, Swift requires explicit imports when referencing code. Swift for Ruby developers crash course is a good place to start if you want to learn more about the language.
After the import statements create a global variable named baseURL
. This is the web address that the app will visit when launched. As mentioned earlier, we will use the demo server:
import Turbo
import UIKit
let baseURL = URL(string: "https://turbo-native-demo.glitch.me/")! // <---
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let _ = (scene as? UIWindowScene) else { return }
}
}
URL(string:)
returns an optional value in Swift. In Swift, variables that can be nil
must be unwrapped before using them. The explanation point at the end of the URL
initializer will force unwrap this URL, changing it from an optional to a concrete URL
.
Next, create a TurboNavigator
property in SceneDelegate
. This is your interaction point with Turbo Native - the navigator handles all of the magic of presenting screens, handling errors, and more.
import Turbo
import UIKit
let baseURL = URL(string: "https://turbo-native-demo.glitch.me/")!
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
private let navigator = TurboNavigator() // <---
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let _ = (scene as? UIWindowScene) else { return }
}
}
After the guard
statement, inside the function, assign the navigator’s rootViewController
to the window, like so:
import Turbo
import UIKit
let baseURL = URL(string: "https://turbo-native-demo.glitch.me/")!
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
private let navigator = TurboNavigator()
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let _ = (scene as? UIWindowScene) else { return }
window?.rootViewController = navigator.rootViewController // <---
}
}
In iOS a view controller manages the state, presentation, and layout of a screen. Every time a link is tapped Turbo Navigator pushes a new view controller onto its stack. Here we assign the root view controller of the window to that of the navigator. This lets Turbo Navigator take complete control of what gets displayed on the screen and do its thing. Perfect!
Finally, tell the navigator to visit the baseURL
.
import Turbo
import UIKit
let baseURL = URL(string: "https://turbo-native-demo.glitch.me/")!
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
private let navigator = TurboNavigator()
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let _ = (scene as? UIWindowScene) else { return }
window?.rootViewController = navigator.rootViewController
navigator.route(baseURL) // <---
}
}
Build and run the app via Product → Run. After a few second delay you’ll see your first Turbo Native app in the simulator. Nice work!
Tap around and notice how screens are pushed and popped with animation. Play around with the different links to get a feel for more of how Turbo Native works.
But remember, this server assumes the app has all the code from the Turbo Native iOS Demo project. So not everything will work with your codebase: the Strada components, modals, or native screens.
Now that you have a barebones iOS app, what steps can you take to bring it to the next level?
First, I recommend running against a local server. Update the baseURL
variable to point to your own Rails app and see how much works. And if anything broke.
Next, add a native screen or a component powered by Strada. A great place to get started on these is to review the demo app and read the through the documentation on GitHub. Or, check out my step-by-step tutorial on how to add Strada to turbo-ios apps.
With the iOS app in a good spot let’s switch gears and do the same for Android.
First, download the latest version of Android Studio. This guide and the screenshots reference Android Studio Iguana.
Once downloaded and installed, open Android Studio and wait for any Android SDKs to finish downloading.
A heads up that the Android app requires quite a bit more work than the iOS one; there’s a good chunk of boilerplate you need to build to get everything working. Here’s what you’ll do:
In Android Studio click File → New → New Project…
Select the Phone and Tablets category on the left and the Empty Views Activity template. Click Next.
Like the template used for iOS, this one gives you a barebones Android app with a single screen.
On the next screen of the wizard, enter or select the following details and click Finish:
Name is the title of the app a user sees when they install it on their device.
Package name is like Organization Identifier on iOS - it’s how the app is uniquely identified in Google Play and won’t be shown to users.
Turbo Android requires API 26 so we choose that as our Minimum SDK.
Finally, we’ll use the modern and recommended Kotlin DSL for the Build configuration language.
And there you have it, a brand new Android app! Let’s add the Turbo Native dependency next.
From the pane on the left expand the chevron next to Gradle Scripts and double-click build.gradle.kts (Module :app) to open it. Make sure you open the one for the module and not the project.
This file lays out a bunch of configuration for Android apps, including dependencies. Scroll to the bottom and add the Turbo Native dependency after the last implementation()
already there.
// ...
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(libs.androidx.activity)
implementation(libs.androidx.constraintlayout)
implementation("dev.hotwire:turbo:7.1.0") // <---
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
}
After adding the dependency, Android Studio will show a blue bar towards the top of the screen letting you know the project needs to be synced.
Click the Sync Now button on the right of the bar to have Android Studio download and integrate the new dependency into the project.
Next, open AndroidManifest.xml from the panel on the left by expanding app then manifests and double-clicking the file.
Add the following permission to allow the app communicate with the internet. The app wouldn’t do much without access to the server!
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Add the following line: --->
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.TurboNative"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
The Turbo Native dependency is in place. But we have a few pieces of boilerplate to add before we can start using the app.
Like iOS has view controllers, Android has fragments. And in modern Android development each screen usually maps 1:1 with a fragment. For Turbo Native to present a stack of screens we need to wrap them in a NavHostFragment
.
From the panel on the left, expand app, kotlin+java, then com.example.turbonative.
Right-click com.example.turbonative and select New → Kotlin Class/File.
Enter MainSessionNavHostFragment
and press Enter.
Android Studio will open the new file automatically.
Start by making this class inherit from TurboSessionNavHostFragment
. Android Studio will automatically add the necessary import statement when you finish typing and press Enter.
package com.example.turbonative
import dev.hotwire.turbo.session.TurboSessionNavHostFragment // <---
class MainSessionNavHostFragment : TurboSessionNavHostFragment() { // <---
}
But the compiler isn’t happy. That red squiggle under MainSessionNavHostFragment
means there’s an error we need to address.
TurboSessionNavHostFragment
is an abstract class, requiring the developer (you!) to implement a few things for it to work.
Click on the red squiggle and press ⌥ + Enter. Then select “Implement members” from the dialog and click OK. Android Studio will add placeholders for each required property.
package com.example.turbonative
import androidx.fragment.app.Fragment
import dev.hotwire.turbo.config.TurboPathConfiguration
import dev.hotwire.turbo.session.TurboSessionNavHostFragment
import kotlin.reflect.KClass
class MainSessionNavHostFragment : TurboSessionNavHostFragment() {
override val pathConfigurationLocation: TurboPathConfiguration.Location
get() = TODO("Not yet implemented")
override val registeredFragments: List<KClass<out Fragment>>
get() = TODO("Not yet implemented")
override val sessionName: String
get() = TODO("Not yet implemented")
override val startLocation: String
get() = TODO("Not yet implemented")
}
First, address sessionName
by setting the value to "main"
like so:
// ...
class MainSessionNavHostFragment : TurboSessionNavHostFragment() {
override val sessionName = "main"
// ...
}
TurboSessionNavHostFragment
uses the sessionName
property to identify the web view session it uses under the hood. main
is arbitrary, you can use whatever you’d like.
Next, set the startLocation
to point to the Turbo Native Demo like in the iOS app:
// ...
class MainSessionNavHostFragment : TurboSessionNavHostFragment() {
override val sessionName = "main"
override val startLocation = "https://turbo-native-demo.glitch.me/"
// ...
}
So far, MainSessionNavHostFragment
should look like the following:
package com.example.turbonative
import androidx.fragment.app.Fragment
import dev.hotwire.turbo.config.TurboPathConfiguration
import dev.hotwire.turbo.session.TurboSessionNavHostFragment
import kotlin.reflect.KClass
class MainSessionNavHostFragment : TurboSessionNavHostFragment() {
override val sessionName = "main"
override val startLocation = "https://turbo-native-demo.glitch.me/"
override val registeredFragments: List<KClass<out Fragment>>
get() = TODO("Not yet implemented")
override val pathConfigurationLocation: TurboPathConfiguration.Location
get() = TODO("Not yet implemented")
}
Before addressing registeredFragments
and pathConfigurationLocation
we need to add a bit more code.
While modern Android apps usually have multiple fragments, they only have a single activity. Remember when we built the iOS app we used SceneDelegate
as our entry point to the application? On Android we will do the same with MainActivity
.
To align the activity with Turbo Native we need to update its view layout first. Expand app, res, then layout and double-click activity_main.xml.
You’ll be presented with a visual version of the layout. But we need to edit the underlying XML directly.
Click on the Code icon represented by three horizontal lines in the upper right.
This layout wraps a <TextView>
inside of a <ConstraintLayout>
. But we want to render our NavHostFragment
, not static text. Keep the wrapper node and replace <TextView>
with an instance of MainSessionNavHostFragment
, like so:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<!--- Replace <TextView> with this node: --->
<androidx.fragment.app.FragmentContainerView
android:id="@+id/main_nav_host"
android:name="com.example.turbonative.MainSessionNavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:defaultNavHost="false" />
</androidx.constraintlayout.widget.ConstraintLayout>
Now we need to tell the activity to use this layout. Double-click MainActivity.kt and implement the TurboActivity
interface by adding it after the call to AppCompatActivity()
. Like before, Android Studio will automatically import the class above.
package com.example.turbonative
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import dev.hotwire.turbo.activities.TurboActivity // <---
class MainActivity : AppCompatActivity(), TurboActivity { // <---
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
}
}
More red squiggles! This time we’ll address the issue manually instead of relying on Android Studio. Hover over MainActivity
for a second or two with your mouse cursor and the full error message will appear.
Fix the issue by adding a TurboActivityDelegate
property to the class. Decorate it with the lateinit
keyword - we will create the instance next. Once again, Android Studio will automatically import the class.
package com.example.turbonative
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import dev.hotwire.turbo.activities.TurboActivity
import dev.hotwire.turbo.delegates.TurboActivityDelegate // <---
class MainActivity : AppCompatActivity(), TurboActivity {
override lateinit var delegate: TurboActivityDelegate // <---
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
}
}
The last change for this file is to create and assign that delegate property you just created. Replace the lines related to ViewCompat
to do just that:
// ...
class MainActivity : AppCompatActivity(), TurboActivity {
override lateinit var delegate: TurboActivityDelegate
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
delegate = TurboActivityDelegate(this, R.id.main_nav_host) // <---
}
}
This finds the MainSessionNavHostFragment
we referenced in the layout file via the assigned android:id
XML attribute. Now when the app launches it will render your NavHostFragment
. Just like on iOS with Turbo Navigator!
All together, MainActivity.kt looks like the following:
package com.example.turbonative
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import dev.hotwire.turbo.activities.TurboActivity
import dev.hotwire.turbo.delegates.TurboActivityDelegate
class MainActivity : AppCompatActivity(), TurboActivity {
override lateinit var delegate: TurboActivityDelegate
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
delegate = TurboActivityDelegate(this, R.id.main_nav_host)
}
}
Three steps down and two to go: a web fragment and a path configuration. Web fragment next.
Turbo Native can present a new fragment every time a link is tapped. But we need to tell the library which fragment to use.
Create a new Kotlin file under com.example.turbonative named WebFragment. When it opens, replace the contents with the following:
import dev.hotwire.turbo.fragments.TurboWebFragment
class WebFragment : TurboWebFragment()
This inherits all of the functionality from the base TurboWebFragment
so no additional code is required. Nice!
You need to register each fragment before it can be used. Register WebFragment
in MainSessionNavHostFragment
by adding it to the list of registeredFragments
, like so:
// ...
class MainSessionNavHostFragment : TurboSessionNavHostFragment() {
// ...
override val registeredFragments: List<KClass<out Fragment>>
get() = listOf(
WebFragment::class
)
}
Web fragment, check. One more step: the path configuration.
The path configuration is a JSON file that outlines a set of rules and settings for Turbo Native apps. On Android, it tells the library which web pages should be rendered via which fragment.
It can also be used to configure modals, route native screens, and more. While out of scope of this guide, you can check out the official documentation for more information on how it can be used.
The path configuration JSON needs to live in the assets directory, which doesn’t yet exist. Create it by right-clicking on app in the left panel and selecting New → Directory.
In the New Directory dialog select or type src/main/assets
and press Enter.
A new directory will appear in the left panel. Right-click it and select New → Directory. This time, type json
in the New Directory dialog and press Enter.
Create the file by right-clicking the new json directory and selecting New → File. Enter configuration.json
in the New File dialog and press Enter.
When the new file opens replace the contents with the following:
{
"settings": {
"screenshots_enabled": true
},
"rules": [
{
"patterns": [
".*"
],
"properties": {
"context": "default",
"uri": "turbo://fragment/web",
"pull_to_refresh_enabled": true
}
}
]
}
This is the minimum path configuration needed for an Android app.
The settings
key enables screenshots via screenshots_enabled
. When navigating back, a snapshot of the previous screen will be shown until the view finished loading (instead of a blank screen).
And the rules
key declares an array of routing rules. Whenever a link is tapped the patterns
key matches the URL path to determine what behavior to apply. The single rule used here routes all URL paths via the .*
wildcard to the fragment identified by turbo://fragment/web
.
But we haven’t identified any fragments yet. Decorate WebFragment
with @TurboNavGraphDestination
to bind the path configuration’s uri
key to this fragment.
import dev.hotwire.turbo.fragments.TurboWebFragment
import dev.hotwire.turbo.nav.TurboNavGraphDestination // <---
@TurboNavGraphDestination(uri = "turbo://fragment/web") // <---
class WebFragment : TurboWebFragment()
Finally, tell MainSessionNavHostFragment
where the path configuration lives:
// ...
class MainSessionNavHostFragment : TurboSessionNavHostFragment() {
// ...
override val pathConfigurationLocation: TurboPathConfiguration.Location
get() = TurboPathConfiguration.Location(
assetFilePath = "json/configuration.json"
)
}
OK… moment of truth. 🤞
Run the app by clicking the green arrow at the top of the screen or via Run → Run ‘app’.
After the emulator spins up you’ll see your Android app hitting the demo server. Nice work, dear reader!
Remember, the server this app is pointing to assumes the app has all the code from the Turbo Native Android Demo project. So not everything will work with your codebase, like the Strada components, modals, or native screens.
First, take a deep breath and congratulate yourself. You just built two Turbo Native apps in 15 minutes. Go you!
If you’re excited to learn more about Turbo Native I recommend a read through the iOS and Android documentation. There’s a lot of gold buried in those docs.
From there, you can have a go at adding a native Strada component, implementing a fully native screen, or conditionally rendering web content. The list goes on…
Finally, subscribe to my weekly newsletter to stay up to date on the latest Turbo Native news and get first access to tutorials like this guide. And if you need more hands-on help or training with your Turbo Native app then check out my services – I’d love to help!
]]>link_to
helper in Rails creates an anchor element, the <a>
tag. It accepts an optional block and three parameters: the name of the link, an options hash, and an HTML options hash.
In Turbo, adding data-turbo-confirm
to the helper can be used to request confirmation from the user before submitting a form. This presents a native browser confirm()
dialog before deleting the blog post.
Here’s how you could prompt the user before deleting a blog post:
<%= link_to "Delete post", post_url(@post),
class: "btn btn-outline-primary",
"data-turbo_method": "delete",
"data-turbo-confirm": "You sure?" %>
As I was writing the Strada chapter for my upcoming book on Turbo Native I stumbled across something exciting. I needed to write a fairly complex link_to
component that had multiple, nested Strada attributes.
I discovered that there’s a more Ruby-friendly way to write data attributes. And in my opinion, the approach is a bit cleaner and easier to read.
Dashes are great but they require wrapping the key in quotes, as shown above. Ideally, we would be able to use underscores, like so:
<%= link_to "Delete post", post_url(@post),
class: "btn btn-outline-primary",
data_turbo_method: "delete",
data_turbo_confirm: "You sure?" %>
Unfortunately, underscores won’t work with Turbo and don’t generate the same HTML output.
<a class="btn btn-outline-primary"
data_turbo_method="delete"
data_turbo_confirm="You sure?"
href="#">Underscores</a>
But what if we nest the data
attributes as a hash?
<%= link_to "Delete post", post_url(@post),
class: "btn btn-outline-primary", data: {
turbo_method: "delete",
turbo_confirm: "You sure?"
} %>
This produces the same output as our first example, data-turbo-method
and data-turbo-confirm
. I like this approach better for two reasons:
data
for every attribute makes it a bit easier to read.But what if we wanted to go… deeper? Let’s see what happens when we nest the turbo
attributes as well.
<%= link_to "Delete post", post_url(@post),
class: "btn btn-outline-primary", data: {
turbo: {
method: "delete",
confirm: "You sure?"
}
} %>
This produces the following HTML:
<a class="btn btn-outline-primary"
data-turbo="{"method":"delete","confirm":"You sure?"}"
href="#">Deeply nested</a>
Oh dear, that certainly didn’t work! It looks like link_to
tried to convert this to JSON and then escape it before writing the HTML. Unfortunately, that won’t work for Turbo or Strada.
For now, I’ll be sticking with a single-level data
hash. What about you? Which approach do you use in your Rails apps?
But you already know the headache that comes with trying to get your business’s app onto iOS and Android. It’s like being in a constant juggling act, dealing with app store review and writing the same code again and again for each platform.
Building fully native apps for iOS and Android is a ton of work. Not only are they a beast to build, but they’re also a nightmare to maintain. Imagine having to create every single screen three times – once for the web, then for iOS, and again for Android. That’s just not possible for small or even medium-sized dev teams.
And don’t get me started on app store reviews. Even a tiny bug fix can get stuck in limbo for days, sometimes weeks. Plus, juggling separate codebases for Ruby, Swift, and Kotlin? That’s just asking for trouble.
You’re probably thinking, “There’s got to be a simpler way to do this, right?” Absolutely, and that’s what we’re here to explore.
Allow me to introduce you to Turbo Native. It’s a framework that lets you build hybrid mobile apps for iOS and Android. Your HTML from the Rails server gets wrapped up in a native app that you release to the App Store and Google Play.
The best part? You build your screens once in HTML, and boom – you’re ready to go on the web, iOS, and Android, all at the same time. Deploy to your server and you’re done. No more hassling with repackaging or resubmitting to app stores. No waiting for the review team to give the green light.
Turbo Native is all about leveraging your team’s strengths – letting them focus on Ruby code while most of the business logic stays on the server. The apps? They’re just there to show off your HTML.
And unlike fully native apps, Turbo Native apps aren’t expensive to build or maintain. I had the same version of a Turbo Native app in the App Store for almost five years, all while continuously adding features and fixing bugs from the server.
In short, Turbo Native is like giving superpowers your Rails developers.
When it comes to adopting new technology, nothing speaks louder than real-world success stories and proven results. And that’s exactly what Turbo Native brings to the table. Let’s look at some compelling cases:
By choosing Turbo Native, you’re not just selecting a technology; you’re embracing a proven pathway to success. It’s about making your app development journey faster, more efficient, and more exciting for your Rails team.
That’s just a quick tour of Turbo Native and how it can make your life easier. It’s all about getting your Rails business onto the app stores without the usual headaches and hair-pulling.
If you’ve got questions or need a hand figuring this all out, I’m here for you. Send me an email me at joe@masilotti.com. Whether you’ve got a big idea or just a small question, I’m all about helping you make your mobile app dreams a reality.
Let’s get your business in the app stores, together!
]]>But of all the questions, one really stuck out. It touches on an essential part of building Turbo Native apps. And I haven’t spoken about it publicly yet!
What’s my roadmap for building Turbo Native apps?
Getting started with Turbo Native apps can feel overwhelming, especially with multiple new platforms to learn. It’s important to know how to prioritize work to get into the app stores as quickly as possible.
I follow this approach for every Turbo Native app I build. I apply it to personal projects, like Daily Log, and my client work as well. Follow my guide for the most effective way to launch your own apps to both app stores.
My first step starts before I even open Xcode or Android Studio.
First up is making sure there are enough mobile-friendly web screens built. At its core, Turbo Native renders web content in native chrome. So having a good sampling of screens already complete will kick start our Turbo Native development.
At a minimum, you’ll want mobile-friendly screens for at least three static pages and one form flow. The static screens ensure that the core navigation between pages works correctly. And the form pushes your hybrid apps to present and dismiss modals. Both are key to making our hybrid app feel like a native one.
Finally, Turbo Drive must be enabled to ensure page transitions happen smoothly. This is the default setting for new Rails apps created with Hotwire. But I’ve also worked in non-Rails codebases that bolted-on the Turbo JavaScript without issue.
The next step is getting your app approved and live in one of the app stores. And if you’re launching on both platforms then submit to Apple first.
Launching an iOS app to the App Store is way more difficult than launching an Android app to Google Play. I should know, I’ve done this 20+ times! This is mostly because the App Store Review Guidelines are more strict than Google Play’s policies.
Apple might dictate that you must add in-app purchases. Or add a link to completely delete the user’s account. Addressing all of these while focusing on a single platform means less overall work. You won’t have to go back and forth between iOS and Android while struggling to stay on top of both submissions at the same time.
Check out my collection of App Store submission tips with examples from real apps I've worked on.
Finally, don’t try to launch a pixel-perfect app right now. Your goal is to get into the app stores as quickly as possible. Take advantage of Turbo Native! Use your existing web screens as much as possible and only upgrade to native when absolutely necessary.
Once your app is in the App Store then it’s time to move on to Android. By now you’ve ideally identified most of the thorny bits. Building the Turbo Native integration on Android will be more straightforward.
Follow the same recommendation as above and build something that works. Nothing more.
Note that there is an exception to this step. Are most of your users are accessing your mobile website via Android devices? If so, launching to Google Play first might make more sense.
By now you’ve launched your apps to both app stores - congratulations! That’s no small feat.
Up next is upgrading these apps to feel more native. This includes adding native components via Strada or converting pages to fully native screens.
Great candidates for native include home screens, maps, and anything dealing with native APIs. High-impact screens, like ones that are core to your business’s unique offering, should also be considered. Reference this short guide I wrote for help deciding when upgrading to native makes sense.
You now have a solid roadmap for building and launching your iOS and Android Turbo Native. But actually writing the code is an entirely different journey!
Here are some resources to help you along your way:
And if you’re looking for a more hands-on approach then check out how I can help. I’ve worked with dozens of businesses to launch their Rails app to the app stores. And I’d love to do the same for you!
]]>undefined local variable or method `title' for #<ActionView::Base:0x0000000001b3f0>
If this error is being raised from a partial then chances are you forgot to pass in a local variable.
Rails 7.1 added a new feature that can completely remove these issues from our codebase. Let’s explore how strict locals can make our Rails partials safer and easier to work with.
Rails partials are a great way to break up complex UI into smaller and easier to work with components. They help reduce the context you need to keep in your head at any given time.
It’s nice to keep verbose styles in a single file, especially when working with Tailwind CSS. Here’s an example of how you might build a badge element.
<%# app/views/shared/_badge.html.erb %>
<span class="text-sm font-medium bg-blue-100 text-blue-800 px-2.5 py-0.5">
<%= title %>
</span>
Anywhere in your codebase you can now render this via:
<%= render "shared/badge", title: "NEW" %>
This partial expects a single local variable to be defined, title
.
But what happens if you forget it pass it along?
<%= render "shared/badge" %>
You get an error message pointing to the line where the variable was first accessed, in the partial. It’s our responsibility to climb up the backtrace and figure out exactly where we forgot to assign that variable.
We can avoid this issue with strict locals. Introduced way back in 2022 but only first available in Rails 7.1, this magic comment defines which variables our partial expects.
<%# app/views/shared/_badge.html.erb %>
<%# locals: (title:) %>
<span class="text-sm font-medium bg-blue-100 text-blue-800 px-2.5 py-0.5">
<%= title %>
</span>
locals:
lets Rails know we are defining the variables. You can think of what’s inside the parenthesis as arguments to a Ruby method. Here, we expect title:
to be passed in.
If we don’t pass in all the expected variables we get a much cleaner error message.
We immediately see where in the calling code we forgot to pass the local variable! We don’t have to dive into the stack trace; we know exactly what line of code to change.
Another benefit of strict locals is the option to set default arguments. Defining a value for a variable makes it optional and not required by the calling code. Just like a Ruby method!
<%# app/views/shared/_badge.html.erb %>
<%# locals: (title: "NEW") %>
<span class="text-sm font-medium bg-blue-100 text-blue-800 px-2.5 py-0.5">
<%= title %>
</span>
Now, title
will be set to "NEW"
unless we override it in the calling code.
This is perhaps my favorite part of strict locals. What used to be a mess of Ruby code in our views boils down to a single line of code that is much easier to understand.
Here’s what that might have looked like before. 😵💫
<%# app/views/shared/_badge.html.erb %>
<% title = local_assigns[:title] || "NEW" %>
<span class="text-sm font-medium bg-blue-100 text-blue-800 px-2.5 py-0.5">
<%= title %>
</span>
This technique has reignited my love for Rails defaults like partials. I still think View Components have their place. But for views light on logic, strict locals with default arguments are an excellent alternative.
Have you experimented with strict locals in your Rails app? How are you liking them? Let me know on Twitter!
]]>I’ve been using Apple Notes to keep track of some important “metrics” throughout my day. But keeping everything in a single huge Apple Note quickly got unwieldy.
So I decided to build a simple but useful app to help me track my exercise, how much water I drink, what I eat, and the medications and supplements I take.
My first step was laying out the overall design and how data flows through the app.
Having tracked these metrics for a while I knew that the most important view is seeing today’s logs. Looking at what I’ve eaten earlier in the day helps me make better decisions when dinner time roles around.
I sketched out a basic design for desktop and mobile on paper. And after a little tinkering I started copy-pasting in some Tailwind UI components. It’s mind-blowing how quickly this started to look like a real app. Especially considering it’s just HTML with Tailwind CSS classes!
With the core of the design done I started to convert these screens into ERB views. I created a new Ruby on Rails app and wired up some static screens.
I took care to extract repeating entries into component-like view partials. This helped a ton as I could tweak individual elements, like the layout of the button, without having to re-apply changes across multiple files. It also helped set up the initial view composition when the views became more dynamic.
From there I created a few migrations to set up the initial database tables, one for each entry type: exercise, medication, water, and food. I wasn’t sure if this project would ever be public so I didn’t even set up a user model or authentication!
With the database in place it was relatively quick to create the models and controllers to handle adding new entries. And it wasn’t before long that I had a fully functioning web app.
After building the core functionally into the web I switched gears to focus on iOS.
To move as quickly as possible I opted for a hybrid app powered by Turbo Native. This “wraps” the web content in some chrome, providing native navigation between screens. It meant I could keep all of my business logic in the Rails app leaving a relatively thin iOS client.
The first iteration was only 20 lines of code!
Want to learn more about Turbo Native? Here's my 30 minute talk from Rails World on how to get started.
With the core of the web and iOS apps built I worked through some features to make it more of a Real Thing™.
First up was adding users and authentication to make sure folks besides me could actually use it. I also encrypted all entries and email addresses, medication and such being fairly sensitive information.
I also added buttons to navigate between different days. I’ve found this useful to get an idea of what I ate or did yesterday, especially if I got a work out in. To make these work on iOS I used Strada to add native buttons to the navigation bar at the top.
App Store Review Guideline 4.2 states that “Your app should include features, content, and UI that elevate it beyond a repackaged website.” I knew from my App Store submission tips that a great way to address this is with a native control or two.
I wrapped up by adding a few settings so folks can choose between imperial and metric systems, change their time zone, and delete their account.
I had a few folks using the app on TestFlight and didn’t see any issues pop up. So I figured it would be a good time to submit to the App Store - less features means less potential bugs!
But Apple was not happy. They didn’t like the idea that you needed an account.
I found myself in a weird situation. There isn’t anything someone can do in the app without an account. Should I try and explain that to them? Or give in and add a new feature for folks that haven’t signed up yet?
I decided to clearly explain that you need an account to use the app and that there’s no way around it. To my surprise, a few hours later the app was approved and live in the App Store!
I saved the best for last… the entire codebase is open source!
Every line of Ruby and Swift that powers the web and iOS app can be viewed on GitHub.
There are so few (zero?) open source Turbo Native apps in the wild. So I’m really excited to have this as an example to point people to. Especially because it includes the full picture: a live Rails app and an iOS app in the App Store.
I’m going to continue to update Daily Log with new features and native integrations. Follow me on Twitter to stay up to date with the day-to-day changes.
Next week I’m doing a video breakdown of the entire codebase for my newsletter. I’ll walk through how the Rails code integrates with the iOS app and how the native components work. The hand-rolled authentication strategy is another topic I’d love to explore.
If you try Daily Log then let me know what you think via email. I’d love to hear from you!
]]>But there are a few hoops you need to jump through to use it. This is especially true when working with Turbo Navigator.
Turbo Navigator is a drop-in class for Turbo Native apps to handle common navigation flows. It removes 100+ lines of boilerplate and will be upstreamed into turbo-ios soon.
Earlier this week someone asked for help integrating Strada with Turbo Navigator. This made me realize it isn’t as straightforward as my response made it seem!
So here’s a step-by-step guide to integrating Strada with Turbo Navigator.
We will start with the demo iOS and Rails app included with Turbo Navigator. Clone the project to your machine from the GitHub repo.
Open Demo/Demo.xcodeproj
in Xcode and start the Rails server located at Demo/Server/
via bin/dev
.
Launch the app in Xcode via Product → Run or with ⌘ + R. You should see the home screen of the Rails server launch in the iOS Simulator.
With everything running we can start integrating Strada, first on the server then in the app.
Integrating Strada with our Rails app requires three steps:
To add the JavaScript package we run the following from the Demo/Server/
directory.
yarn add @hotwired/strada
Note that if you are using importmap in your Rails app you would instead run:
bin/importmap pin @hotwired/strada
Next, create a new Strada component. Use the Stimulus generator to build an empty Stimulus controller nested under the bridge/
subdirectory.
bin/rails generate stimulus bridge/hello
Update the controller to following, the bare minimum needed. When connected, this component will fire the "connect"
message to our iOS app.
// app/javascript/controllers/bridge/hello_controller.js
import { BridgeComponent } from "@hotwired/strada"
export default class extends BridgeComponent {
static component = "hello"
connect() {
super.connect()
this.send("connect", {}, () => {
})
}
}
Our last step on the server is to add the HTML markup needed to connect the controller. Add the following to the top of the navigations show template. Note the double dashes that namespace to our bridge/
directory.
<!-- app/views/navigations/show.html.erb -->
<div data-controller="bridge--hello">
Integrating Strada with our iOS app requires a bit more work, six steps in total:
But there’s good news! If you’ve followed the official Strada Quick Start Guide you can skip all the way to step 5.
Open the Xcode project and click File → Add Package Dependencies…
Copy-paste the strada-ios URL in the upper right and click Add Package.
https://github.com/hotwired/strada-ios
Create a new file by right-clicking the Demo group on the left and clicking New File… Select Swift File from the iOS tab and click Next.
Name this file HelloComponent
and click Create.
Replace the contents of this file with the following. This registers a native component with the "hello"
name to match the Stimulus controller we built in Rails. Any time a message is received it will log to the console.
// HelloComponent.swift
import Strada
class HelloComponent: BridgeComponent {
override class var name: String { "hello" }
override func onReceive(message: Message) {
print(#function, message)
}
}
Create another Swift file and name it BridgeComponent+App
. Replace the contents with the following. This holds a global reference to all of our Strada components to refer to later.
// BridgeComponent+App.swift
import Strada
extension BridgeComponent {
static var allTypes: [BridgeComponent.Type] {
[
HelloComponent.self
]
}
}
Create a third Swift file and name this one TurboWebViewController
. Replace the contents with the following, taken directly from the Quick Start Guide. This controller helps bridge the gap to Strada, passing along view lifecycle events.
// TurboWebViewController.swift
import Strada
import Turbo
import WebKit
final class TurboWebViewController: VisitableViewController, BridgeDestination {
private lazy var bridgeDelegate = BridgeDelegate(
location: visitableURL.absoluteString,
destination: self,
componentTypes: BridgeComponent.allTypes
)
// MARK: View lifecycle
override func viewDidLoad() {
super.viewDidLoad()
bridgeDelegate.onViewDidLoad()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
bridgeDelegate.onViewWillAppear()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
bridgeDelegate.onViewDidAppear()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
bridgeDelegate.onViewWillDisappear()
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
bridgeDelegate.onViewDidDisappear()
}
// MARK: Visitable
override func visitableDidActivateWebView(_ webView: WKWebView) {
bridgeDelegate.webViewDidBecomeActive(webView)
}
override func visitableDidDeactivateWebView() {
bridgeDelegate.webViewDidBecomeDeactivated()
}
}
Back in our scene delegate we need to configure Strada to use our components. Create a new private function with the following. Don’t forget to import Strada and WebKit at the top!
// SceneDelegate.swift
import Strada
import WebKit
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
/* ... */
private func configureStrada() {
TurboConfig.shared.userAgent += " \(Strada.userAgentSubstring(for: BridgeComponent.allTypes))"
TurboConfig.shared.makeCustomWebView = { configuration in
let webView = WKWebView(frame: .zero, configuration: configuration)
Bridge.initialize(webView)
return webView
}
}
}
Call this function in scene(_:willConnectTo:options:)
right after the guard
statement. This ensures Strada is configured before we actually route the URL.
// SceneDelegate.swift
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
/* ... */
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }
configureStrada() // <-- Add this line.
self.window = UIWindow(windowScene: windowScene)
self.window?.makeKeyAndVisible()
self.window?.rootViewController = self.turboNavigator.rootViewController
self.turboNavigator.route(baseURL)
}
/* ... */
}
Finally, we to tell Turbo Navigator to use our TurboWebViewController
when visiting pages.
Implement handle(proposal:)
in the extension at the bottom of our scene delegate. Use the .acceptCustom
option to return an instance of our new controller.
// SceneDelegate.swift
extension SceneDelegate: TurboNavigationDelegate {
func handle(proposal: VisitProposal) -> ProposalResult {
.acceptCustom(TurboWebViewController(url: proposal.url))
}
}
Run the app in Xcode and click on the Basic navigation link. If all went well you should see the following in the Xcode logs when the page loads.
If you don’t see the debug area then click View → Debug Area → Show Debug Area.
If you’re not seeing this then then try enabling Strada’s debug logging. Add this to the top of the configureStrada()
function.
Strada.config.debugLoggingEnabled = true
Integrating Strada requires multiple steps to get started. And an additional two for Turbo Navigator projects. At least once initially configured adding additional components only requires the component file and referencing it in BridgeComponent+App
.
But I feel like that is still too much work. And I’m hoping to change that.
I’m working on something that will make it even easier to integrate Strada into Turbo Native apps. Subscribe to my newsletter to be the first to know when I release something public!
]]>Skip ahead to 5:48 to watch me build a Turbo Native iOS app from scratch, live. 😱
I also cover ways to make the app feel more native, how to integrate with native Swift SDKs, and review some before/after Strada examples.
The talk is a great resource if you’re new to Turbo Native or want a refresher on the why of the framework. But I think anyone who is interested in hybrid apps can learn something new.
If you give it a watch please let me know. I’d love to hear what you think!
]]>In Amsterdam I spoke with a ton of folks in the community. I even got some time with the other maintainer of Turbo Native, Jay Ohms. And good news… we are almost ready to upstream Turbo Navigator into turbo-ios!
The package handles common navigation flows from configuration that lives on your server. It goes beyond the demo app of pushing screens and presenting modals, covering 15+ different flows.
Turbo Navigator reduces boilerplate code in my Turbo Native apps by at least a few hundred lines. And it’s always the first thing I add when I start a new project for a client. And I’m excited to share that productivity boost with you!
Here are two recent additions that make it even easier for Rails developers to get started with Turbo Native.
We improved the developer experience around deciding which view controller to display.
Respond with .accept
to have the default VisitableViewController
perform a Turbo Visit. Or cancel the navigation entirely with .reject
.
And for customizations pass a UIViewController
instance to .acceptCustom
! All default routing (modals, dismissing, popping, etc.) will occur on this custom controller.
extension SceneDelegate: TurboNavigationDelegate {
func handle(proposal: VisitProposal) -> ProposalResult {
if proposal.properties["vc"] as? String == "numbers" {
return .acceptCustom(NumbersController())
} else if proposal.presentation == .none {
return .reject
}
return .accept
}
}
Then we made it a bit easier to customize the Turbo Session
. Which was required for a smoother integration with Strada.
You can now initialize TurboNavigator
with preconfigured sessions. For example, customize the web view or its configuration so you can attach a JavaScript handler.
let configuration = WKWebViewConfiguration()
// Customize configuration...
let mainSession = Session(webViewConfiguration: configuration)
let webView = WKWebView()
// Customize web view...
let modalSession = Session(webView: webView)
let navigator = TurboNavigator(
preconfiguredMainSession: mainSession,
preconfiguredModalSession: modalSession,
delegate: self
)
Stay up to date with Turbo Navigator by watching the repository or giving it a star.
Next week I’m hosting a 2-hour live session on Turbo Native for Rails developers.
This is perfect for some guided, hands-on experience working with Turbo Navigator. It will also cover how to integrate Strada to create native components.
Registration closes tomorrow. Grab your ticket here.
Send me an email if you have any questions – I hope to see you there!
]]>Strada is an optional add-on for Turbo Native apps that enables native components driven by the web. It unlocks progressive enhancement of individual controls without converting entire screens to native.
For example, converting a <button>
to a UIBarButtonItem
on iOS or rendering a HTML modal with ModalBottomSheetLayout
on Android.
It’s important to call out that Strada alone doesn’t unlock new features for Turbo Native apps. Everything you can do with the framework you could already do before. Albeit with much, much more code.
Strada provides structure and organization to the tangled mess that is the JavaScript bridge. It simplifies and standardizes communication between web and native components, which makes building robust native elements a joy. 🤓
Here’s an example from the turbo-ios demo that renders a dialog when tapping a button.
The web <button>
is rendered inline with the HTML at the bottom of the content. But on iOS, that moves to a native UIBarButtonItem
placed nicely in the navigation bar.
Tapping the button displays a dialog that’s rendered via HTML. While this looks fine on the web, it feels pretty out of place in an iOS app. Strada replaces it with a UIActionSheet
, making the experience feel right at home in a native app.
The documentation includes a guide on how to augment a web form with a native button.
It starts with a <form>
element in your web app and wires up the Strada component via Stimulus. Then it walks through building the native side on both iOS and Android.
Let’s walk through what each line does and how it relates to the rest of the demo.
Subscribe to my newsletter with a new Turbo Native tip every week. And get first access to my upcoming workshops and book.
The tutorial starts with the HTML <form>
and a submit <button>
. This looks pretty similar to how you would wire up a Stimulus controller, so far.
<form method="post" data-controller="bridge--form"> <!-- 1. -->
<!-- Form elements... -->
<!-- 2. -->
<button
type="submit"
data-bridge--form-target="submit"
data-bridge-title="Submit">
Submit Form
</button>
</form>
<form>
element wired up to a Stimulus controller.<button>
sets its target and title to pass to the controller.The “Stimulus controller” is actually a BridgeComponent
!
This subclass is where the JavaScript “glue” is written to send and receive messages to the apps. It’s an extension of a Stimulus controller and follows a lot of the same conventions, like targets and actions.
import { BridgeComponent, BridgeElement } from "@hotwired/strada"
export default class extends BridgeComponent { // 1.
static component = "form" // 2.
static targets = ["submit"]
submitTargetConnected(target) {
const submitButton = new BridgeElement(target) // 3.
const submitTitle = submitButton.title
this.send("connect", {submitTitle}, () => { // 4.
target.click() // 5.
})
}
}
Controller
but something very close."form"
to the native apps.BridgeElement
to access bridge-specific behaviors and elements. 🤩<submit>
button connects send the button title to the app.<submit>
button.I’m very excited about BridgeElement
! There are a ton of useful attributes like reading the title, checking if the element is disabled, and querying if a CSS class is attached. This will clean up a lot of my existing JavaScript code.
It’s also important to shine light on click()
. Asking the HTML element do its thing means we can reuse existing form submission logic (controller actions, error handling, flash messages, etc.) without having to do anything in native code. 👍
The iOS code looks a bit more complicated. Let’s walk though each step one at a time.
final class FormComponent: BridgeComponent { // 1.
override class var name: String { "form" } // 2.
override func onReceive(message: Message) { // 3.
if message.event == "connect" {
handleConnectEvent(message: message)
}
}
private func handleConnectEvent(message: Message) { // 4.
guard let data: MessageData = message.data() else { return }
configureBarButton(with: data.submitTitle)
}
private func configureBarButton(with title: String) { // 5.
let action = UIAction { _ in
self.reply(to: "connect") // 6.
}
let item = UIBarButtonItem(title: title, primaryAction: action)
// Display the button in the app bar
}
}
private extension FormComponent {
struct MessageData: Decodable { // 7.
let submitTitle: String
}
}
BridgeComponent
which I imagine includes a lot of glue magic."connect"
.UIBarButton
to the screen."connect"
.Decodable
enables conversion from JSON to a Swift object.All in all, not too much code to get a native submit button for every form on our web app. And the concrete relationship between components helps keep the code well contained.
Heads up: I’ve only just started exploring Strada so some of this might not be 100% correct. If I made a mistake then please let me know!
I’m excited to integrate Strada into my Turbo Native apps. The existing bridge is notoriously finicky and this looks like a great answer to the JavaScript spaghetti I’ve been writing. 🍝
Being able to reply directly to a message is going to simplify a lot of behavior around native components. And unlock some new behavior that would not have been worth the effort before Strada.
When I return from Rails World I have a lot of work ahead of me! I plan to rewrite the JavaScript bridge section of my turbo-ios tutorial to use Strada. I’d also love to compare and contrast the two approaches when building a UIMenu
.
And I’ll obviously be including an entire chapter (or more) about Strada in my upcoming book on Turbo Native. So many things to do and so little time!
]]>