Complete Edition · 2026
Android Keyboard Design Guide
The comprehensive resource for building production-grade Android Input Method Editors (IME) from first principles to expert-level implementations. Covers API 30 through 36, Material You 3.0, Jetpack Compose, and modern UI/UX best practices.
Introduction to Android Keyboard Development
BeginnerUnderstanding the Android Input Method Editor (IME) framework, its role in the system, and what makes a production-quality keyboard application.
What is an Android Input Method Editor?
An Input Method Editor (IME) is a specialized Android application that provides text input capabilities to users across all applications on the device. Unlike standard apps, IMEs operate as system-level services with deep integration into the Android framework, mediating all text input between users and applications.
When a user taps on a text field in any application—whether composing an email, searching the web, or filling out a form—the currently active IME appears at the bottom of the screen, providing the interface through which the user generates text. This makes keyboard apps uniquely powerful: they are present in virtually every user interaction that involves text entry.
The IME Lifecycle
Android keyboards operate through a well-defined lifecycle managed by the InputMethodService base class. Understanding this lifecycle is fundamental to building reliable keyboards:
- Service Initialization: The IME service is created when the user selects your keyboard as their active input method in Android Settings. The system binds to your service, calling
onCreate()andonInitializeInterface(). - Input Session Start: When a text field gains focus, the system calls
onStartInput(EditorInfo, boolean), providing metadata about the input field (input type, content type, field attributes). This is where you configure your keyboard's behavior for the specific input context. - View Creation: The first time input is needed,
onCreateInputView()is called to generate the keyboard UI. This view is cached and reused across input sessions for performance. - Input View Display:
onStartInputView(EditorInfo, boolean)signals that the keyboard should become visible. This is where you apply input-specific UI changes and prepare for user interaction. - Active Input: During active typing, your keyboard receives touch events, processes key presses, and communicates with the target application through an
InputConnectionobject. - Input Session End: When the user navigates away or the input field loses focus,
onFinishInput()andonFinishInputView(boolean)are called to clean up state. - Service Destruction: When the system needs to reclaim resources or the user switches keyboards,
onDestroy()terminates the service.
The IME lifecycle differs from standard Activity lifecycles in critical ways. Your keyboard service may be created once and persist for hours or days, handling thousands of input sessions. Memory leaks and resource management issues that would be minor in an Activity can become catastrophic in an IME. Always clean up listeners, cancel coroutines, and release heavy resources in onFinishInputView(), not just onDestroy().
System Integration Points
Android keyboards integrate with the system through several key touchpoints:
- InputMethodManager: The system service that manages all IMEs, handles switching between keyboards, and coordinates input sessions across applications.
- InputConnection: The communication channel between your keyboard and the active text field. All text manipulation—insertion, deletion, selection changes—flows through this interface.
- EditorInfo: Metadata about the current input context, including input type (text, number, email), IME options (action button type), and field attributes (multiline, autocorrect preferences).
- Window Insets: API 30+ provides sophisticated window inset handling for edge-to-edge keyboards, allowing you to draw behind system bars while respecting safe areas.
- VibrationEffect: System haptic feedback API for tactile key presses, integrated with user haptic preferences.
- AudioManager: System audio for key click sounds, respecting user sound effect settings.
Why Build a Custom Keyboard?
Despite the availability of excellent system and third-party keyboards, custom keyboard development remains relevant for several compelling reasons:
- Specialized Input Methods: Domain-specific keyboards for medical terminology, mathematical notation, programming languages, or constructed languages (like Klingon or Elvish) that standard keyboards cannot efficiently support.
- Enhanced Privacy: On-device keyboards that never transmit typed content to external servers, critical for healthcare, legal, and government applications.
- Accessibility Innovation: Custom keyboards optimized for specific disabilities, motor control challenges, or alternative input methods (eye-tracking, switch control, single-handed operation).
- Brand Integration: Enterprise keyboards with organization-specific features—secure password managers, custom autocorrect dictionaries, compliance logging, or integration with corporate systems.
- Novel Interaction Models: Experimental input paradigms like gesture-based character recognition, AI-powered contextual suggestions, or multi-modal input combining text, voice, and drawing.
Prerequisites for This Guide
This guide assumes you have:
- Kotlin proficiency: Comfort with Kotlin coroutines, sealed classes, delegated properties, and extension functions. We use Kotlin exclusively—Java examples are not provided.
- Android fundamentals: Understanding of Services, Views, Fragments, and the Android component lifecycle. Experience building at least one production Android application.
- Jetpack Compose familiarity: Basic knowledge of Compose UI, State, remember, and LaunchedEffect. Advanced IME implementations leverage Compose extensively.
- UI/UX awareness: Appreciation for touch target sizes, color contrast ratios, and accessibility standards. Keyboard UX is notoriously difficult to get right.
Building a production-quality keyboard is a substantial undertaking. A minimal viable keyboard can be created in a few days, but a fully-featured, polished keyboard with gesture typing, smart suggestions, and comprehensive accessibility support typically requires 6-12 months of development. This guide provides the roadmap, but expect to invest significant time in testing, iteration, and refinement.
API Level Overview: Android 11–16 (API 30–36)
BeginnerCritical IME-related features, behavioral changes, and new capabilities introduced across Android versions from API 30 (Android 11) through API 36 (Android 16).
Why API Levels Matter for Keyboards
Unlike many Android features that can be backported through AndroidX libraries, Input Method Editor APIs are tightly coupled to the Android framework version. New IME capabilities—such as inline autofill integration, improved window insets, or dynamic color theming—are only available on specific API levels. Understanding these boundaries is essential for:
- Setting appropriate
minSdkVersionandtargetSdkVersionfor your keyboard app - Implementing feature detection and graceful degradation for older Android versions
- Taking advantage of new APIs while maintaining backward compatibility
- Avoiding crashes from calling unavailable methods on older devices
API 30 (Android 11) — Foundation for Modern IMEs
Key IME Features:
- Inline Autofill Integration: IMEs can now display autofill suggestions directly within the suggestion strip, eliminating the dropdown menu. This creates a seamless UX for password managers and form fillers. Implement via
InlineSuggestionsRequestandInlineSuggestionAPIs. - Improved Window Insets: Enhanced
WindowInsets.Type.ime()provides precise keyboard visibility and dimension information, critical for edge-to-edge keyboard rendering and avoiding content occlusion. - Package Visibility: New package visibility restrictions require IMEs to declare intent filters or use special permissions to query installed applications. This affects custom dictionary sourcing and app-specific customization.
- Scoped Storage Enforcement: IMEs must use scoped storage for user dictionaries and theme assets. Direct filesystem access to
/sdcard/is no longer permitted.
API 30 represents the minimum recommended minSdkVersion for new keyboard projects in 2026. While you can technically support older versions, the inline autofill and window insets improvements are fundamental to modern keyboard UX. Supporting API 29 and below requires significant additional effort for marginal user coverage (~5% of active devices as of 2026).
API 31 (Android 12) — Material You Foundation
Key IME Features:
- Material You Dynamic Color: Introduction of dynamic color theming based on user wallpaper. IMEs can extract color palettes via
DynamicColorsAPI and apply them to keyboard themes. - Splash Screen API: New splash screen requirements affect keyboard service initialization timing. IMEs should use
SplashScreen.setOnExitAnimationListener()to coordinate UI visibility. - Expanded Haptic Feedback:
VibrationEffect.Compositionallows complex haptic patterns. Keyboards can create distinctive tactile feedback for different key types (letters vs. symbols vs. modifiers). - Blur Effects:
RenderEffect.createBlurEffect()enables frosted-glass keyboard backgrounds that blur underlying content, popular in iOS-style keyboard aesthetics.
Kotlin// API 31+: Dynamic color extraction for keyboard theming
val colorScheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (isSystemInDarkTheme()) dynamicDarkColorScheme(context)
else dynamicLightColorScheme(context)
} else {
// Fallback to static color scheme for API 30
if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()
}
API 32 (Android 12L) — Large Screen Optimizations
Key IME Features:
- Taskbar Integration: On tablets and foldables, IMEs must respect taskbar insets to avoid overlapping the persistent taskbar. Use
WindowInsets.Type.systemBars()in conjunction withType.ime(). - Multi-Display IME Support: Improved multi-display handling for external monitors and foldable inner/outer screens. IMEs can now maintain separate state per display.
- Split-Screen Keyboard Behavior: Enhanced split-screen APIs allow keyboards to adapt their layout when the active text field is in split-screen mode.
API 33 (Android 13) — Privacy and Predictive Back
Key IME Features:
- Predictive Back Gesture: IMEs must implement
OnBackInvokedCallbackto support the new predictive back animation. When users initiate a back gesture, keyboards should animate their dismissal progressively. - Per-App Language Preferences: IMEs can now query and respect per-app language settings via
LocaleManager.getApplicationLocales(). For example, automatically switch to Spanish layout when typing in a Spanish-configured app. - Clipboard Access Notifications: Reading clipboard content triggers a user-visible toast. IMEs should minimize clipboard reads and never read clipboard on keyboard launch—only when the user explicitly requests paste.
- Themed App Icons: Keyboard launcher icons can now adapt to user theme colors via themed icon resources.
Kotlin// API 33+: Predictive back implementation for IME
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
override fun onCreateInputView(): View {
return ComposeView(context).apply {
setContent {
BackHandler(enabled = true, onBack = {
// Animate keyboard dismissal
animateKeyboardOut()
requestHideSelf(0)
})
}
}
}
API 34 (Android 14) — Material You Refinements
Key IME Features:
- Enhanced Dynamic Color:
DynamicColorsAPI gains fidelity improvements with expanded color roles (22 colors instead of 12). Keyboards gain access to surface-tint, outline-variant, and additional neutral tones. - Grammatical Inflection API:
GrammaticalInflectionManagerallows IMEs to adapt autocorrect and suggestions based on user's grammatical gender preferences (masculine, feminine, neuter). - Foreground Service Restrictions: Stricter foreground service policies affect background keyboard operations. IMEs should minimize background processing and use WorkManager for non-immediate tasks.
- Regional Preferences: New
LocalePreferencesAPI provides temperature unit, measurement system, and calendar preferences. Useful for keyboards with calculator or unit conversion features.
API 35 (Android 15) — Edge-to-Edge Enforcement
Key IME Features:
- Edge-to-Edge Enforcement: All apps targeting API 35 must support edge-to-edge UI. IMEs must draw behind navigation bars and handle insets correctly. No exceptions.
- 16KB Page Size Support: Keyboards must be compatible with devices using 16KB memory pages (up from 4KB). This primarily affects native code and large bitmap loading.
- Private Space: New Private Space feature isolates sensitive apps. IMEs should avoid logging keystrokes when Private Space apps are active.
- Satellite Connectivity: While not directly IME-related, keyboards should handle offline mode gracefully on satellite-connected devices with intermittent internet.
If your keyboard targets API 35+, edge-to-edge support is non-negotiable. The system will force your keyboard window to extend beneath navigation bars. Failing to apply proper insets will result in keys being obscured by system UI. This is the single biggest breaking change for legacy keyboards.
Kotlin// API 35+: Mandatory edge-to-edge insets handling
override fun onCreateInputView(): View {
return ComposeView(context).apply {
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
KeyboardTheme {
Box(
Modifier
.fillMaxSize()
.navigationBarsPadding() // Prevents keys behind nav bar
.imePadding() // Respects IME window insets
) {
KeyboardLayout()
}
}
}
}
}
API 36 (Android 16) — Dual-Release Model & Material 3 Expressive
Android 16 introduces a dual-release cadence: a major API release in Q2 and a minor release in Q4. This is a fundamental shift — Build.VERSION.SDK_INT alone no longer uniquely identifies feature availability. Use the new SDK_INT_FULL constant and Build.getMinorSdkVersion() for precise version checks.
Kotlin// Android 16+: Precise version checking with dual-release model
import android.os.Build
import android.os.ext.SdkExtensions
// Check major SDK version (traditional)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
// Major release features available
}
// Check full version (major + minor) for Q4 minor release features
if (Build.VERSION.SDK_INT_FULL >= Build.VERSION_CODES_FULL.BAKLAVA_1) {
// Minor release features available (Q4 2025)
}
// Query minor version programmatically
val minorVersion: Int = Build.getMinorSdkVersion() // 0 = major, 1+ = minor
Key IME Features:
Richer Haptic Feedback
Android 16 replaces simple predefined effects with amplitude and frequency curves that abstract away device-specific haptic hardware differences. IMEs can now create nuanced, device-adaptive tactile feedback:
- Amplitude & Frequency Curves: Define vibration intensity and frequency over time with
VibrationEffect.WaveformEnvelopeBuilder(advanced) orBasicEnvelopeBuilder(simplified). Create soft press-and-release envelopes for letter keys versus sharp impulses for error feedback. - Frequency Curves: Control vibration motor frequency for different tactile textures. Low frequency for "thud" feedback, high frequency for "click" feedback.
- Device Abstraction: The system translates haptic curves to the best possible representation on each device's actuator (LRA, ERM, or piezo), ensuring consistent perceived feedback across hardware.
Kotlin// Android 16+: Rich haptic curves for keyboard feedback
// BasicEnvelopeBuilder — simplified, hardware-agnostic (recommended for most IMEs)
fun createKeyPressHaptic(): VibrationEffect {
return VibrationEffect.BasicEnvelopeBuilder()
// intensity: 0.0–1.0 perceived strength, sharpness: 0.0–1.0 crispness
.addControlPoint(/* intensity */ 0.8f, /* sharpness */ 0.9f, /* durationMs */ 5)
.addControlPoint(0.5f, 0.7f, 10) // Sustain at medium intensity
.addControlPoint(0.0f, 0.5f, 15) // Soft release — natural fade
.build()
}
// WaveformEnvelopeBuilder — advanced, direct frequency control (requires haptics expertise)
fun createErrorHaptic(): VibrationEffect {
return VibrationEffect.WaveformEnvelopeBuilder()
// amplitude: 0.0–1.0, frequencyHz: must be within device's supported range
.addControlPoint(1.0f, 150f, 8) // Sharp tap
.addControlPoint(0.0f, 150f, 40) // Brief pause
.addControlPoint(1.0f, 150f, 8) // Double-tap pattern
.addControlPoint(0.0f, 150f, 10) // Fade out
.build()
}
Comprehensive Accessibility Improvements
Android 16 significantly expands accessibility APIs — many directly benefit keyboard navigation and screen reader interaction:
- Outline Text for Maximum Contrast: A new system setting renders text with outlines for users with low vision. Keyboards should test key labels with this mode enabled to ensure readability.
TtsSpan.TYPE_DURATION: New span type for duration values. Useful for keyboards displaying timer or countdown UI in suggestion strips.- Multi-Label Support:
addLabeledBy()/getLabeledByList()allows associating multiple labels with a single element. Keys with primary and secondary labels (e.g., "A" with "1" superscript) can now be fully described. - Expandable Elements:
setExpandedState()communicates expand/collapse state for popups, long-press menus, and suggestion panels to screen readers. - Indeterminate ProgressBars:
RANGE_TYPE_INDETERMINATEfor loading indicators in voice input or dictionary download states. - Tri-State CheckBox: Proper accessibility support for partially-checked states (useful for keyboard settings with mixed selection).
- Supplemental Descriptions: Additional descriptive text beyond content descriptions — enables richer context for complex key arrangements.
- Required Form Fields:
setFieldRequired()marks mandatory inputs. When your keyboard serves a form, accessibility services can announce which fields are required. - LE Audio Hearing Aid Support: Improved audio routing for LE Audio hearing aids. Keyboards that produce audio feedback (key clicks, voice input) should respect the new audio routing APIs.
Kotlin// Android 16+: Enhanced accessibility for keyboard keys
override fun onPopulateNodeForVirtualView(
virtualViewId: Int,
node: AccessibilityNodeInfoCompat,
) {
val key = getKeys().getOrNull(virtualViewId) ?: return
node.contentDescription = key.contentDescription ?: key.label.ifEmpty { "Unknown key" }
node.className = "android.widget.Button"
node.isClickable = true
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
// Multi-label: primary label + superscript secondary
key.secondaryLabel?.let { secondary ->
node.addLabeledBy(/* secondaryLabelView */ secondaryLabelNode)
}
// Expandable state for long-press popup menus
if (key.hasPopupChars) {
node.setExpandedState(key.isPopupShowing)
}
// Supplemental description for complex keys
key.hint?.let { hint ->
node.extras.putString("androidx.view.accessibility.supplementalDescription", hint)
}
}
}
System-Triggered Profiling
ProfilingManager lets keyboards register for system-triggered profiles on cold start and ANR events. Instead of manually enabling profiling during development, the system captures traces when performance issues actually occur:
Kotlin// Android 16+: Register for system-triggered profiling
class KeyboardProfilingSetup(private val context: Context) {
fun registerProfilingTriggers() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
val pm = context.getSystemService(ProfilingManager::class.java)
// 1. Register listener for profiling results
pm.registerForAllProfilingResults(
context.mainExecutor,
) { profilingResult ->
if (profilingResult.errorCode == ProfilingResult.ERROR_NONE) {
Timber.i("[PERF] System trace captured: ${profilingResult.resultFilePath}")
// Upload to your analytics backend for analysis
}
}
// 2. Register triggers — without this, no traces will be captured
val triggers = listOf(
ProfilingTrigger.Builder(ProfilingTrigger.TRIGGER_TYPE_COLD_START)
.setRateLimitingPeriodHours(1)
.build(),
ProfilingTrigger.Builder(ProfilingTrigger.TRIGGER_TYPE_ANR)
.build()
)
pm.addProfilingTriggers(triggers)
}
}
}
Adaptive Refresh Rate (ARR)
Android 16 introduces Adaptive Refresh Rate (hasArrSupport()) that lets the system dynamically adjust display refresh rate per-frame. Keyboards can use getSuggestedFrameRate() to request optimal refresh rates during animations versus static display:
- Request high refresh rate (90–120 Hz) during key press animations and gesture trails
- Drop to low refresh rate during idle keyboard display to save battery
RecyclerView 1.4includes built-in ARR support for suggestion strip scrolling
ADPF Thermal & Performance Headroom
The Android Dynamic Performance Framework now exposes CPU and GPU headroom via SystemHealthManager. Keyboards performing on-device ML (autocorrect models, voice recognition) can query available headroom before launching heavy inference:
Kotlin// Android 16+: Query thermal headroom before heavy inference
// getCpuHeadroom / getGpuHeadroom return values in range [0, 100]
// where 0 = no resources available, 100 = full capacity
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
val healthManager = context.getSystemService(SystemHealthManager::class.java)
val cpuHeadroom = healthManager.getCpuHeadroom(null) // null = default params
val gpuHeadroom = healthManager.getGpuHeadroom(null)
// Defer ML inference if CPU headroom is low (< 30% available)
if (!cpuHeadroom.isNaN() && cpuHeadroom < 30f) {
Timber.w("[THERMAL] Low CPU headroom ($cpuHeadroom), deferring autocorrect model")
useSimpleFallbackCorrection()
}
}
Predictive Back Updates
PRIORITY_SYSTEM_NAVIGATION_OBSERVER: New priority level for keyboard back handlers. Keyboards can observe predictive back gestures without intercepting system navigation.finishAndRemoveTaskCallback/moveTaskToBackCallback: New lifecycle callbacks that inform keyboards when the host activity is being dismissed via predictive back, allowing graceful keyboard hiding.
Additional API 36 Capabilities
- Vertical Text:
VERTICAL_TEXT_FLAGinEditorInfosignals that the target text field displays vertical text. CJK keyboard layouts should adapt accordingly. - Measurement System Customization: Users can now set preferred measurement units (metric vs. imperial) at the OS level. Keyboards providing unit conversion features should query and respect this preference.
- Material 3 Expressive: New design language emphasizing "emotional" UI with springy animations, bolder colors, and more expressive motion. Keyboards adopting this aesthetic will stand out.
- Enhanced Autofill: Improvements to inline autofill UX, including passkey integration and cross-device credential sync.
- On-Device ML Improvements: Tighter integration with on-device ML models for autocorrect, predictive text, and entity recognition. IMEs can leverage system-provided language models without shipping their own.
- Desktop Mode Refinements: Better keyboard behavior when Android devices are connected to external displays in desktop mode.
- Photo Picker for IME: Embedded photo picker with cloud search support — keyboards can offer image/GIF insertion with richer media browsing.
- AGSL Graphics:
RuntimeColorFilterandRuntimeXfermodefor custom shader-driven key effects without OpenGL.
With Android 16's two-release model, always use SDK_INT_FULL when checking for features introduced in the Q4 minor release. Using SDK_INT alone will match the Q2 major release but miss minor-release-only APIs. This is the most significant behavioral change for feature detection code since API levels were introduced.
The major Q2 release is stable as of mid-2025. The Q4 minor release extends the API surface with additional features. Keyboard developers should target API 35 for immediate Play Store compliance and begin compileSdk = 36 adoption once the full API surface is finalized. Test your edge-to-edge, haptic, and accessibility implementations against both the major and minor release emulator images.
API Level Strategy
For new keyboard projects starting in 2026, the recommended API level configuration is:
Gradleandroid {
compileSdk = 36 // Android 16 — dual-release model
defaultConfig {
minSdk = 30 // Android 11 for inline autofill
targetSdk = 36 // Android 16 — required by Play Store as of Aug 31, 2026
}
}
This configuration provides:
- ~85% market coverage: API 30+ encompasses the large majority of active Android devices as of 2026
- Modern feature access: Inline autofill, dynamic colors, predictive back, edge-to-edge
- Manageable complexity: Only 7 API levels to support (30–36), reducing testing burden and conditional code
- Play Store compliance: Meets Google Play's targetSdk requirements
| API Level | Android Version | Market Share (2026) | Support Recommendation |
|---|---|---|---|
| 30 | Android 11 | ~11% | Minimum supported |
| 31 | Android 12 | ~7% | Material You foundation |
| 32 | Android 12L | ~2% | Tablet/foldable support |
| 33 | Android 13 | ~12% | Predictive back |
| 34 | Android 14 | ~15% | Enhanced dynamic color |
| 35 | Android 15 | ~28% | Edge-to-edge enforcement |
| 36 | Android 16 | ~15% | Compile target, dual-release APIs, richer haptics |
Market share data approximated from Google Play distribution dashboard & Statcounter, March 2026
Architecture Patterns for Production Keyboards
IntermediateModern architectural approaches for building maintainable, testable, and scalable keyboard applications using MVVM, clean architecture principles, and Kotlin coroutines.
Why Architecture Matters for Keyboards
Unlike traditional Android applications that users launch and close frequently, keyboard applications are long-lived services that may run continuously for days or weeks. A poorly architected keyboard accumulates memory leaks, degrades performance over time, and becomes unmaintainable as features are added. Professional keyboard architecture must prioritize:
- Memory efficiency: The service persists across thousands of input sessions—every leaked listener or uncanceled coroutine compounds over time.
- State management: Keyboards maintain complex state (current layout, shift state, autocorrect buffer, suggestion cache) that must remain consistent across configuration changes and input session transitions.
- Separation of concerns: UI rendering, text processing, dictionary lookups, and machine learning inference should be cleanly separated for testability and parallel development.
- Testability: Core business logic (text prediction, autocorrect algorithms, layout generation) must be unit-testable without requiring Android framework dependencies.
Recommended Architecture: MVVM + Repository Pattern
The Model-View-ViewModel pattern with Repository abstraction provides the best balance of testability, maintainability, and alignment with Jetpack Compose for keyboard development:
Text// Project structure for professional keyboard
app/
├── src/
│ ├── main/
│ │ ├── kotlin/com/example/keyboard/
│ │ │ ├── service/
│ │ │ │ └── KeyboardService.kt // InputMethodService implementation
│ │ │ ├── ui/
│ │ │ │ ├── KeyboardUI.kt // Compose UI root
│ │ │ │ ├── layouts/
│ │ │ │ │ ├── QwertyLayout.kt
│ │ │ │ │ ├── SymbolsLayout.kt
│ │ │ │ │ └── EmojiLayout.kt
│ │ │ │ └── theme/
│ │ │ │ ├── KeyboardTheme.kt
│ │ │ │ └── DynamicColors.kt
│ │ │ ├── viewmodel/
│ │ │ │ ├── KeyboardViewModel.kt // State management
│ │ │ │ └── SuggestionViewModel.kt
│ │ │ ├── repository/
│ │ │ │ ├── DictionaryRepository.kt // Dictionary data source
│ │ │ │ ├── SettingsRepository.kt // User preferences
│ │ │ │ └── ClipboardRepository.kt
│ │ │ ├── domain/
│ │ │ │ ├── TextProcessor.kt // Business logic
│ │ │ │ ├── AutocorrectEngine.kt
│ │ │ │ └── PredictionEngine.kt
│ │ │ └── data/
│ │ │ ├── model/
│ │ │ │ ├── KeyboardState.kt
│ │ │ │ ├── Suggestion.kt
│ │ │ │ └── KeyLayout.kt
│ │ │ └── local/
│ │ │ └── KeyboardDatabase.kt
│ │ └── res/
│ │ ├── layout/
│ │ ├── values/
│ │ └── xml/
│ │ └── method.xml // IME configuration
│ └── test/
│ └── kotlin/com/example/keyboard/
│ ├── domain/
│ │ ├── TextProcessorTest.kt // Unit tests
│ │ └── AutocorrectEngineTest.kt
│ └── viewmodel/
│ └── KeyboardViewModelTest.kt
└── build.gradle.kts
KeyboardService: Minimal Service Layer
The InputMethodService subclass should be a thin coordinator that delegates to ViewModels and Repositories. It handles only lifecycle callbacks and Android framework integration:
Kotlinclass KeyboardService : InputMethodService() {
// Lazy initialization to avoid premature resource allocation
private val viewModel: KeyboardViewModel by viewModels {
KeyboardViewModelFactory(
dictionaryRepository = DictionaryRepository(applicationContext),
settingsRepository = SettingsRepository(applicationContext)
)
}
private val inputConnectionHandler by lazy {
InputConnectionHandler(viewModel)
}
override fun onCreateInputView(): View {
return ComposeView(context).apply {
// Edge-to-edge window setup for API 35+
window?.let { window ->
WindowCompat.setDecorFitsSystemWindows(window, false)
}
setContent {
KeyboardTheme {
KeyboardUI(
viewModel = viewModel,
onKeyPress = ::handleKeyPress,
onLayoutChange = viewModel::setLayout
)
}
}
}
}
override fun onStartInputView(info: EditorInfo, restarting: Boolean) {
super.onStartInputView(info, restarting)
// Update ViewModel with input context
viewModel.onInputContextChanged(
inputType = info.inputType,
imeOptions = info.imeOptions,
packageName = info.packageName
)
// Initialize input connection handler
currentInputConnection?.let { connection ->
inputConnectionHandler.setConnection(connection)
}
}
override fun onFinishInputView(finishingInput: Boolean) {
super.onFinishInputView(finishingInput)
// Clear input-specific state
viewModel.onInputContextCleared()
inputConnectionHandler.clearConnection()
}
private fun handleKeyPress(key: Key) {
when (key) {
is Key.Character -> inputConnectionHandler.commitText(key.char)
is Key.Delete -> inputConnectionHandler.deleteBackward()
is Key.Enter -> inputConnectionHandler.performEnter()
is Key.Space -> inputConnectionHandler.commitText(" ")
is Key.Shift -> viewModel.toggleShift()
is Key.LayoutSwitch -> viewModel.setLayout(key.targetLayout)
}
}
}
ViewModel: State Management Hub
The ViewModel owns all keyboard state and exposes it as Compose State for reactive UI updates. This pattern eliminates callback hell and makes the keyboard behavior predictable and testable:
Kotlinclass KeyboardViewModel(
private val dictionaryRepository: DictionaryRepository,
private val settingsRepository: SettingsRepository
) : ViewModel() {
// Keyboard state as Compose State
private val _keyboardState = MutableStateFlow(KeyboardState())
val keyboardState: StateFlow = _keyboardState.asStateFlow()
// Suggestion state
private val _suggestions = MutableStateFlow>(emptyList())
val suggestions: StateFlow> = _suggestions.asStateFlow()
// Current input buffer for autocorrect
private var currentWord = StringBuilder()
init {
// Load user preferences
viewModelScope.launch {
settingsRepository.preferences.collect { prefs ->
_keyboardState.update { state ->
state.copy(
enableHaptics = prefs.enableHaptics,
enableSounds = prefs.enableSounds,
theme = prefs.theme
)
}
}
}
}
fun onInputContextChanged(
inputType: Int,
imeOptions: Int,
packageName: String
) {
val layout = determineLayout(inputType)
val actionButton = determineActionButton(imeOptions)
_keyboardState.update { state ->
state.copy(
currentLayout = layout,
actionButton = actionButton,
targetPackage = packageName
)
}
// Clear word buffer on new input
currentWord.clear()
_suggestions.value = emptyList()
}
fun onCharacterTyped(char: Char) {
currentWord.append(char)
// Trigger suggestion generation
viewModelScope.launch {
val newSuggestions = dictionaryRepository.getSuggestions(
prefix = currentWord.toString(),
limit = 3
)
_suggestions.value = newSuggestions
}
}
fun toggleShift() {
_keyboardState.update { state ->
state.copy(
shiftState = when (state.shiftState) {
ShiftState.OFF -> ShiftState.ON
ShiftState.ON -> ShiftState.CAPS_LOCK
ShiftState.CAPS_LOCK -> ShiftState.OFF
}
)
}
}
fun setLayout(layout: KeyboardLayout) {
_keyboardState.update { it.copy(currentLayout = layout) }
}
private fun determineLayout(inputType: Int): KeyboardLayout {
return when {
inputType and InputType.TYPE_CLASS_NUMBER != 0 -> KeyboardLayout.NUMERIC
inputType and InputType.TYPE_CLASS_PHONE != 0 -> KeyboardLayout.PHONE
else -> KeyboardLayout.QWERTY
}
}
private fun determineActionButton(imeOptions: Int): ActionButton {
return when (imeOptions and EditorInfo.IME_MASK_ACTION) {
EditorInfo.IME_ACTION_SEARCH -> ActionButton.SEARCH
EditorInfo.IME_ACTION_SEND -> ActionButton.SEND
EditorInfo.IME_ACTION_NEXT -> ActionButton.NEXT
EditorInfo.IME_ACTION_GO -> ActionButton.GO
else -> ActionButton.DONE
}
}
}
// State model
data class KeyboardState(
val currentLayout: KeyboardLayout = KeyboardLayout.QWERTY,
val shiftState: ShiftState = ShiftState.OFF,
val actionButton: ActionButton = ActionButton.DONE,
val enableHaptics: Boolean = true,
val enableSounds: Boolean = true,
val theme: KeyboardTheme = KeyboardTheme.SYSTEM,
val targetPackage: String = ""
)
enum class ShiftState { OFF, ON, CAPS_LOCK }
enum class KeyboardLayout { QWERTY, NUMERIC, SYMBOLS, EMOJI, PHONE }
enum class ActionButton { DONE, GO, SEARCH, SEND, NEXT }
This architecture cleanly separates Android framework concerns (InputMethodService) from business logic (ViewModels, Repositories) and UI (Compose). Unit tests can verify autocorrect algorithms without instantiating InputMethodService. UI tests can verify layout rendering without touching dictionary code. This separation is critical for large keyboard projects with multiple developers.
Dependency Injection with Hilt
For complex keyboards, Hilt dependency injection simplifies dependency management and improves testability. Hilt integration for IME services requires special handling since Services aren't standard Activities:
Gradle// build.gradle.kts
plugins {
id("com.google.devtools.ksp")
}
dependencies {
implementation("com.google.dagger:hilt-android:2.59.2")
ksp("com.google.dagger:hilt-compiler:2.59.2")
}
// Application class
@HiltAndroidApp
class KeyboardApplication : Application()
// Service with Hilt
@AndroidEntryPoint
class KeyboardService : InputMethodService() {
@Inject
lateinit var dictionaryRepository: DictionaryRepository
@Inject
lateinit var settingsRepository: SettingsRepository
// ViewModels are automatically injected via Hilt
private val viewModel: KeyboardViewModel by viewModels()
}
// Module for dependencies
@Module
@InstallIn(SingletonComponent::class)
object KeyboardModule {
@Provides
@Singleton
fun provideDictionaryRepository(
@ApplicationContext context: Context
): DictionaryRepository = DictionaryRepository(context)
@Provides
@Singleton
fun provideSettingsRepository(
@ApplicationContext context: Context
): SettingsRepository = SettingsRepository(context)
}
Project Setup & Build Configuration
BeginnerSet up an Android keyboard project from scratch: Gradle configuration, manifest declarations, input method XML metadata, and the minimal files needed to get a working IME on a device or emulator.
4.1 — Gradle Configuration
A keyboard app targets the same build system as any Android project, but several configuration choices directly affect IME behaviour. Start with the Kotlin DSL and the latest AGP (Android Gradle Plugin) version available:
Kotlin — build.gradle.kts (app)plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose") // Kotlin 2.0+ Compose compiler
}
android {
namespace = "com.example.keyboard"
compileSdk = 36
defaultConfig {
applicationId = "com.example.keyboard"
minSdk = 30 // Android 11 — inline autofill, dynamic color
targetSdk = 36 // Android 16 — required by Play Store as of Aug 31, 2026
versionCode = 1
versionName = "1.0.0"
}
buildFeatures {
viewBinding = true // For XML-based keyboard layouts
compose = true // For Compose-based keyboards
}
// No composeOptions needed — Kotlin 2.0+ Compose plugin handles compiler config
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
// Core Android
implementation("androidx.core:core-ktx:1.16.0")
implementation("androidx.appcompat:appcompat:1.7.0")
// Material Design 3 (dynamic color, theme tokens)
implementation("com.google.android.material:material:1.12.0")
// Jetpack Compose (optional — for Compose-based keyboard UI)
implementation(platform("androidx.compose:compose-bom:2025.06.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.ui:ui-tooling-preview")
debugImplementation("androidx.compose.ui:ui-tooling")
// Lifecycle
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.0")
// Testing
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
}
API 21 is the absolute minimum for modern features. API 24 unlocks Java 8 desugaring without a library. API 28+ enables inline autofill suggestions. API 33+ enables stylus handwriting. API 35+ enables connectionless stylus handwriting. Choose based on your target audience; as of 2026, API 24 covers >99% of active devices.
4.2 — AndroidManifest.xml
The manifest declares your IME as a system-level input method service. Three elements are required: the <service> itself, the BIND_INPUT_METHOD permission, and the XML metadata:
XML — AndroidManifest.xml<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:theme="@style/Theme.MyKeyboard">
<!-- The IME Service -->
<service
android:name=".KeyboardService"
android:label="@string/keyboard_name"
android:permission="android.permission.BIND_INPUT_METHOD"
android:exported="true">
<intent-filter>
<action android:name="android.view.InputMethod" />
</intent-filter>
<meta-data
android:name="android.view.im"
android:resource="@xml/method" />
</service>
<!-- Optional: Settings Activity -->
<activity
android:name=".KeyboardSettingsActivity"
android:label="@string/settings_title"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
</intent-filter>
</activity>
</application>
</manifest>
The android.permission.BIND_INPUT_METHOD permission is signature-level. Only the system can bind to your service. Never remove this permission—without it, any app could intercept keystrokes. This is the primary security boundary for every IME.
4.3 — Input Method XML Metadata
The @xml/method resource describes your keyboard's capabilities: supported language subtypes, input modes, and switching behaviour:
XML — res/xml/method.xml<input-method
xmlns:android="http://schemas.android.com/apk/res/android"
android:settingsActivity="com.example.keyboard.KeyboardSettingsActivity"
android:supportsSwitchingToNextInputMethod="true">
<!-- English QWERTY subtype -->
<subtype
android:label="@string/subtype_en_us"
android:languageTag="en-US"
android:imeSubtypeMode="keyboard"
android:imeSubtypeExtraValue="TkeyboardLayoutSet=qwerty"
android:isAsciiCapable="true" />
<!-- Spanish QWERTY subtype -->
<subtype
android:label="@string/subtype_es"
android:languageTag="es"
android:imeSubtypeMode="keyboard"
android:imeSubtypeExtraValue="TKeyboardLayoutSet=qwerty"
android:isAsciiCapable="true" />
<!-- Voice input subtype -->
<subtype
android:label="@string/subtype_voice"
android:languageTag="en-US"
android:imeSubtypeMode="voice"
android:overridesImplicitlyEnabledSubtype="true" />
</input-method>
| Attribute | Purpose | Example Values |
|---|---|---|
languageTag | BCP 47 locale tag (replaces deprecated imeSubtypeLocale) | en-US, es, fr-FR, ja |
imeSubtypeMode | Input modality | keyboard, voice, handwriting |
isAsciiCapable | Whether the subtype can input ASCII characters | true / false |
supportsSwitchingToNextInputMethod | Show globe key for IME switching | true (set on <input-method>) |
overridesImplicitlyEnabledSubtype | Override default subtype resolution | true for voice/handwriting |
4.4 — Enabling the Keyboard on Device
After installing, users must manually enable the IME in Settings → System → Languages & Input → On-screen keyboard → Manage keyboards. This two-step activation (enable then select) is a deliberate security measure—Android requires explicit user consent before any third-party app can read keystrokes.
During development, use adb to enable and select your IME without navigating system settings:
Shell# Enable the IME
adb shell ime enable com.example.keyboard/.KeyboardService
# Set it as the current input method
adb shell ime set com.example.keyboard/.KeyboardService
# List all installed IMEs
adb shell ime list -a
4.5 — ProGuard / R8 Rules
Input method services are instantiated by the system via reflection, so you must preserve the service class and any XML-referenced classes:
ProGuard — proguard-rules.pro# Keep the IME service class (instantiated by system)
-keep class com.example.keyboard.KeyboardService { *; }
# Keep any custom views referenced in keyboard XML layouts
-keep class * extends android.inputmethodservice.InputMethodService { *; }
-keep class * extends android.view.View {
public <init>(android.content.Context);
public <init>(android.content.Context, android.util.AttributeSet);
public <init>(android.content.Context, android.util.AttributeSet, int);
}
4.6 — Project Directory Structure
A recommended directory layout for a keyboard project:
Textapp/src/main/
├── java/com/example/keyboard/
│ ├── KeyboardService.kt ← InputMethodService subclass
│ ├── KeyboardView.kt ← Custom view for rendering keys
│ ├── Keyboard.kt ← Key layout model (rows, keys, codes)
│ ├── correction/ ← Autocorrect, tap correction
│ ├── gesture/ ← Swipe/gesture typing engine
│ ├── voice/ ← Voice input integration
│ ├── theme/ ← Theming system (colors, shapes)
│ └── settings/ ← Preferences and data store
├── res/
│ ├── layout/
│ │ └── keyboard_view.xml ← Main keyboard layout
│ ├── xml/
│ │ ├── method.xml ← IME metadata
│ │ └── keyboard_qwerty.xml ← Key definitions (optional)
│ ├── values/
│ │ ├── strings.xml
│ │ ├── colors.xml
│ │ └── themes.xml
│ └── drawable/ ← Key backgrounds, icons
└── AndroidManifest.xml
InputMethodService — The Core Engine
Intermediate API 3+
Deep dive into android.inputmethodservice.InputMethodService: the lifecycle, callback sequence, window management, and how every keyboard interacts with the Android text framework. Updated for API 36.
5.1 — Class Hierarchy
InputMethodService sits in a well-defined inheritance chain. Understanding each level clarifies which callbacks come from the framework versus the IME contract:
Textandroid.content.Context
└── android.content.ContextWrapper
└── android.app.Service
└── android.inputmethodservice.AbstractInputMethodService
└── android.inputmethodservice.InputMethodService
└── com.example.keyboard.KeyboardService ← Your class
AbstractInputMethodService handles the low-level IPC binder. InputMethodService provides the high-level API: view creation, input connection, insets, and fullscreen management. Your subclass overrides only the callbacks it needs.
5.2 — Lifecycle Callbacks (Complete Sequence)
The IME lifecycle is more nuanced than a typical Activity. Here is the complete callback sequence from service creation to destruction:
| Phase | Callback | When It Fires | Typical Use |
|---|---|---|---|
| Creation | onCreate() | Service first created | Initialize resources, theme engine, dependency injection |
onInitializeInterface() | Configuration changes (screen rotation, locale) | Rebuild internal state, re-measure layouts | |
| View Setup | onCreateInputView() | First time input view is needed | Inflate or compose the keyboard UI; return the root View |
onCreateCandidatesView() | First time candidates strip is needed | Inflate suggestion bar (or return null) | |
| Binding | onBindInput() | Client app first connects | Cache client package name, prepare per-app settings |
| Session | onStartInput(EditorInfo, Boolean) | Editor field gains focus | Read input type, action button, configure keyboard mode |
onStartInputView(EditorInfo, Boolean) | Keyboard becomes visible | Refresh key labels, update language, start animations | |
onStartCandidatesView(EditorInfo, Boolean) | Candidates strip shown | Pre-load dictionary suggestions | |
| Teardown | onFinishInputView(Boolean) | Keyboard hidden | Stop animations, save draft state |
onFinishInput() | Editor loses focus | Clear composing text, release input connection | |
| Unbinding | onUnbindInput() | Client app disconnects | Release per-app resources |
| Destruction | onDestroy() | Service being killed | Free native resources, close databases, cancel coroutines |
5.3 — Minimal Implementation
Kotlinclass KeyboardService : InputMethodService() {
private lateinit var keyboardView: KeyboardView
private var currentEditorInfo: EditorInfo? = null
override fun onCreateInputView(): View {
keyboardView = layoutInflater.inflate(
R.layout.keyboard_view, null
) as KeyboardView
keyboardView.setOnKeyboardActionListener(keyListener)
return keyboardView
}
override fun onStartInput(info: EditorInfo, restarting: Boolean) {
super.onStartInput(info, restarting)
currentEditorInfo = info
// Adapt keyboard to the editor's input type
configureKeyboardForInputType(info.inputType)
}
override fun onStartInputView(info: EditorInfo, restarting: Boolean) {
super.onStartInputView(info, restarting)
// Refresh visual state each time keyboard appears
keyboardView.invalidateAllKeys()
}
private fun configureKeyboardForInputType(inputType: Int) {
when (inputType and InputType.TYPE_MASK_CLASS) {
InputType.TYPE_CLASS_NUMBER -> keyboardView.setKeyboard(numberKeyboard)
InputType.TYPE_CLASS_PHONE -> keyboardView.setKeyboard(phoneKeyboard)
InputType.TYPE_CLASS_TEXT -> {
when (inputType and InputType.TYPE_MASK_VARIATION) {
InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS -> keyboardView.showEmailLayout()
InputType.TYPE_TEXT_VARIATION_URI -> keyboardView.showUrlLayout()
InputType.TYPE_TEXT_VARIATION_PASSWORD -> keyboardView.disablePrediction()
else -> keyboardView.setKeyboard(qwertyKeyboard)
}
}
else -> keyboardView.setKeyboard(qwertyKeyboard)
}
}
private val keyListener = object : OnKeyboardActionListener {
override fun onKey(primaryCode: Int, keyCodes: IntArray) {
val ic = currentInputConnection ?: return
when (primaryCode) {
Keyboard.KEYCODE_DELETE -> ic.deleteSurroundingText(1, 0)
Keyboard.KEYCODE_DONE -> ic.performEditorAction(currentEditorInfo?.imeOptions?.and(EditorInfo.IME_MASK_ACTION) ?: EditorInfo.IME_ACTION_DONE)
else -> ic.commitText(primaryCode.toChar().toString(), 1)
}
}
// ... other callbacks
}
}
5.4 — Window & Insets Management
The IME window sits in a special system layer above the app. Two callbacks control its geometry:
Kotlinoverride fun onComputeInsets(outInsets: Insets) {
super.onComputeInsets(outInsets)
// The visible area of the keyboard (affects app content displacement)
outInsets.visibleTopInsets = keyboardView.top
outInsets.contentTopInsets = keyboardView.top
// touchableInsets controls which region captures touch events
outInsets.touchableInsets = Insets.TOUCHABLE_INSETS_VISIBLE
}
override fun onEvaluateFullscreenMode(): Boolean {
// Return false to prevent fullscreen extract editing in landscape
// Most modern keyboards override this to always return false
return false
}
In landscape orientation, the default InputMethodService enters fullscreen mode where it replaces the app UI with a text extract area. Almost all modern keyboards disable this by returning false from onEvaluateFullscreenMode(). Users expect to see the app content above the keyboard, even in landscape.
5.5 — API 33–36 Additions
Recent Android versions have expanded InputMethodService significantly:
| API | Method | Purpose |
|---|---|---|
| 33 | onStartStylusHandwriting() |
System delegates stylus input to the IME for handwriting recognition |
| 33 | onStylusHandwritingMotionEvent() |
Receives each MotionEvent from the stylus during handwriting |
| 33 | getStylusHandwritingWindow() |
Returns the overlay window for rendering ink strokes |
| 34 | onUpdateEditorToolType(Int) |
Notified when user switches between finger, stylus, and mouse — adapt UI accordingly |
| 35 | onStartConnectionlessStylusHandwriting() |
Stylus handwriting without an active InputConnection (e.g., lock screen) |
| 35 | finishConnectionlessStylusHandwriting() |
Complete connectionless handwriting and return recognized text |
| 36 | onShouldVerifyKeyEvent(KeyEvent) |
Verify that a KeyEvent originated from the system (HMAC-based hardware key verification) |
Kotlin// API 34: Adapt keyboard UI based on input tool
override fun onUpdateEditorToolType(toolType: Int) {
super.onUpdateEditorToolType(toolType)
when (toolType) {
MotionEvent.TOOL_TYPE_STYLUS -> showHandwritingMode()
MotionEvent.TOOL_TYPE_FINGER -> showStandardKeyboard()
MotionEvent.TOOL_TYPE_MOUSE -> showDesktopLayout()
}
}
// API 33: Stylus handwriting support
override fun onStartStylusHandwriting(): Boolean {
// Return true if your IME supports handwriting recognition
inkCanvas.clear()
inkCanvas.visibility = View.VISIBLE
return true
}
override fun onStylusHandwritingMotionEvent(event: MotionEvent): Boolean {
// Render ink strokes and accumulate points for recognition
inkCanvas.onTouchEvent(event)
if (event.action == MotionEvent.ACTION_UP) {
recognizeHandwriting(inkCanvas.getStrokePoints())
}
return true
}
5.6 — IME Switching
The globe key behaviour is controlled by your method.xml attribute supportsSwitchingToNextInputMethod="true" and the runtime API:
Kotlin// Show globe key only if other IMEs are available
fun shouldShowGlobeKey(): Boolean {
return shouldOfferSwitchingToNextInputMethod()
}
// Switch to next IME when globe key is tapped
fun onGlobeKeyTapped() {
switchToNextInputMethod(false /* onlyCurrentIme */)
}
// Switch to a specific IME by token
fun switchToSpecificIme(imeId: String) {
switchInputMethod(imeId)
}
The older switchToNextInputMethod(IBinder, boolean) on InputMethodManager is deprecated. Use the InputMethodService instance methods directly: switchToNextInputMethod(boolean), switchToPreviousInputMethod(), and switchInputMethod(String). On API 36+, the system may also call onCustomImeSwitcherButtonRequestedVisible(boolean) to request your IME show its built-in language switcher.
Layout System — Rows, Keys, and XML
IntermediateDefine keyboard layouts using XML or programmatic approaches: row definitions, key sizing in proportional units, gap management, special keys, and multi-layout switching between QWERTY, symbols, and number pads.
6.1 — XML-Based Key Definitions
The traditional approach uses XML resources parsed at runtime. Each <Row> contains <Key> elements with codes, labels, widths, and visual attributes:
XML — res/xml/keyboard_qwerty.xml<Keyboard xmlns:android="http://schemas.android.com/apk/res/android"
android:keyWidth="10%p"
android:keyHeight="56dp"
android:horizontalGap="0.5%p"
android:verticalGap="6dp">
<!-- Row 1: Q W E R T Y U I O P -->
<Row>
<Key android:codes="113" android:keyLabel="q" android:keyEdgeFlags="left" />
<Key android:codes="119" android:keyLabel="w" />
<Key android:codes="101" android:keyLabel="e" />
<Key android:codes="114" android:keyLabel="r" />
<Key android:codes="116" android:keyLabel="t" />
<Key android:codes="121" android:keyLabel="y" />
<Key android:codes="117" android:keyLabel="u" />
<Key android:codes="105" android:keyLabel="i" />
<Key android:codes="111" android:keyLabel="o" />
<Key android:codes="112" android:keyLabel="p" android:keyEdgeFlags="right" />
</Row>
<!-- Row 2: A S D F G H J K L (with half-key indent) -->
<Row>
<Key android:codes="97" android:keyLabel="a"
android:horizontalGap="5%p" android:keyEdgeFlags="left" />
<Key android:codes="115" android:keyLabel="s" />
<Key android:codes="100" android:keyLabel="d" />
<Key android:codes="102" android:keyLabel="f" />
<Key android:codes="103" android:keyLabel="g" />
<Key android:codes="104" android:keyLabel="h" />
<Key android:codes="106" android:keyLabel="j" />
<Key android:codes="107" android:keyLabel="k" />
<Key android:codes="108" android:keyLabel="l" android:keyEdgeFlags="right" />
</Row>
<!-- Row 3: Shift + Z X C V B N M + Delete -->
<Row>
<Key android:codes="-1" android:keyLabel="⇧"
android:keyWidth="15%p" android:isModifier="true"
android:isSticky="true" android:keyEdgeFlags="left" />
<Key android:codes="122" android:keyLabel="z" />
<Key android:codes="120" android:keyLabel="x" />
<Key android:codes="99" android:keyLabel="c" />
<Key android:codes="118" android:keyLabel="v" />
<Key android:codes="98" android:keyLabel="b" />
<Key android:codes="110" android:keyLabel="n" />
<Key android:codes="109" android:keyLabel="m" />
<Key android:codes="-5" android:keyLabel="⌫"
android:keyWidth="15%p" android:isRepeatable="true"
android:keyEdgeFlags="right" />
</Row>
<!-- Row 4: Symbols, Comma, Space, Period, Enter -->
<Row android:rowEdgeFlags="bottom">
<Key android:codes="-2" android:keyLabel="?123" android:keyWidth="15%p"
android:keyEdgeFlags="left" />
<Key android:codes="44" android:keyLabel="," android:keyWidth="10%p" />
<Key android:codes="32" android:keyLabel=" " android:keyWidth="40%p"
android:isRepeatable="true" />
<Key android:codes="46" android:keyLabel="." android:keyWidth="10%p" />
<Key android:codes="-3" android:keyLabel="↵" android:keyWidth="15%p"
android:keyEdgeFlags="right" />
</Row>
</Keyboard>
6.2 — Key Size System (%p Units)
Android keyboard XML uses %p (percent of parent) for widths. This simplifies responsive design across screen densities:
| Key Role | Typical Width | %p Value | Touch Target |
|---|---|---|---|
| Letter key | 1/10 of row | 10%p | ~40dp on a 400dp-wide screen |
| Shift / Delete | 1.5× letter | 15%p | ~60dp |
| Space bar | 4× letter | 40%p | ~160dp |
| Symbols toggle | 1.5× letter | 15%p | ~60dp |
| Key height (rows) | Absolute dp | 52–58dp | Material recommends ≥48dp touch targets |
Material Design accessibility guidelines require a minimum 48×48dp touch target. For keyboard keys, the visual size can be smaller as long as the touchable area (including gap padding) meets this threshold. Use horizontalGap and verticalGap to add dead zones between keys that still count toward the touch area.
6.3 — Programmatic Layouts
XML layouts are simple but inflexible. For dynamic keyboards (runtime language switching, adaptive sizing, custom shapes), build layouts programmatically:
Kotlindata class KeyDefinition(
val code: Int,
val label: String,
val widthWeight: Float = 1.0f,
val isModifier: Boolean = false,
val isRepeatable: Boolean = false,
)
data class RowDefinition(
val keys: List<KeyDefinition>,
val indentWeight: Float = 0f, // Left indent in key-width units
)
object QwertyLayout {
fun build(): List<RowDefinition> = listOf(
RowDefinition(
keys = "qwertyuiop".map { KeyDefinition(it.code, it.toString()) }
),
RowDefinition(
keys = "asdfghjkl".map { KeyDefinition(it.code, it.toString()) },
indentWeight = 0.5f
),
RowDefinition(
keys = listOf(
KeyDefinition(-1, "⇧", widthWeight = 1.5f, isModifier = true),
*"zxcvbnm".map { KeyDefinition(it.code, it.toString()) }.toTypedArray(),
KeyDefinition(-5, "⌫", widthWeight = 1.5f, isRepeatable = true),
)
),
RowDefinition(
keys = listOf(
KeyDefinition(-2, "?123", widthWeight = 1.5f),
KeyDefinition(44, ","),
KeyDefinition(32, " ", widthWeight = 4.0f),
KeyDefinition(46, "."),
KeyDefinition(-3, "↵", widthWeight = 1.5f),
)
),
)
}
6.4 — Multi-Layout Switching
Most keyboards need at least three layouts: letters (QWERTY), symbols page 1, and symbols page 2. Manage switching with a simple state machine:
Kotlinenum class KeyboardMode {
LETTERS, // QWERTY / QWERTZ / AZERTY
SYMBOLS_1, // !@#$%^&*()-+=
SYMBOLS_2, // ~`{}[]|\/<>
NUMBER, // 0-9 numeric pad
PHONE, // Telephone dialer layout
}
class LayoutManager(private val keyboardView: KeyboardView) {
private var currentMode = KeyboardMode.LETTERS
fun switchTo(mode: KeyboardMode) {
currentMode = mode
val layout = when (mode) {
KeyboardMode.LETTERS -> QwertyLayout.build()
KeyboardMode.SYMBOLS_1 -> SymbolsLayout.page1()
KeyboardMode.SYMBOLS_2 -> SymbolsLayout.page2()
KeyboardMode.NUMBER -> NumberLayout.build()
KeyboardMode.PHONE -> PhoneLayout.build()
}
keyboardView.setLayout(layout)
}
fun handleModeKey(code: Int) {
when (code) {
-2 -> switchTo(
if (currentMode == KeyboardMode.LETTERS) KeyboardMode.SYMBOLS_1
else KeyboardMode.LETTERS
)
-6 -> switchTo(KeyboardMode.SYMBOLS_2) // ALT key
}
}
}
6.5 — Internationalization Considerations
Different languages require different row structures. Russian Cyrillic has 33 characters (vs. 26 Latin), requiring an extra column or row. Japanese needs a mode switch between Romaji, Hiragana, and Katakana. Plan your layout system to be flexible from the start.
The half-key stagger between rows 1 and 2 (and rows 2 and 3) in QWERTY is not decorative—it matches the physical keyboard layout users have typed on for decades. If you change this stagger (e.g., to align columns vertically), expect a measurable increase in typing errors. This is documented in Bi et al., 2012 — ACM CHI.
Compose UI for Keyboards
Intermediate API 21+
Build keyboard UIs with Jetpack Compose: integrating ComposeView inside InputMethodService, managing recomposition performance, state hoisting for key states, and bridging Compose with the IME window system.
7.1 — Why Compose for Keyboards?
Compose offers real advantages over XML layouts for keyboards: declarative UI reduces state bugs, animations are first-class, and theming (especially Material You dynamic color) works out of the box. The trade-off is recomposition performance—a keyboard must render at 60fps with zero frame drops during fast typing.
| Approach | Strengths | Weaknesses |
|---|---|---|
Custom View + Canvas | Maximum control, zero allocation during draw, lowest latency | Complex state management, manual invalidation |
| XML Layout | Familiar, stable, works everywhere | Verbose, hard to animate, poor dynamic theming |
| Jetpack Compose | Declarative, excellent theming, built-in animations | Recomposition overhead, ComposeView in Service requires care |
7.2 — Embedding ComposeView in InputMethodService
InputMethodService.onCreateInputView() must return a View. Wrap your Compose keyboard in a ComposeView:
Kotlinclass KeyboardService : InputMethodService() {
override fun onCreateInputView(): View {
return ComposeView(this).apply {
setViewCompositionStrategy(
ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
)
setContent {
KeyboardTheme {
KeyboardScreen(
onKeyPress = ::handleKeyPress,
onBackspace = ::handleBackspace,
onEnter = ::handleEnter,
)
}
}
}
}
private fun handleKeyPress(char: Char) {
currentInputConnection?.commitText(char.toString(), 1)
}
private fun handleBackspace() {
currentInputConnection?.deleteSurroundingText(1, 0)
}
private fun handleEnter() {
currentInputConnection?.performEditorAction(EditorInfo.IME_ACTION_DONE)
}
}
ComposeView needs a LifecycleOwner. Since InputMethodService is not a LifecycleOwner by default, you have two options: (1) implement LifecycleOwner in your service class and manage LifecycleRegistry manually, or (2) use ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed which uses the window's decor view lifecycle. Option (2) is simpler and recommended for most use cases. From the official Compose interop guide.
7.3 — Compose Keyboard Layout
Kotlin@Composable
fun KeyboardScreen(
onKeyPress: (Char) -> Unit,
onBackspace: () -> Unit,
onEnter: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainerLow)
.padding(horizontal = 4.dp, vertical = 6.dp),
) {
val rows = remember { QwertyLayout.build() }
rows.forEach { row ->
KeyboardRow(
row = row,
onKeyPress = onKeyPress,
onBackspace = onBackspace,
onEnter = onEnter,
)
Spacer(Modifier.height(6.dp))
}
}
}
@Composable
fun KeyboardRow(
row: RowDefinition,
onKeyPress: (Char) -> Unit,
onBackspace: () -> Unit,
onEnter: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
if (row.indentWeight > 0f) {
Spacer(Modifier.weight(row.indentWeight))
}
row.keys.forEach { key ->
KeyButton(
definition = key,
modifier = Modifier.weight(key.widthWeight),
onPress = {
when (key.code) {
-5 -> onBackspace()
-3 -> onEnter()
else -> onKeyPress(key.code.toChar())
}
},
)
}
if (row.indentWeight > 0f) {
Spacer(Modifier.weight(row.indentWeight))
}
}
}
@Composable
fun KeyButton(
definition: KeyDefinition,
modifier: Modifier = Modifier,
onPress: () -> Unit,
) {
var isPressed by remember { mutableStateOf(false) }
val elevation by animateDpAsState(
targetValue = if (isPressed) 1.dp else 4.dp,
label = "keyElevation",
)
Surface(
modifier = modifier
.height(52.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
isPressed = true
tryAwaitRelease()
isPressed = false
onPress()
}
)
},
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shadowElevation = elevation,
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = definition.label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
}
}
}
7.4 — Recomposition Performance
Keyboard performance is non-negotiable. A recomposition that drops to 30fps during rapid typing is a deal-breaker. Follow these Compose optimizations:
| Technique | Impact | Implementation |
|---|---|---|
Use key() for stable composition | Prevents unnecessary recomposition of unchanged keys | key(key.code) { KeyButton(...) } |
| Hoist pressed state to parent | Only recomposes the pressed key, not the entire row | var pressedKey by remember { mutableStateOf(-1) } |
Use derivedStateOf | Debounces recomposition for computed values | val isShifted by remember { derivedStateOf { shift > 0 } } |
| Avoid allocations in draw | Reduces GC pauses during fast typing | Pre-allocate Paint, Path, RectF objects |
Use Modifier.graphicsLayer | Hardware-accelerated transforms without recomposition | Scale and alpha changes via lambda, not state |
Use the Compose Compiler Metrics (-PcomposeCompilerReports=true) to identify unstable classes causing recomposition. The Layout Inspector's recomposition counter shows which composables recompose on every frame. Target: zero recompositions per frame during typing except the active key.
Keyboard Views — Custom Rendering
Intermediate
Build a high-performance custom View for keyboard rendering: Canvas drawing, touch event handling, double-buffering, key press animations, and achieving consistent 60fps rendering under fast typing loads.
8.1 — Why Custom Views?
The framework's android.inputmethodservice.KeyboardView was deprecated in API 29. It was never suitable for production keyboards: no hardware acceleration, rigid styling, and poor multi-touch support. Every shipping keyboard uses a custom View or SurfaceView subclass.
8.2 — View Architecture
Kotlinabstract class AbstractKeyboardView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : View(context, attrs, defStyleAttr) {
// Model
var keyboard: Keyboard? = null
set(value) {
field = value
requestLayout()
invalidate()
}
// Double-buffer for off-screen rendering
private var buffer: Bitmap? = null
private var bufferCanvas: Canvas? = null
private var bufferDirty = true
// Pre-allocated drawing objects (avoid GC during onDraw)
private val keyPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val keyRect = RectF()
private val cornerRadius = 8f * resources.displayMetrics.density
// Touch state
private var currentKeyIndex = -1
private var touchDownTime = 0L
private val longPressTimeout = ViewConfiguration.getLongPressTimeout().toLong()
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val kb = keyboard
if (kb == null) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
return
}
val width = MeasureSpec.getSize(widthMeasureSpec)
val height = kb.height + paddingTop + paddingBottom
setMeasuredDimension(width, height)
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
buffer?.recycle()
buffer = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
bufferCanvas = Canvas(buffer!!)
bufferDirty = true
}
override fun onDraw(canvas: Canvas) {
val buf = buffer ?: return
if (bufferDirty) {
drawKeyboard(bufferCanvas!!)
bufferDirty = false
}
canvas.drawBitmap(buf, 0f, 0f, null)
// Draw press highlight on top of buffer (avoids full redraw)
drawPressHighlight(canvas)
}
private fun drawKeyboard(canvas: Canvas) {
canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
val kb = keyboard ?: return
for (key in kb.keys) {
drawKey(canvas, key)
}
}
protected open fun drawKey(canvas: Canvas, key: Keyboard.Key) {
keyRect.set(
key.x.toFloat(),
key.y.toFloat(),
(key.x + key.width).toFloat(),
(key.y + key.height).toFloat(),
)
// Key background
keyPaint.color = if (key.pressed) pressedColor else keyColor
canvas.drawRoundRect(keyRect, cornerRadius, cornerRadius, keyPaint)
// Key label
textPaint.textSize = key.label?.let { 20f * resources.displayMetrics.density } ?: 0f
textPaint.color = textColor
textPaint.textAlign = Paint.Align.CENTER
key.label?.let { label ->
val textX = keyRect.centerX()
val textY = keyRect.centerY() - (textPaint.descent() + textPaint.ascent()) / 2
canvas.drawText(label.toString(), textX, textY, textPaint)
}
}
fun invalidateKey(keyIndex: Int) {
// Partial invalidation: only redraw the affected key region
val kb = keyboard ?: return
val key = kb.keys.getOrNull(keyIndex) ?: return
invalidate(key.x, key.y, key.x + key.width, key.y + key.height)
bufferDirty = true
}
fun invalidateAllKeys() {
bufferDirty = true
invalidate()
}
}
8.3 — Touch Event Handling
Kotlinoverride fun onTouchEvent(event: MotionEvent): Boolean {
val kb = keyboard ?: return false
val x = event.x.toInt()
val y = event.y.toInt()
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
touchDownTime = SystemClock.uptimeMillis()
val keyIndex = getKeyAtPoint(x, y)
if (keyIndex >= 0) {
currentKeyIndex = keyIndex
kb.keys[keyIndex].pressed = true
invalidateKey(keyIndex)
actionListener?.onPress(kb.keys[keyIndex].codes[0])
// Schedule long-press detection
handler.postDelayed(longPressRunnable, longPressTimeout)
}
}
MotionEvent.ACTION_MOVE -> {
val newIndex = getKeyAtPoint(x, y)
if (newIndex != currentKeyIndex && newIndex >= 0) {
// Finger slid to a different key
if (currentKeyIndex >= 0) {
kb.keys[currentKeyIndex].pressed = false
invalidateKey(currentKeyIndex)
}
currentKeyIndex = newIndex
kb.keys[newIndex].pressed = true
invalidateKey(newIndex)
handler.removeCallbacks(longPressRunnable)
}
}
MotionEvent.ACTION_UP -> {
handler.removeCallbacks(longPressRunnable)
if (currentKeyIndex >= 0) {
val key = kb.keys[currentKeyIndex]
key.pressed = false
invalidateKey(currentKeyIndex)
actionListener?.onKey(key.codes[0], key.codes)
actionListener?.onRelease(key.codes[0])
}
currentKeyIndex = -1
}
MotionEvent.ACTION_CANCEL -> {
handler.removeCallbacks(longPressRunnable)
if (currentKeyIndex >= 0) {
kb.keys[currentKeyIndex].pressed = false
invalidateKey(currentKeyIndex)
}
currentKeyIndex = -1
}
}
return true
}
private fun getKeyAtPoint(x: Int, y: Int): Int {
val kb = keyboard ?: return -1
return kb.keys.indexOfFirst { key ->
x >= key.x && x < key.x + key.width &&
y >= key.y && y < key.y + key.height
}
}
8.4 — Performance Checklist
| Rule | Why | Violation Cost |
|---|---|---|
Zero allocations in onDraw() | GC pauses cause frame drops | ~2–6ms per minor GC on mid-range devices |
Pre-allocate Paint, RectF, Path | Reuse objects across frames | Each new Paint() allocates ~400 bytes |
Use invalidateKey() not invalidate() | Partial invalidation redraws only the changed region | Full redraw costs 2–5× more than single key |
| Double-buffer keyboard state | Avoid redrawing all 30+ keys each frame | Without buffering: ~3ms per frame on Pixel 8 |
| Hardware-accelerated canvas | GPU-backed drawing for shadows and anti-aliasing | Software rendering: 4–8× slower shadows |
TextureView creates a separate texture that the compositor must blend, adding 1–2ms latency per frame. It also doesn't support partial invalidation. Use a standard View with hardware acceleration enabled (the default since API 14). SurfaceView can work for 120Hz rendering but complicates the IME window insets.
InputConnection — Talking to the Text Field
Intermediate
Master the InputConnection API: committing text, composing regions, cursor movement, text selection, editor actions, and handling the quirks of different text fields across apps.
9.1 — Core Concepts
InputConnection is the channel between your keyboard and the app's text field. Every keystroke, backspace, autocorrect replacement, and cursor movement flows through this interface. The connection is obtained via currentInputConnection in your InputMethodService.
Two critical patterns exist: direct commit (simple characters) and composing text (autocorrect preview).
9.2 — Direct Text Commitment
Kotlinfun commitCharacter(char: Char) {
val ic = currentInputConnection ?: return
ic.commitText(char.toString(), 1) // 1 = move cursor after the inserted text
}
fun commitWord(word: String) {
val ic = currentInputConnection ?: return
ic.commitText(word, 1)
}
fun deleteBackward(count: Int = 1) {
val ic = currentInputConnection ?: return
// Delete 'count' characters before cursor, 0 characters after
ic.deleteSurroundingText(count, 0)
}
fun deleteForward(count: Int = 1) {
val ic = currentInputConnection ?: return
ic.deleteSurroundingText(0, count)
}
fun performAction(editorInfo: EditorInfo) {
val ic = currentInputConnection ?: return
val action = editorInfo.imeOptions and EditorInfo.IME_MASK_ACTION
ic.performEditorAction(action)
// Common actions: IME_ACTION_DONE, IME_ACTION_SEARCH,
// IME_ACTION_NEXT, IME_ACTION_GO, IME_ACTION_SEND
}
9.3 — Composing Text (Autocorrect Preview)
Composing text shows an underlined preview in the text field while the user types. When they confirm (space, punctuation), you commit the final text and clear the composing region:
Kotlinclass ComposingManager(private val service: InputMethodService) {
private val composingBuffer = StringBuilder()
fun onKeyPress(char: Char) {
composingBuffer.append(char)
val ic = service.currentInputConnection ?: return
// Show composing text with underline in the text field
ic.setComposingText(composingBuffer, 1)
// Get autocorrect candidates for the composing buffer
val candidates = dictionary.getSuggestions(composingBuffer.toString())
updateCandidateBar(candidates)
}
fun commitTopCandidate() {
val ic = service.currentInputConnection ?: return
val bestWord = dictionary.getTopSuggestion(composingBuffer.toString())
?: composingBuffer.toString()
// finishComposingText() removes the underline and commits as-is
// commitText() replaces the composing region with the corrected word
ic.commitText("$bestWord ", 1) // Add trailing space
composingBuffer.clear()
}
fun commitAsTyped() {
val ic = service.currentInputConnection ?: return
ic.finishComposingText()
composingBuffer.clear()
}
}
setComposingText() creates a temporary region (shown underlined) that can be freely replaced until the user confirms. commitText() permanently inserts text and clears any composing region. finishComposingText() locks the composing text as-is without replacement. Always call one of the commit methods before switching text fields — leftover composing state causes visual artifacts.
9.4 — Reading Context
Tap correction and autocomplete need to read the text around the cursor:
Kotlinfun getContext(): Pair<String, String> {
val ic = currentInputConnection ?: return "" to ""
val before = ic.getTextBeforeCursor(50, 0)?.toString().orEmpty()
val after = ic.getTextAfterCursor(50, 0)?.toString().orEmpty()
return before to after
}
fun getWordBeforeCursor(): String {
val (before, _) = getContext()
return before.trimEnd().split(' ').lastOrNull().orEmpty()
}
fun getSelectedText(): String? {
val ic = currentInputConnection ?: return null
return ic.getSelectedText(0)?.toString()
}
9.5 — Cursor & Selection Control
Kotlinfun moveCursorLeft() {
val ic = currentInputConnection ?: return
// Extract existing selection
val extracted = ic.getExtractedText(ExtractedTextRequest(), 0)
val pos = extracted?.selectionStart ?: return
if (pos > 0) {
ic.setSelection(pos - 1, pos - 1)
}
}
fun selectAll() {
val ic = currentInputConnection ?: return
val extracted = ic.getExtractedText(ExtractedTextRequest(), 0)
val text = extracted?.text ?: return
ic.setSelection(0, text.length)
}
fun replaceSelection(replacement: String) {
val ic = currentInputConnection ?: return
ic.commitText(replacement, 1)
// commitText replaces selected text automatically
}
9.6 — EditorInfo — Adapting to the Text Field
The EditorInfo object, received in onStartInput(), tells you everything about the target text field:
| Field | What It Tells You | How to Adapt |
|---|---|---|
inputType & TYPE_MASK_CLASS | Text, number, phone, datetime | Switch keyboard layout |
inputType & TYPE_MASK_VARIATION | Password, email, URI, person name | Disable prediction for passwords; show @ for email |
inputType & TYPE_MASK_FLAGS | Autocomplete, multiline, no suggestions | Respect TYPE_TEXT_FLAG_NO_SUGGESTIONS; enable multiline enter |
imeOptions & IME_MASK_ACTION | Done, search, next, go, send | Change enter key label and icon |
packageName | Which app owns the text field | Per-app quirk workarounds (some apps have broken InputConnections) |
privateImeOptions | App-specific hints | Custom integration (e.g., "com.example.disableGesture=true") |
Kotlinoverride fun onStartInput(info: EditorInfo, restarting: Boolean) {
super.onStartInput(info, restarting)
// Determine enter key appearance
val enterLabel = when (info.imeOptions and EditorInfo.IME_MASK_ACTION) {
EditorInfo.IME_ACTION_SEARCH -> "🔍"
EditorInfo.IME_ACTION_SEND -> "➤"
EditorInfo.IME_ACTION_NEXT -> "→"
EditorInfo.IME_ACTION_GO -> "Go"
EditorInfo.IME_ACTION_DONE -> "✓"
else -> "↵"
}
keyboardView.setEnterKeyLabel(enterLabel)
// Disable prediction for passwords
val variation = info.inputType and InputType.TYPE_MASK_VARIATION
val isPassword = variation == InputType.TYPE_TEXT_VARIATION_PASSWORD
|| variation == InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
|| variation == InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD
keyboardView.setPredictionEnabled(!isPassword)
}
(1) Always null-check currentInputConnection — it's null when no editor is active. (2) getTextBeforeCursor() returns null in some WebView fields — always use ?.toString().orEmpty(). (3) Some apps (notably Samsung Notes, older WhatsApp versions) implement InputConnection with non-standard behavior — test against popular apps. (4) Never cache the InputConnection object — it changes between onStartInput calls.
Material You 3.0: The Complete Design System
Intermediate API 31+Comprehensive coverage of Material You 3.0 and Material Design 3 Expressive for keyboard design, including dynamic color, theming tokens, motion system, and expressive UI patterns introduced in 2025-2026.
Understanding Material You Evolution
Material You represents Google's most significant design language evolution since the original Material Design in 2014. First introduced with Android 12 (API 31) in 2021, Material You has undergone continuous refinement, culminating in Material Design 3 Expressive announced at Google I/O 2025 and rolling out through Android 16 (2026). The philosophy centers on three core principles:
- Personalization: Interfaces adapt to user preferences, extracting color palettes from wallpapers and respecting system-wide theme choices.
- Adaptability: Design tokens scale across device types (phones, tablets, foldables, desktops) and contexts (light mode, dark mode, high contrast).
- Expressiveness: The 2025 "Expressive" update introduces bolder colors, springy motion, and emotional design patterns that make interfaces feel alive rather than static.
Material You Color System
The color system is Material You's most distinctive feature. Instead of designer-defined brand colors, Material You generates entire color palettes dynamically from the user's wallpaper. As of Material You 3.0 (API 34+), the Compose ColorScheme provides 36 color roles (expanded from 29 in earlier M3 versions, with 7 new surface container roles: surfaceBright, surfaceDim, surfaceContainer, surfaceContainerHigh, surfaceContainerHighest, surfaceContainerLow, surfaceContainerLowest):
| Color Role | Purpose | Light Mode Example | Dark Mode Example |
|---|---|---|---|
primary |
High-emphasis keys, primary actions | #6750A4 | #D0BCFF |
onPrimary |
Text/icons on primary color | #FFFFFF | #381E72 |
primaryContainer |
Background for primary elements | #EADDFF | #4F378B |
onPrimaryContainer |
Text on primaryContainer | #21005D | #EADDFF |
secondary |
Medium-emphasis keys | #625B71 | #CCC2DC |
tertiary |
Accent colors, special keys | #7D5260 | #EFB8C8 |
surface |
Keyboard background | #FFFBFE | #1C1B1F |
surfaceVariant |
Alternative surface color | #E7E0EC | #49454F |
surfaceTint |
Overlay for elevation (API 34+) |
#6750A4 | #D0BCFF |
outline |
Key borders | #79747E | #938F99 |
outlineVariant |
Subtle borders (API 34+) |
#CAC4D0 | #49454F |
surfaceBright |
Brighter surface (dark mode elevation) (M3 1.2+) |
#FFFBFE | #3B3838 |
surfaceDim |
Dimmer surface (light mode depth) (M3 1.2+) |
#DED8E1 | #131214 |
surfaceContainer |
Default container background (M3 1.2+) |
#F3EDF7 | #211F26 |
surfaceContainerLow |
Keyboard background (M3 1.2+) |
#F7F2FA | #1D1B20 |
surfaceContainerHigh |
Key face background (M3 1.2+) |
#ECE6F0 | #2B2930 |
surfaceContainerHighest |
Pressed key background (M3 1.2+) |
#E6E0E9 | #36343B |
surfaceContainerLowest |
Lowest-emphasis surface (M3 1.2+) |
#FFFFFF | #0F0D13 |
Implementing Dynamic Color in Your Keyboard
To adopt Material You dynamic color, use the DynamicColors API available on API 31+. This automatically extracts colors from the user's wallpaper and applies them to your keyboard:
Kotlin// Compose implementation of Material You dynamic color
@Composable
fun KeyboardTheme(
useDynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
// API 31+ supports dynamic color
useDynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (isSystemInDarkTheme()) {
dynamicDarkColorScheme(context)
} else {
dynamicLightColorScheme(context)
}
}
// Fallback to static colors for API 30
isSystemInDarkTheme() -> darkColorScheme(
primary = Color(0xFFD0BCFF),
onPrimary = Color(0xFF381E72),
primaryContainer = Color(0xFF4F378B),
onPrimaryContainer = Color(0xFFEADDFF),
// ... full color scheme
)
else -> lightColorScheme(
primary = Color(0xFF6750A4),
onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFEADDFF),
onPrimaryContainer = Color(0xFF21005D),
// ... full color scheme
)
}
MaterialTheme(
colorScheme = colorScheme,
typography = KeyboardTypography,
content = content
)
}
Using Color Roles Correctly
Material You's power comes from using semantic color roles instead of hardcoded colors. Here's how to apply color roles to keyboard keys:
Kotlin@Composable
fun KeyboardKey(
key: Key,
modifier: Modifier = Modifier,
onPress: (Key) -> Unit
) {
val colorScheme = MaterialTheme.colorScheme
// Determine color role based on key type
val (backgroundColor, contentColor) = when (key.type) {
KeyType.CHARACTER -> {
// Standard letter keys use surface colors
colorScheme.surfaceVariant to colorScheme.onSurface
}
KeyType.ACTION -> {
// Action keys (Enter, Search) use primary
colorScheme.primaryContainer to colorScheme.onPrimaryContainer
}
KeyType.MODIFIER -> {
// Shift, Caps Lock use secondary
colorScheme.secondaryContainer to colorScheme.onSecondaryContainer
}
KeyType.SPECIAL -> {
// Emoji, Voice use tertiary for visual distinction
colorScheme.tertiaryContainer to colorScheme.onTertiaryContainer
}
}
Surface(
modifier = modifier
.padding(2.dp)
.aspectRatio(1f),
color = backgroundColor,
contentColor = contentColor,
shape = RoundedCornerShape(8.dp),
tonalElevation = 1.dp, // Subtle elevation
onClick = { onPress(key) }
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
Text(
text = key.label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
}
}
}
A common mistake is hardcoding brand colors in a Material You keyboard. If your keyboard always uses blue keys regardless of the user's wallpaper, you're breaking the Material You contract and creating visual discord with the rest of the system. Trust the dynamic color system—it's been extensively tested to ensure good contrast and aesthetic harmony. If you must include brand colors, limit them to your app icon and settings screen, not the keyboard itself.
Dynamic Color Extraction & Integration
Intermediate API 31+
Extract wallpaper-derived colors from the system's DynamicColors API, apply them to keyboard surfaces, and handle graceful fallbacks on devices below API 31. Includes per-key color mapping strategies and contrast validation.
11.1 — How Dynamic Color Works
Starting with Android 12 (API 31), the system extracts a source color from the user's wallpaper, then generates a complete Material 3 color scheme through HCT (Hue-Chroma-Tone) color space transformations. This scheme is exposed through system resource overlays and the DynamicColors API.
The generated scheme contains five tonal palettes (Primary, Secondary, Tertiary, Neutral, Neutral-Variant), each with 13 tonal values (0–100). Your keyboard reads these as standard color resource tokens.
| Token | Use in Keyboards | Light Mode Tone | Dark Mode Tone |
|---|---|---|---|
colorSurfaceContainerLow | Keyboard background | 96 | 10 |
colorSurfaceContainerHigh | Key face background | 92 | 17 |
colorSurfaceContainerHighest | Pressed key background | 90 | 22 |
colorOnSurface | Key label text | 10 | 90 |
colorOnSurfaceVariant | Secondary key labels (symbols) | 30 | 80 |
colorPrimary | Active toggle keys (shift, language) | 40 | 80 |
colorPrimaryContainer | Enter key, special action keys | 90 | 30 |
colorOnPrimaryContainer | Enter key label/icon | 10 | 90 |
11.2 — Applying Dynamic Colors in an IME
Kotlinimport com.google.android.material.color.DynamicColors
import com.google.android.material.color.MaterialColors
class KeyboardService : InputMethodService() {
override fun onCreate() {
super.onCreate()
// Apply dynamic color overlay to the service's theme
DynamicColors.applyToActivitiesIfAvailable(application)
}
override fun onCreateInputView(): View {
// Wrap context with Material3 dynamic colors
val themedContext = DynamicColors.wrapContextIfAvailable(this)
return LayoutInflater.from(themedContext)
.inflate(R.layout.keyboard_view, null)
}
}
// Extracting individual colors programmatically
fun getKeyboardColors(context: Context): KeyboardColorScheme {
return KeyboardColorScheme(
background = MaterialColors.getColor(
context, com.google.android.material.R.attr.colorSurfaceContainerLow, Color.WHITE
),
keyFace = MaterialColors.getColor(
context, com.google.android.material.R.attr.colorSurfaceContainerHigh, Color.LTGRAY
),
keyLabel = MaterialColors.getColor(
context, com.google.android.material.R.attr.colorOnSurface, Color.BLACK
),
accentKey = MaterialColors.getColor(
context, com.google.android.material.R.attr.colorPrimaryContainer, Color.BLUE
),
accentLabel = MaterialColors.getColor(
context, com.google.android.material.R.attr.colorOnPrimaryContainer, Color.WHITE
),
)
}
data class KeyboardColorScheme(
val background: Int,
val keyFace: Int,
val keyLabel: Int,
val accentKey: Int,
val accentLabel: Int,
)
11.3 — Fallback Strategy for Pre-API 31
Dynamic color is only available on API 31+. On older devices, fall back to a static Material 3 theme or your brand colors:
Kotlinfun resolveKeyboardTheme(context: Context): KeyboardColorScheme {
return if (DynamicColors.isDynamicColorAvailable()) {
// Dynamic wallpaper-derived colors
val dynamicContext = DynamicColors.wrapContextIfAvailable(context)
getKeyboardColors(dynamicContext)
} else {
// Static fallback — Material baseline or brand colors
KeyboardColorScheme(
background = context.getColor(R.color.keyboard_bg_static),
keyFace = context.getColor(R.color.key_face_static),
keyLabel = context.getColor(R.color.key_label_static),
accentKey = context.getColor(R.color.accent_key_static),
accentLabel = context.getColor(R.color.accent_label_static),
)
}
}
11.4 — Contrast Validation
Dynamic colors can occasionally produce low-contrast key labels. Always validate contrast ratios at runtime, especially for accessibility:
Kotlinimport androidx.core.graphics.ColorUtils
fun ensureMinimumContrast(
foreground: Int,
background: Int,
minimumRatio: Double = 4.5, // WCAG AA for normal text
): Int {
val ratio = ColorUtils.calculateContrast(foreground, background)
return if (ratio >= minimumRatio) {
foreground
} else {
// Find the closest color in the same hue with sufficient contrast
val hsl = FloatArray(3)
ColorUtils.colorToHSL(foreground, hsl)
// Adjust lightness up or down depending on background luminance
val bgLuminance = ColorUtils.calculateLuminance(background)
if (bgLuminance > 0.5) {
// Dark text on light background — decrease lightness
hsl[2] = (hsl[2] - 0.1f).coerceAtLeast(0f)
} else {
// Light text on dark background — increase lightness
hsl[2] = (hsl[2] + 0.1f).coerceAtMost(1f)
}
ColorUtils.HSLToColor(hsl)
}
}
When the user changes their wallpaper, the system triggers a configuration change. Your IME's onInitializeInterface() callback fires, which is the appropriate place to re-extract dynamic colors and refresh the keyboard appearance. You do not need a BroadcastReceiver for ACTION_WALLPAPER_CHANGED — the IME lifecycle handles this automatically.
Theming System Architecture
IntermediateDesign a scalable theming system for keyboards: theme data models, real-time theme switching, custom user themes, theme persistence, and integrating static themes alongside Material You dynamic theming.
12.1 — Theme Data Model
A robust keyboard theme extends beyond colors to include shapes, typography, spacing, and effects. Define an immutable data class for the complete visual specification:
Kotlindata class KeyboardTheme(
val id: String,
val name: String,
val isDynamic: Boolean = false,
// Color palette
val colors: ThemeColors,
// Shape & geometry
val keyCornerRadius: Dp = 8.dp,
val keyElevation: Dp = 2.dp,
val keySpacingHorizontal: Dp = 4.dp,
val keySpacingVertical: Dp = 6.dp,
val keyboardPadding: PaddingValues = PaddingValues(horizontal = 3.dp, vertical = 4.dp),
// Typography
val letterTextSize: TextUnit = 22.sp,
val symbolTextSize: TextUnit = 16.sp,
val fontFamily: FontFamily = FontFamily.Default,
val fontWeight: FontWeight = FontWeight.Medium,
// Visual effects
val keyPressedEffect: PressEffect = PressEffect.DARKEN,
val borderWidth: Dp = 0.dp,
val borderColor: Color = Color.Transparent,
val shadowType: ShadowType = ShadowType.ELEVATION,
)
data class ThemeColors(
val keyboardBackground: Color,
val keyFace: Color,
val keyFacePressed: Color,
val keyLabel: Color,
val keyLabelSecondary: Color,
val accentKeyFace: Color,
val accentKeyLabel: Color,
val functionKeyFace: Color,
val functionKeyLabel: Color,
val popupBackground: Color,
val popupText: Color,
val suggestionBarBackground: Color,
val suggestionText: Color,
val suggestionTextHighlighted: Color,
)
enum class PressEffect { DARKEN, LIGHTEN, SCALE, RIPPLE, INVERT, NONE }
enum class ShadowType { ELEVATION, DROP, INNER, NONE }
12.2 — Theme Registry
Kotlinobject ThemeRegistry {
private val themes = mutableMapOf<String, KeyboardTheme>()
fun register(theme: KeyboardTheme) {
themes[theme.id] = theme
}
fun get(id: String): KeyboardTheme? = themes[id]
fun getAll(): List<KeyboardTheme> = themes.values.toList()
init {
// Built-in themes
register(ClassicLightTheme())
register(ClassicDarkTheme())
register(HighContrastTheme())
register(OceanBlueTheme())
register(ForestGreenTheme())
register(SunsetOrangeTheme())
}
}
// Dynamic color theme — generated at runtime
fun createDynamicTheme(context: Context): KeyboardTheme {
val colors = resolveKeyboardTheme(context)
return KeyboardTheme(
id = "dynamic_material_you",
name = "Material You",
isDynamic = true,
colors = ThemeColors(
keyboardBackground = Color(colors.background),
keyFace = Color(colors.keyFace),
keyFacePressed = Color(colors.keyFace).copy(alpha = 0.7f),
keyLabel = Color(colors.keyLabel),
keyLabelSecondary = Color(colors.keyLabel).copy(alpha = 0.7f),
accentKeyFace = Color(colors.accentKey),
accentKeyLabel = Color(colors.accentLabel),
functionKeyFace = Color(colors.keyFace).copy(alpha = 0.8f),
functionKeyLabel = Color(colors.keyLabel),
popupBackground = Color(colors.keyFace),
popupText = Color(colors.keyLabel),
suggestionBarBackground = Color(colors.background),
suggestionText = Color(colors.keyLabel),
suggestionTextHighlighted = Color(colors.accentLabel),
),
)
}
12.3 — Theme Persistence with DataStore
Kotlinimport androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.edit
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class ThemePreferences(private val dataStore: DataStore<Preferences>) {
companion object {
private val SELECTED_THEME_KEY = stringPreferencesKey("selected_theme_id")
private const val DEFAULT_THEME = "dynamic_material_you"
}
val selectedThemeId: Flow<String> = dataStore.data.map { prefs ->
prefs[SELECTED_THEME_KEY] ?: DEFAULT_THEME
}
suspend fun setTheme(themeId: String) {
dataStore.edit { prefs ->
prefs[SELECTED_THEME_KEY] = themeId
}
}
}
12.4 — Real-Time Theme Switching
The keyboard must apply a new theme without closing and reopening. The pattern: observe the theme preference, rebuild colors, and propagate to the view:
Kotlin// In KeyboardService
private val themeJob = SupervisorJob()
private val themeScope = CoroutineScope(Dispatchers.Main.immediate + themeJob)
override fun onCreate() {
super.onCreate()
themeScope.launch {
themePreferences.selectedThemeId.collectLatest { themeId ->
val theme = if (themeId == "dynamic_material_you") {
createDynamicTheme(this@KeyboardService)
} else {
ThemeRegistry.get(themeId) ?: createDynamicTheme(this@KeyboardService)
}
keyboardView?.applyTheme(theme)
}
}
}
override fun onDestroy() {
themeJob.cancel()
super.onDestroy()
}
Allow users to preview themes before committing. Show a miniature keyboard preview in your settings screen that responds immediately to theme selection. Use a lightweight Canvas composable that renders just 3 rows of simplified key shapes — enough to convey the visual style without building a full keyboard instance.
Motion & Animations
ExpertKey press animations, layout transition choreography, spring physics, gesture trails, popup animations, and the Material Motion system — all within the frame budget of a real-time input surface.
13.1 — The Frame Budget Problem
At 60fps, each frame has a 16.67ms budget. A keyboard must complete touch processing, layout updates, animation calculations, and rendering within this window. At 120fps (common on flagship devices since 2023), the budget shrinks to 8.33ms. Every animation must be designed with this constraint.
| Operation | Budget at 60fps | Budget at 120fps | Priority |
|---|---|---|---|
| Touch event processing | <2ms | <1ms | Critical |
| Key state update | <1ms | <0.5ms | Critical |
| Animation calculation | <2ms | <1ms | High |
| Layout/measure | <3ms | <1.5ms | High |
| Canvas draw | <6ms | <3ms | Medium |
| GPU composition | <2ms | <1ms | System |
13.2 — Key Press Animations (Custom View)
Kotlinclass KeyAnimator {
// Per-key animation state — no allocation on each press
private val keyAnimations = SparseArray<KeyAnimState>()
data class KeyAnimState(
var pressScale: Float = 1f,
var pressAlpha: Float = 0f,
var pressStartTime: Long = 0L,
var isPressed: Boolean = false,
)
fun onKeyDown(keyIndex: Int) {
val state = keyAnimations.getOrPut(keyIndex) { KeyAnimState() }
state.isPressed = true
state.pressStartTime = SystemClock.uptimeMillis()
}
fun onKeyUp(keyIndex: Int) {
keyAnimations[keyIndex]?.isPressed = false
}
fun updateAnimation(keyIndex: Int): KeyAnimState {
val state = keyAnimations[keyIndex] ?: return KeyAnimState()
val elapsed = SystemClock.uptimeMillis() - state.pressStartTime
if (state.isPressed) {
// Press-in: fast ease-out to slightly smaller scale
val progress = (elapsed / PRESS_DURATION_MS.toFloat()).coerceAtMost(1f)
val eased = 1f - (1f - progress) * (1f - progress) // Ease-out quad
state.pressScale = 1f - (PRESS_SCALE_DELTA * eased)
state.pressAlpha = PRESS_OVERLAY_ALPHA * eased
} else {
// Release: spring back
val releaseProgress = (elapsed / RELEASE_DURATION_MS.toFloat()).coerceAtMost(1f)
val springy = overshootInterpolator(releaseProgress)
state.pressScale = (1f - PRESS_SCALE_DELTA) + (PRESS_SCALE_DELTA * springy)
state.pressAlpha = PRESS_OVERLAY_ALPHA * (1f - releaseProgress)
}
return state
}
private fun overshootInterpolator(t: Float): Float {
// Slight overshoot (1.02x) for tactile feel
val s = 1.5f
val t1 = t - 1f
return t1 * t1 * ((s + 1f) * t1 + s) + 1f
}
companion object {
const val PRESS_DURATION_MS = 50L // Fast engage
const val RELEASE_DURATION_MS = 120L // Slower spring release
const val PRESS_SCALE_DELTA = 0.04f // 4% shrink
const val PRESS_OVERLAY_ALPHA = 0.12f // Subtle darkening
}
}
13.3 — Compose Key Press Animation
Kotlin@Composable
fun AnimatedKeyButton(
definition: KeyDefinition,
modifier: Modifier = Modifier,
onPress: () -> Unit,
) {
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val scale by animateFloatAsState(
targetValue = if (isPressed) 0.96f else 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessHigh,
),
label = "keyScale",
)
val elevation by animateDpAsState(
targetValue = if (isPressed) 0.dp else 3.dp,
animationSpec = tween(durationMillis = if (isPressed) 40 else 100),
label = "keyElevation",
)
Surface(
modifier = modifier
.height(52.dp)
.graphicsLayer {
scaleX = scale
scaleY = scale
},
onClick = onPress,
interactionSource = interactionSource,
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shadowElevation = elevation,
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = definition.label,
style = MaterialTheme.typography.bodyLarge,
)
}
}
}
13.4 — Layout Transition Animations
Switching between letter and symbol layouts should feel fluid. Use crossfade or shared-element transitions:
Kotlin@Composable
fun AnimatedKeyboardContent(
mode: KeyboardMode,
onKeyPress: (Char) -> Unit,
) {
AnimatedContent(
targetState = mode,
transitionSpec = {
// Slide + fade for mode switches
if (targetState == KeyboardMode.SYMBOLS_1) {
slideInVertically { height -> height / 4 } + fadeIn(
animationSpec = tween(150)
) togetherWith slideOutVertically { height -> -height / 4 } + fadeOut(
animationSpec = tween(100)
)
} else {
slideInVertically { height -> -height / 4 } + fadeIn(
animationSpec = tween(150)
) togetherWith slideOutVertically { height -> height / 4 } + fadeOut(
animationSpec = tween(100)
)
}
},
label = "keyboardModeTransition",
) { targetMode ->
KeyboardLayout(mode = targetMode, onKeyPress = onKeyPress)
}
}
13.5 — Key Popup Animations
Long-press popups (for accented characters, alternate symbols) should emerge from the key with a spring effect:
Kotlin@Composable
fun KeyPopup(
visible: Boolean,
characters: List<Char>,
anchorBounds: Rect,
onSelect: (Char) -> Unit,
) {
AnimatedVisibility(
visible = visible,
enter = scaleIn(
initialScale = 0.7f,
transformOrigin = TransformOrigin(0.5f, 1f), // Scale from bottom-center
animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessMediumLow,
),
) + fadeIn(tween(60)),
exit = scaleOut(
targetScale = 0.8f,
animationSpec = tween(80),
) + fadeOut(tween(60)),
) {
Surface(
shape = RoundedCornerShape(12.dp),
shadowElevation = 8.dp,
color = MaterialTheme.colorScheme.surfaceContainerHighest,
) {
Row(
modifier = Modifier.padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
) {
characters.forEach { char ->
PopupKeyItem(
character = char,
onSelect = { onSelect(char) },
)
}
}
}
}
}
Respect the system's Reduce Motion setting (Settings.Global.ANIMATOR_DURATION_SCALE). When the user sets animation duration to 0×, replace all spring and tween animations with instant state changes. Use val reduceMotion = LocalReduceMotion.current.enabled() in Compose, or check the system setting in custom Views via ValueAnimator.areAnimatorsEnabled().
13.6 — Gesture Trail Rendering
For swipe typing, render a smooth trail following the finger. Use a Path with quadratic Bézier curves for smoothness:
Kotlinclass GestureTrailRenderer {
private val trailPath = Path()
private val trailPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
}
private val points = ArrayDeque<PointF>(64)
private val maxPoints = 50
fun addPoint(x: Float, y: Float) {
points.addLast(PointF(x, y))
if (points.size > maxPoints) points.removeFirst()
rebuildPath()
}
fun clear() {
points.clear()
trailPath.reset()
}
fun draw(canvas: Canvas, color: Int, maxWidth: Float = 6f) {
if (points.size < 2) return
trailPaint.color = color
// Variable-width stroke: thicker at recent points, thinner at old
for (i in 1 until points.size) {
val fraction = i.toFloat() / points.size
trailPaint.strokeWidth = maxWidth * fraction
trailPaint.alpha = (255 * fraction * fraction).toInt()
canvas.drawLine(
points[i - 1].x, points[i - 1].y,
points[i].x, points[i].y,
trailPaint,
)
}
}
private fun rebuildPath() {
trailPath.reset()
if (points.size < 2) return
trailPath.moveTo(points[0].x, points[0].y)
for (i in 1 until points.size) {
val mid = PointF(
(points[i - 1].x + points[i].x) / 2,
(points[i - 1].y + points[i].y) / 2,
)
trailPath.quadTo(points[i - 1].x, points[i - 1].y, mid.x, mid.y)
}
}
}
Dark Mode Best Practices for 2026
IntermediateModern dark mode implementation using dark grey (#1E1E1E) instead of pure black, off-white text for reduced eye strain, OLED optimization, and adaptive contrast ratios.
The Evolution of Dark Mode Design
Dark mode has evolved significantly since its mainstream adoption in 2019. Early implementations often used pure black (#000000) backgrounds with bright white (#FFFFFF) text, creating harsh contrast that caused eye strain and OLED screen burn-in. By 2026, industry best practices have converged on a more refined approach:
- Dark grey surfaces: Use #1E1E1E (or similar dark grey) instead of pure black for the primary background. This reduces extreme contrast while maintaining OLED power savings.
- Off-white text: Use #E1E1E1 or similar off-white instead of pure white for body text. This prevents glare and reduces eye fatigue during extended typing sessions.
- Elevated surfaces: Layer lighter grey surfaces (#282828, #323232) on top of the base dark grey to create depth and hierarchy through elevation, not just shadows.
- Contextual adaptation: Automatically adjust contrast based on ambient light conditions detected via device sensors.
Why Dark Grey Instead of Pure Black?
The shift from pure black to dark grey (#1E1E1E) is based on extensive UX research and user feedback throughout 2020-2025:
- Reduced Visual Fatigue: Pure black backgrounds with white text create maximum contrast (21:1 ratio), which sounds good on paper but causes eye strain in practice. The stark transition between black and white forces the iris to constantly adjust. Dark grey (#1E1E1E) with off-white text provides a more comfortable ~15:1 contrast ratio that still exceeds WCAG AAA standards.
- Better Elevation Perception: Material Design's elevation system uses subtle surface lightening to indicate component layering. On pure black, there's no room to go darker for recessed elements or lighter for elevated elements without jumping to grey. Starting at #1E1E1E provides headroom for both directions.
- OLED Burn-In Prevention: While OLED displays save power by turning off black pixels, pure black with sharp white UI elements creates high-contrast edges that accelerate pixel degradation. Dark grey reduces this differential while maintaining ~95% of the power savings.
- Ambient Light Compatibility: Pure black looks stunning in pitch-dark rooms but appears unnatural in environments with any ambient light. Dark grey maintains readability across a wider range of lighting conditions.
Implementing Professional Dark Mode
Here's a production-grade dark mode implementation for keyboards following 2026 best practices:
Kotlin// Dark mode color palette (2026 standard)
object DarkModeColors {
// Surface colors (dark grey, not black)
val Surface = Color(0xFF1E1E1E) // Base surface: #1E1E1E
val SurfaceVariant = Color(0xFF282828) // Slightly elevated: #282828
val SurfaceTint = Color(0xFF323232) // More elevated: #323232
// Text colors (off-white, not pure white)
val OnSurface = Color(0xFFE1E1E1) // Primary text: #E1E1E1
val OnSurfaceVariant = Color(0xFFCAC4D0) // Secondary text
val OnSurfaceDisabled = Color(0xFF938F99) // Disabled state
// Key colors for dark mode
val KeyBackground = Color(0xFF282828)
val KeyBackgroundPressed = Color(0xFF323232)
val KeyLabel = Color(0xFFE1E1E1)
// Action keys (slight color tint)
val ActionKeyBackground = Color(0xFF2A2D3A)
val ActionKeyLabel = Color(0xFF93C5FD)
// Dividers and borders (very subtle)
val Outline = Color(0xFF3E3E3E)
val OutlineVariant = Color(0xFF323232)
}
@Composable
fun KeyboardDarkTheme(
content: @Composable () -> Unit
) {
val darkColorScheme = darkColorScheme(
// Surface colors using dark grey
surface = DarkModeColors.Surface,
surfaceVariant = DarkModeColors.SurfaceVariant,
surfaceTint = DarkModeColors.SurfaceTint,
// Text colors using off-white
onSurface = DarkModeColors.OnSurface,
onSurfaceVariant = DarkModeColors.OnSurfaceVariant,
// Primary, secondary, tertiary from Material You
primary = Color(0xFFD0BCFF),
onPrimary = Color(0xFF381E72),
primaryContainer = Color(0xFF4F378B),
onPrimaryContainer = Color(0xFFEADDFF),
// Outline colors
outline = DarkModeColors.Outline,
outlineVariant = DarkModeColors.OutlineVariant,
)
MaterialTheme(
colorScheme = darkColorScheme,
typography = KeyboardTypography,
content = content
)
}
Key Rendering with Proper Dark Mode Colors
When rendering individual keys in dark mode, use elevated surfaces for keys and ensure text maintains WCAG AA contrast minimums (4.5:1 for normal text):
Kotlin@Composable
fun DarkModeKey(
label: String,
modifier: Modifier = Modifier,
isPressed: Boolean = false,
onPress: () -> Unit
) {
val colorScheme = MaterialTheme.colorScheme
val isDarkMode = isSystemInDarkTheme()
// Use elevated surface color for keys in dark mode
val keyColor = when {
!isDarkMode -> colorScheme.surfaceVariant
isPressed -> DarkModeColors.KeyBackgroundPressed
else -> DarkModeColors.KeyBackground
}
// Off-white text in dark mode
val labelColor = if (isDarkMode) {
DarkModeColors.KeyLabel
} else {
colorScheme.onSurface
}
Surface(
modifier = modifier
.padding(2.dp)
.size(width = 48.dp, height = 54.dp),
color = keyColor,
shape = RoundedCornerShape(8.dp),
// Subtle elevation in dark mode creates depth
tonalElevation = if (isDarkMode) 2.dp else 1.dp,
onClick = onPress
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
Text(
text = label,
color = labelColor,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
}
}
}
Testing Dark Mode Across Conditions
Professional keyboards test dark mode in multiple scenarios:
| Condition | Test Focus | Pass Criteria |
|---|---|---|
| Pitch Black Room | No glow/bloom around white text | Off-white text (#E1E1E1) should not cause eye fatigue after 5 minutes of typing |
| Dim Indoor Lighting | Key labels remain legible | Contrast ratio ≥ 4.5:1 between key background and label |
| Bright Office | Dark mode doesn't wash out | Surface color remains visible, not appearing as black void |
| OLED Display | Power consumption vs. pure black | Dark grey (#1E1E1E) should consume ≤105% of pure black's power |
| LCD Display | Backlight uniformity | No visible backlight bleed on dark surfaces |
Implement automatic contrast adjustment based on ambient light. In very dark environments (cinema, bedroom at night), reduce text color from #E1E1E1 to #C0C0C0 for even gentler contrast. In bright sunlight, increase surface brightness to #2A2A2A and text to #F0F0F0 for better outdoor visibility. This adaptive approach mirrors Apple's iOS keyboard behavior and significantly improves real-world usability.
Adaptive Layouts & Responsive Design
IntermediateBuild keyboards that adapt to screen size, orientation, split-screen, foldable devices, and multi-window mode. Covers window size classes, hinge-aware layouts, one-handed mode, and tablet split keyboards.
15.1 — Detecting Display Configuration
Android provides several paths to detect the current display dimensions. In an IME, use WindowManager metrics rather than DisplayMetrics, which may report stale values:
Kotlinimport android.view.WindowManager
import android.view.WindowMetrics
fun getCurrentDisplayMetrics(service: InputMethodService): WindowMetrics {
val wm = service.getSystemService(WindowManager::class.java)
return wm.currentWindowMetrics
}
fun computeKeyboardDimensions(metrics: WindowMetrics): KeyboardDimensions {
val bounds = metrics.bounds
val screenWidth = bounds.width()
val screenHeight = bounds.height()
val density = Resources.getSystem().displayMetrics.density
val isLandscape = screenWidth > screenHeight
val isTablet = minOf(screenWidth, screenHeight) / density > 600f
return when {
isTablet && isLandscape -> KeyboardDimensions(
width = (screenWidth * 0.65f).toInt(), // Split keyboard
rowHeight = (52 * density).toInt(),
keyGap = (5 * density).toInt(),
)
isTablet -> KeyboardDimensions(
width = screenWidth,
rowHeight = (56 * density).toInt(),
keyGap = (5 * density).toInt(),
)
isLandscape -> KeyboardDimensions(
width = screenWidth,
rowHeight = (40 * density).toInt(), // Shorter rows for landscape
keyGap = (3 * density).toInt(),
)
else -> KeyboardDimensions(
width = screenWidth,
rowHeight = (52 * density).toInt(),
keyGap = (4 * density).toInt(),
)
}
}
data class KeyboardDimensions(
val width: Int,
val rowHeight: Int,
val keyGap: Int,
)
15.2 — Foldable Device Support
Use Jetpack WindowManager to detect foldable posture and hinge position:
Kotlinimport androidx.window.layout.FoldingFeature
import androidx.window.layout.WindowInfoTracker
// In your Service or Activity
private fun observeFoldingState() {
lifecycleScope.launch {
WindowInfoTracker.getOrCreate(this@KeyboardService)
.windowLayoutInfo(this@KeyboardService)
.collect { layoutInfo ->
val foldingFeature = layoutInfo.displayFeatures
.filterIsInstance<FoldingFeature>()
.firstOrNull()
when {
foldingFeature == null -> applyStandardLayout()
foldingFeature.state == FoldingFeature.State.HALF_OPENED
&& foldingFeature.orientation == FoldingFeature.Orientation.HORIZONTAL
-> applyTabletopLayout(foldingFeature.bounds)
else -> applyStandardLayout()
}
}
}
}
15.3 — One-Handed Mode
Kotlinenum class OneHandedSide { LEFT, RIGHT, DISABLED }
fun applyOneHandedMode(
keyboardView: View,
side: OneHandedSide,
screenWidth: Int,
) {
val lp = keyboardView.layoutParams as FrameLayout.LayoutParams
val keyboardWidthFraction = 0.78f
when (side) {
OneHandedSide.LEFT -> {
lp.width = (screenWidth * keyboardWidthFraction).toInt()
lp.gravity = Gravity.START or Gravity.BOTTOM
}
OneHandedSide.RIGHT -> {
lp.width = (screenWidth * keyboardWidthFraction).toInt()
lp.gravity = Gravity.END or Gravity.BOTTOM
}
OneHandedSide.DISABLED -> {
lp.width = ViewGroup.LayoutParams.MATCH_PARENT
lp.gravity = Gravity.BOTTOM
}
}
keyboardView.layoutParams = lp
keyboardView.requestLayout()
}
15.4 — Split Keyboard for Tablets
Tablets wider than 600dp benefit from a split keyboard where keys cluster near the thumbs. The center gap should account for the device's physical center:
Kotlindata class SplitKeyboardConfig(
val leftKeys: List<KeyDefinition>,
val rightKeys: List<KeyDefinition>,
val centerGapDp: Float = 48f,
)
fun splitQwertyRow(row: List<KeyDefinition>): SplitKeyboardConfig {
val midpoint = row.size / 2
return SplitKeyboardConfig(
leftKeys = row.subList(0, midpoint),
rightKeys = row.subList(midpoint, row.size),
)
}
When the app enters split-screen or picture-in-picture mode, the IME receives onConfigurationChanged(). Use this callback to recalculate keyboard dimensions with the new window bounds. Do not cache display metrics at startup — always query current metrics during configuration changes.
Haptic Feedback
Intermediate
Implement responsive, accessible haptic feedback for key presses using the Android Vibrator API and HapticFeedbackConstants. Covers API-level selection, custom waveforms, and respecting system haptic settings.
16.1 — Haptic Feedback Strategy
| Event | API 30+ | API 33+ | Fallback |
|---|---|---|---|
| Key press (letter) | KEYBOARD_TAP | KEYBOARD_TAP | performHapticFeedback(VIRTUAL_KEY) |
| Key press (delete) | KEYBOARD_TAP | SEGMENT_FREQUENT_TICK | performHapticFeedback(LONG_PRESS) |
| Long press popup | LONG_PRESS | LONG_PRESS | 50ms vibration |
| Shift toggle | CONFIRM | CONFIRM | performHapticFeedback(VIRTUAL_KEY) |
| Gesture word confirmed | VibrationEffect (40ms) | GESTURE_END | 40ms vibration |
| Error (invalid input) | REJECT | REJECT | 100ms vibration |
16.2 — Implementation
Kotlinimport android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import android.view.HapticFeedbackConstants
class KeyboardHaptics(private val service: InputMethodService) {
private val vibrator: Vibrator = if (Build.VERSION.SDK_INT >= 31) {
val mgr = service.getSystemService(VibratorManager::class.java)
mgr.defaultVibrator
} else {
@Suppress("DEPRECATION")
service.getSystemService(Vibrator::class.java)
}
private val isHapticEnabled: Boolean
get() = Settings.System.getInt(
service.contentResolver,
Settings.System.HAPTIC_FEEDBACK_ENABLED, 1,
) == 1
fun onKeyPress(view: View) {
if (!isHapticEnabled) return
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
}
fun onLongPress(view: View) {
if (!isHapticEnabled) return
view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
}
fun onGestureWordConfirmed() {
if (!isHapticEnabled) return
if (Build.VERSION.SDK_INT >= 33) {
vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK))
} else if (Build.VERSION.SDK_INT >= 26) {
vibrator.vibrate(
VibrationEffect.createOneShot(30, VibrationEffect.DEFAULT_AMPLITUDE)
)
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(30)
}
}
fun onError() {
if (!isHapticEnabled) return
if (Build.VERSION.SDK_INT >= 30) {
vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_DOUBLE_CLICK))
}
}
}
16.3 — Android 16: Richer Haptic Curves API 36
Android 16 introduces amplitude and frequency curves via VibrationEffect.BasicEnvelopeBuilder (simplified) and VibrationEffect.WaveformEnvelopeBuilder (advanced), replacing the binary on/off waveform patterns with smooth, device-adaptive haptic envelopes. The system translates your curves to the optimal representation for each device's haptic actuator (LRA, ERM, or piezo), so you design the intended sensation rather than raw motor timings.
Kotlin// Android 16+: Expressive haptic curves for keyboard events
// BasicEnvelopeBuilder: intensity (0–1) + sharpness (0–1) + durationMs
@RequiresApi(Build.VERSION_CODES.BAKLAVA)
class RichKeyboardHaptics(private val service: InputMethodService) {
private val vibrator: Vibrator = service.getSystemService(VibratorManager::class.java)
.defaultVibrator
/** Soft, natural key tap with attack–sustain–release envelope. */
fun onKeyTap() {
vibrator.vibrate(
VibrationEffect.BasicEnvelopeBuilder()
.addControlPoint(0.75f, 0.9f, 4) // Sharp attack — crisp onset
.addControlPoint(0.4f, 0.6f, 8) // Brief sustain at medium intensity
.addControlPoint(0.0f, 0.4f, 12) // Smooth release — natural fade
.build()
)
}
/** Stronger haptic for spacebar and enter — "thud" texture. */
fun onActionKey() {
vibrator.vibrate(
VibrationEffect.BasicEnvelopeBuilder()
.addControlPoint(1.0f, 0.3f, 6) // Full-intensity attack, low sharpness = "thud"
.addControlPoint(0.6f, 0.3f, 15) // Longer sustain for weight
.addControlPoint(0.0f, 0.2f, 20) // Slow release
.build()
)
}
/** Double-pulse error pattern — unmistakable rejection feedback. */
fun onError() {
vibrator.vibrate(
VibrationEffect.BasicEnvelopeBuilder()
.addControlPoint(1.0f, 1.0f, 6) // First pulse — sharp
.addControlPoint(0.0f, 0.5f, 8) // Quick fade
.addControlPoint(0.0f, 0.5f, 35) // Pause between pulses
.addControlPoint(1.0f, 1.0f, 6) // Second pulse
.addControlPoint(0.0f, 0.5f, 10) // Fade out
.build()
)
}
/** Gentle tick for gesture trail waypoints. */
fun onGestureWaypoint() {
vibrator.vibrate(
VibrationEffect.BasicEnvelopeBuilder()
.addControlPoint(0.3f, 0.8f, 3) // Light, sharp tick
.addControlPoint(0.0f, 0.5f, 6) // Quick release
.build()
)
}
}
Wrap rich haptic curves in an if (Build.VERSION.SDK_INT >= 36) check and fall back to the VibrationEffect.createPredefined() patterns from Section 16.2 on older devices. The system silently degrades curves to the closest supported effect on devices with simpler actuators, but explicit fallbacks give you full control over the pre-API 36 experience. See also the API 36 haptic curves overview in Section 2.
Always check Settings.System.HAPTIC_FEEDBACK_ENABLED before triggering vibrations. Additionally, provide a per-app toggle in your keyboard settings. Users with motor disabilities or certain medical conditions may find haptic feedback disorienting. See also Section 23: Accessibility.
Sound Effects
Beginner
Add responsive audio feedback (key clicks, modifiers, deletion) using AudioManager system sounds and custom SoundPool assets. Covers volume scaling, system setting compliance, and sound-haptic synchronization.
17.1 — System Key Click Sounds
The simplest approach uses the system's built-in key click through AudioManager. This automatically respects the user's system sound preferences:
Kotlinimport android.media.AudioManager
class KeyboardSounds(private val service: InputMethodService) {
private val audioManager = service.getSystemService(AudioManager::class.java)
fun playKeyClick() {
audioManager.playSoundEffect(AudioManager.FX_KEYPRESS_STANDARD, -1f)
}
fun playDeleteClick() {
audioManager.playSoundEffect(AudioManager.FX_KEYPRESS_DELETE, -1f)
}
fun playReturnClick() {
audioManager.playSoundEffect(AudioManager.FX_KEYPRESS_RETURN, -1f)
}
fun playSpacebar() {
audioManager.playSoundEffect(AudioManager.FX_KEYPRESS_SPACEBAR, -1f)
}
}
17.2 — Custom Sound Pool
For branded audio experiences, load custom sound assets with SoundPool:
Kotlinimport android.media.SoundPool
import android.media.AudioAttributes
class CustomKeyboardSounds(private val context: Context) {
private val soundPool: SoundPool
private val sounds = mutableMapOf<String, Int>()
init {
val attrs = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
soundPool = SoundPool.Builder()
.setMaxStreams(4) // Up to 4 simultaneous sounds
.setAudioAttributes(attrs)
.build()
// Pre-load sounds from raw resources
sounds["tap"] = soundPool.load(context, R.raw.key_tap, 1)
sounds["delete"] = soundPool.load(context, R.raw.key_delete, 1)
sounds["return"] = soundPool.load(context, R.raw.key_return, 1)
sounds["space"] = soundPool.load(context, R.raw.key_space, 1)
}
fun play(soundName: String, volumeScale: Float = 0.6f) {
val id = sounds[soundName] ?: return
soundPool.play(id, volumeScale, volumeScale, 1, 0, 1f)
}
fun release() {
soundPool.release()
}
}
When both sound and haptic feedback are enabled, trigger them simultaneously in the same callback. Android's audio pipeline adds approximately 10-30ms latency for sound playback, while haptic feedback is near-instant. This small offset is imperceptible to users. Do not add artificial delays to synchronize them — it will make both feel sluggish.
Gesture Typing (Swipe Input)
ExpertImplement swipe-to-type input: path recording, key proximity detection, candidate word generation, dictionary integration, and the gesture trail rendering pipeline. Also covers stylus handwriting support added in API 33.
18.1 — Gesture Recognition Pipeline
| Stage | Input | Output | Time Budget |
|---|---|---|---|
| 1. Path capture | MotionEvent stream | List<PointF> (decimated) | <0.5ms per event |
| 2. Key proximity | Path points + key layout | List<List<Key>> (near keys per point) | <2ms total |
| 3. Segment extraction | Proximity matrix | Sequence of key groups | <1ms |
| 4. Dictionary lookup | Key sequences | Top-N word candidates | <15ms |
| 5. Ranking | Candidates + path shape | Sorted results with scores | <3ms |
| 6. Commit | Best candidate | Text to InputConnection | <0.5ms |
18.2 — Path Recording
Kotlinclass GesturePathRecorder {
private val rawPoints = mutableListOf<GesturePoint>()
private val decimatedPoints = mutableListOf<GesturePoint>()
data class GesturePoint(
val x: Float,
val y: Float,
val timestamp: Long,
)
private val minPointDistanceSq = 9f // 3px minimum between sampled points
fun start(x: Float, y: Float, time: Long) {
rawPoints.clear()
decimatedPoints.clear()
addPoint(x, y, time)
}
fun addPoint(x: Float, y: Float, time: Long) {
val point = GesturePoint(x, y, time)
rawPoints.add(point)
// Decimate: skip points too close to previous
val last = decimatedPoints.lastOrNull()
if (last == null || distanceSq(last.x, last.y, x, y) >= minPointDistanceSq) {
decimatedPoints.add(point)
}
}
fun getDecimatedPath(): List<GesturePoint> = decimatedPoints.toList()
private fun distanceSq(x1: Float, y1: Float, x2: Float, y2: Float): Float {
val dx = x2 - x1
val dy = y2 - y1
return dx * dx + dy * dy
}
}
18.3 — Key Proximity Detection
Kotlinclass KeyProximityResolver(
private val keys: List<KeyDefinition>,
private val proximityRadiusPx: Float,
) {
/**
* For each gesture point, find all keys within proximity radius.
* Returns a list of key-sets — one per decimated path point.
*/
fun resolveProximity(path: List<GesturePoint>): List<Set<KeyDefinition>> {
val radiusSq = proximityRadiusPx * proximityRadiusPx
return path.map { point ->
keys.filter { key ->
val cx = key.x + key.width / 2f
val cy = key.y + key.height / 2f
val dx = point.x - cx
val dy = point.y - cy
(dx * dx + dy * dy) <= radiusSq
}.toSet()
}
}
}
18.4 — Stylus Handwriting (API 33+)
Android 13 introduced an IME handwriting API for stylus input. The system handles ink rendering; the IME provides recognition:
Kotlinclass HandwritingKeyboardService : InputMethodService() {
override fun onStartStylusHandwriting(): Boolean {
// Return true to accept handwriting sessions
return true
}
override fun onStylusHandwritingMotionEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> beginStroke(event)
MotionEvent.ACTION_MOVE -> continueStroke(event)
MotionEvent.ACTION_UP -> {
endStroke(event)
recognizeAndCommit()
}
}
return true
}
override fun onFinishStylusHandwriting() {
// Clean up recognition state
clearStrokes()
}
// API 35 adds connectionless stylus handwriting
// for hover-to-write without tapping into a text field
override fun onStartConnectionlessStylusHandwriting(
inputType: Int,
cursorAnchorInfo: CursorAnchorInfo?,
): Boolean {
return true // Accept connectionless sessions
}
}
Gesture typing quality depends almost entirely on the dictionary. A production gesture keyboard needs a frequency-weighted lexicon of 50,000–200,000 words per language, typically compressed into a trie or DAWG structure. Open-source dictionaries are available from projects like keyboard-word-lists and the Leipzig Corpora Collection. See Section 27 for a curated list.
Autocorrect & Text Prediction
ExpertBuild a suggestion strip with auto-correction, next-word prediction, and user dictionary learning. Covers edit-distance scoring, language model integration, suggestion UI, and respecting editor flags.
19.1 — Suggestion Architecture
Kotlininterface SuggestionProvider {
/** Return ranked suggestions for the current composing word. */
fun getSuggestions(
composingWord: String,
precedingText: String,
maxResults: Int = 5,
): List<Suggestion>
/** Return next-word predictions (no composing word). */
fun getPredictions(
precedingText: String,
maxResults: Int = 3,
): List<Suggestion>
}
data class Suggestion(
val word: String,
val score: Float, // 0.0–1.0 confidence
val isAutoCorrect: Boolean, // Should replace typed text automatically
val source: SuggestionSource,
)
enum class SuggestionSource {
DICTIONARY, // Static lexicon match
LANGUAGE_MODEL, // N-gram or neural prediction
USER_DICTIONARY, // Learned from user typing
CONTACTS, // Contact name matches
CLIPBOARD, // Recent clipboard content
}
19.2 — Edit Distance Scoring
Kotlin/**
* Weighted Damerau-Levenshtein distance with keyboard layout awareness.
* Adjacent key substitutions cost less than distant key substitutions.
*/
fun keyboardAwareEditDistance(
typed: String,
candidate: String,
keyDistanceMap: Map<Pair<Char, Char>, Float>,
): Float {
val n = typed.length
val m = candidate.length
val d = Array(n + 1) { FloatArray(m + 1) }
for (i in 0..n) d[i][0] = i.toFloat()
for (j in 0..m) d[0][j] = j.toFloat()
for (i in 1..n) {
for (j in 1..m) {
val cost = if (typed[i - 1] == candidate[j - 1]) {
0f
} else {
// Adjacent keys on keyboard layout cost less
val pair = Pair(
typed[i - 1].lowercaseChar(),
candidate[j - 1].lowercaseChar(),
)
keyDistanceMap[pair] ?: 1f
}
d[i][j] = minOf(
d[i - 1][j] + 1f, // Deletion
d[i][j - 1] + 1f, // Insertion
d[i - 1][j - 1] + cost, // Substitution (distance-weighted)
)
// Transposition
if (i > 1 && j > 1
&& typed[i - 1] == candidate[j - 2]
&& typed[i - 2] == candidate[j - 1]
) {
d[i][j] = minOf(d[i][j], d[i - 2][j - 2] + 0.5f)
}
}
}
return d[n][m]
}
19.3 — Suggestion Strip UI
Kotlin@Composable
fun SuggestionStrip(
suggestions: List<Suggestion>,
onSuggestionSelected: (Suggestion) -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.height(40.dp)
.background(MaterialTheme.colorScheme.surfaceContainerLow),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
suggestions.forEachIndexed { index, suggestion ->
if (index > 0) {
VerticalDivider(
modifier = Modifier
.height(20.dp)
.width(1.dp),
color = MaterialTheme.colorScheme.outlineVariant,
)
}
Text(
text = suggestion.word,
modifier = Modifier
.weight(1f)
.clickable { onSuggestionSelected(suggestion) }
.padding(horizontal = 8.dp, vertical = 8.dp),
color = if (suggestion.isAutoCorrect)
MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface,
fontWeight = if (suggestion.isAutoCorrect)
FontWeight.Bold
else FontWeight.Normal,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
19.4 — Respecting Editor Flags
Some text fields disable autocorrect. Always check EditorInfo flags:
Kotlinfun shouldOfferSuggestions(editorInfo: EditorInfo): Boolean {
// TYPE_TEXT_FLAG_NO_SUGGESTIONS explicitly disables suggestions
if (editorInfo.inputType and InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS != 0) return false
// Password fields — never suggest
val variation = editorInfo.inputType and InputType.TYPE_MASK_VARIATION
if (variation == InputType.TYPE_TEXT_VARIATION_PASSWORD ||
variation == InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD ||
variation == InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD
) return false
// Auto-correct flag is the positive signal
val autoCorrect = editorInfo.inputType and InputType.TYPE_TEXT_FLAG_AUTO_CORRECT != 0
return autoCorrect || variation == InputType.TYPE_TEXT_VARIATION_NORMAL
}
fun shouldAutoCorrect(editorInfo: EditorInfo): Boolean {
return editorInfo.inputType and InputType.TYPE_TEXT_FLAG_AUTO_CORRECT != 0
}
When learning words from user input, never store entries from password fields, credit card fields, or any field where TYPE_TEXT_FLAG_NO_SUGGESTIONS is set. Additionally, provide a clear mechanism for users to review and delete their learned words. Under GDPR and similar regulations, this data is considered personal information.
Voice Input Integration
Expert
Add speech-to-text input to your keyboard using Android's SpeechRecognizer API and on-device recognition models. Covers microphone permission flow, streaming recognition, voice command handling, and the IME-specific lifecycle constraints.
20.1 — Architecture Overview
Voice input in an IME is constrained by two factors: (1) the keyboard runs as a service, not an activity, so you cannot launch RecognizerIntent directly; and (2) microphone access requires RECORD_AUDIO permission, which must be granted through an activity context. The recommended architecture:
| Component | Responsibility | Runs In |
|---|---|---|
| Permission Activity | Request RECORD_AUDIO on first use | Transparent Activity |
| SpeechRecognizer wrapper | Manage recognizer lifecycle | IME Service (main thread) |
| Voice UI overlay | Show microphone state, waveform, partial results | Keyboard view layer |
| Text committer | Route recognized text to InputConnection | IME Service |
20.2 — SpeechRecognizer in an IME
Kotlinimport android.speech.RecognitionListener
import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer
class VoiceInputController(private val service: InputMethodService) {
private var recognizer: SpeechRecognizer? = null
fun startListening(languageCode: String = "en-US") {
// SpeechRecognizer MUST be created and used on the main thread
recognizer?.destroy()
recognizer = SpeechRecognizer.createSpeechRecognizer(service).apply {
setRecognitionListener(recognitionListener)
}
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM,
)
putExtra(RecognizerIntent.EXTRA_LANGUAGE, languageCode)
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 3)
// API 33+: prefer on-device recognition
if (Build.VERSION.SDK_INT >= 33) {
putExtra(RecognizerIntent.EXTRA_ENABLE_LANGUAGE_DETECTION, true)
}
}
recognizer?.startListening(intent)
}
fun stopListening() {
recognizer?.stopListening()
}
fun destroy() {
recognizer?.destroy()
recognizer = null
}
private val recognitionListener = object : RecognitionListener {
override fun onResults(results: Bundle?) {
val matches = results
?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
?: return
val bestResult = matches.firstOrNull() ?: return
service.currentInputConnection?.commitText(bestResult, 1)
}
override fun onPartialResults(partialResults: Bundle?) {
val partial = partialResults
?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
?.firstOrNull() ?: return
// Show partial text in suggestion strip or composing text
service.currentInputConnection?.setComposingText(partial, 1)
}
override fun onError(error: Int) {
val message = when (error) {
SpeechRecognizer.ERROR_NO_MATCH -> "No speech detected"
SpeechRecognizer.ERROR_NETWORK -> "Network unavailable"
SpeechRecognizer.ERROR_AUDIO -> "Audio recording error"
SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "Microphone permission required"
else -> "Recognition error: $error"
}
// Show error in voice UI overlay
}
override fun onReadyForSpeech(params: Bundle?) {}
override fun onBeginningOfSpeech() {}
override fun onRmsChanged(rmsdB: Float) {}
override fun onBufferReceived(buffer: ByteArray?) {}
override fun onEndOfSpeech() {}
override fun onEvent(eventType: Int, params: Bundle?) {}
}
}
20.3 — On-Device Recognition (API 33+)
Starting with API 33, you can check for and trigger on-device speech recognition, which works offline with lower latency:
Kotlinfun createRecognizer(context: Context): SpeechRecognizer {
return if (Build.VERSION.SDK_INT >= 33 &&
SpeechRecognizer.isOnDeviceRecognitionAvailable(context)
) {
SpeechRecognizer.createOnDeviceSpeechRecognizer(context)
} else {
SpeechRecognizer.createSpeechRecognizer(context)
}
}
20.4 — Voice Subtype Declaration
Declare a voice input subtype in your IME's method.xml so the system knows your keyboard supports voice:
XML<input-method xmlns:android="http://schemas.android.com/apk/res/android"
android:settingsActivity=".ui.SettingsActivity">
<subtype
android:imeSubtypeMode="keyboard"
android:languageTag="en-US"
android:label="@string/subtype_en_keyboard" />
<subtype
android:imeSubtypeMode="voice"
android:languageTag="en-US"
android:label="@string/subtype_en_voice"
android:overridesImplicitlyEnabledSubtype="false" />
</input-method>
The RECORD_AUDIO permission can be revoked at any time. Always check checkSelfPermission() before starting the recognizer, and handle ERROR_INSUFFICIENT_PERMISSIONS gracefully. Launch a transparent activity for the permission dialog — calling requestPermissions() from a service context will crash.
Clipboard Management
IntermediateImplement a clipboard history panel, rich content pasting (images, URIs), and the ClipboardManager API with Android 13+ privacy restrictions.
21.1 — Clipboard API Basics
Kotlinimport android.content.ClipboardManager
import android.content.ClipData
import android.content.ClipDescription
class KeyboardClipboardManager(private val service: InputMethodService) {
private val clipboardManager = service.getSystemService(ClipboardManager::class.java)
fun pasteFromClipboard() {
val clip = clipboardManager.primaryClip ?: return
if (clip.itemCount == 0) return
val item = clip.getItemAt(0)
val text = item.coerceToText(service)
if (text.isNotEmpty()) {
service.currentInputConnection?.commitText(text, 1)
}
}
fun hasClipboardContent(): Boolean {
return clipboardManager.hasPrimaryClip() &&
clipboardManager.primaryClipDescription
?.hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN) == true
}
}
21.2 — Clipboard History
Android 13 (API 33) restricts clipboard access: apps cannot read clipboard content unless they are the current IME or the currently focused app. IMEs are explicitly exempted from this restriction, making keyboards the ideal place for clipboard history.
Kotlinclass ClipboardHistoryStore(private val maxEntries: Int = 20) {
private val history = ArrayDeque<ClipEntry>(maxEntries)
data class ClipEntry(
val text: String,
val timestamp: Long,
val isPinned: Boolean = false,
)
fun addEntry(text: String) {
// Don't add duplicate of the most recent entry
if (history.firstOrNull()?.text == text) return
history.addFirst(ClipEntry(text = text, timestamp = System.currentTimeMillis()))
// Remove oldest unpinned entries beyond limit
while (history.size > maxEntries) {
val candidate = history.indexOfLast { !it.isPinned }
if (candidate >= 0) history.removeAt(candidate) else break
}
}
fun getHistory(): List<ClipEntry> = history.toList()
fun togglePin(index: Int) {
if (index in history.indices) {
val entry = history[index]
history[index] = entry.copy(isPinned = !entry.isPinned)
}
}
fun deleteEntry(index: Int) {
if (index in history.indices) history.removeAt(index)
}
fun clearUnpinned() {
history.removeAll { !it.isPinned }
}
}
21.3 — Rich Content (Images & URIs)
API 25+ supports commitContent() for sending images, GIFs, and other media from the keyboard to an app:
Kotlinimport android.view.inputmethod.InputContentInfo
fun commitImage(
service: InputMethodService,
contentUri: Uri,
mimeType: String,
description: ClipDescription,
) {
val inputContentInfo = InputContentInfo(
contentUri,
description,
null, // linkUri — optional web URL
)
val ic = service.currentInputConnection ?: return
val editorInfo = service.currentInputEditorInfo ?: return
// Check if the target app accepts this MIME type
val supportedMimes = EditorInfoCompat.getContentMimeTypes(editorInfo)
val isSupported = supportedMimes.any {
ClipDescription.compareMimeTypes(mimeType, it)
}
if (!isSupported) return
InputConnectionCompat.commitContent(
ic, editorInfo, inputContentInfo,
InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION,
null,
)
}
Never store clipboard entries from password fields. Check EditorInfo.inputType before capturing clipboard content. Also, auto-clear unpinned clipboard history after a configurable timeout (e.g., 60 minutes) to minimize data retention. See privacy considerations in Section 19.
Performance Optimization
ExpertAchieve consistent sub-16ms frame times and instantaneous key response. Covers rendering optimization, memory management, startup latency, profiling tools, and Android-specific performance traps for IME developers.
22.1 — IME Startup Latency
The time from tapping a text field to the keyboard being fully visible and interactive is the most critical performance metric. Target: under 200ms cold start, under 100ms warm start.
| Optimization | Impact | Effort |
|---|---|---|
Pre-inflate keyboard layout in onCreate() | 50-100ms saved on first show | Low |
| Lazy-load dictionaries (async) | 100-300ms off cold start | Medium |
| Cache rendered key bitmaps | 10-30ms per frame saved | Medium |
Avoid View.inflate() in onCreateInputView() | 20-50ms saved | Low — reuse cached view |
Use ViewStub for secondary panels | 30-80ms deferred | Low |
| Minimize Hilt injection graph | 15-40ms on DI init | Medium |
22.2 — Rendering Optimization
Kotlin// Pre-allocate all Paint objects — NEVER create Paint in onDraw()
class OptimizedKeyboardRenderer {
// Paint objects created once and reused
private val keyFacePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
}
private val keyLabelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textAlign = Paint.Align.CENTER
typeface = Typeface.DEFAULT_BOLD
}
private val pressedOverlayPaint = Paint().apply {
style = Paint.Style.FILL
}
// Pre-allocated RectF — reused per key
private val keyRect = RectF()
// Double-buffered: draw to offscreen bitmap, blit to canvas
private var buffer: Bitmap? = null
private var bufferCanvas: Canvas? = null
private var isDirty = true
fun onSizeChanged(w: Int, h: Int) {
buffer?.recycle()
buffer = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
bufferCanvas = Canvas(buffer!!)
isDirty = true
}
fun drawKeyboard(canvas: Canvas, keys: List<KeyState>) {
if (isDirty) {
val bc = bufferCanvas ?: return
bc.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
for (key in keys) {
drawSingleKey(bc, key)
}
isDirty = false
}
buffer?.let { canvas.drawBitmap(it, 0f, 0f, null) }
}
fun invalidateKey(keyIndex: Int) {
isDirty = true // Mark for redraw on next frame
}
private fun drawSingleKey(canvas: Canvas, key: KeyState) {
keyRect.set(
key.x.toFloat(), key.y.toFloat(),
(key.x + key.width).toFloat(), (key.y + key.height).toFloat(),
)
keyFacePaint.color = key.backgroundColor
canvas.drawRoundRect(keyRect, key.cornerRadius, key.cornerRadius, keyFacePaint)
keyLabelPaint.color = key.labelColor
keyLabelPaint.textSize = key.textSize
canvas.drawText(
key.label,
keyRect.centerX(),
keyRect.centerY() + key.textSize / 3f, // Vertical centering
keyLabelPaint,
)
}
}
22.3 — Memory Management
Kotlin// Pooled key state objects — avoid GC pressure during rapid typing
class KeyStatePool(initialCapacity: Int = 48) {
private val pool = ArrayDeque<MutableKeyState>(initialCapacity)
init {
repeat(initialCapacity) { pool.add(MutableKeyState()) }
}
fun acquire(): MutableKeyState {
return pool.removeFirstOrNull() ?: MutableKeyState()
}
fun release(state: MutableKeyState) {
state.reset()
pool.addLast(state)
}
}
class MutableKeyState {
var x = 0; var y = 0; var width = 0; var height = 0
var label = ""; var labelColor = 0; var backgroundColor = 0
var textSize = 0f; var cornerRadius = 0f; var isPressed = false
fun reset() {
x = 0; y = 0; width = 0; height = 0
label = ""; labelColor = 0; backgroundColor = 0
textSize = 0f; cornerRadius = 0f; isPressed = false
}
}
22.4 — Profiling Tools
| Tool | What It Measures | When to Use |
|---|---|---|
| Android Studio Profiler | CPU, memory, energy, network | General performance analysis |
adb shell dumpsys gfxinfo <package> | Frame rendering times | Detect janky frames |
| Perfetto | System-wide trace (scheduling, binder, GPU) | Deep latency analysis |
Debug.startMethodTracing() | Method-level CPU profiling | Find hot methods in draw/touch |
Trace.beginSection() / endSection() | Custom trace sections in systrace | Measure specific code paths |
| LeakCanary | Memory leaks | Debug builds — detect view/context leaks |
Trap 1: String formatting in draw. Calling String.format() or string interpolation inside onDraw() allocates on every frame. Pre-compute label strings and cache them.
Trap 2: Iterator allocation. Using for (key in keys) on a non-inline collection creates an Iterator object each time. Use indexed for (i in 0 until keys.size) or pre-converted arrays for tight loops in rendering code.
22.5 — Android 16: System-Triggered Profiling & ADPF Headroom API 36
Android 16 adds two performance APIs that are particularly valuable for keyboard apps:
System-Triggered Profiling (ProfilingManager)
Instead of manually attaching profilers during development, register your keyboard for system-triggered profiles. The system automatically captures traces during cold start, ANR events, and other performance-critical moments:
Kotlin// Register once in Application.onCreate() or service creation
@RequiresApi(Build.VERSION_CODES.BAKLAVA)
fun registerSystemProfiling(context: Context) {
val profilingMgr = context.getSystemService(ProfilingManager::class.java)
profilingMgr.registerForAllProfilingResults(
context.mainExecutor,
) { result ->
// System captured a cold-start or ANR trace for your keyboard
Timber.i("[PERF] System-triggered profile: tag=${result.tag}, " +
"path=${result.resultFilePath}")
// Upload trace file to your crash/perf analytics backend
uploadTraceToAnalytics(result.resultFilePath, result.tag)
}
}
// Also use ApplicationStartInfo for cold-start diagnostics
@RequiresApi(Build.VERSION_CODES.BAKLAVA)
fun logStartupComponent(context: Context) {
val info = (context as? Application)
?.getHistoricalProcessStartReasons(1)
?.firstOrNull()
info?.let {
Timber.i("[PERF] Last start component: ${it.startComponent}, " +
"type=${it.startType}")
}
}
ADPF Thermal Headroom for ML Inference
Keyboards that perform on-device ML (autocorrect models, voice recognition, next-word prediction) can now query CPU and GPU thermal headroom before launching inference. This prevents thermal throttling that causes stuttery typing on sustained use:
Kotlin@RequiresApi(Build.VERSION_CODES.BAKLAVA)
class ThermalAwareInference(private val context: Context) {
private val healthMgr = context.getSystemService(SystemHealthManager::class.java)
enum class InferenceMode { FULL, LIGHTWEIGHT, FALLBACK }
/** Choose inference mode based on current thermal state.
* Headroom values range from 0–100, where 0 = no resources available.
* Returns Float.NaN if temporarily unavailable — treat NaN as "unknown, proceed cautiously." */
fun selectInferenceMode(): InferenceMode {
val cpuHeadroom = healthMgr.getCpuHeadroom(null) // null = default params
val gpuHeadroom = healthMgr.getGpuHeadroom(null)
if (cpuHeadroom.isNaN() || gpuHeadroom.isNaN()) {
return InferenceMode.LIGHTWEIGHT // Unknown state — don't go full throttle
}
return when {
cpuHeadroom > 50f && gpuHeadroom > 40f -> InferenceMode.FULL
cpuHeadroom > 20f -> InferenceMode.LIGHTWEIGHT
else -> InferenceMode.FALLBACK // Use simple dictionary lookup
}
}
}
Adaptive Refresh Rate (ARR)
Android 16's hasArrSupport() API lets the system vary display refresh rate per-frame. Keyboards should request high refresh rates (90–120 Hz) during gesture trail animations and key press animations, then drop to the base rate during idle display:
getSuggestedFrameRate()— query the optimal rate for your current content typeRecyclerView 1.4has built-in ARR integration — suggestion strips using RecyclerView get smooth scrolling for free- Idle keyboards should allow the system to drop to 60 Hz (or lower on LTPO panels) to save battery
Combine ProfilingManager system-triggered traces with your own Trace.beginSection() custom trace sections. When the system captures a cold-start profile, your custom sections will appear in the Perfetto trace, giving you both system-level and application-level visibility into startup bottlenecks.
Accessibility
IntermediateMake keyboards usable by everyone: TalkBack support, Switch Access, content descriptions, touch exploration, high-contrast mode, and WCAG compliance for custom-rendered key surfaces.
23.1 — TalkBack & Explore by Touch
When TalkBack is active, users explore the keyboard by dragging their finger. The system calls onHoverEvent() to determine which key is under the finger, then speaks its description:
Kotlinimport android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
import androidx.core.view.AccessibilityDelegateCompat
import androidx.core.view.ViewCompat
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
import androidx.customview.widget.ExploreByTouchHelper
class KeyboardAccessibilityDelegate(
private val hostView: View,
private val getKeys: () -> List<KeyDefinition>,
) : ExploreByTouchHelper(hostView) {
override fun getVirtualViewAt(x: Float, y: Float): Int {
val keys = getKeys()
for (i in keys.indices) {
val key = keys[i]
if (x >= key.x && x < key.x + key.width &&
y >= key.y && y < key.y + key.height
) {
return i
}
}
return HOST_ID
}
override fun getVisibleVirtualViews(virtualViewIds: MutableList<Int>) {
for (i in getKeys().indices) {
virtualViewIds.add(i)
}
}
override fun onPopulateNodeForVirtualView(
virtualViewId: Int,
node: AccessibilityNodeInfoCompat,
) {
val key = getKeys().getOrNull(virtualViewId) ?: return
node.contentDescription = key.contentDescription
?: key.label.ifEmpty { "Unknown key" }
node.className = "android.widget.Button"
node.isClickable = true
node.setBoundsInParent(Rect(key.x, key.y, key.x + key.width, key.y + key.height))
node.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_CLICK)
}
override fun onPerformActionForVirtualView(
virtualViewId: Int,
action: Int,
arguments: Bundle?,
): Boolean {
if (action == AccessibilityNodeInfoCompat.ACTION_CLICK) {
val key = getKeys().getOrNull(virtualViewId) ?: return false
// Trigger the same key press as a touch
hostView.performClick()
return true
}
return false
}
}
23.2 — Content Descriptions
| Key Type | Content Description | Example |
|---|---|---|
| Letter | The letter itself (uppercase) | "A", "Z" |
| Number | The number word | "One", "Zero" |
| Symbol | Full symbol name | "Exclamation mark", "Period" |
| Space | "Space" | "Space" |
| Backspace | "Delete" | "Delete" |
| Shift | State-aware: "Shift off", "Shift on", "Caps lock" | "Shift on" |
| Enter | Action-aware: "Send", "Search", "Next", "Done" | "Send" |
| Language switch | "Switch language, current: English" | "Switch language, current: Spanish" |
| Voice input | "Voice input" | "Voice input" |
23.3 — High Contrast & Large Text
Kotlinfun isHighContrastEnabled(context: Context): Boolean {
val am = context.getSystemService(AccessibilityManager::class.java)
return am.isEnabled && am.isHighTextContrastEnabled
}
fun adjustForAccessibility(
theme: KeyboardTheme,
context: Context,
): KeyboardTheme {
val fontScale = context.resources.configuration.fontScale
return theme.copy(
letterTextSize = theme.letterTextSize * fontScale.coerceAtMost(1.3f),
symbolTextSize = theme.symbolTextSize * fontScale.coerceAtMost(1.3f),
colors = if (isHighContrastEnabled(context)) {
theme.colors.copy(
keyLabel = Color.White,
keyFace = Color.Black,
keyFacePressed = Color(0xFF333333),
keyLabelSecondary = Color(0xFFCCCCCC),
accentKeyFace = Color(0xFF0050FF),
accentKeyLabel = Color.White,
)
} else {
theme.colors
},
)
}
23.4 — Android 16: Enhanced Accessibility APIs API 36
Android 16 introduces several accessibility APIs that directly improve keyboard usability with assistive technologies:
Multi-Label Support
Keys often carry multiple labels — a primary letter and a secondary number or symbol superscript. Previously, screen readers could only associate one label with an element. Android 16's addLabeledBy() allows associating multiple describing views:
Kotlin// Android 16+: Multi-label accessibility for composite keys
@RequiresApi(Build.VERSION_CODES.BAKLAVA)
override fun onPopulateNodeForVirtualView(
virtualViewId: Int,
node: AccessibilityNodeInfoCompat,
) {
val key = getKeys().getOrNull(virtualViewId) ?: return
node.contentDescription = key.label.ifEmpty { "Unknown key" }
node.className = "android.widget.Button"
// Primary label + secondary superscript (e.g., "A" with "1" hint)
key.secondaryLabel?.let { secondary ->
node.extras.putString(
"androidx.view.accessibility.supplementalDescription",
"also enters $secondary"
)
}
}
Expandable State for Popup Menus
Long-press popup menus (accent characters, secondary symbols) can now announce their expand/collapse state to screen readers via setExpandedState():
Kotlin// Announce popup menu state to accessibility services
@RequiresApi(Build.VERSION_CODES.BAKLAVA)
fun updatePopupAccessibility(
node: AccessibilityNodeInfoCompat,
isPopupShowing: Boolean,
key: KeyDefinition,
) {
node.setExpandedState(isPopupShowing)
if (isPopupShowing) {
node.contentDescription = "${key.label}, popup menu open, " +
"${key.popupCharacters?.length ?: 0} characters available"
}
}
Outline Text for Maximum Contrast
Android 16 adds a system-level "outline text" setting for users with low vision. When enabled, all rendered text gains a contrasting outline. Keyboard developers should:
- Test key labels with outline text enabled to ensure readability. Thin/condensed fonts may become illegible with outlines.
- Avoid competing outlines — if your keyboard already draws text shadows or stroke effects, detect the system setting and disable your custom outlines to prevent visual clutter.
- Verify popup menus — small popup characters benefit most from outlines but may run out of room with larger glyph footprints.
Additional Android 16 Accessibility Features
| Feature | API | IME Relevance |
|---|---|---|
| Indeterminate progress | RANGE_TYPE_INDETERMINATE | Voice input loading states, dictionary downloads |
| Tri-state checkbox | AccessibilityNodeInfo tri-state | Keyboard settings with partial/mixed selections |
| Required form fields | setFieldRequired() | When IME detects required fields, announce status |
TtsSpan.TYPE_DURATION | New TtsSpan type | Timer/countdown UI in suggestion strips |
| LE Audio hearing aids | Audio routing APIs | Key click audio and voice input routing |
Switch Access users navigate keyboards sequentially (row by row, key by key). Ensure your ExploreByTouchHelper reports virtual views in a logical left-to-right, top-to-bottom order. Test with Settings → Accessibility → Switch Access enabled.
Testing
IntermediateTest strategies for keyboard applications: unit testing key layout logic, integration testing InputConnection, instrumentation testing with Espresso and UI Automator, and benchmarking frame performance.
24.1 — Testing Pyramid for Keyboards
| Layer | What to Test | Framework | Count |
|---|---|---|---|
| Unit | Key layout computation, edit distance, suggestion scoring | JUnit 5 + MockK | ~200-500 tests |
| Integration | InputConnection text operations, composing text, IME lifecycle | Robolectric or AndroidX Test | ~50-100 tests |
| Instrumentation | Full keyboard rendering, touch events, theme switching | Espresso + UI Automator | ~30-60 tests |
| Benchmark | Frame times, startup latency, dictionary lookup speed | Macrobenchmark + Microbenchmark | ~10-20 tests |
24.2 — Unit Testing Key Layout
Kotlinimport org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
class QwertyLayoutTest {
@Test
fun `QWERTY row 1 has 10 keys`() {
val layout = QwertyLayout.build(widthPx = 1080, density = 2.75f)
assertEquals(10, layout.rows[0].keys.size)
}
@Test
fun `all keys are within keyboard bounds`() {
val layout = QwertyLayout.build(widthPx = 1080, density = 2.75f)
for (row in layout.rows) {
for (key in row.keys) {
assert(key.x >= 0) { "Key ${key.label} has negative x" }
assert(key.x + key.width <= 1080) { "Key ${key.label} exceeds width" }
}
}
}
@ParameterizedTest
@CsvSource("720,2.0", "1080,2.75", "1440,3.5")
fun `key sizes scale with density`(widthPx: Int, density: Float) {
val layout = QwertyLayout.build(widthPx = widthPx, density = density)
val keyWidthDp = layout.rows[0].keys[0].width / density
// Standard letter key should be ~32-36dp wide
assert(keyWidthDp in 28f..40f) {
"Key width ${keyWidthDp}dp outside expected range at density $density"
}
}
}
24.3 — Testing InputConnection
Kotlinimport android.view.inputmethod.BaseInputConnection
import io.mockk.mockk
import io.mockk.verify
class InputConnectionTest {
private val editable = android.text.SpannableStringBuilder("Hello World")
private val connection = BaseInputConnection(mockk(relaxed = true), true).apply {
// Set initial text
}
@Test
fun `commitText inserts at cursor position`() {
val ic = FakeInputConnection(initialText = "Hello ")
ic.commitText("World", 1)
assertEquals("Hello World", ic.getText())
}
@Test
fun `deleteSurroundingText removes characters before cursor`() {
val ic = FakeInputConnection(
initialText = "Helloo",
cursorPosition = 6,
)
ic.deleteSurroundingText(1, 0)
assertEquals("Hello", ic.getText())
}
@Test
fun `composing text is replaced by subsequent commits`() {
val ic = FakeInputConnection(initialText = "")
ic.setComposingText("hel", 1)
ic.setComposingText("hell", 1)
ic.setComposingText("hello", 1)
ic.finishComposingText()
assertEquals("hello", ic.getText())
}
}
24.4 — Benchmarking Frame Performance
Kotlinimport androidx.benchmark.macro.ExperimentalBaselineProfilesApi
import androidx.benchmark.macro.junit4.MacrobenchmarkRule
import androidx.benchmark.macro.StartupMode
import androidx.benchmark.macro.CompilationMode
import org.junit.Rule
import org.junit.Test
class KeyboardBenchmark {
@get:Rule
val benchmarkRule = MacrobenchmarkRule()
@Test
fun startup() = benchmarkRule.measureRepeated(
packageName = "com.example.keyboard",
metrics = listOf(
androidx.benchmark.macro.StartupTimingMetric(),
),
iterations = 10,
startupMode = StartupMode.COLD,
compilationMode = CompilationMode.DEFAULT,
) {
pressHome()
// Open app that will trigger keyboard
}
@Test
fun frameTiming() = benchmarkRule.measureRepeated(
packageName = "com.example.keyboard",
metrics = listOf(
androidx.benchmark.macro.FrameTimingMetric(),
),
iterations = 5,
) {
// Simulate rapid typing
}
}
Run unit tests on every PR. Run instrumentation tests nightly on a Firebase Test Lab device farm covering at least 5 screen densities and 3 API levels (latest, latest-2, minSdk). Run benchmarks weekly and track regression trends in a dashboard.
Deployment & Publishing
IntermediatePrepare your keyboard for production: Play Store requirements, IME enablement flow, multi-keyboard switching, update strategy, and review guidelines specific to input method apps.
25.1 — IME Enablement Flow
Unlike regular apps, keyboards require two steps before they can be used: (1) the user must enable the IME in System Settings, and (2) the user must select it as the active keyboard. Guide users through both steps:
Kotlinimport android.provider.Settings
import android.view.inputmethod.InputMethodManager
fun isImeEnabled(context: Context, imePackage: String): Boolean {
val enabledImes = Settings.Secure.getString(
context.contentResolver,
Settings.Secure.ENABLED_INPUT_METHODS,
) ?: return false
return enabledImes.contains(imePackage)
}
fun openImeSettings(context: Context) {
context.startActivity(
Intent(Settings.ACTION_INPUT_METHOD_SETTINGS).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
},
)
}
fun showImePicker(context: Context) {
val imm = context.getSystemService(InputMethodManager::class.java)
imm.showInputMethodPicker()
}
25.2 — Keyboard Switching
Declare supportsSwitchingToNextInputMethod in your method.xml so the system shows the globe/language-switch key:
XML<input-method xmlns:android="http://schemas.android.com/apk/res/android"
android:settingsActivity=".ui.SettingsActivity"
android:supportsSwitchingToNextInputMethod="true">
<subtype
android:imeSubtypeMode="keyboard"
android:languageTag="en-US"
android:isAsciiCapable="true"
android:label="@string/subtype_en_us" />
</input-method>
In your service, implement the globe key to cycle between installed IMEs:
Kotlinfun handleGlobeKeyPress() {
// switchToNextInputMethod(boolean) is an InputMethodService instance method
// The boolean controls whether to include IMEs that don't support switching
switchToNextInputMethod(false)
}
25.3 — Play Store Requirements
| Requirement | Details | Status |
|---|---|---|
| Privacy Policy | Required for all IMEs — must disclose what keystrokes/data are collected | Mandatory |
| Target API Level | Must target API 36 (Android 16) for new apps and updates as of Aug 31, 2026; existing apps must target API 35+ to remain visible to new users | Mandatory |
| FULL_NETWORK_ACCESS | If your IME uses network (cloud speech, sync), disclose in listing | If applicable |
| Data Safety Section | Declare all data collection (keystrokes, voice audio, analytics) | Mandatory |
| Deceptive Behavior | IME must not log keystrokes for advertising or send to undisclosed servers | Policy violation |
| Accessibility Service | Do not request AccessibilityService permission unless you are an accessibility tool | Policy violation risk |
25.4 — Release Checklist
- Test on at least 3 screen densities (hdpi, xxhdpi, xxxhdpi) and 3 API levels
- Verify TalkBack navigation works for all keys (see Section 23)
- Enable R8/ProGuard and test the minified build — keyboard Views are often broken by shrinking
- Ensure
onCreateInputView()handlesnullkeyboard gracefully after process death - Test with split-screen and floating window modes
- Verify the IME shows correctly in both portrait and landscape (Section 15)
- Run
adb shell dumpsys input_methodto verify subtypes are registered - Generate a Baseline Profile for faster cold start
Complete Code Examples
Beginner IntermediateEnd-to-end runnable keyboard implementations. Each example is self-contained with all required files listed. Copy into an Android Studio project to build and test immediately.
26.1 — Minimal Working Keyboard
The absolute minimum code to display a functional keyboard. Three files total:
File 1: AndroidManifest.xml (IME registration)
XML<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<service
android:name=".MinimalKeyboardService"
android:permission="android.permission.BIND_INPUT_METHOD"
android:exported="true">
<intent-filter>
<action android:name="android.view.InputMethod" />
</intent-filter>
<meta-data
android:name="android.view.im"
android:resource="@xml/method" />
</service>
</application>
</manifest>
File 2: res/xml/method.xml
XML<input-method xmlns:android="http://schemas.android.com/apk/res/android"
android:supportsSwitchingToNextInputMethod="true">
<subtype
android:imeSubtypeMode="keyboard"
android:languageTag="en-US"
android:isAsciiCapable="true"
android:label="@string/subtype_en" />
</input-method>
File 3: MinimalKeyboardService.kt
Kotlinimport android.inputmethodservice.InputMethodService
import android.view.View
import android.widget.Button
import android.widget.LinearLayout
class MinimalKeyboardService : InputMethodService() {
override fun onCreateInputView(): View {
// Build a simple 3×1 keyboard: A, B, ⌫
val row = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT,
)
}
val keys = listOf("A" to 65, "B" to 66)
for ((label, code) in keys) {
row.addView(
Button(this).apply {
text = label
layoutParams = LinearLayout.LayoutParams(0, 160, 1f)
setOnClickListener {
currentInputConnection?.commitText(label, 1)
}
},
)
}
// Delete key
row.addView(
Button(this).apply {
text = "⌫"
layoutParams = LinearLayout.LayoutParams(0, 160, 1f)
setOnClickListener {
currentInputConnection?.deleteSurroundingText(1, 0)
}
},
)
return row
}
}
Every keyboard needs exactly three things: (1) a Service with BIND_INPUT_METHOD permission, (2) a method.xml metadata file declaring subtypes, and (3) an onCreateInputView() that returns the keyboard View. Everything else — themes, gestures, haptics, autocorrect — builds on top of this foundation.
26.2 — Multi-Language Keyboard with Subtype Switching
Kotlinimport android.inputmethodservice.InputMethodService
import android.view.inputmethod.InputMethodSubtype
class MultiLanguageKeyboardService : InputMethodService() {
private var currentLayout: KeyboardLayoutDefinition = QwertyLayout
override fun onCurrentInputMethodSubtypeChanged(newSubtype: InputMethodSubtype) {
// Called when user switches subtypes (language)
currentLayout = when (newSubtype.languageTag) {
"fr-FR" -> AzertyLayout
"de-DE" -> QwertzLayout
else -> QwertyLayout
}
setInputView(onCreateInputView()) // Rebuild with new layout
}
override fun onCreateInputView(): View {
return buildKeyboardView(currentLayout)
}
private fun buildKeyboardView(layout: KeyboardLayoutDefinition): View {
// Build rows from layout definition
// (See Section 06 for full XML-based approach)
val container = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
}
for (row in layout.rows) {
val rowView = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
}
for (key in row) {
rowView.addView(createKeyButton(key))
}
container.addView(rowView)
}
return container
}
private fun createKeyButton(key: KeyDef): Button {
return Button(this).apply {
text = key.label
setOnClickListener {
currentInputConnection?.commitText(key.output, 1)
}
}
}
}
// Layout definitions (simplified)
interface KeyboardLayoutDefinition { val rows: List<List<KeyDef>> }
data class KeyDef(val label: String, val output: String)
object QwertyLayout : KeyboardLayoutDefinition {
override val rows = listOf(
"QWERTYUIOP".map { KeyDef(it.toString(), it.toString()) },
"ASDFGHJKL".map { KeyDef(it.toString(), it.toString()) },
"ZXCVBNM".map { KeyDef(it.toString(), it.toString()) },
)
}
Resources & Further Reading
BeginnerCurated collection of official documentation, open-source keyboard projects, dictionaries, design references, and community resources for Android keyboard developers.
27.1 — Official Documentation
| Resource | Description |
|---|---|
| Creating an Input Method | Official guide — start here for the canonical IME tutorial |
| InputMethodService Reference | Complete API reference with all 50+ callbacks documented |
| InputConnection Reference | The text editing interface — every method your keyboard uses to modify text |
| EditorInfo Reference | Describes the text field: input type, IME options, initial text selection |
| Input Method Subtypes | Multi-language support: declaring and switching between keyboard layouts |
| Image Keyboard Support | Committing images, GIFs, and stickers from your keyboard |
| Stylus Handwriting | API 33+ stylus-to-text integration for handwriting recognition |
| Android 15 (API 35) Features | Platform additions: edge-to-edge enforcement, predictive back, 16 KB pages |
| Android 16 (API 36) Features | Dual-release cadence, richer haptics, enhanced accessibility, ADPF headroom, ARR |
| ProfilingManager Reference | API 36 system-triggered profiling: heap dumps, stack sampling, and system traces |
| Android Dynamic Performance Framework (ADPF) | CPU/GPU headroom queries, thermal status, and adaptive frame-rate guidance |
| SystemHealthManager Reference | API 36 getCpuHeadroom() and getGpuHeadroom() thermal-aware tuning |
| Compose Text User Interactions | SelectionContainer, DisableSelection, LinkAnnotation, and custom link handlers |
| Material Design 3 | Design guidelines, color system, motion, and component specs |
| Material You Dynamic Color | Dynamic color system and tone-based color roles |
| Haptic Feedback Guide | Platform haptic primitives, custom waveforms, and UX best practices |
| Accessibility Developer Guide | AccessibilityNodeInfo, TalkBack integration, testing, and best practices |
27.2 — Open-Source Keyboards
Study these production keyboards for reference implementations:
| Project | Language | Notable Features |
|---|---|---|
| OpenBoard Renewed | Kotlin/Java | AOSP-based, gesture typing, multi-language dictionaries |
| FlorisBoard | Kotlin | Modern architecture, Jetpack Compose UI, extension system |
| AnySoftKeyboard | Java | 200+ language packs, extensive subtype system |
| GBoard Keyboard Decode | Go | Reverse-engineering resource for GBoard layout formats |
| Tiny Keyboard | Java | Minimal educational keyboard (~300 lines total) |
27.3 — Dictionary & Language Resources
| Resource | Format | Use Case |
|---|---|---|
| OpenBoard Dictionaries | AOSP binary dict | Pre-built frequency dictionaries for 50+ languages |
| Lexique (French) | TSV / SQL | Comprehensive French lexicon with frequency data |
| OpenSubtitles Word Freq | CSV | Word frequency lists from subtitle corpora (100+ languages) |
| FrequencyWords | Text | Word frequency lists derived from OpenSubtitles |
| Unicode CLDR Keyboard Charts | Web / XML | Standard keyboard layouts for every locale |
27.4 — Design & UX References
| Resource | Category |
|---|---|
| How Google Designed a Keyboard | Design case study |
| Compose Keyboard Input | Framework integration — physical key events |
| Compose Text Selection & Links | SelectionContainer, LinkAnnotation.Url, TextLinkStyles, custom URI handlers |
| Touch Target Size (NN/g) | Usability research — optimal touch target dimensions |
| WCAG 2.2 Understanding Docs | Accessibility — contrast ratios, target sizes, motion |
27.5 — Internal Cross-References
This guide is part of a documentation set. Related volumes:
| Guide | Focus |
|---|---|
| Android Keyboard 3D & Personalization Guide 2026 — Extension Volume | 3D rendering, shaders, Canvas/OpenGL effects, AGSL, font systems, sound packs, AI-driven personalization |
| Mobile STT Engineering Guide 2026 | On-device speech-to-text: audio capture, VAD, model inference, post-processing, evaluation harness |
| Debugging Field Manual | Cross-platform debugging across Android, native C/C++, and Python tooling |
Android keyboard APIs evolve with each platform release. Starting with Android 16, Google follows a dual-release cadence — a major SDK bump in Q2 and a minor (maintenance + feature) release in Q4. Bookmark the Android version overview page and check the Input & IME section of each release's behavior changes document. Key areas that change frequently: InputConnection methods, EditorInfo fields, privacy restrictions on clipboard and text access, haptic primitives, accessibility node info APIs, and stylus/handwriting APIs. Use Build.VERSION.SDK_INT_FULL (API 36+) to detect minor-release features at runtime.
How to Cite This Guide
If you reference this guide in academic work, documentation, or project
READMEs, please use one of the formats below. Both are consistent with the
output generated by GitHub's "Cite this repository" button and the
cffconvert
tool from the repository's CITATION.cff file.
APA (7th Edition)
Made in Jurgistan. (2026). Android Keyboard Design Guide (Version 2026.2.0) [Computer software]. https://Made-in-Jurgistan.github.io/android-keyboard-design-guide/
BibTeX
@software{Made_in_Jurgistan_Android_Keyboard_2026,
author = {Made in Jurgistan},
month = {1},
title = {{Android Keyboard Design Guide}},
url = {https://Made-in-Jurgistan.github.io/android-keyboard-design-guide/},
version = {2026.2.0},
year = {2026}
}
A CITATION.cff file at the repository root also enables GitHub's
built-in "Cite this repository" button (right sidebar on the repo page), which
auto-generates APA and BibTeX citations from the same metadata. This button
appears on github.com only — it does not render on the published GitHub Pages
site, which is why the citation blocks above are included here.