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.