How to Render a Native Home Screen with Turbo-iOS
Photo by Tyler Lastovich on Unsplash
Sometimes Rails isn’t enough. I’ve been building Smart Strength as a hobby project. Smart Strength is a workout app that makes it easy to share your workouts with your coaches, physios and friends for quick feedback.
For the iPhone app, I’ve been using the Jumpstart-iOS as the base, which works great. The approach I have taken is to build the app in Rails, ensure it works well for mobile web users knowing that it will generally work well on the iPhone app and then test it in the gym.
However, I want to take the app further by making the whole workout experience native. This way, it will be faster, have access to more touch controls and, most important, offline support.
So, I have decided that the first screen users encounter will always be the native app. In this article, I will demonstrate how to do this from start to finish. As background reading, I highly recommend reading Joe Masilotti’s articles on Turbo iOS.
1. Set up a basic rails app with Turbo enabled(optional)
This step is optional. You may already have an app with Turbo enabled.
A standard Rails 7 app has everything you need to get up and running with Turbo. So make sure you have the latest version of Rails.
rails -v
Your output should look like the following:
Rails 7.0.3
Otherwise, make sure you have the latest version of Rails.
If you have Rails 7 installed, you’re good to go. We’ll generate the classic 5-minute blog and add SimpleCSS so the app looks somewhat decent.
rails new ios_blog
After that has run, we cd
into our new rails app and create the database if it doesn’t exist.
cd ios_blog && rails db:create
Now we can run our app.
rails s
Then navigate to localhost:3000, and boom, you’re nearly good to go.
Yet, the only thing here is the Ruby on Rails welcome page. Let’s change that by creating a scaffold of our posts model and then setting that to our homepage. We’ll also install ActionText.
rails action_text:install
rails g scaffold Post title:string content:rich_text
Then migrate the database,
rails db:migrate
Change the routes file so the posts#index
is the home page, and then we are good to go.
#config/routes.rb
root "posts#index"
Now we’ll add SimpleCSS to the head tag in our app/views/layouts/application.html.erb
because the default style Rails gives leaves a little to be desired.
<!DOCTYPE html>
<html>
<head>
<title>IosBlog</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
<link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css">
</head>
<body>
<%= yield %>
</body>
That’s as far as we’ll take our Rails app for the moment. Let’s take some time to see what we have and how that will integrate with the Turbo-ios adapter.
What have we got
So now that we have our Rails app set up, we can play around with it. The first thing we can do is create new posts. The second thing we can do is look through the code and open our web inspector.
Notice that we should have Turbo available as a object on our window object.
Turbo is actively running and taking over your navigation. Let’s navigate to the Network Tab to understand what Turbo is doing under the hood. As you can see, when we navigate, there is a fetch request instead of a full-blown request. In simple terms, the Turbo navigation effectively replaces everything except the head tag, which prevents additional network requests for assets such as CSS and JavaScript.
To see what happens when Turbo is not enabled, disable the Turbo Session Drive in app/javascript/application.js
, replacing import "@hotwired/turbo-rails"
with the following:
// app/javascript/application.js
import { Turbo } from "@hotwired/turbo-rails"
Turbo.session.drive = false
Now when we navigate, we can see that requests submitted are no longer using Fetch/XHR
.
Now that we know how Turbo navigation works under the hood, let’s reset our app/javascript/application.js
back to the defaults and build our iOS app.
2. Build a basic iOS app with the Coordinator pattern
We’re into the iOS part; we aim to build two tabs, one native and one that is our Turbo enabled app.
First, we’ll build our native screen. To handle our app’s navigation, we’ll take advantage of the Coordinator pattern that is quite popular in the iOS community.
The Coordinator pattern is similar to the Router in Rails. A request comes to our routes file, and it then decides which controller and controller action to trigger. With the Coordinator pattern, the parent Coordinator decides which child coordinator to run. Each child coordinator, in turn, run the view controllers with its relevant ViewModel and View. This pattern is known as MVVM-C.
If you’ve come from a web background like me, this can be a bit confusing because most web applications are based on MVC, and many of Apple’s frameworks are as well. As you’ll hopefully see, the Coordinator pattern is a separation of concern with View Controllers. Instead of using View Controllers to decide which View Controllers to run, we instead use the Coordinator.
First, open Xcode and create a new app.
For the template, choose App, and for the Interface, select Storyboard.
If you’re new to iOS development and coming from a Rails background, you’ll notice that it’s not as organised as a new Rails project. IOS does not come with the convention over configuration approach except in some rare cases.
The first thing we will do is organise our code. I like to group the delegates into their own folder. Next, we will implement our coordinator pattern to get to HelloWorld.
Create a new group called Coordinators, then create a new file named Coordinator.swift
.
import UIKit
class Coordinator: NSObject, UINavigationControllerDelegate {
var didFinish: ((Coordinator) -> Void)?
var childCoordinators: [Coordinator] = []
// MARK: - Methods
func start() {}
func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {}
func pushCoordinator(_ coordinator: Coordinator) {
// Install Handler
coordinator.didFinish = { [weak self] (coordinator) in
self?.popCoordinator(coordinator)
}
// Start Coordinator
coordinator.start()
// Append to Child Coordinators
childCoordinators.append(coordinator)
}
func popCoordinator(_ coordinator: Coordinator) {
// Remove Coordinator From Child Coordinators
if let index = childCoordinators.firstIndex(where: { $0 === coordinator }) {
childCoordinators.remove(at: index)
}
}
}
This is the parent Coordinator, and every subsequent coordinator inherits from this. This is similar to ActionController::Base
or ActiveRecord::Base
for your Rails app. Before we implant an ApplicationCoordinator
, let’s build a small HelloWorld controller and Coordinator
.
First, let’s create a HelloWorldCoordinator
import UIKit
import Foundation
class HelloWorldCoordinator :Coordinator {
var rootViewController: UIViewController {
return helloWorldViewController
}
private let helloWorldViewController = HelloWorldViewController()
}
Now, let’s create the new View Controller.
import UIKit
import SwiftUI
class HelloWorldViewController: UIHostingController<HelloWorldView> {
init() {
super.init(rootView: HelloWorldView())
}
@MainActor required dynamic init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
struct HelloWorldView: View {
var body: some View {
Text("hello world")
}
}
struct HelloWorld_Previews: PreviewProvider {
static var previews: some View {
HelloWorldView()
}
}
Now we can implement our ApplicationCoordinator
, and then we instruct iOS to use the Coordinators for the flow of the app.
import UIKit
class ApplicationCoordinator: Coordinator {
var rootViewController: UIViewController {
return tabBarController
}
private let tabBarController = UITabBarController()
override init() {
super.init()
let helloWorldCoordinator = HelloWorldCoordinator()
tabBarController.viewControllers = [
helloWorldCoordinator.rootViewController
]
childCoordinators.append(helloWorldCoordinator)
}
override func start() {
childCoordinators.forEach { (childCoordinator) in
childCoordinator.start()
}
}
}
Finally, in our SceneDelegate
, we do the following:
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
private let coordinator = ApplicationCoordinator()
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
// If using a storyboard, the `window` property will automatically be initialised and attached to the scene.
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
guard let windowScene = (scene as? UIWindowScene) else { return }
let window = UIWindow(windowScene: windowScene)
window.rootViewController = coordinator.rootViewController
window.makeKeyAndVisible()
self.window = window
coordinator.start()
}
....
Now Run and build the app.
We’ve come a long way to get to Hello World, and we don’t even have tabs yet. But we’ve built an essential foundation. In the next step, we’re going to introduce actual tabs, and then we’re going to introduce Turbo.
Step 3: Introduce Turbo Coordinator
Import the Turbo package by going to File > Add Packages and add turbo-ios
.
Next, toggle your NSAppTransportSecurity to allow Arbitrary loads.
This time, we’re just going to get everything up and running. This Coordinator will not cover forms, modals or anything like that. For a more detailed TurboCoordinator, I highly recommend checking out Joe Masolitti’s guide.
Create a new file in the Coordinator group and call it TurboCoordinator
.
import Turbo
import UIKit
class TurboCoordinator: Coordinator {
var rootViewController: UIViewController {
navigationController.tabBarItem = tab()
return navigationController
}
var resetApp: (() -> Void)?
func tab() -> UITabBarItem {
let item = UITabBarItem()
item.title = "Turbo"
item.image = UIImage(systemName: "tram")
return item
}
override func start() {
visit(url: URL(string: "http://localhost:3000/")!)
}
// MARK: Private
private let navigationController = UINavigationController()
private lazy var session = makeSession()
private lazy var modalSession = makeSession()
private func makeSession() -> Session {
let session = Session()
session.delegate = self
return session
}
private func visit(url: URL, action: VisitAction = .advance, properties: PathProperties = [:]) {
let viewController = makeViewController(for: url, from: properties)
let action: VisitAction = url ==
session.topmostVisitable?.visitableURL ? .replace : action
navigate(to: viewController, via: action)
visit(viewController)
}
private func makeViewController(for url: URL, from properties: PathProperties) -> UIViewController {
return VisitableViewController(url: url)
}
private func navigate(to viewController: UIViewController, via action: VisitAction) {
if action == .advance {
navigationController.pushViewController(viewController, animated: true)
} else if action == .replace {
navigationController.dismiss(animated: true)
navigationController.viewControllers = Array(navigationController.viewControllers.dropLast()) + [viewController]
} else {
navigationController.viewControllers = Array(navigationController.viewControllers.dropLast()) + [viewController]
}
}
private func visit(_ viewController: UIViewController) {
guard let visitable = viewController as? Visitable else { return }
session.visit(visitable)
}
}
extension TurboCoordinator: SessionDelegate {
func session(_ session: Session, didProposeVisit proposal: VisitProposal) {
visit(url: proposal.url)
}
func session(_ session: Session, didFailRequestForVisitable visitable: Visitable, error: Error) {
print("didFailRequestForVisitable: \(error)")
}
func sessionWebViewProcessDidTerminate(_ session: Session) {
session.reload()
}
}
Now in our HelloWorld Coordinator, let’s add a tab.
import UIKit
import Foundation
class HelloWorldCoordinator: Coordinator {
var rootViewController: UIViewController {
helloWorldViewController.tabBarItem = tab()
return helloWorldViewController
}
func tab() -> UITabBarItem {
let item = UITabBarItem()
item.title = "Hello"
item.image = UIImage(systemName: "network")
return item
}
private let helloWorldViewController = HelloWorldViewController()
}
Finally, we need to adjust our ApplicationCoordinator
.
import UIKit
class ApplicationCoordinator: Coordinator {
var rootViewController: UIViewController {
return tabBarController
}
private let tabBarController = UITabBarController()
override init() {
super.init()
let helloWorldCoordinator = HelloWorldCoordinator()
let turboCoodinator = TurboCoordinator()
tabBarController.viewControllers = [
helloWorldCoordinator.rootViewController,
turboCoodinator.rootViewController
]
childCoordinators.append(helloWorldCoordinator)
childCoordinators.append(turboCoodinator)
}
override func start() {
childCoordinators.forEach { (childCoordinator) in
childCoordinator.start()
}
}
}
Now rebuild our in Xcode again, and we should see tabs.
Hopefully, you’ll see how flexible this approach can be and how easy it is to take advantage of both a server-side rendered app and the power of iOS. The source code can be found here.
One downside of this approach is that each child coordinator that renders Turbo, will make a network request when the app boots up.