First, a full disclosure: I’m part of the StimulusReflex core team and have been in love with it for about a year now. Having done many flavors of client-side JavaScript programming, I’ve also become a fan of the HTML-over-the-wire approach which is how I shape my Rails (and other) apps with Stimulus.

All that said, when @hopsoft published his Twitter clone in 10 minutes video, my interest to pair it with the Turbolinks iOS Wrapper was sparked. After all, it is fair to assume that websockets (which is what StimulusReflex is ultimately built upon) work natively with iOS’s WKWebView, but I wanted to see it live.

That’s why I made this short video walkthrough, below are my step by step notes:

(Note: you can also skip everything and go straight to the source code on Github)

So here’s what we’re going to do:

  1. Set up an iOS app with Turbolinks enabled
  2. Render HTML in a Turbolinks-powered WKWebView
  3. Navigate the iOS app by vanilla Turbolinks visits
  4. Handle form submissions in the iOS app
  5. Use Action Pack Variants to spice up native views (and point out some gotchas)

1. Setup

First of all we need to clone the chatter project from Github, and start it:

$ git clone https://github.com/hopsoft/chatter.git
$ cd chatter && bin/rails s

Next, open XCode and create a single app view project:

../assets/images/posts/2020/2020-06-30-chatter-ios-0.png

For easy configuration, we’re going to use CocoaPods to set up our dependencies.

$ cd chatter-ios && pod init

In our Podfile, let’s add Turbolinks as a dependency and install it:

target 'chatter-ios' do
  # Comment the next line if you don't want to use dynamic frameworks
  use_frameworks!

  # Pods for chatter-ios
  pod 'Turbolinks', :git => 'https://github.com/turbolinks/turbolinks-ios.git'

  # ...
end
$ pod install

On a final note, as in Sam Stephenson’s Turbolinks 5 talk, we have to change a plist setting in order for local (non-TLS) development to work:

plutil -insert NSAppTransportSecurity -json '{"NSAllowsArbitraryLoads":true}' chatter-ios/Info.plist 

2. Add Turbolinks-iOS to Your iOS App

First of all, close XCode again and make sure to open the workspace instead, to load the project along with its dependencies:

$ open chatter-ios.xcworkspace

In SceneDelegate, add a UINavigationController and use it as the window.rootViewController in scene:

var navigationController = UINavigationController()
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    // ...
    if let windowScene = scene as? UIWindowScene {
        let window = UIWindow(windowScene: windowScene)
        window.rootViewController = navigationController
        self.window = window
        // ...
    }
}

Next, we’re going to actually import Turbolinks and set up a session. To your import statements, add

import Turbolinks

and to your top-level variable declarations, add a session:

var window: UIWindow?
var navigationController = UINavigationController()
var session = Session()

We are going to need a method to navigate from one ViewController (roughly equalling a server route) to the next. In Turbolinks, this functionality is called visiting, so let’s declare a visit method:

func visit(URL: NSURL) {
    let viewController = VisitableViewController(url: URL as URL)
    navigationController.pushViewController(viewController, animated: true)
    session.visit(viewController)
}

Let’s give it a spin by doing an initial visit:

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    // ...
  
    if let windowScene = scene as? UIWindowScene {
        // ...
        visit(URL: NSURL(string: "http://localhost:3000")!)
        // ...
    }
}

After running the app again, you should see the Chatter start screen in your iOS Simulator:

../assets/images/posts/2020/2020-06-30-chatter-ios-1.png

3. Set Up a Session Delegate to Handle Visit Proposals

Our initial app setup may be complete, but if you try clicking some links, you might run into errors. That’s because we need to tell Turbolinks how it should handle visit proposals, i.e., navigation events triggered from within the WKWebView. To do this, we need to make our SceneDelegate the session’s delegate, and implement the protocol:

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    // ...
  
    if let windowScene = scene as? UIWindowScene {
        // ...
        visit(URL: NSURL(string: "http://localhost:3000")!)
        self.session.delegate = self
        // ...
    }
}

and somewhere outside, for example in the bottom of your SceneDelegate file, we have to set up the callbacks for a visit proposal:

extension SceneDelegate: SessionDelegate {
    func session(_ session: Session, didProposeVisitToURL URL: URL, withAction action: Action) {
        visit(URL: URL as NSURL)
    }
    
    func session(_ session: Session, didFailRequestForVisitable visitable: Visitable, withError error: NSError) {
        // TODO handle errors
    }
}

4. Handle Form Submission

Form submissions need special treatment on the server side as well as in the iOS app. First of all, we need to make sure that our forms are handled by Turbolinks in the Rails app. By making the posts form remote: true, we will achieve that

  1. The form is sent via a XHR request
  2. Rails responds to that request with a Turbolinks visit:
Turbolinks.visit("http://localhost:3000/posts", {"action":"replace"})

In the iOS SceneDelegate, we refactor the visit method to accept an action and switch on it. In the case of a Replace, we reset the current view controller, either by popping the last one and pushing the new one onto the stack, or just by setting the new view controller as the only one:

func visit(URL: NSURL, action: Action = .Advance) {
    let viewController = VisitableViewController(url: URL as URL)
    
    if action == .Advance {
        navigationController.pushViewController(viewController, animated: true)
    } else if action == .Replace {
        // in case there is only one view controller on the stack, replace it manually
        if navigationController.viewControllers.count == 1 {
            navigationController.setViewControllers([viewController], animated: false)
        } else {
            navigationController.popViewController(animated: false)
            navigationController.pushViewController(viewController, animated: false)
        }
    }
    
    session.visit(viewController)
}

5. Use Variants to Improve Native Views

As a last step, we’re going to try and give our iOS app a different look than the browser-based app. In reality, you might want to do this to give your Turbolinks-iOS app a more native look and feel. The way to do this in Rails is via Action Pack Variants, i.e. different view templates rendered based on a condition that is evaluated server side. The most common condition that’s used in such a case is the User Agent. Therefore, we’re going to append a custom string, Chatter iOS, to the user agent emitted by iOS:

import Webkit
// ...
lazy var session: Session = {
     let configuration = WKWebViewConfiguration()
     configuration.applicationNameForUserAgent = "Chatter iOS"
     return Session(webViewConfiguration: configuration)
}()

In the Rails app, let’s spice up the views with variants. Before we do that, though, let’s add two <meta> tags to the application layout, so the view fits into the viewport nicely:

<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

In our posts_controller.rb, we set the request.variant in a before_action, by checking if the string we added to the user agent before is present. Note that the :ios symbal is completely arbitrary.

before_action :set_variant

# ...

private

def set_variant
  request.variant = :ios if request.user_agent =~ /Chatter iOS/
end

Now create index.html+ios.erb and _post.html+ios.erb by duplicating the files, and change something. In my example, I made the card background dark:

<div class="card mb-2 text-white bg-dark"> 

Now reload your simulator and browser, and try it out. First we notice that the variant gets picked up by our iOS app. Hooray 🎉! Soon you’ll realize, though, that now we have a different problem: CableReady sends the iOS variant to the browser and vice versa. Behold:

../assets/images/posts/2020/2020-06-30-chatter-ios-2.gif

That’s actually not surprising, because CableReady is executing the broadcast in the context of the sender. So if I submit the post form from the iOS app, it will render_to_string the iOS variant and broadcast it to all subscribers. That’s clearly not what we want - we want the subscriber to decide which variant to receive.

cable_ready["timeline"].insert_adjacent_html(
  selector: "#timeline",
  position: "afterbegin",
  html: render_to_string(partial: "post", locals: {post: post})
)
cable_ready.broadcast

To be completely honest, I chewed on this for some time, but as you’ll see, the solution is quite simple and elegant.

6. Parameterize the Channel

In many ways, WebSockets are quite orthogonal to regular HTTP request/responses. In a normal request, we can tell which variant is to be served in a variety of ways. With a web socket connection, there is no request, just a subscriber consuming a channel. So what we need to do is prepare different streams to broadcast our variants to. Let’s start on the client side, again:

In app/javascript/channels/timeline_channel.js, we need to pass some parameter according to the user agent, (similar to setting the variant). That’s easy, because the create method for subscriptions lets us specify arbitrary parameters along with the channel identifier. Let’s rewrite this as follows:

consumer.subscriptions.create({ channel: "TimelineChannel", agent: /Chatter iOS/.test(window.navigator.userAgent) ? "ios" : "" }. {...})

Essentially, this is the corresponding way of identifying the user agent as we did on the server: We check a regular expression against the window.navigator.userAgent. Easy-peasy! Now, on the ruby side, in timeline_channel.rb, we need to pick this up:

class TimelineChannel < ApplicationCable::Channel
  def subscribed
   stream_from "timeline#{params[:agent]}"
  end
end

All that’s left to do is, in posts_controller, we have to add a second cable ready broadcast:

cable_ready["timeline"].insert_adjacent_html(
  selector: "#timeline",
  position: "afterbegin",
  html: render_to_string(partial: "post", locals: {post: post}, variants: [])
)
cable_ready["timelineios"].insert_adjacent_html(
  selector: "#timeline",
  position: "afterbegin",
  html: render_to_string(partial: "post", locals: {post: post}, variants: [:ios])
)

Notice that in the first call, I explicitly set the variants to an empty array. That’s because the default Rails logic of looking up variants based on the request.variant is still in place. We need to explicitly remove the ios variant from this call, whereas in the ios stream, we need to explicitly add it. And now, everything works as expected:

../assets/images/posts/2020/2020-06-30-chatter-ios-3.gif

In a real-world scenario, we’d probably try to introduce some abstraction for creating the appropriate cable_ready calls, as there’s a little bit of duplication here. For this demo though, let’s call it a day 😅