Skip to main content

Custom UI Components & KeyboardView

Overview

FlorisBoard is built entirely with Jetpack Compose, providing a modern, declarative UI framework for creating custom keyboard components. This document explores the architecture, components, and patterns used to build the keyboard interface.

Introduction

FlorisBoard leverages Jetpack Compose to create a fully customizable keyboard UI. The entire keyboard interface—from individual keys to complex layouts like emoji pickers—is built using Compose composables. This approach provides reactive state management, efficient recomposition, and a declarative API for building UI.

Key Concepts

Jetpack Compose in IME Context

Using Compose in an InputMethodService requires special setup:

  • AbstractComposeView: Bridge between traditional Android Views and Compose
  • Lifecycle Integration: Proper lifecycle management for Compose in IME
  • ViewTreeOwners: Setting up LifecycleOwner, ViewModelStoreOwner, and SavedStateRegistryOwner
  • Window Management: Handling IME window constraints and sizing

Snygg Theme System

FlorisBoard uses a custom styling engine called Snygg (Swedish for "stylish"):

  • CSS-like Syntax: Familiar styling approach with selectors and properties
  • Dynamic Theming: Runtime theme switching without recomposition
  • Component-based: Styles are scoped to specific UI elements
  • Extension Support: Themes can be packaged as extensions

Key UI Components

ComposeInputView

The root Compose view for the keyboard:

private inner class ComposeInputView : AbstractComposeView(this) {
init {
isHapticFeedbackEnabled = true
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
}

@Composable
override fun Content() {
ImeUiWrapper()
}

override fun getAccessibilityClassName(): CharSequence {
return javaClass.name
}
}

ImeUiWrapper

Main container that provides theme and manages keyboard modes:

@Composable
fun ImeUiWrapper() {
FlorisImeTheme {
when (state.imeUiMode) {
ImeUiMode.TEXT -> TextInputLayout()
ImeUiMode.MEDIA -> MediaInputLayout()
ImeUiMode.CLIPBOARD -> ClipboardInputLayout()
}
}
}

TextKeyboardLayout

Renders individual keyboard layouts with touch handling:

BoxWithConstraints(
modifier = modifier
.pointerInteropFilter { event ->
when (event.actionMasked) {
MotionEvent.ACTION_DOWN,
MotionEvent.ACTION_POINTER_DOWN,
MotionEvent.ACTION_MOVE,
MotionEvent.ACTION_POINTER_UP,
MotionEvent.ACTION_UP,
MotionEvent.ACTION_CANCEL -> {
val clonedEvent = MotionEvent.obtain(event)
touchEventChannel.trySend(clonedEvent)
return@pointerInteropFilter true
}
}
return@pointerInteropFilter false
}
)

Implementation Details

Setting Up Compose in IME

1. Create AbstractComposeView

override fun onCreateInputView(): View {
super.installViewTreeOwners()
val composeView = ComposeInputView()
inputWindowView = composeView
return composeView
}

2. Install ViewTree Owners

fun installViewTreeOwners() {
val decorView = window!!.window!!.decorView
decorView.setViewTreeLifecycleOwner(this)
decorView.setViewTreeViewModelStoreOwner(this)
decorView.setViewTreeSavedStateRegistryOwner(this)
}

Snygg Theme System

Theme Definition

val FlorisImeThemeBaseStyle = SnyggStylesheet.v2 {
defines {
"--primary" to rgbaColor(76, 175, 80)
"--primary-variant" to rgbaColor(56, 142, 60)
"--secondary" to rgbaColor(245, 124, 0)
"--background" to rgbaColor(33, 33, 33)
"--surface" to rgbaColor(66, 66, 66)
"--on-primary" to rgbaColor(240, 240, 240)
"--shape" to roundedCornerShape(8.dp)
}
}

Applying Themes

@Composable
fun FlorisImeTheme(content: @Composable () -> Unit) {
val keyboardManager by context.keyboardManager()
val themeManager by context.themeManager()
val activeStyle = themeManager.activeStyle
val snyggTheme = rememberSnyggTheme(activeStyle, assetResolver)

MaterialTheme {
ProvideSnyggTheme(
snyggTheme = snyggTheme,
dynamicAccentColor = accentColor,
fontSizeMultiplier = fontSizeMultiplier,
content = content,
)
}
}

Custom Snygg Components

SnyggBox

Basic container with theme support:

@Composable
fun SnyggBox(
elementName: String,
modifier: Modifier = Modifier,
attributes: SnyggQueryAttributes = emptyMap(),
content: @Composable BoxScope.() -> Unit
) {
ProvideSnyggStyle(elementName, attributes, null) { style ->
Box(
modifier = modifier
.background(style.background())
.border(style.border())
.padding(style.padding()),
content = content
)
}
}

SnyggButton

Interactive button with state management:

@Composable
fun SnyggButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
content: @Composable RowScope.() -> Unit
) {
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()

val selector = when {
!enabled -> SnyggSelector.DISABLED
isPressed -> SnyggSelector.PRESSED
else -> SnyggSelector.NONE
}

ProvideSnyggStyle(elementName, attributes, selector) { style ->
Row(
modifier = modifier
.clickable(
onClick = onClick,
enabled = enabled,
interactionSource = interactionSource,
indication = null
)
.background(style.background())
.padding(style.padding()),
content = content
)
}
}

Key Rendering

TextKeyButton

Individual key rendering with touch bounds:

val desiredKey = remember(
keyboard, keyboardWidth, keyboardHeight, keyMarginH, keyMarginV,
keyboardRowBaseHeight, evaluator
) {
TextKey(data = TextKeyData.UNSPECIFIED).also { desiredKey ->
desiredKey.touchBounds.apply {
width = keyboardWidth / 10f
height = when (keyboard.mode) {
KeyboardMode.CHARACTERS,
KeyboardMode.NUMERIC_ADVANCED,
KeyboardMode.SYMBOLS,
KeyboardMode.SYMBOLS2 -> {
(keyboardHeight / keyboard.rowCount)
.coerceAtMost(keyboardRowBaseHeight.toPx() * 1.12f)
}
else -> keyboardRowBaseHeight.toPx()
}
}
desiredKey.visibleBounds.applyFrom(desiredKey.touchBounds).deflateBy(keyMarginH, keyMarginV)
keyboard.layout(keyboardWidth, keyboardHeight, desiredKey, true)
}
}

Media Input Layout

Emoji picker and media input:

SnyggColumn(
elementName = FlorisImeUi.Media.elementName,
modifier = modifier
.fillMaxWidth()
.height(FlorisImeSizing.imeUiHeight()),
) {
EmojiPaletteView(
modifier = Modifier.weight(1f),
fullEmojiMappings = emojiLayoutDataMap,
)
SnyggRow(
elementName = FlorisImeUi.MediaBottomRow.elementName,
modifier = Modifier
.fillMaxWidth()
.height(FlorisImeSizing.keyboardRowBaseHeight * 0.8f),
) {
KeyboardLikeButton(
elementName = FlorisImeUi.MediaBottomRowButton.elementName,
keyData = TextKeyData.IME_UI_MODE_TEXT,
) {
Text(text = "ABC", fontWeight = FontWeight.Bold)
}
}
}

Code Examples

Creating a Custom Keyboard Component

@Composable
fun CustomKeyboardRow(
keys: List<KeyData>,
onKeyPress: (KeyData) -> Unit
) {
SnyggRow(
elementName = FlorisImeUi.KeyboardRow.elementName,
modifier = Modifier.fillMaxWidth()
) {
keys.forEach { keyData ->
CustomKey(
data = keyData,
modifier = Modifier.weight(1f),
onClick = { onKeyPress(keyData) }
)
}
}
}

@Composable
fun CustomKey(
data: KeyData,
modifier: Modifier = Modifier,
onClick: () -> Unit
) {
val inputFeedbackController = LocalInputFeedbackController.current

SnyggButton(
elementName = FlorisImeUi.Key.elementName,
onClick = {
inputFeedbackController.keyPress(data)
onClick()
},
modifier = modifier.aspectRatio(1f)
) {
Text(
text = data.label,
style = MaterialTheme.typography.bodyLarge
)
}
}

Responsive Layout

@Composable
fun ResponsiveKeyboard() {
BoxWithConstraints {
val isLandscape = maxWidth > maxHeight
val keyboardHeight = if (isLandscape) {
maxHeight * 0.5f
} else {
maxHeight * 0.4f
}

KeyboardLayout(
modifier = Modifier.height(keyboardHeight)
)
}
}

State-Driven UI

@Composable
fun StatefulKeyboard() {
val keyboardManager by LocalContext.current.keyboardManager()
val state by keyboardManager.activeState.collectAsState()

val attributes = mapOf(
FlorisImeUi.Attr.Mode to state.keyboardMode.toString(),
FlorisImeUi.Attr.ShiftState to state.inputShiftState.toString(),
)

SnyggBox(
elementName = FlorisImeUi.Keyboard.elementName,
attributes = attributes
) {
// Keyboard content that reacts to state changes
}
}

Best Practices

1. Use remember for Expensive Calculations

val layoutData = remember(keyboard, width, height) {
computeKeyboardLayout(keyboard, width, height)
}

2. Minimize Recomposition Scope

@Composable
fun KeyboardKey(key: TextKey) {
// Only this key recomposes when its state changes
val isPressed by key.isPressedState.collectAsState()

KeyContent(key, isPressed)
}

3. Use derivedStateOf for Computed Values

val shouldShowPopup by remember {
derivedStateOf {
isPressed && hasPopupKeys
}
}

4. Proper Lifecycle Management

@Composable
fun KeyboardComponent() {
DisposableEffect(Unit) {
// Setup
onDispose {
// Cleanup
}
}
}

5. Handle Touch Events Efficiently

LaunchedEffect(Unit) {
for (event in touchEventChannel) {
if (!isActive) break
controller.onTouchEventInternal(event)
event.recycle()
}
}

Common Patterns

Theme-Aware Components

@Composable
fun ThemedKey(label: String) {
ProvideSnyggStyle(
elementName = FlorisImeUi.Key.elementName,
attributes = emptyMap(),
selector = SnyggSelector.NONE
) { style ->
Box(
modifier = Modifier
.background(style.background())
.border(style.border())
.padding(style.padding())
) {
Text(
text = label,
color = style.foreground(),
fontSize = style.fontSize()
)
}
}
}
@Composable
fun KeyWithPopup(key: TextKey) {
var showPopup by remember { mutableStateOf(false) }

Box {
KeyButton(
onLongPress = { showPopup = true }
)

if (showPopup) {
Popup(
alignment = Alignment.TopCenter,
onDismissRequest = { showPopup = false }
) {
PopupContent(key.popupKeys)
}
}
}
}

Troubleshooting

Compose Not Rendering

Problem: Compose UI doesn't appear in IME.

Solutions:

  • Ensure installViewTreeOwners() is called
  • Check lifecycle state is properly managed
  • Verify AbstractComposeView is returned from onCreateInputView()

Performance Issues

Problem: UI lags or stutters during typing.

Solutions:

  • Use remember for expensive calculations
  • Minimize recomposition scope with derivedStateOf
  • Profile with Compose Layout Inspector
  • Check for unnecessary recompositions

Theme Not Applying

Problem: Custom theme styles not showing.

Solutions:

  • Verify theme is loaded in ThemeManager
  • Check element names match stylesheet
  • Ensure ProvideSnyggTheme wraps content
  • Validate stylesheet syntax

Next Steps


Note: This documentation is continuously being improved. Contributions are welcome!