Up and Running with Hotwire Native Android Part 5 - Custom Android Keyboard Extension

In the last few posts, we have achieved a lot. We rendered our Rails app within a native app with very few lines of code. We added some navigation tricks that demonstrate how to make the app feel native and finally we added bridge components to demonstrate native sprinkles such as a Save button on our modals.

However, we are really just beginning to touch on what’s possible. Between the native APIs and web APIs, the world is truly your oyster.

Today we are going to build a keyboard extension for our Rails app. This will combine our Kotlin, JavaScript, Trix and various other tidbits to truly delight the end-user.

Here is what we are building:

Creating the JavaScript Bridge Component

The first place to start is on our server. Remember, we start with the JavaScript that sends the message to the server.

Afterwards, we need to manipulate the Android views to display a toolbar that sits just above the keyboard when the focus event is sent.

So let’s start by creating a new file called keyboard_controller.js in app/javascript/controllers/bridge in our Rails app.

Since this is primarily a Android tutorial, I’m just going to give you all the code.

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.send("heading1", {}, () => {
      this.toggleAttribute(element.editor, "heading1")
    })

    this.send('bold', {}, () => {
      this.toggleAttribute(element.editor, "bold")
    })


    this.send('italic', {}, () => {
      this.toggleAttribute(element.editor, "italic")
    })


    this.send('undo', {}, () => {
      element.editor.undo()
    })


    this.send('redo', {}, () => {
      element.editor.redo()
  })
  }


  toggleAttribute(editor, attribute) {
    const isActive = editor.attributeIsActive(attribute)

    if (isActive) {
      editor.deactivateAttribute(attribute)
    } else {
      editor.activateAttribute(attribute)
    }
  }
}

Now that we have the JavaScript setup, let’s go through exactly what’s it’s doing.

First things first, when we receive the focus event, we call the toggleToolbar method which sends a “focus” message to the Android app.

In our app/views/todos/_form.html.erb, we call the method when we focusin and focusout of the text area input.

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


In our setUpToolbar method, we are responding to messages we receive from the Android app and toggling the Trix button on/off.

Now that we have the JavaScript setup, we can move on over to the Android side.

Adding a toolbar

We are going to display a toolbar wherever we have a form and that could be anywhere. The default Android View that is rendered comes from the Hotwire Native library. We need to create our own and add the toolbar.

Create a new fragment called WebFragment as well as a layout file called fragment_web_home

@HotwireDestinationDeepLink(uri = "hotwire://fragment/web")
open class WebFragment : HotwireWebFragment() {
    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        return inflater.inflate(R.layout.fragment_web_home, container, false)
    }
}

Next, create the corresponding layout.

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    
    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/app_bar"
        android:layout_width="match_parent"
        android:layout_height="56dp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent">

        <com.google.android.material.appbar.MaterialToolbar
            android:id="@+id/top_toolbar"
            android:layout_width="match_parent"
            android:layout_height="wrap_content" />
    </com.google.android.material.appbar.AppBarLayout>

        <FrameLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent">

            <include
                layout="@layout/hotwire_view"
                android:layout_width="match_parent"
                android:layout_height="match_parent" />
        </FrameLayout>

    <!-- Bottom toolbar -->
    <LinearLayout
        android:id="@+id/bottom_toolbar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        android:gravity="start"
        android:background="?attr/colorBackgroundFloating"
        app:layout_constraintBottom_toBottomOf="parent"
        android:elevation="0dp"
        android:visibility="invisible"
        >
    </LinearLayout>

</androidx.constraintlayout.widget.ConstraintLayout>

Now that we have a bottom toolbar, we can start manipulating it. I have left the colors as they are and have not styled it. That is an exercise up to the reader.

Creating our Bridge Component

Let’s start by creating and registering our Bridge components.

class KeyboardComponent(
    name: String,
    private val delegate: BridgeDelegate<HotwireDestination>,
)  : BridgeComponent<HotwireDestination>(name, delegate) {
`    override fun onReceive(message: Message) {}
}

Let’s register it in our Application file.

      // Bridge
Hotwire.registerBridgeComponents(
  BridgeComponentFactory("form", ::FormComponent),
  BridgeComponentFactory("keyboard", ::KeyboardComponent)
)

Now we can start building.

Building the buttons

When a user clicks on the Trix field, that is when we show the toolbar but before we do that, we must build the toolbar.

class KeyboardComponent(
    name: String,
    private val delegate: BridgeDelegate<HotwireDestination>,
) : BridgeComponent<HotwireDestination>(name, delegate) {

    private val fragment: Fragment
        get() = delegate.destination.fragment

    private val bottomToolbar: LinearLayout?
        get() = fragment.view?.findViewById(R.id.bottom_toolbar)

    private var hidden = true
    private var built = false

    override fun onReceive(message: Message) {
        when (message.event) {
            "focus" -> handleFocusEvent()
            else -> Log.w("KeyboardComponent", "Unknown event for message: $message")
        }
    }

   private fun handleFocusEvent() {
        buildKeyboard()
    }

Our buildKeyboard method needs to create buttons that can be added to the toolbar. We also need to download some drawable icons. I download my drawable icons from Pictorgrammer.

We are going to download format-header-1, format-italic and format-bold. Be sure to click the advanced drop-down to download the XML vector.

Once our icons are downloaded, we can build out our function.

  private fun buildKeyboard() {
        if (!built) {
            addButton("heading1",R.drawable.format_h1_24px)
            addButton("bold", R.drawable.format_bold_24px)
            addButton("italic", R.drawable.format_italic_24px)
            built = true
        } else {
            built = true
        }
    }

    private fun addButton(eventName: String, iconRedId: Int?) {
        val btn = fragment.view?.context?.let {
            MaterialButton(it).apply {

                setOnClickListener { replyTo(eventName) }

                iconRedId?.let {
                    icon = rootView?.context?.let { it1 -> ContextCompat.getDrawable(it1, it) }
                    iconGravity = MaterialButton.ICON_GRAVITY_TEXT_START
                }

            }
        }
        bottomToolbar?.addView(btn)
    }

Finally, we just need to show/hide the keyboard when the field is focused.

private fun toggleKeyboard() {
        hidden = !hidden

        if (hidden) hideToolbarButton() else showToolbarButton()
}

Then update our handleFocusEvent function to toggle the keyboard.

    private fun handleFocusEvent() {
        buildKeyboard()
        toggleKeyboard()
    }

There you have it. Of course, the toolbar needs work to blend into your app but this is a great feature to build if you want to build a markdown editor in your app that’s accessible via mobile.

The final code is as follows.


class KeyboardComponent(
    name: String,
    private val delegate: BridgeDelegate<HotwireDestination>,
) : BridgeComponent<HotwireDestination>(name, delegate) {

    private val fragment: Fragment
        get() = delegate.destination.fragment

    private val bottomToolbar: LinearLayout?
        get() = fragment.view?.findViewById(R.id.bottom_toolbar)

    private var hidden = true
    private var built = false

    override fun onReceive(message: Message) {
        when (message.event) {
            "focus" -> handleFocusEvent()
            else -> Log.w("KeyboardComponent", "Unknown event for message: $message")
        }
    }

    private fun handleFocusEvent() {
        buildKeyboard()
        toggleKeyboard()
    }

    private fun buildKeyboard() {
        if (!built) {
            addButton("heading1",R.drawable.format_h1_24px)
            addButton("bold", R.drawable.format_bold_24px)
            addButton("italic", R.drawable.format_italic_24px)
            built = true
        } else {
            built = true
        }
    }

    private fun addButton(eventName: String, iconRedId: Int?) {
        val btn = fragment.view?.context?.let {
            MaterialButton(it).apply {

                setOnClickListener { replyTo(eventName) }

                iconRedId?.let {
                    icon = rootView?.context?.let { it1 -> ContextCompat.getDrawable(it1, it) }
                    iconGravity = MaterialButton.ICON_GRAVITY_TEXT_START
                }

            }
        }
        bottomToolbar?.addView(btn)
    }

    private fun showToolbarButton() {
        bottomToolbar?.visibility = View.VISIBLE
    }

    private fun hideToolbarButton() {
        bottomToolbar?.visibility = View.GONE
    }

    private fun toggleKeyboard() {
        hidden = !hidden

        if (hidden) hideToolbarButton() else showToolbarButton()
    }
}

Until next time, happy hacking.

Learn to Build Android, iOS and Rails Apps using Hotwire

© 2025 William Kennedy, Inc. All rights reserved.