Skip to main content

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.

2026 Edition API 30–36 27 Sections All Levels Material You 3.0 Made in Jurgistan
Beginner IME basics, InputMethodService lifecycle, simple keyboard UI
Intermediate Compose UI, Material You theming, gesture typing, performance optimization
Expert Custom rendering engines, accessibility architecture, security hardening, CI/CD
Section 01

Introduction to Android Keyboard Development

Beginner

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

  1. 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() and onInitializeInterface().
  2. 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.
  3. 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.
  4. 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.
  5. Active Input: During active typing, your keyboard receives touch events, processes key presses, and communicates with the target application through an InputConnection object.
  6. Input Session End: When the user navigates away or the input field loses focus, onFinishInput() and onFinishInputView(boolean) are called to clean up state.
  7. Service Destruction: When the system needs to reclaim resources or the user switches keyboards, onDestroy() terminates the service.
Production Insight

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.
Section 02

API Level Overview: Android 11–16 (API 30–36)

Beginner

Critical 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 minSdkVersion and targetSdkVersion for 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

API 30 Released: September 2020

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 InlineSuggestionsRequest and InlineSuggestion APIs.
  • 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.
Minimum Recommended API

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

API 31 Released: October 2021

Key IME Features:

  • Material You Dynamic Color: Introduction of dynamic color theming based on user wallpaper. IMEs can extract color palettes via DynamicColors API 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.Composition allows 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

API 32 Released: March 2022

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 with Type.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

API 33 Released: October 2022

Key IME Features:

  • Predictive Back Gesture: IMEs must implement OnBackInvokedCallback to 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

API 34 Released: October 2023

Key IME Features:

  • Enhanced Dynamic Color: DynamicColors API 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: GrammaticalInflectionManager allows 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 LocalePreferences API 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

API 35 Released: October 2024

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

API 36 Q2 2025 (major) + Q4 2025 (minor release)

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) or BasicEnvelopeBuilder (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_INDETERMINATE for 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.4 includes 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_FLAG in EditorInfo signals 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: RuntimeColorFilter and RuntimeXfermode for custom shader-driven key effects without OpenGL.
Targeting API 36

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

Section 03

Architecture Patterns for Production Keyboards

Intermediate

Modern 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 }
Production Pattern: Separation of Concerns

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)
}
Section 04

Project Setup & Build Configuration

Beginner

Set 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")
}
minSdk Considerations

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>

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>
AttributePurposeExample Values
languageTagBCP 47 locale tag (replaces deprecated imeSubtypeLocale)en-US, es, fr-FR, ja
imeSubtypeModeInput modalitykeyboard, voice, handwriting
isAsciiCapableWhether the subtype can input ASCII characterstrue / false
supportsSwitchingToNextInputMethodShow globe key for IME switchingtrue (set on <input-method>)
overridesImplicitlyEnabledSubtypeOverride default subtype resolutiontrue 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.

Developer Shortcut

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

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:

PhaseCallbackWhen It FiresTypical Use
CreationonCreate()Service first createdInitialize resources, theme engine, dependency injection
onInitializeInterface()Configuration changes (screen rotation, locale)Rebuild internal state, re-measure layouts
View SetuponCreateInputView()First time input view is neededInflate or compose the keyboard UI; return the root View
onCreateCandidatesView()First time candidates strip is neededInflate suggestion bar (or return null)
BindingonBindInput()Client app first connectsCache client package name, prepare per-app settings
SessiononStartInput(EditorInfo, Boolean)Editor field gains focusRead input type, action button, configure keyboard mode
onStartInputView(EditorInfo, Boolean)Keyboard becomes visibleRefresh key labels, update language, start animations
onStartCandidatesView(EditorInfo, Boolean)Candidates strip shownPre-load dictionary suggestions
TeardownonFinishInputView(Boolean)Keyboard hiddenStop animations, save draft state
onFinishInput()Editor loses focusClear composing text, release input connection
UnbindingonUnbindInput()Client app disconnectsRelease per-app resources
DestructiononDestroy()Service being killedFree 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
}
Fullscreen Mode — Avoid It

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:

APIMethodPurpose
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)
}
Section 06

Layout System — Rows, Keys, and XML

Intermediate

Define 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 RoleTypical Width%p ValueTouch Target
Letter key1/10 of row10%p~40dp on a 400dp-wide screen
Shift / Delete1.5× letter15%p~60dp
Space bar4× letter40%p~160dp
Symbols toggle1.5× letter15%p~60dp
Key height (rows)Absolute dp52–58dpMaterial recommends ≥48dp touch targets
Minimum 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.

Section 07

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.

ApproachStrengthsWeaknesses
Custom View + CanvasMaximum control, zero allocation during draw, lowest latencyComplex state management, manual invalidation
XML LayoutFamiliar, stable, works everywhereVerbose, hard to animate, poor dynamic theming
Jetpack ComposeDeclarative, excellent theming, built-in animationsRecomposition 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)
    }
}

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:

TechniqueImpactImplementation
Use key() for stable compositionPrevents unnecessary recomposition of unchanged keyskey(key.code) { KeyButton(...) }
Hoist pressed state to parentOnly recomposes the pressed key, not the entire rowvar pressedKey by remember { mutableStateOf(-1) }
Use derivedStateOfDebounces recomposition for computed valuesval isShifted by remember { derivedStateOf { shift > 0 } }
Avoid allocations in drawReduces GC pauses during fast typingPre-allocate Paint, Path, RectF objects
Use Modifier.graphicsLayerHardware-accelerated transforms without recompositionScale and alpha changes via lambda, not state
Measure Before Optimizing

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.

Section 08

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

RuleWhyViolation Cost
Zero allocations in onDraw()GC pauses cause frame drops~2–6ms per minor GC on mid-range devices
Pre-allocate Paint, RectF, PathReuse objects across framesEach new Paint() allocates ~400 bytes
Use invalidateKey() not invalidate()Partial invalidation redraws only the changed regionFull redraw costs 2–5× more than single key
Double-buffer keyboard stateAvoid redrawing all 30+ keys each frameWithout buffering: ~3ms per frame on Pixel 8
Hardware-accelerated canvasGPU-backed drawing for shadows and anti-aliasingSoftware rendering: 4–8× slower shadows
Section 09

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()
    }
}
Composing vs. Committing

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:

FieldWhat It Tells YouHow to Adapt
inputType & TYPE_MASK_CLASSText, number, phone, datetimeSwitch keyboard layout
inputType & TYPE_MASK_VARIATIONPassword, email, URI, person nameDisable prediction for passwords; show @ for email
inputType & TYPE_MASK_FLAGSAutocomplete, multiline, no suggestionsRespect TYPE_TEXT_FLAG_NO_SUGGESTIONS; enable multiline enter
imeOptions & IME_MASK_ACTIONDone, search, next, go, sendChange enter key label and icon
packageNameWhich app owns the text fieldPer-app quirk workarounds (some apps have broken InputConnections)
privateImeOptionsApp-specific hintsCustom 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)
}
Section 10

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
            )
        }
    }
}
Section 11

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.

TokenUse in KeyboardsLight Mode ToneDark Mode Tone
colorSurfaceContainerLowKeyboard background9610
colorSurfaceContainerHighKey face background9217
colorSurfaceContainerHighestPressed key background9022
colorOnSurfaceKey label text1090
colorOnSurfaceVariantSecondary key labels (symbols)3080
colorPrimaryActive toggle keys (shift, language)4080
colorPrimaryContainerEnter key, special action keys9030
colorOnPrimaryContainerEnter key label/icon1090

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)
    }
}
Wallpaper Change Detection

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.

Section 12

Theming System Architecture

Intermediate

Design 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()
}
Theme Preview in Settings

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.

Section 13

Motion & Animations

Expert

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

OperationBudget at 60fpsBudget at 120fpsPriority
Touch event processing<2ms<1msCritical
Key state update<1ms<0.5msCritical
Animation calculation<2ms<1msHigh
Layout/measure<3ms<1.5msHigh
Canvas draw<6ms<3msMedium
GPU composition<2ms<1msSystem

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) },
                    )
                }
            }
        }
    }
}

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)
        }
    }
}
Section 14

Dark Mode Best Practices for 2026

Intermediate

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

  1. 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.
  2. 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.
  3. 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.
  4. 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
Production Tip: Adaptive Brightness

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.

Section 15

Adaptive Layouts & Responsive Design

Intermediate

Build 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),
    )
}
Multi-Window Configuration Changes

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.

Section 16

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

EventAPI 30+API 33+Fallback
Key press (letter)KEYBOARD_TAPKEYBOARD_TAPperformHapticFeedback(VIRTUAL_KEY)
Key press (delete)KEYBOARD_TAPSEGMENT_FREQUENT_TICKperformHapticFeedback(LONG_PRESS)
Long press popupLONG_PRESSLONG_PRESS50ms vibration
Shift toggleCONFIRMCONFIRMperformHapticFeedback(VIRTUAL_KEY)
Gesture word confirmedVibrationEffect (40ms)GESTURE_END40ms vibration
Error (invalid input)REJECTREJECT100ms 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()
        )
    }
}
Graceful Degradation Strategy

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.

Section 17

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()
    }
}
Sound & Haptic Synchronization

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.

Section 18

Gesture Typing (Swipe Input)

Expert

Implement 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

StageInputOutputTime Budget
1. Path captureMotionEvent streamList<PointF> (decimated)<0.5ms per event
2. Key proximityPath points + key layoutList<List<Key>> (near keys per point)<2ms total
3. Segment extractionProximity matrixSequence of key groups<1ms
4. Dictionary lookupKey sequencesTop-N word candidates<15ms
5. RankingCandidates + path shapeSorted results with scores<3ms
6. CommitBest candidateText 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
    }
}
Dictionary Requirements

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.

Section 19

Autocorrect & Text Prediction

Expert

Build 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
}
Section 20

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:

ComponentResponsibilityRuns In
Permission ActivityRequest RECORD_AUDIO on first useTransparent Activity
SpeechRecognizer wrapperManage recognizer lifecycleIME Service (main thread)
Voice UI overlayShow microphone state, waveform, partial resultsKeyboard view layer
Text committerRoute recognized text to InputConnectionIME 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>
Section 21

Clipboard Management

Intermediate

Implement 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,
    )
}
Clipboard Privacy

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.

Section 22

Performance Optimization

Expert

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

OptimizationImpactEffort
Pre-inflate keyboard layout in onCreate()50-100ms saved on first showLow
Lazy-load dictionaries (async)100-300ms off cold startMedium
Cache rendered key bitmaps10-30ms per frame savedMedium
Avoid View.inflate() in onCreateInputView()20-50ms savedLow — reuse cached view
Use ViewStub for secondary panels30-80ms deferredLow
Minimize Hilt injection graph15-40ms on DI initMedium

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

ToolWhat It MeasuresWhen to Use
Android Studio ProfilerCPU, memory, energy, networkGeneral performance analysis
adb shell dumpsys gfxinfo <package>Frame rendering timesDetect janky frames
PerfettoSystem-wide trace (scheduling, binder, GPU)Deep latency analysis
Debug.startMethodTracing()Method-level CPU profilingFind hot methods in draw/touch
Trace.beginSection() / endSection()Custom trace sections in systraceMeasure specific code paths
LeakCanaryMemory leaksDebug builds — detect view/context leaks

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 type
  • RecyclerView 1.4 has 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
Performance Monitoring Strategy

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.

Section 23

Accessibility

Intermediate

Make 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 TypeContent DescriptionExample
LetterThe letter itself (uppercase)"A", "Z"
NumberThe number word"One", "Zero"
SymbolFull symbol name"Exclamation mark", "Period"
Space"Space""Space"
Backspace"Delete""Delete"
ShiftState-aware: "Shift off", "Shift on", "Caps lock""Shift on"
EnterAction-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

FeatureAPIIME Relevance
Indeterminate progressRANGE_TYPE_INDETERMINATEVoice input loading states, dictionary downloads
Tri-state checkboxAccessibilityNodeInfo tri-stateKeyboard settings with partial/mixed selections
Required form fieldssetFieldRequired()When IME detects required fields, announce status
TtsSpan.TYPE_DURATIONNew TtsSpan typeTimer/countdown UI in suggestion strips
LE Audio hearing aidsAudio routing APIsKey click audio and voice input routing
Switch Access

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.

Section 24

Testing

Intermediate

Test 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

LayerWhat to TestFrameworkCount
UnitKey layout computation, edit distance, suggestion scoringJUnit 5 + MockK~200-500 tests
IntegrationInputConnection text operations, composing text, IME lifecycleRobolectric or AndroidX Test~50-100 tests
InstrumentationFull keyboard rendering, touch events, theme switchingEspresso + UI Automator~30-60 tests
BenchmarkFrame times, startup latency, dictionary lookup speedMacrobenchmark + 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
    }
}
CI Integration

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.

Section 25

Deployment & Publishing

Intermediate

Prepare 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

RequirementDetailsStatus
Privacy PolicyRequired for all IMEs — must disclose what keystrokes/data are collectedMandatory
Target API LevelMust 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 usersMandatory
FULL_NETWORK_ACCESSIf your IME uses network (cloud speech, sync), disclose in listingIf applicable
Data Safety SectionDeclare all data collection (keystrokes, voice audio, analytics)Mandatory
Deceptive BehaviorIME must not log keystrokes for advertising or send to undisclosed serversPolicy violation
Accessibility ServiceDo not request AccessibilityService permission unless you are an accessibility toolPolicy violation risk

25.4 — Release Checklist

Pre-Release Checklist
  1. Test on at least 3 screen densities (hdpi, xxhdpi, xxxhdpi) and 3 API levels
  2. Verify TalkBack navigation works for all keys (see Section 23)
  3. Enable R8/ProGuard and test the minified build — keyboard Views are often broken by shrinking
  4. Ensure onCreateInputView() handles null keyboard gracefully after process death
  5. Test with split-screen and floating window modes
  6. Verify the IME shows correctly in both portrait and landscape (Section 15)
  7. Run adb shell dumpsys input_method to verify subtypes are registered
  8. Generate a Baseline Profile for faster cold start
Section 26

Complete Code Examples

Beginner Intermediate

End-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
    }
}
Why This Works

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()) },
    )
}
Section 27

Resources & Further Reading

Beginner

Curated collection of official documentation, open-source keyboard projects, dictionaries, design references, and community resources for Android keyboard developers.

27.1 — Official Documentation

ResourceDescription
Creating an Input MethodOfficial guide — start here for the canonical IME tutorial
InputMethodService ReferenceComplete API reference with all 50+ callbacks documented
InputConnection ReferenceThe text editing interface — every method your keyboard uses to modify text
EditorInfo ReferenceDescribes the text field: input type, IME options, initial text selection
Input Method SubtypesMulti-language support: declaring and switching between keyboard layouts
Image Keyboard SupportCommitting images, GIFs, and stickers from your keyboard
Stylus HandwritingAPI 33+ stylus-to-text integration for handwriting recognition
Android 15 (API 35) FeaturesPlatform additions: edge-to-edge enforcement, predictive back, 16 KB pages
Android 16 (API 36) FeaturesDual-release cadence, richer haptics, enhanced accessibility, ADPF headroom, ARR
ProfilingManager ReferenceAPI 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 ReferenceAPI 36 getCpuHeadroom() and getGpuHeadroom() thermal-aware tuning
Compose Text User InteractionsSelectionContainer, DisableSelection, LinkAnnotation, and custom link handlers
Material Design 3Design guidelines, color system, motion, and component specs
Material You Dynamic ColorDynamic color system and tone-based color roles
Haptic Feedback GuidePlatform haptic primitives, custom waveforms, and UX best practices
Accessibility Developer GuideAccessibilityNodeInfo, TalkBack integration, testing, and best practices

27.2 — Open-Source Keyboards

Study these production keyboards for reference implementations:

ProjectLanguageNotable Features
OpenBoard RenewedKotlin/JavaAOSP-based, gesture typing, multi-language dictionaries
FlorisBoardKotlinModern architecture, Jetpack Compose UI, extension system
AnySoftKeyboardJava200+ language packs, extensive subtype system
GBoard Keyboard DecodeGoReverse-engineering resource for GBoard layout formats
Tiny KeyboardJavaMinimal educational keyboard (~300 lines total)

27.3 — Dictionary & Language Resources

ResourceFormatUse Case
OpenBoard DictionariesAOSP binary dictPre-built frequency dictionaries for 50+ languages
Lexique (French)TSV / SQLComprehensive French lexicon with frequency data
OpenSubtitles Word FreqCSVWord frequency lists from subtitle corpora (100+ languages)
FrequencyWordsTextWord frequency lists derived from OpenSubtitles
Unicode CLDR Keyboard ChartsWeb / XMLStandard keyboard layouts for every locale

27.4 — Design & UX References

ResourceCategory
How Google Designed a KeyboardDesign case study
Compose Keyboard InputFramework integration — physical key events
Compose Text Selection & LinksSelectionContainer, LinkAnnotation.Url, TextLinkStyles, custom URI handlers
Touch Target Size (NN/g)Usability research — optimal touch target dimensions
WCAG 2.2 Understanding DocsAccessibility — contrast ratios, target sizes, motion

27.5 — Internal Cross-References

This guide is part of a documentation set. Related volumes:

GuideFocus
Android Keyboard 3D & Personalization Guide 2026 — Extension Volume3D rendering, shaders, Canvas/OpenGL effects, AGSL, font systems, sound packs, AI-driven personalization
Mobile STT Engineering Guide 2026On-device speech-to-text: audio capture, VAD, model inference, post-processing, evaluation harness
Debugging Field ManualCross-platform debugging across Android, native C/C++, and Python tooling
Staying Current

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.

Citation

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}
}
GitHub "Cite this repository" button

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.