Rails: Hotwire Nativeで作るネイティブモバイルアプリ: iOS編(3)ブリッジコンポーネント(翻訳)
前回の記事では、パス設定とhandle関数を編集することで、iOSアプリのネイティブ画面に移動できるようにしました。ネイティブ画面はアプリのUXを向上させることが可能なので、これは大きなメリットです。
しかしネイティブ画面にはトレードオフがあります。同じネイティブ画面を「iOS用」「Android」用にそれぞれ作成すると、複雑になってコストがかさみます。
モバイルアプリでHTMLのみの画面を使うと、どことなくネイティブ画面らしくない感じがしてしまい、ユーザーに「何か物足りないな」と気づかれてしまうかもしれません。熟練した開発者(つまりあなた)ならそうならないようにできるでしょう。
しかし、HTMLビューでネイティブアプリの操作を追加可能にするような中間点、つまり一種のブリッジのようなものが使えるとしたらどうでしょうか?
本記事では、このブリッジコンポーネントのしくみをデモアプリの例を用いて解説します。
🔗 ブリッジコンポーネント
ブリッジコンポーネント(bridge component)はStimulusコントローラの拡張で、ネイティブアプリにメッセージを送信できるようにします。
ブリッジコンポーネントは以下の手順で実装します。
- ネイティブアプリにメッセージを送受信できるJavaScriptコンポーネントを作成する
 - Webアプリからメッセージを受信して返信できるネイティブブリッジコンポーネントを作成する
 - iOSアプリのHotwireコンポーネントレジスタにネイティブコンポーネントを追加する
 
ブリッジで重要なメソッドはsendメソッドです。このメソッドは、「イベント」「データ」「コールバック」を受け取り、ネイティブコンポーネントがメッセージに返信したときに実行します。
フォームのモーダルに送信ボタンを追加するフォームコンポーネントを構築してみましょう。
最初にRailsアプリ側で作業します。
以下のコマンドを実行してブリッジライブラリをインストールします。
./bin/importmap pin @hotwired/stimulus @hotwired/hotwire-native-bridge
次に、app/javascript/controllers/bridge/form_controller.jsファイルを作成して以下のブリッジコンポーネントを作成します。
import { BridgeComponent, BridgeElement } from "@hotwired/hotwire-native-bridge"
export default class extends BridgeComponent {
  static component = "form"
  static targets = [ "submit" ]
  submitTargetConnected(target) {
    const submitButton = new BridgeElement(target)
    const submitTitle = submitButton.title
    this.send("connect", { submitTitle }, () => {
      target.click()
    })
  }
}
最後に、todos/_form.html.erbファイルにあるフォームにStimulus用のマークアップを追加します。
-<%= form_with(model: todo) do |form| %>
+<%= form_with(model: todo, data: {controller: 'bridge--form'}) do |form| %>
  <% if todo.errors.any? %>
    <div style="color: red">
      <h2><%= pluralize(todo.errors.count, "error") %> prohibited this todo from being saved:</h2>
      <ul>
        <% todo.errors.each do |error| %>
          <li><%= error.full_message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>
  <div>
    <%= form.label :title, style: "display: block" %>
    <%= form.text_field :title %>
  </div>
  <div>
    <%= form.label :complete, style: "display: block" %>
    <%= form.checkbox :complete %>
  </div>
  <div>
-   <%= form.submit %>
+   <%= form.submit data: {bridge__form_target: "submit", bridge_title: 'Save'} %>
  </div>
<% end %>
Rails側の作業はこれですべておしまいです。
次はiOSアプリ側で作業しましょう。
ネイティブのブリッジコンポーネントは、それぞれ以下を行います。
- iOSアプリからメッセージを受信する。
これはonReceive関数を介して行われます。 - 
reply関数でWebアプリにメッセージを返信する。 
最初にフォームコンポーネントを構築してコードを見ていきましょう。Bridge/FormComponent.swiftという新しいファイルを以下の内容で追加します。
import Foundation
import HotwireNative
import UIKit
final class FormComponent: BridgeComponent {
    override class var name: String { "form" }
    override func onReceive(message: Message) {
        guard let event = Event(rawValue: message.event) else {
            return
        }
        switch event {
        case .connect:
            handleConnectEvent(message: message)
        }
    }
    private var viewController: UIViewController? {
        delegate.destination as? UIViewController
    }
    private func handleConnectEvent(message: Message) {
        guard let data: MessageData = message.data() else { return }
        configureBarButton(with: data.submitTitle)
    }
    private func configureBarButton(with title: String) {
        guard let viewController else { return }
        let action = UIAction { [unowned self] _ in
            reply(to: Event.connect.rawValue)
        }
        let item = UIBarButtonItem(title: title, primaryAction: action)
        viewController.navigationItem.rightBarButtonItem = item
    }
}
private extension FormComponent {
    enum Event: String {
        case connect
    }
}
private extension FormComponent {
    struct MessageData: Decodable {
        let submitTitle: String
    }
}
上はデモアプリのコードですが、初めての人にも理解しやすいようにいくつかの行を削除してあります。
最後にコンポーネントを登録します。 AppDelegateで以下のコードをapplication関数に追加します。
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
      ...
+       Hotwire.registerBridgeComponents([
+           FormComponent.self
+       ])
        Hotwire.loadPathConfiguration(from: [
            .file(localPathConfigURL),
            .server(remotePathConfigURL)
        ])
        Hotwire.config.debugLoggingEnabled = true
        return true
    }}
注意: rootViewControllerを設定する前にHotwireコンポーネントを登録しておく必要があります。
アプリを実行して、エミュレータでNewまたはEditに移動すると、モーダルにネイティブのボタンが表示されていることがわかります。

🔗 FormComponentを理解する
FormComponentのコードについて少し説明を加えます。
JavaScriptでsendメッセージを実装したことを思い出してください。
iOS側のonReceive関数では、イベント名に基づいて応答を変えています。
イベントが"connect"の場合、handleConnectEvent(message: message)にブリッジコンポーネントから送信されたメッセージを渡して呼び出します。
次にUIBarButtonItemをビルドして、重要なreply関数を呼び出すアクションをそこに渡しています。
最後にバーボタンをnavigationItemに追加して、画面の右側に配置します。
フォームコンポーネントを独自に構築する場合は、「最初にonReceive関数を使い、次にそれを元にビルドし、必要に応じてreply関数を取り入れる」という重要な流れを覚えておきましょう。

🔗 CSSで不要なボタンを隠す
これで、同じ機能を持つ2つのボタン、つまりHTMLボタンとネイティブのボタンが使えるようになりました。
今度はCSSを使って、Hotwire Nativeアプリでナビゲーションする場合はHTMLボタンを非表示にしましょう。
app/views/layouts/application.html.erbファイルに以下を追加します。
    <%= stylesheet_link_tag "native", "data-turbo-track": "reload" if turbo_native_app? %>
次に、app/assets/stylesheets/ディレクトリの下にnative.cssという名前で以下の内容のスタイルシートを作成します。
.hidden-on-native {
  display: none;
}
これで、非表示にしたい任意の要素に上のCSSクラスを適用可能になりました。
app/views/todos/_form.html.erbファイルで、上のCSSクラスを以下のようにフォームの送信ボタンに追加します。
    <%= form.submit data: {bridge__form_target: "submit", bridge_title: 'Save'}, class: 'hidden-on-native' %>
訳注
これで、アプリを実行して、エミュレータでNewまたはEditに移動すると、Railsのerbの送信ボタンが非表示になり、iOSネイティブのSaveボタンだけが表示されます。

🔗 最後に
今回の説明は盛りだくさんでした。ブリッジコンポーネントは非常に強力ですが、iOSとJavaScriptの両方の知識を必要とするので、習得がかなり難しい場合もありますが、練習を重ねれば完璧になります。
次回の記事では、iOSネイティブアプリのさまざまなコンセプトとWebのコンセプトを結び付けて、Hotwire Nativeの本物の威力を実証してみせます。
      
概要
原著者の許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。
従来Turbo NativeとStradaと呼ばれていたものは、現在はHotwire Nativeに統合されました。
参考: Hotwire Native: Hotwire Native is a web-first framework for building native mobile apps.