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:
- Create a javascript component that can send and receive messages to your native app
- Create a native bridge component that can receive and reply to messages from your web app
- 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.
- Receive a message
- React to that message
- 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.