March 14, 2021

Native tab bar with Turbo-iOS

The turbo-ios repository includes an excellent demo app to get started with iOS development, powered by Turbo. After figuring out how the demo app works, you should have a pretty decent grasp of the basics. You'll be able to create a simple iOS app based on your Rails application.

In this article I will show you how to go from there and how to create an iOS app with a tab bar. Adding a tab bar to your iOS app greatly increases the number of options you have when designing an interface. It also makes your app feel more native than just wrapping your Rails app in a WKWebView.

Step 1. Main.storyboard

Remove the existing Navigation Controller Scene and replace it with a Tab Bar Controller from the Library (Cmd+Shift+L). Select the Tab Bar Controller Scene and in the attributes inspector, select Is Initial View Controller.
Main.storyboard

Step 2. Create an ApplicationViewController

The demo app includes a SceneController to create and manage a Turbo session. For a tab based layout, I like to create multiple controllers that all inherit from an ApplicationViewController. Here's an example of that controller:
//
//  ApplicationViewController.swift
//  Turbo Demo
//

import UIKit
import Turbo
import SafariServices
import WebKit

class ApplicationViewController: UINavigationController {
    private let rootURL = Demo.current
    
    override func viewDidLoad() {
        super.viewDidLoad()
        initialVisit()
    }
    
    func initialVisit() {
        route(url: rootURL, options: VisitOptions(action: .replace), properties: [:])
    }
    
    func route(url: URL, options: VisitOptions, properties: PathProperties) {
        // Dismiss any modals when receiving a new navigation
        if presentedViewController != nil {
            dismiss(animated: true)
        }

        // Special case of navigating home, issue a reload
        if url.path == "/", !viewControllers.isEmpty {
            popViewController(animated: false)
            session.reload()
            return
        }
        
        // - Create view controller appropriate for url/properties
        // - Navigate to that with the correct presentation
        // - Initiate the visit with Turbo
        let viewController = makeViewController(for: url, properties: properties)
        navigate(to: viewController, action: options.action, properties: properties)
        visit(viewController: viewController, modal: isModal(properties))
    }
    
    private func makeViewController(for url: URL, properties: PathProperties = [:]) -> UIViewController {
        return ViewController(url: url)
    }
    
    private func navigate(to viewController: UIViewController, action: VisitAction, properties: PathProperties = [:], animated: Bool = true) {
        if isModal(properties) {
            let modal = UINavigationController(rootViewController: viewController)
            present(modal, animated: animated)
        } else if action == .replace {
            let newViewControllers = Array(viewControllers.dropLast()) + [viewController]
            setViewControllers(newViewControllers, animated: false)
        } else {
            pushViewController(viewController, animated: animated)
        }
    }
    
    private func visit(viewController: UIViewController, modal: Bool = false) {
        guard let visitable = viewController as? Visitable else { return }
        
        if modal {
            modalSession.visit(visitable)
        } else {
            session.visit(visitable)
        }
    }
    
    // MARK: - Authentication
    
    private func promptForAuthentication() {
        let authURL = rootURL.appendingPathComponent("/login")
        let properties = pathConfiguration.properties(for: authURL)
        route(url: authURL, options: VisitOptions(), properties: properties)
    }
    
    // MARK: - Sessions
    
    private lazy var session = makeSession()
    private lazy var modalSession = makeSession()
    
    private func makeSession() -> Session {
        let configuration = WKWebViewConfiguration()
        configuration.applicationNameForUserAgent = "Turbo Native iOS"
        configuration.processPool = Demo.webViewProcessPool
        
        let session = Session(webViewConfiguration: configuration)
        session.delegate = self
        session.pathConfiguration = pathConfiguration
        return session
    }
    
    // MARK: - Path Configuration
    
    private lazy var pathConfiguration = PathConfiguration(sources: [
        .file(Bundle.main.url(forResource: "path-configuration", withExtension: "json")!)
    ])
    
    private func isModal(_ properties: PathProperties) -> Bool {
        let presentation = properties["presentation"] as? String
        return presentation == "modal"
    }
}

extension ApplicationViewController: SessionDelegate {
    func session(_ session: Session, didProposeVisit proposal: VisitProposal) {
        route(url: proposal.url, options: proposal.options, properties: proposal.properties)
    }
    
    func session(_ session: Session, didFailRequestForVisitable visitable: Visitable, error: Error) {
        if let turboError = error as? TurboError, case let .http(statusCode) = turboError, statusCode == 401 {
            promptForAuthentication()
        } else if let errorPresenter = visitable as? ErrorPresenter {
            errorPresenter.presentError(error) { [weak self] in
                self?.session.reload()
            }
        } else {
            let alert = UIAlertController(title: "Visit failed!", message: error.localizedDescription, preferredStyle: .alert)
            alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
            present(alert, animated: true)
        }
    }
    
    func sessionDidLoadWebView(_ session: Session) {
        session.webView.navigationDelegate = self
    }
}

extension ApplicationViewController: WKNavigationDelegate {
    func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
        if navigationAction.navigationType == .linkActivated {
            // Any link that's not on the same domain as the Turbo root url will go through here
            // Other links on the domain, but that have an extension that is non-html will also go here
            // You can decide how to handle those, by default if you're not the navigationDelegate
            // the Session will open them in the default browser
            
            let url = navigationAction.request.url!
            
            // For this demo, we'll load files from our domain in a SafariViewController so you
            // don't need to leave the app. You might expand this in your app
            // to open all audio/video/images in a native media viewer
            if url.host == rootURL.host, !url.pathExtension.isEmpty {
                let safariViewController = SFSafariViewController(url: url)
                present(safariViewController, animated: true)
            } else {
                UIApplication.shared.open(url)
            }
            
            decisionHandler(.cancel)
        } else {
            decisionHandler(.allow)
        }
    }
}

Step 3. Create a new SceneDelegate.swift file

Since we won't be using the SceneController anymore, we need a new class to act as a UIWindowSceneDelegate. You can use the following SceneDelegate.swift example:
//
//  SceneDelegate.swift
//  Turbo Demo
//

import UIKit

final 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 }
    }
}
Make sure to set the correct value in your Info.plist file under Application Scene Manifest.
Info.plist

Step 4. Add webViewProcessPool to Demo struct

Because we're going to have multiple instances of the ApplicationViewController, we need to have a shared WKProcessPool so that all sessions are shared. We can accomplish that by adding it to the Demo.swift file. Don't forget to import WebKit.
import Foundation
import WebKit

struct Demo {
    static let webViewProcessPool = WKProcessPool()
    
    static let basic = URL(string: "https://turbo-native-demo.glitch.me")!
    static let turbolinks5 = URL(string: "https://turbo-native-demo.glitch.me?turbolinks=1")!
    
    static let local = URL(string: "http://localhost:45678")!
    static let turbolinks5Local = URL(string: "http://localhost:45678?turbolinks=1")!

    /// Update this to choose which demo is run
    static var current: URL {
        basic
    }
}

Step 5. Set custom class for Item 1 Scene

Go back to your Main.storyboard and select Item 1 Scene. Show the Identity inspector and choose your newly created ApplicationViewController as the custom class. Make sure to select Inherit Module From Target (else XCode won't find your class).
Custom class

Try running your app.
You should now have an app with two tab items where the first tab loads the root URL of the demo app.

Step 6. Add additional controllers

Having just one tab is obviously pointless, so we're going to add another one. The demo app example doesn't include a lot of content, so for this example we'll add another tab loading a different page.

I've created a new file called SomePageController.swift. This class inherits from ApplicationViewController and overrides the initialVisit method. Instead of visiting Demo.current, we append /one (this is a route in the demo server app).
//
//  SomePageController.swift
//  Turbo Demo
//

import UIKit
import Turbo

class SomePageController: ApplicationViewController {
    
    override func initialVisit() {
        route(url: Demo.current.appendingPathComponent("one"), options: VisitOptions(action: .replace), properties: [:])
    }
    
}
In your Main.storyboard file, change the custom class of Item 2 Scene to your new controller, just like we did with Item 1.
SomePageController Item 2 Scene

Step 7. Run your app!

Run your app and be amazed! Notice how each tab maintains it's state and navigation. Also notice that the tab item's title is fetched from the <title>-tag of your initial visit. The label will not change on subsequent visits.

Of course, this is just the beginning. Use Interface Builder to change your ViewControllers' icons, or add more items to the Tab Bar Controller.
iOS App with tabs using Turbo-iOS