- Ruby / Rails関連
Rails: Hotwire Nativeで作るネイティブモバイルアプリ: iOS編(4)iOSキーボードをカスタマイズ(翻訳)
Rails: Hotwire Nativeで作るネイティブモバイルアプリ: iOS編(4)iOSキーボードをカスタマイズ(翻訳)
前回までのシリーズ記事を通じて、かなりのことができるようになりました。
- ほんの数行のコードを書くだけで、iOSネイティブアプリ内にRailsアプリを表示しました。
- ナビゲーション実装のいくつかのコツを活用して、アプリをネイティブらしくしました。
- 最後にブリッジコンポーネントを使って、モーダルのSaveボタンなどのネイティブ機能を追加しました。
しかし私たちは、まだHotwire Nativeが秘めている可能性のほんの一端に触れたばかりです。ネイティブAPIとWeb APIの間にある世界は、まさに思いのままです。
今回は、Railsアプリ用のキーボード拡張機能を構築します。これにより、Swift、JavaScript、Trixなどのさまざまな機能を組み合わせて、エンドユーザーに心から満足してもらいましょう。
これから構築するのは以下です。
訳注
Xcodeのエミュレータでカスタムキーボードを表示するには、フィールドにカーソルを置いてからコマンド+Shift+Kを押します。
🔗 JavaScriptでブリッジコンポーネントを作成する
最初はRailsサーバー側で作業します。
JavaScriptでサーバーにメッセージを送信する部分を最初に作ることを思い出しましょう。
今回は、inputAccesspryViewをビルドするようiOSに指示します。これはiOSキーボード上に表示されるカスタムツールバーです。
訳注
この記事で作業を進めるには、RailsアプリでAction Textを有効にしてTrixを使えるようにする必要があります。
bin/rails action_text:install
bundle install
bin/rails db:migrate
それでは、最初にRailsアプリのapp/javascript/controllers/bridge/ディレクトリの下にkeyboard_controller.jsというファイルを作成しましょう。
このチュートリアルではiOSがメインなので、コードを省略せずに掲載します。
// /app/javascript/controllers/bridge/keyboard_controller.js
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)
}
}
}
JavaScriptのセットアップが終わったら、ここで行っていることを見ていきましょう。
最初に、Rails側でこのブリッジコンポーネントが"focus"イベントを受信すると、toggleToolbarメソッドが呼び出されます。このメソッドは、"focus"メッセージをiOSアプリに送信します。
app/views/todos/_form.html.erbファイルのテキストエリアでfocusinやfocusoutが発生すると、toggleToolbarメソッドが呼び出されます(訳注: 以下をapp/views/todos/_form.html.erbに追加します)。
<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>
setUpToolbarメソッドでは、iOSアプリから受信したメッセージに応じて、Trixのボタンをオンオフしています。
これでJavaScript側のセットアップが完了したので、iOS側で作業できるようになります。
🔗 iOSのブリッジコンポーネントを作成する
このコンポーネントは、前回のコンポーネントよりも少し複雑です。カスタムWebViewを作成する必要があり、これはhotwire-native-iOSが提供するWebViewにinputAccessoryViewを追加したものに似ています。
カスタムWebViewをセットアップしたら、次はさまざまなTrix属性のオンオフを切り替えられるブリッジコンポーネントをセットアップします。
🔗 カスタムWebView
このコンポーネントを作成するには、CustomWebViewをセットアップして、HotwireでそのWebViewを利用するように構成する必要があります。
訳注
CustomWebViewのセットアップは、前回までのチュートリアルで行ったのと同様にコマンド+NでSwiftUIとして作成します。
この作業は簡単なので心配はありません。
最初に、CustomWebViewでWKWebViewを継承します。
import UIKit
import WebKit
class CustomWebView: WkWebView {}
次に、Trixツールバーが表示されたときにWebViewに送信する各メソッドに対して、ツールバーとオプショナルのVoidクロージャを定義します。
class CustomWebView: WKWebView {
let toolbar: UIToolbar = UIToolbar()
var heading1: (() -> Void)?
var bold: (() -> Void)?
var italic: (() -> Void)?
var undo: (() -> Void)?
var redo: (() -> Void)?
}
次に、個別のオプショナルクロージャに対応するカスタムツールバーを作成します。これは、タップした操作に対応するクロージャを呼び出します。
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) // キーボードを消す
}
}
最後に、もう使わなくなったツールバーを非表示にする方法が必要です。
class CustomWebView: WKWebView {
...
func toggleCustomToolbar() {
toolbar.isHidden = !toolbar.isHidden
}
}
完璧です。これで、いみじくもCustomWebViewと名付けたカスタムWebViewの準備が整いました。
最終的なコードは以下のようになります。
import UIKit
import WebKit
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
}
}
🔗 カスタムWebViewを統合する
Hotwireでは、カスタムWebViewにコンフィグのブロックを渡せます。AppDelegateで以下のように、新しいWebViewを設定する新しいメソッドを作成します。
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
}
}
AppDelegateのapplication関数を以下のように更新します。ここでは、コンポーネントが登録された直後にconfigureWebViewを呼び出しています。
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
let localPathConfigURL = Bundle.main.url(forResource: "path-configuration", withExtension: "json")!
configureWebView()
Hotwire.registerBridgeComponents([
FormComponent.self,
KeyboardComponent.self
])
Hotwire.loadPathConfiguration(from: [
.file(localPathConfigURL)
])
Hotwire.config.debugLoggingEnabled = true
return true
}
🔗 キーボードコンポーネントを構築する
ついにコンポーネント作成までこぎつけました。
Bridgeの下にKeyboardComponentという新しいファイルを作成します。
最初に、関連するライブラリをインポートして、delegate.webViewが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
}
}
他のBridgeComponentと同様に、最初にonRecieveメソッドを定義しておく必要があります。
final class KeyboardComponent: BridgeComponent {
...
override func onReceive(message: Message) {
guard let event = Event(rawValue: message.event) else {
return
}
}
}
これは、現時点では何も行いません。
ここで必要なのは、JavaScript側のブリッジコンポーネントにいくつかのJavaScriptメッセージを送信することです。
個別のメッセージ種別を扱うenumを定義しましょう。
以下はKeyboardComponentファイルの末尾に配置できます。
final class KeyboardComponent: BridgeComponent {
...
}
extension KeyboardComponent {
enum Event: String {
case focus
case heading1
case bold
case italic
case undo
case redo
}
}
それではonReceive関数を仕上げましょう。
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)
}
}
}
}
}
これで、アプリを実行すると、カスタムツールバーがキーボードに表示されるはずです。
KeyboardComponentの最終的なコードは以下のようになります。
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
}
}
以上でおしまいです。
次回から始まるAndroid編ではHotwire-Native-Androidに進みます。これまでHotwire NativeのiOSアプリで行ってきたことを、Androidアプリですべて再現していきます。
関連記事
Rails: Hotwire Nativeで作るネイティブモバイルアプリ: iOS編(3)ブリッジコンポーネント(翻訳)
概要
原著者の許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。
従来Turbo NativeとStradaと呼ばれていたものは、現在はHotwire Nativeに統合されました。
参考: Hotwire Native: Hotwire Native is a web-first framework for building native mobile apps.