Sitemap

Implementation of a custom soft keyboard in Android using Compose

9 min readJun 3, 2025

While working on my Android project, I had the idea of ​​improving the user experience with the application. The idea was to make it easier to enter specific symbols and actions over them for the Android application in one of its parts, which led to the decision to create my own software keyboard using Compose since the entire graphical part was already built with this tool.

So, I will describe this process in this article. Find the attached example at the end.

Let`s imagine that we need to implement a numeric keyboard in the 16-base number system, with the ability to enter a space, the symbols: “-”, “,”, “.”,” p”, the value “0x” and with editing and completion actions. This keyboard will allow us to display the 16th number in the usual display format 0x3afe1, or with a floating point -0x3.14p3 in a readable form.

The keyboard will look like this.

Light theme
Light theme
Dark theme
Dark theme

Our main actions will be deployed on four main components of our system: The service that will act as an input method editor (IME), extend the InputMethodService class, and create our keyboard. We will call it IMEHexadecimalService. In order to bind the keyboard to our service, it must be presented in the View. As we remember, the entire graphical part will be written in Compose.

Therefore, the next component will be a class that will be responsible for “transforming” the keyboard into a View. It will extend the abstract class AbstractComposeView. Let`s call it ComposeHexadecimalKeyBoardView. We can conclude that we need to insert the keyboard Compose function into this class.

The next component will be the primary Compose function, which will be responsible for building a keyboard, let`s call it HexadecimalKeyBoard, from the components that will be described below.

The last Compose function will be responsible for the graphical representation of the keyboard symbol and its interaction with the user (pressing and holding the key, informing him that the action was reflected visually, haptically, and by sound). Its name will be HexadecimalKey.

Let`s start with the most crucial part, IME.

IMEHexadecimalService

From a short examination of the official documentation, “Create an input method,” we need to extend the InputMethodService class, declare the extended class in the manifest, and return the keyboard using the onCreateInputView() method. But not everything is so simple and not so complicated at the same time. Since we will use Compose to build the graphical part and the onCreateInputView method accepts only a View, we must wrap it with the AbstractComposeView class to “convert” the Compose function into a View. But this class has limitations, AbstractComposeView only supports being added into view hierarchies propagating LifecycleOwner and SavedStateRegistryOwner via setViewTreeLifecycleOwner and setViewTreeSavedStateRegistryOwner.

To do this, we need to bind the above-mentioned entities to the root container in the onCreateInputView() method. Fortunately, InputMethodService has this capability. Using the getWindow() (Kotlin — window) method, you can get a Dialog from it, a Window via getWindow() (Kotlin — window), and finally the root container for our View getDecorView() (Kotlin — decorView).

Let`s skip all the details to move on to the final code.

class IMEHexadecimalService : InputMethodService(), LifecycleOwner,
SavedStateRegistryOwner {

private var lifecycleRegistry: LifecycleRegistry = LifecycleRegistry(this)

override val lifecycle: Lifecycle
get() = lifecycleRegistry

private val savedStateRegistryController = SavedStateRegistryController.create(this)

override val savedStateRegistry: SavedStateRegistry
get() = savedStateRegistryController.savedStateRegistry

override fun onCreateInputView(): View {
window?.window?.decorView?.let { decorView ->
decorView.setViewTreeLifecycleOwner(this)
decorView.setViewTreeSavedStateRegistryOwner(this)
}
return ComposeHexadecimalKeyBoardView(this)
}

override fun onCreate() {
super.onCreate()
savedStateRegistryController.performRestore(null)
handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
}

override fun onStartInputView(editorInfo: EditorInfo?, restarting: Boolean) {
super.onStartInputView(editorInfo, restarting)
handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
}

override fun onFinishInputView(finishingInput: Boolean) {
super.onFinishInputView(finishingInput)
handleLifecycleEvent(Lifecycle.Event.ON_PAUSE)
}

override fun onDestroy() {
super.onDestroy()
handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
}

private fun handleLifecycleEvent(event: Lifecycle.Event) =
lifecycleRegistry.handleLifecycleEvent(event)
}

It is essential to rewrite the onCreate(), onStartInputView(), onFinishInputView() and onDestroy() methods described above for the keyboard to work. The order is only important for Lifecycle.Event.ON_CREATE, and it should be last.

After that, we must declare our service in the manifest described in the example below.

<service
android:name=".service.IMEHexadecimalService"
android:exported="false"
android:label="@string/keyboard_name"
android:permission="android.permission.BIND_INPUT_METHOD">
<intent-filter>
<action android:name="android.view.InputMethod" />
</intent-filter>

<meta-data
android:name="android.view.im"
android:resource="@xml/method" />
</service>

Where xml/method

<input-method xmlns:android="http://schemas.android.com/apk/res/android">
<subtype android:imeSubtypeMode="keyboard" />
</input-method>

Let`s now move on to the ComposeHexadecimalKeyBoardView entity that we used in onCreateInputView().

ComposeHexadecimalKeyBoardView

It should be simpler here. Here is the class itself that is responsible for “transforming” the Compose keyboard into a View.

class ComposeHexadecimalKeyBoardView(context: Context) : AbstractComposeView(context) {
@Composable
override fun Content() {
HexadecimalBoardTheme {
HexadecimalKeyBoard()
}
}
}

Where HexadecimalBoardTheme is the keyboard theme.

Let`s start delving into the implementation of the HexadecimalKeyBoard graphical interface.

HexadecimalKeyBoard

Here is the code that describes the Composable function HexadecimalKeyBoard() which consists of the basic building blocks of HexadecimalKey. Which are responsible for constructing the button graphics and their interaction with the user.

@Composable
fun HexadecimalKeyBoard() {
val hexadecimalKeySymbols = listOf(
"0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f",
"-", "0x", ".", "p",
).chunked(7)

Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.primary)
) {
Spacer(
modifier = Modifier
.fillMaxWidth()
.height(10.dp)
)
GenerateRowWithKeyTexts(itemsText = hexadecimalKeySymbols[2]) {
HexadecimalKey(
key = KeyItem(
keyAction = KeyAction.Delete,
keyType = KeyType.KeyIcon(
icon = ImageVector.vectorResource(R.drawable.ic_delete_text),
description = R.string.clear
)
),
vibrateOnClick = true,
soundOnClick = true,
keyPadding = 2,
keyHeight = 54f,
keyWidth = 60f,
keyBorderWidth = 1f,
keyRadius = 5f,
modifier = Modifier.weight(1f)
)
}
GenerateRowWithKeyTexts(itemsText = hexadecimalKeySymbols[1]) {
HexadecimalKey(
key = KeyItem(
keyAction = KeyAction.Done,
keyType = KeyType.KeyIcon(
icon = Icons.Default.Done,
description = R.string.done
)
),
vibrateOnClick = true,
soundOnClick = true,
keyPadding = 2,
keyHeight = 54f,
keyWidth = 60f,
keyBorderWidth = 1f,
keyRadius = 5f,
modifier = Modifier.weight(1f)
)
}

GenerateRowWithKeyTexts(itemsText = hexadecimalKeySymbols[0]) {
HexadecimalKey(
key = KeyItem(
keyAction = KeyAction.CommitText(text = " "),
keyType = KeyType.KeyText(
value = " ",
description = R.string.space
)
),
vibrateOnClick = true,
soundOnClick = true,
keyPadding = 2,
keyHeight = 54f,
keyWidth = 60f,
keyBorderWidth = 1f,
keyRadius = 5f,
modifier = Modifier.weight(2f)
)
HexadecimalKey(
key = KeyItem(
keyAction = KeyAction.Enter,
keyType = KeyType.KeyIcon(
icon = ImageVector.vectorResource(R.drawable.ic_enter),
description = R.string.enter
)
),
vibrateOnClick = true,
soundOnClick = true,
keyPadding = 2,
keyHeight = 54f,
keyWidth = 60f,
keyBorderWidth = 1f,
keyRadius = 5f,
modifier = Modifier.weight(1f)
)
}
Spacer(
modifier = Modifier
.fillMaxWidth()
.height(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) 60.dp else 10.dp)
)
}
}

Where hexadecimalKeySymbols is a list of symbols for building a keyboard consisting of 3 rows, the first two sets have seven members each, and the last one has 6. GenerateRowWithKeyTexts is a helper function for automating the creation of a row of text buttons. It is also possible to add unique buttons with icons or bare text and of different weights (change the default width).

@Composable
private fun GenerateRowWithKeyTexts(
itemsText: List<String>,
rightKey: @Composable () -> Unit = {}
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
for (item in itemsText) {
HexadecimalKey(
key = KeyItem(
keyAction = KeyAction.CommitText(text = item),
keyType = KeyType.KeyText(value = item)
),
vibrateOnClick = true,
soundOnClick = true,
keyPadding = 2,
keyHeight = 54f,
keyWidth = 60f,
keyBorderWidth = 1f,
keyRadius = 5f,
modifier = Modifier.weight(1f)
)
}
rightKey()
}
}

Let`s take a closer look at the keyboard base HexadecimalKey.

HexadecimalKey

This component is one of the most important because it is through it that the user can interact with the Input Method Editor (IME).

private const val MINUTE_IN_MILLISECONDS = 60000L
private const val REPEATABLE_ACTION_TIME_DELAY = 60L

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun HexadecimalKey(
key: KeyItem,
keyPadding: Int,
keyHeight: Float,
keyWidth: Float,
keyBorderWidth: Float,
keyRadius: Float,
vibrateOnClick: Boolean,
soundOnClick: Boolean,
modifier: Modifier
) {

val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()

val context = LocalContext.current
val ime = context as? IMEHexadecimalService


val coroutineScope = rememberCoroutineScope()
val longClickPressed = remember { mutableStateOf(false) }

val view = LocalView.current
val audioManager = context.getSystemService(AUDIO_SERVICE) as AudioManager

val backgroundColor =
if (!isPressed) {
MaterialTheme.colorScheme.secondary
} else {
MaterialTheme.colorScheme.primary
}
val keyInfoColor =
if (!isPressed) {
MaterialTheme.colorScheme.onSecondary
} else {
MaterialTheme.colorScheme.onPrimary
}


val keyBorderColour = MaterialTheme.colorScheme.outline

fun soundAndVibrate() {
if (vibrateOnClick) {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
}
if (soundOnClick) {
audioManager.playSoundEffect(AudioManager.FX_KEY_CLICK, .1f)
}
}

fun onLongClick() {
if (key.keyAction != KeyAction.DoneKey) {
longClickPressed.value = true
coroutineScope.launch(Dispatchers.IO) {
withTimeout(MINUTE_IN_MILLISECONDS) {
while (true) {
performKeyAction(
action = key.keyAction,
ime = ime,
)
delay(
REPEATABLE_ACTION_TIME_DELAY
)
}
}
}
} else {
performKeyAction(
action = key.keyAction,
ime = ime,
)
}
soundAndVibrate()
}

LaunchedEffect(key1 = isPressed, key2 = longClickPressed) {
if (isPressed) {
soundAndVibrate()
} else {
if (longClickPressed.value) {
coroutineScope.coroutineContext.cancelChildren()
longClickPressed.value = false
}
}
}

val keyboardKeyModifier =
modifier
.height(keyHeight.dp)
.defaultMinSize(minWidth = keyWidth.dp)
.padding(keyPadding.dp)
.clip(RoundedCornerShape(keyRadius.dp))
.then(
if (keyBorderWidth > 0.0) {
Modifier.border(
keyBorderWidth.dp,
keyBorderColour,
shape = RoundedCornerShape(keyRadius.dp),
)
} else {
(Modifier)
},
)
.background(color = backgroundColor)
.combinedClickable(
interactionSource = interactionSource,
indication = null,
onClick = {
performKeyAction(
action = key.keyAction,
ime = ime,
)
},
onLongClick = {
onLongClick()
}
)

Box(
modifier = keyboardKeyModifier
) {
when (val type = key.keyType) {
is KeyType.KeyText -> {
Text(
text = type.value,
style = MaterialTheme.typography.titleMedium,
color = keyInfoColor,
modifier = Modifier.align(Alignment.Center),
)
if (type.showDescription) {
Text(
text = "(${stringResource(type.description!!)})",
style = MaterialTheme.typography.labelMedium,
fontFamily = FontFamily.Serif,
color = keyInfoColor,
modifier = Modifier.align(Alignment.BottomCenter),
)
}

}

is KeyType.KeyIcon -> {
Icon(
imageVector = type.icon,
contentDescription = stringResource(
type.description ?: R.string.description_not_available
),
tint = keyInfoColor,
modifier = Modifier.align(Alignment.Center)
)
}
}
}
}

The key parameter of the above function is the type KeyItem, and it is responsible for the graphical description of the button type KeyType and the kind of actions it can perform KeyAction.
KeyItem.

data class KeyItem(val keyAction: KeyAction, val keyType: KeyType)

KeyAction

sealed interface KeyAction {

data class CommitText(

val text: String

) : KeyAction



data object Delete : KeyAction

data object Done : KeyAction

data object Enter : KeyAction

}

KeyType

sealed class KeyType(

open val description: Int? = null,

open val showDescription: Boolean = false

) {

data class KeyIcon(

val icon: ImageVector,

override val description: Int? = null,

override val showDescription: Boolean = false

) : KeyType(description, showDescription)



data class KeyText(

val value: String,

override val description: Int? = null,

override val showDescription: Boolean = false

) : KeyType(description, showDescription)

}

The top-level function performKeyAction is responsible for interacting with the input method editor (IME), to which you can pass the null-type IMEHexadecimalService argument.

The ime parameter is made nullable so that the HexadecimalKeyBoard() and HexadecimalKey() functions can be previewed using @Preview by using the context cast to IMEHexadecimalService in order to interact with it (send events, commit text, delete, etc.).

performKeyAction

fun performKeyAction(

action: KeyAction,

ime: IMEHexadecimalService? = null

) {

when (action) {

is KeyAction.CommitText -> {

val text = action.text

ime?.currentInputConnection?.commitText(

text,

1,

)

Log.d("Test", "committing key text: $text")

}



is KeyAction.Delete -> {

val event = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL)

ime?.currentInputConnection?.sendKeyEvent(event)

Log.d("Test", "delete")

}



KeyAction.Done -> {

ime?.requestHideSelf(0)

Log.d("Test", "hide")

}



KeyAction.Enter -> {

val event = KeyEvent(

KeyEvent.ACTION_DOWN,

KeyEvent.KEYCODE_ENTER,

)

ime?.currentInputConnection?.sendKeyEvent(

event

)

Log.d("Test", "Enter")

}

}

}

HexadecimalKey() can also repeat an action at a certain time interval, except for the Done action, when a key is held down using a coroutine. In the internal function onLongClick(), the execution of which depends on the values of isPressed and longClickPressed, this was done to be able to stop the execution of automatic repeating actions when the user removes his finger from the keyboard. It is also vital to close the execution of these actions using cancelChildren(). For this, it was possible to repeat the automatic operations.

Final Part

A working example looks like this.

Initial View
Character input

To use our Hexadecimal keyboard for text input, we need to enable it in the system settings: On-screen keyboard. Then, select it in the dialog box “Choose input method”.

Next, the video will show how to enable it in the settings and select the input method using the example of a test case. The link to the project will be at the end of the article.

Enabling Hexadecimal Keyboard in System Preferences
Selecting the Hexadecimal Keyboard input method

Recap

As we can see from this example, Compose can be used to build a graphical part of your on-screen keyboard, depending on your requirements. This feature allows you to create unique input methods depending on your requirements using modern tools for building graphical interfaces, which in turn can improve users’ UI/UX experience in applications where this feature is implemented. The created keyboards can be used in other places where (IME) text input methods (on-screen keyboards) are used. It is also important to note that you need to be careful when entering sensitive information through third-party keyboards. We cannot be sure that they do not use the entered information for purposes other than their intended purpose.

Thank you for your attention! I hope this article was helpful to readers.
Here is a link to a working example (GitHub project)

--

--

Responses (1)