Twitter Clone with StimulusReflex gone Hybrid Native App
Tags: rails, turbolinks, ios, stimulusreflex
Published: 2020-07-01 09:04:07

---
title: Twitter Clone with StimulusReflex gone Hybrid Native App
published: true
date: 2020-06-30 00:00:00 UTC
tags: rails,turbolinks,ios,stimulusreflex
canonical_url: https://blog.minthesize.com/stimulus-reflex-twitter-clone-native-ios
---

First, a full disclosure: I’m part of the [StimulusReflex](https://docs.stimulusreflex.com) 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](https://dev.to/scottw/stimulus-2jio).

All that said, when @hopsoft published his [Twitter clone in 10 minutes](https://www.youtube.com/watch?v=F5hA79vKE_E) video, my interest to pair it with the [Turbolinks iOS Wrapper](https://github.com/turbolinks/turbolinks-ios/) 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](https://developer.apple.com/documentation/webkit/wkwebview), but I wanted to see it live.

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

{% youtube TFymV80ZuAo %}

(Note: you can also skip everything and go straight to the [source code](https://github.com/julianrubisch/chatter-ios) 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](https://guides.rubyonrails.org/4_1_release_notes.html#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](https://github.com/hopsoft/chatter), and start it:

```sh
$ 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](https://blog.minthesize.com/assets/images/posts/2020/2020-06-30-chatter-ios-0.png)

For easy configuration, we’re going to use [CocoaPods](https://cocoapods.org/) to set up our dependencies.

```sh
$ cd chatter-ios && pod init
```

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

```ruby
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
```

```sh
$ pod install
```

On a final note, as in [Sam Stephenson’s Turbolinks 5 talk](https://www.youtube.com/watch?v=SWEts0rlezA), we have to change a plist setting in order for local (non-TLS) development to work:

```sh
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:

```sh
$ open chatter-ios.xcworkspace
```

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

```swift
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

```swift
import Turbolinks
```

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

```swift
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:

```swift
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:

```swift
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](https://blog.minthesize.com/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:

```js
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:

```swift
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:

```swift
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:

```html
<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.

```ruby
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:

```html
<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](https://blog.minthesize.com/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.

```ruby
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:

```js
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:

```ruby
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:

```ruby
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](https://blog.minthesize.com/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 😅