Tech Racho エンジニアの「?」を「!」に。
  • 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

参考: Action Text の概要 - Railsガイド


それでは、最初に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ファイルのテキストエリアでfocusinfocusoutが発生すると、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として作成します。

この作業は簡単なので心配はありません。

最初に、CustomWebViewWKWebViewを継承します。

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
    }
}

AppDelegateapplication関数を以下のように更新します。ここでは、コンポーネントが登録された直後に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.webViewCustomWebViewにキャストされるようにしましょう。

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で作るネイティブモバイルアプリ: Android編(1)セットアップ(翻訳)

hotwired/hotwire-native-android - GitHub

関連記事

Rails: Hotwire Nativeでネイティブモバイルアプリを作ろう: iOS編(1)(翻訳)

Rails: Hotwire Nativeで作るネイティブモバイルアプリ: iOS編(2)ネイティブ画面(翻訳)

Rails: Hotwire Nativeで作るネイティブモバイルアプリ: iOS編(3)ブリッジコンポーネント(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。