Up and Running with Hotwire Native iOS 4 - Custom iOS Keyboard

So in the last few posts, we have achieved quite a lot. We rendered our Rails app within a native app with very few lines of code. We added some navigation tricks that demonstrate how to make the app feel native and finally we used some bridge components to add native sprinkles such as a Save button on our modal.

However, we are really just beginning to touch on what’s possible. Between the native apis and web apis, the world is truly your oyster.

Today we are going to build a keyboard extension for our Rails app. This will combine our Swift, JavaScript, Trix and various other tidbits to truly delight the end-user.

Here is what we are building:

Creating the JavaScript Bridge Component

The first place to start is on our server. Remember, we start with the JavaScript that sends the message to the server. In this case, we are telling iOS to build us a inputAccesspryView. This is a custom toolbar that sits atop your keyboard.

So let’s start by creating a new file called keyboard_controller.js in app/javascript/controllers/bridge in our Rails app.

Since this is primarily a iOS tutorial, I’m just going to give you all the code.

import { BridgeComponent } from "@hotwired/hotwire-native-bridge"

export default class extends BridgeComponent {
  static component = "keyboard"

  connect() {
    super.connect()
    this.setUpToolbarListeners()
  }

  toggleToolbar() {
    this.send("focus", {}, () => {})

  }

  setUpToolbarListeners() {
    const element = this.element 

    this.receive("heading1", {}, () => {
      this.toggleAttribute(element.editor, "heading1")
    })

    this.receive('bold', {}, () => {
      this.toggleAttribute(element.editor, "bold")
    })


    this.receive('italic', {}, () => {
      this.toggleAttribute(element.editor, "italic")
    })


    this.receive('undo', {}, () => {
      element.editor.undo()
    })


    this.receive('redo', {}, () => {
      element.editor.redo()
  })
  }


  toggleAttribute(editor, attribute) {
    const isActive = editor.attributeIsActive(attribute)

    if (isActive) {
      editor.deactivateAttribute(attribute)
    } else {
      editor.activateAttribute(attribute)
    }
  }
}

Now that we have the javascript setup, let’s go through exactly what’s it’s doing.

First things first, when we receive the focus event, we call the toggleToolbar method which sends a “focus” message to the iOS app.

In our app/views/todos/_form.html.erb, we call the method when we focusin and focusout of the text area input.

  <div>
    <%= form.label :description, style: "display: block" %>
    <%= form.rich_text_area :descritpion, data: {controller: 'bridge--keyboard', action: 'focusin->bridge--keyboard#toggleToolbar focusout->bridge--keyboard#toggleToolbar'} %>
  </div>


In our setUpToolbar method, we are responding to messages we receive from the iOS app and toggling the Trix button on/off.

Now that we have the JavaScript setup, we can move on over to the iOS side.

Creating our iOS Bridge Component

This component is a bit more complex than our last component. We need to create a custom WebView that is similar to the WebView that hotwire-native-iOS supplies us but with the an added inputAccessoryView.

After we setup the custom webView, we then setup our bridge component that allows is to toggle the different Trix attributes on andoff.

Custom WebView

Creating this component requires setting up a custom webview and then configuring Hotwire to use that WebView.

Don’t worry though.

This is easy-peasy.

First we inherit from WKWebView.

class CustomWebView: WkWebView {}

Next, we define a toolbar and an optional Void closure for each method that we want to send to the webview when a Trix toolbar appear.

class CustomWebView: WKWebView {
    let toolbar: UIToolbar = UIToolbar()
    var heading1: (() -> Void)?
    var bold: (() -> Void)?
    var italic: (() -> Void)?
    var undo: (() -> Void)?
    var redo: (() -> Void)?
}

Next, we create a custom toolbar for each Optional closure which has a corresponding tapped action that calls the closure.

class CustomWebView: WKWebView {
...
    override var inputAccessoryView: UIView {
        toolbar.isHidden = true

        toolbar.sizeToFit()

        let heading1 = UIBarButtonItem(
            title: "h1", style: .plain, target: self,
            action: #selector(heading1Tapped))
        let bold = UIBarButtonItem(
            image: UIImage(systemName: "bold"), style: .plain, target: self,
            action: #selector(boldTapped))
        let italic = UIBarButtonItem(
            image: UIImage(systemName: "italic"), style: .plain, target: self,
            action: #selector(italicTapped))
        let undo = UIBarButtonItem(
            image: UIImage(systemName: "arrow.uturn.backward"), style: .plain,
            target: self, action: #selector(undoTapped))
        let redo = UIBarButtonItem(
            image: UIImage(systemName: "arrow.uturn.forward"), style: .plain,
            target: self, action: #selector(redoTapped))
        let flexibleSpace = UIBarButtonItem(
            barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
        let done = UIBarButtonItem(
            title: "Done", style: .plain, target: self,
            action: #selector(doneButtonTapped))

        toolbar.setItems(
            [heading1, bold, italic, undo, redo, flexibleSpace, done],
            animated: true)

        return toolbar

    }

    @objc private func heading1Tapped() {
        heading1?()
    }

    @objc private func boldTapped() {
        bold?()
    }

    @objc private func italicTapped() {
        italic?()
    }

    @objc private func undoTapped() {
        undo?()
    }

    @objc private func redoTapped() {
        redo?()
    }

    @objc private func doneButtonTapped() {
        self.endEditing(true)  // Dismiss the keyboard
    }
}

Finally, we need a way to hide the toolbar when it’s not in use.

class CustomWebView: WKWebView {
...
    func toggleCustomToolbar() {
        toolbar.isHidden = !toolbar.isHidden
    }
}

Perfect. Now our custom web view, which we creatively called CustomWebView is ready.

Your finished code should look like this.

class CustomWebView: WKWebView {
    let toolbar: UIToolbar = UIToolbar()
    var heading1: (() -> Void)?
    var bold: (() -> Void)?
    var italic: (() -> Void)?
    var undo: (() -> Void)?
    var redo: (() -> Void)?

    override var inputAccessoryView: UIView {
        toolbar.isHidden = true

        toolbar.sizeToFit()

        let heading1 = UIBarButtonItem(
            title: "h1", style: .plain, target: self,
            action: #selector(heading1Tapped))
        let bold = UIBarButtonItem(
            image: UIImage(systemName: "bold"), style: .plain, target: self,
            action: #selector(boldTapped))
        let italic = UIBarButtonItem(
            image: UIImage(systemName: "italic"), style: .plain, target: self,
            action: #selector(italicTapped))
        let undo = UIBarButtonItem(
            image: UIImage(systemName: "arrow.uturn.backward"), style: .plain,
            target: self, action: #selector(undoTapped))
        let redo = UIBarButtonItem(
            image: UIImage(systemName: "arrow.uturn.forward"), style: .plain,
            target: self, action: #selector(redoTapped))
        let flexibleSpace = UIBarButtonItem(
            barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
        let done = UIBarButtonItem(
            title: "Done", style: .plain, target: self,
            action: #selector(doneButtonTapped))

        toolbar.setItems(
            [heading1, bold, italic, undo, redo, flexibleSpace, done],
            animated: true)

        return toolbar

    }

    @objc private func heading1Tapped() {
        heading1?()
    }

    @objc private func boldTapped() {
        bold?()
    }

    @objc private func italicTapped() {
        italic?()
    }

    @objc private func undoTapped() {
        undo?()
    }

    @objc private func redoTapped() {
        redo?()
    }

    @objc private func doneButtonTapped() {
        self.endEditing(true)  // Dismiss the keyboard
    }

    func toggleCustomToolbar() {
        toolbar.isHidden = !toolbar.isHidden
    }
}

Integrating Our Custom WebView

Hotwire allows you to pass in a block for a custom webview config. In our SceneController, we create new method that will configure the new web view.

private func configureWebView() {
    Hotwire.config.makeCustomWebView = { config in
        let customWebView = CustomWebView(frame: .zero, configuration: config)
        #if DEBUG
            if #available(iOS 16.4, *) {
                customWebView.isInspectable = true
            }
        #endif
        Bridge.initialize(customWebView)
        return customWebView
    }
}

This is our scene function, we call the configureCustomWeView function just after we register the component.

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
																					guard let windowScene = scene as? UIWindowScene else { return }
																					
    window = UIWindow(windowScene: windowScene)
    Hotwire.registerBridgeComponents([
      FormComponent.self
    ])

    configureWebView()

    window?.rootViewController = navigator.rootViewController
    window?.makeKeyAndVisible()



    navigator.route(rootURL)
}

Building our Keyboard Component

We are finally here. We are finally creating the component.

Let’s create a new file under Bridge, called KeyboardComponent.

Let’s start off with importing the relevant libraries making sure that the delegate.webView is cast to CustomWebView.

import Foundation
import HotwireNative
import UIKit
import WebKit


final class KeyboardComponent: BridgeComponent {
    override class var name: String { "keyboard" }

    private var viewController: UIViewController? {
        delegate.destination as? UIViewController
    }

    private var webView: WKWebView? {
        delegate.webView as? CustomWebView
    }
}

As with every BridgeComponent, the first method that we must define is the onRecieve

final class KeyboardComponent: BridgeComponent {
  ...
   override func onReceive(message: Message) {
        guard let event = Event(rawValue: message.event) else {
            return
        }
    }


}

Now at the moment, this doesn’t do anything.

What we need to do is send a message to send some javascript messages to our JS bridge component.

Let’s define an enum to handle each kind of message. You can put this down at the bottom of our KeyboardComponent file.

final class KeyboardComponent: BridgeComponent {
...
}


extension KeyboardComponent {
    enum Event: String {
        case focus
        case heading1
        case bold
        case italic
        case undo
        case redo
    }
}

Now let’s finish off our onReceive function.

final class KeyboardComponent: BridgeComponent {
    ...
    override func onReceive(message: Message) {
        guard let event = Event(rawValue: message.event) else {
            return
        }

        if let webView = delegate.webView as? CustomWebView {
            switch event {
            case .focus:
                webView.toggleCustomToolbar()
            case .heading1:
                webView.heading1 = {
                    self.reply(to: Event.heading1.rawValue)
                }
            case .bold:
                webView.bold = {
                    self.reply(to: Event.bold.rawValue)
                }
            case .italic:
                webView.italic = {
                    self.reply(to: Event.italic.rawValue)
                }
            case .undo:
                webView.undo = {
                    self.reply(to: Event.undo.rawValue)
                }
            case .redo:
                webView.redo = {
                    self.reply(to: Event.redo.rawValue)
                }
            }
        }
    }
}

Now when we run our app, we should have a custom toolbar for our keyboard.

The final code for our KeyboardComponent should look like this.

import Foundation
import HotwireNative
import UIKit
import WebKit


final class KeyboardComponent: BridgeComponent {
    override class var name: String { "keyboard" }

    private var viewController: UIViewController? {
        delegate.destination as? UIViewController
    }

    private var webView: WKWebView? {
        delegate.webView as? CustomWebView
    }

    override func onReceive(message: Message) {
        guard let event = Event(rawValue: message.event) else {
            return
        }

        if let webView = delegate.webView as? CustomWebView {
            switch event {
            case .focus:
                webView.toggleCustomToolbar()
            case .heading1:
                webView.heading1 = {
                    self.reply(to: Event.heading1.rawValue)
                }
            case .bold:
                webView.bold = {
                    self.reply(to: Event.bold.rawValue)
                }
            case .italic:
                webView.italic = {
                    self.reply(to: Event.italic.rawValue)
                }
            case .undo:
                webView.undo = {
                    self.reply(to: Event.undo.rawValue)
                }
            case .redo:
                webView.redo = {
                    self.reply(to: Event.redo.rawValue)
                }
            }
        }
    }
}

extension KeyboardComponent {
    enum Event: String {
        case focus
        case heading1
        case bold
        case italic
        case undo
        case redo
    }
}

Now that’s a wrap.

In the next few articles, I’m going to move on to Hotwire-Native-Android, replicating everything we’ve done here with Hotwire Native iOS.

Learn to Build Android, iOS and Rails Apps using Hotwire

© 2025 William Kennedy, Inc. All rights reserved.