Up and Running with Hotwire Native Android Part 4 - Bridge Components

In the last post, we explained how to navigate to a native screen and render it using Android Views or Jetpack compose.

However, there is a trade-off. Duplicating your screen multiple times across platforms comes at a price. That price is complexity and cost.

Yet, sometimes, we want to take advantage of native platform features.

Today, we’ll show you how to take advantage of native features from a web screen.

Bridge Components

A Bridge component is an extension of a Stimulus controller that allows you to send messages to the native app.

To implement a bridge component, you do the following:

  1. Create a javascript component that can send and receive messages to your native app
  2. Create a native bridge component that can receive and reply to messages from your web app
  3. Add your native component to the Hotwire component register in our iOS app.

The critical part of the Bridge method is the send method. This method takes an event, some data and a callback, which gets executed when the native component replies to the message.

Our first component

Let’s build the form component that adds a submit button to our modal for our forms.

The first thing we do is start with the Rails app.

Ensure you have the Bridge library installed.

./bin/importmap pin @hotwired/stimulus @hotwired/hotwire-native-bridge

Then we can create a form bridge component under 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()
    })
  }
}

Finally, we add our stimulus markup in our form under todos/_form.html.erb.

<%= 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 data: {bridge__form_target: "submit", bridge_title: 'Save'} %>
  </div>
<% end %>

Good job. We have everything set up on our Rails app.

Building our Android Component

It involves the following, similar to building a bridge component in iOS.

  1. Receive a message
  2. React to that message
  3. reply to the webview with a message from the Android app

When building a new bridge component, I usually do it in stages, as there can be many moving pieces. First, I connect my component. Then I start building the functionality in stages. There is a lot of trial and error.

Create a new package called bridge, and in that, create a file called FormComponent.

class FormComponent(  
    name: String,  
    private val formDelegate: BridgeDelegate<HotwireDestination>  
) : BridgeComponent<HotwireDestination>(name, formDelegate) {

  override fun onReceive(message: Message) {  
    when (message.event) {  
        "connect" -> handleConnectEvent(message)  
        else -> Log.w("FormComponent", "Unknown event for message: $message")  
    }  
  }
  
  private fun handleConnectEvent(message: Message) {  
     

    Log.w("FormComponent", "connected")
  }
}

Now, in our HotwireApplication file, we register our Bridge component.

Hotwire.registerBridgeComponents(  
 BridgeComponentFactory("form", ::FormComponent)  
)

Now rebuild the app and navigate to our form page to display the modal.

Check the logs in the Android Studio, and you should see our message printed.

Now, we are on the way to building out our form component.

This is the easy part. Now, we have to navigate a few different aspects of Android’s ecosystem.

Firstly, we need to edit our WebBottomSheetFragment so that instead of using hotwire-native’s bottom sheet, we will use our own.

@HotwireDestinationDeepLink(uri = "hotwire://fragment/web/modal/sheet")  
class WebBottomSheetFragment : HotwireWebBottomSheetFragment() {  
    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {  
        return inflater.inflate(R.layout.fragment_web_bottom_sheet, container, false)  
    }  
}

Now we create layout/fragment_web_bottom_sheet

<?xml version="1.0" encoding="utf-8"?>  
<LinearLayout  
    xmlns:android="http://schemas.android.com/apk/res/android"  
    android:layout_width="match_parent"  
    android:layout_height="match_parent"  
    android:orientation="vertical">  
  
    <com.google.android.material.appbar.AppBarLayout  
        android:id="@+id/app_bar"  
        android:layout_width="match_parent"  
        android:layout_height="wrap_content"  
        android:background="@color/transparent"  
        android:stateListAnimator="@null">  
  
        <com.google.android.material.appbar.MaterialToolbar  
            android:id="@+id/toolbar"  
            android:layout_width="match_parent"  
            android:layout_height="wrap_content"  
            android:background="@color/transparent">  
  
        <androidx.compose.ui.platform.ComposeView  
            android:id="@+id/compose_view"  
            android:layout_width="wrap_content"  
            android:layout_height="wrap_content" />  
        </com.google.android.material.appbar.MaterialToolbar>  
  
    </com.google.android.material.appbar.AppBarLayout>  
  
    <include  
        layout="@layout/hotwire_view_bottom_sheet"  
        android:layout_width="match_parent"  
        android:layout_height="match_parent"  
        android:background="?android:colorBackground" />  
  
</LinearLayout>

Now back to our FormComponent, we need to be able to parse the message we receive from the web.

@Serializable 
class FormExampleComponent(  
    name: String,  
    private val formDelegate: BridgeDelegate<HotwireDestination>  
) : BridgeComponent<HotwireDestination>(name, formDelegate) {  
  
    ...  
  
    private fun handleConnectEvent(message: Message) {  
        val data = message.data<MessageData>() ?: return
        Log.w("FormComponent", "connected")  
    }  
}

@Serializable
data class MessageData(  
    @SerialName("submitTitle") val title: String  
)

You may get a serializable error which means that you need to configure the correct libraries.

In our libs.versions.toml file

[versions]  
...
serialization = "1.5.0"  
  
  
[libraries]  
...  
kotlin-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization"}  
  
  
[plugins]  
...
jetbrains-serialization = {id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin"}

Then in our app’s build.grade.kts file,

plugins {  
   ... 
    alias(libs.plugins.jetbrains.serialization)  
  
}  
  
android {  
  ....  
}  
  
dependencies {  
.   ...
    implementation(libs.kotlin.serialization.json)  
  
}

Make sure to sync the gradle files.

Now we have everything in place to continue building out our BridgeComponent so we can finally head back to our FormComponent

Finishing the FormComponent

Now that we have the skeleton in place, let’s start by telling our class about the Android view they will be manipulating.

class FormComponent(  
    name: String,  
    private val formDelegate: BridgeDelegate<HotwireDestination>  
) : BridgeComponent<HotwireDestination>(name, formDelegate) {  
  
    private val fragment: Fragment  
        get() = formDelegate.destination.fragment  
  
    private val toolbar: MaterialToolbar?  
        get() = fragment.view?.findViewById(R.id.toolbar)  
    private var submitMenuItem: android.view.MenuItem? = null  
  ....

In our handleConnectEvent function, let’s parse the data and start building the toolbar with that data.

  private fun handleConnectEvent(message: Message) {  
        val data = message.data<MessageData>() ?: return  
        Log.w("FormComponent", "connected")  
        showToolbarButton(data)  
    }  
  
    private fun showToolbarButton(data: MessageData) {}

Now we just have two more pieces to build out.

private fun showToolbarButton(data: MessageData) {  
    val menu = toolbar?.menu ?: return  
  
    // Find the ComposeView by ID in the fragment's view hierarchy  
    val composeView: ComposeView = fragment.view?.findViewById(R.id.compose_view) ?: return  
  
    // Set the Compose content in the ComposeView  
    composeView.setContent {  
        SubmitButton(  
            title = data.title,  
            onSubmitClick = {  
                Log.d("FormComponent", "FormComponent button pressed")  
                replyTo("connect")  
            }  
        )  
    }  
  
    // Add the ComposeView as the actionView for the menu item  
    submitMenuItem = menu.add(Menu.NONE, 20, 999, data.title).apply {  
        actionView = composeView  
        setShowAsAction(android.view.MenuItem.SHOW_AS_ACTION_ALWAYS)  
    }  
  
    Log.d("FormComponent", "FormComponent showToolbarButton with ComposeView from layout")  
}  
  
@Composable  
fun SubmitButton(title: String, onSubmitClick: () -> Unit) {  
    TextButton(onClick = onSubmitClick) {  
        Text(text = title)  
    }  
}

Just to not that the numbers 20 and 999 are not picked for any particularly reason.

The completed component should look like this.

class FormComponent(  
    name: String,  
    private val formDelegate: BridgeDelegate<HotwireDestination>  
) : BridgeComponent<HotwireDestination>(name, formDelegate) {  
  
    private val fragment: Fragment  
        get() = formDelegate.destination.fragment  
  
    private val toolbar: MaterialToolbar?  
        get() = fragment.view?.findViewById(R.id.toolbar)  
    private var submitMenuItem: android.view.MenuItem? = null  
  
    override fun onReceive(message: Message) {  
        when (message.event) {  
            "connect" -> handleConnectEvent(message)  
            else -> Log.w("FormComponent", "Unknown event for message: $message")  
        }  
    }  
  
    private fun handleConnectEvent(message: Message) {  
        val data = message.data<MessageData>() ?: return  
        Log.w("FormComponent", "connected")  
        showToolbarButton(data)  
    }  
  
    private fun showToolbarButton(data: MessageData) {  
        val menu = toolbar?.menu ?: return  
  
        // Find the ComposeView by ID in the fragment's view hierarchy  
        val composeView: ComposeView = fragment.view?.findViewById(R.id.compose_view) ?: return  
  
        // Set the Compose content in the ComposeView  
        composeView.setContent {  
            SubmitButton(  
                title = data.title,  
                onSubmitClick = {  
                    Log.d("FormComponent", "FormComponent button pressed")  
                    replyTo("connect")  
                }  
            )  
        }  
  
        // Add the ComposeView as the actionView for the menu item  
        submitMenuItem = menu.add(Menu.NONE, 20, 999, data.title).apply {  
            actionView = composeView  
            setShowAsAction(android.view.MenuItem.SHOW_AS_ACTION_ALWAYS)  
        }  
  
        Log.d("FormComponent", "FormComponent showToolbarButton with ComposeView from layout")  
    }  
  
    @Composable  
    fun SubmitButton(title: String, onSubmitClick: () -> Unit) {  
        TextButton(onClick = onSubmitClick) {  
            Text(text = title)  
        }  
    }  
}

And that’s everything.

In the next article, we’ll combine everything to build a custom Trix component, just like we did for our iOS series.

Until then, happy hacking.

Learn to Build Android, iOS and Rails Apps using Hotwire

© 2025 William Kennedy, Inc. All rights reserved.