Navigation in a Turbo Native App
There’s a very little talked about part of the turbo-rails
gem, tucked away deep into the source code. It’s called navigation.rb
, which at first glance is a little bit cryptic, but it’s foundational to any complex Turbo Native app. It’s short enough to include here in it’s entirety, for your convenience:
module Turbo::Native::Navigation
private
def recede_or_redirect_to(url, **options)
turbo_native_action_or_redirect url, :recede, :to, options
end
def resume_or_redirect_to(url, **options)
turbo_native_action_or_redirect url, :resume, :to, options
end
def refresh_or_redirect_to(url, **options)
turbo_native_action_or_redirect url, :refresh, :to, options
end
def recede_or_redirect_back_or_to(url, **options)
turbo_native_action_or_redirect url, :recede, :back, options
end
def resume_or_redirect_back_or_to(url, **options)
turbo_native_action_or_redirect url, :resume, :back, options
end
def refresh_or_redirect_back_or_to(url, **options)
turbo_native_action_or_redirect url, :refresh, :back, options
end
# :nodoc:
def turbo_native_action_or_redirect(url, action, redirect_type, options = {})
if turbo_native_app?
redirect_to send("turbo_#{action}_historical_location_url", notice: options[:notice] || options.delete(:native_notice))
elsif redirect_type == :back
redirect_back fallback_location: url, **options
else
redirect_to url, options
end
end
# Turbo Native applications are identified by having the string "Turbo Native" as part of their user agent.
def turbo_native_app?
request.user_agent.to_s.match?(/Turbo Native/)
end
end
There are accompanying controller and routes files:
#Controller
class Turbo::Native::NavigationController < ActionController::Base
def recede
render html: "Going back…"
end
def refresh
render html: "Refreshing…"
end
def resume
render html: "Staying put…"
end
end
#Routes
Rails.application.routes.draw do
get "recede_historical_location" => "turbo/native/navigation#recede", as: :turbo_recede_historical_location
get "resume_historical_location" => "turbo/native/navigation#resume", as: :turbo_resume_historical_location
get "refresh_historical_location" => "turbo/native/navigation#refresh", as: :turbo_refresh_historical_location
end
So there are three components that are given to us in the turbo-rails
gem: helper methods, a controller, and routes. But what are these for?
Let’s start with the routes. turbo-rails
creates three new routes to use in your rails app: recede, resume, and refresh. These routes map to the three controller actions shown above, but these actions don’t actually do anything. They’re not meant to. These are dummy routes meant to be called by your Turbo Native client, which should map to Native navigations, such as popping view controllers, or actions such as reloading the webview.
Why are these necessary? Consider a Rails app with Group
s that have Post
s, with the following post creation flow:
#The post controller as it stands
class PostsController < ApplicationController
# ...
def create
@post = current_user.posts.build(post_params)
@post.group_id = @group.id
if @post.save
flash[:success] = "Post Created : Post was successfully created."
redirect_to @group, status: :see_other
else
render :new, status: :unprocessable_entity
end
end
# ...
end
This is fine for the web, but it looks strange in a native app:
We’re stacking the group show page on top of the new post page, when what we actually wanted was to simply pop the post page to go back to the group page on succesfull post creation. This is the desired post creation flow on a Native app:
How do we acheive the desired navigation flow? This is where the helpers shown in the beginning of the article come in.
Let’s break down the core method of navigation.rb
:
def turbo_native_action_or_redirect(url, action, redirect_type, options = {})
if turbo_native_app?
redirect_to send("turbo_#{action}_historical_location_url", notice: options[:notice] || options.delete(:native_notice))
elsif redirect_type == :back
redirect_back fallback_location: url, **options
else
redirect_to url, options
end
end
This helper method does the following:
-
If this is a Turbo Native app, the client is redirected to the
action
parameter passed in. This will redirect to one of the dummy routes (recede, refresh, or resume). It’s the responsibility of the Turbo Native client to handle the route appropriately. -
If it’s not a Turbo Native app, it must be a Web Browser. If the redirect type is :back, it will issue a
redirect_back
to the browser.redirect_back
is discussed in the Rails docs. -
If it’s not a Turbo Native app and a
redirect_back
is not desired, it falls back to a standardredirect_to
.
So this method dynamically generates an appropriate redirect depending on if the client is a Turbo Native app or not. How does the server know? It checks for “Turbo Native” in the User Agent. If it’s not there, the server won’t know, so be sure to include it client-side.
All of the other helper methods are convenience methods, so that we don’t have to provide the parameters every time we want to use this method. Let’s update the redirect_to
in our Posts controller to handle Turbo Native requests!
First, let’s include the Turbo Navigation module in our Application Helper so we can use the helpers:
module ApplicationHelper
include Turbo::Native::Navigation
#...
end
#The updated Post Controller
class PostsController < ApplicationController
# ...
def create
@post = current_user.posts.build(post_params)
@post.group_id = @group.id
if @post.save
flash[:success] = "Post Created : Post was successfully created."
recede_or_redirect_to @group, status: :see_other
else
render :new, status: :unprocessable_entity
end
end
# ...
end
Turbo Native clients will now receive a redirect to the recede
route. Let’s look at how an iOS app could handle a redirect like this. Suppose you have a TurboNavigationController similar to the one in the Turbo iOS demo. Let’s rewrite the route
function to look something like this:
func route(url: URL, options: VisitOptions, properties: PathProperties) {
if presentedViewController != nil {
dismiss(animated: true)
}
if url.path == "/recede_historical_location"{
popViewController(animated: true)
return
}
if url.path == "/resume_historical_location"{
return
}
if url.path == "/refresh_historical_location"{
self.session.reload()
return
}
let viewController = makeViewController(for: url, properties: properties)
navigate(to: viewController, action: options.action, properties: properties)
visit(viewController: viewController, with: options, modal: isModal(properties))
}
This will interrupt the routing if a visit is proposed to any of the three dummy routes, and take appropriate action. In the case of the recede
route, we’re going to simply pop the view controller. Here’s what the final result looks like.
The power of this method is that we continue with the same Post controller as before, simply changing redirect_to
to recede_or_redirect_to
upon successfull post creation. You now have a majestic monolith, elegantly adapting to both web browsers and Turbo Native apps.
Wondering how I got those cool toast notifications popping up? Check out my article on toast notifications with Shoelace and Turbo.
I'm Joseph Izaguirre, a web dev focusing on Ruby, Rails, Hotwire, and Turbo Native. If you'd like to get in contact, reach out to me on Twitter at @izaguirrejoe_ or email me at izaguirrejoe@hey.com