How to Implement Long-Press and Multi-Touch Handling for Special Keyboard Actions in Android
Implement long-press detection using per-pointer coroutine jobs with timeout delays, and handle multi-touch by tracking pointer IDs with SparseArray maps while suppressing conflicting gestures to enable complex keyboard interactions like flick input and cursor movement.
The kazumaproject/japanesekeyboard repository demonstrates production-grade touch handling for custom Android keyboards. This article explains how to implement long-press and multi-touch handling for special keyboard actions using the actual source code from QWERTYKeyboardView.kt and related components.
Long-Press Detection Implementation
The LongPressListener Interface
The foundation of long-press handling starts with a simple callback interface defined in LongPressListener.kt:
interface LongPressListener {
fun onLongPress(key: Key)
}
The keyboard view receives a concrete implementation—usually the IME service—that reacts to long-press events. This decoupling allows the view to remain agnostic about specific actions while the listener handles business logic.
Per-Pointer Coroutine Management
QWERTYKeyboardView maintains a SparseArray<Job> called longPressJobs (lines 92‑93) to track active coroutines for each pointer:
private val longPressJobs = SparseArray<Job>()
Each pointer gets its own coroutine that delays for the system timeout before firing:
longPressJobs.put(pointerId, scope.launch {
delay(longPressTimeout) // system default timeout (lines 15‑16)
longPressListener?.onLongPress(pressedKey.key)
// Show popup, lock pointer, etc.
})
The job is cancelled immediately when the pointer lifts or a competing gesture (e.g., a flick) is detected via cancelLongPressForPointer.
Key-Specific Long-Press Handling
Only specific keys trigger long-press actions without visual popups. The longPressEnabledKeys set (lines 49‑56) defines these:
private val longPressEnabledKeys = setOf(
QWERTYKey.QWERTYKeyDelete,
QWERTYKey.QWERTYKeySpace,
QWERTYKey.QWERTYKeySwitchDefaultLayout,
QWERTYKey.QWERTYKeyCursorLeft,
QWERTYKey.QWERTYKeyCursorRight
)
During the callback, the view checks if (key in longPressEnabledKeys) to decide whether to execute the action directly or display a variation picker popup.
Multi-Touch Management Strategies
Pointer-to-View Mapping
The view tracks which key each finger presses using pointerButtonMap, a SparseArray<View?> (lines 90‑91):
private val pointerButtonMap = SparseArray<View?>()
This map enables independent tracking of multiple simultaneous key presses, ensuring each pointer ID correlates to its specific button view throughout the gesture lifecycle.
First Finger Suppression
When a second finger touches down while the first is held, the first pointer is suppressed to prevent ambiguous gestures:
if (pointerButtonMap.size == 1) {
val firstPointerId = pointerButtonMap.keyAt(0)
// Dismiss preview, cancel long-press job...
suppressedPointerId = firstPointerId
pointerButtonMap.remove(firstPointerId)
}
(lines 11‑27 in the ACTION_POINTER_DOWN block)
The suppressed ID is ignored for subsequent move events (if (pid == suppressedPointerId) continue), allowing the second finger to take primary control without interference from the first finger's initial touch.
Flick-Locking Mechanism
During flick gestures, the initiating pointer enters flickLockedPointers to prevent standard key-up handling:
if (!flickLockedPointers.contains(pointerId)) {
// Normal release handling
}
(lines 83‑85 in ACTION_POINTER_UP)
This separation ensures that completing a flick gesture does not simultaneously trigger the underlying key's tap action. The set clears when the gesture finishes (flickLockedPointers.clear()).
Pointer Start Coordinates
pointerStartCoords stores initial (x, y) positions for each pointer (lines 72‑73):
private val pointerStartCoords = SparseArray<Pair<Float, Float>>()
These coordinates enable:
- Flick direction detection via
detectFlickDirection - Threshold validation against
flickThresholdto distinguish flicks from accidental movement
Cursor Mode Handling
When isCursorMode is active, the view intercepts all motion events and translates them into directional cursor commands, bypassing standard long-press and flick detection:
if (isCursorMode) {
// Translate motion to cursor left/right/up/down
}
(lines 66‑95)
This special multi-touch state prioritizes arrow key simulation over character input gestures.
Practical Implementation Example
Below is a minimal example showing how an IME service enables long-press and multi-touch handling using the JapaneseKeyboard components:
// Register the LongPressListener
keyboardView.setOnLongPressListener(object : LongPressListener {
override fun onLongPress(key: Key) {
when (key) {
QWERTYKey.QWERTYKeyDelete -> {
// Execute delete-all or show popup
inputConnection.deleteSurroundingText(100, 0)
}
QWERTYKey.QWERTYKeySpace -> {
// Insert full-width space
inputConnection.commitText(" ", 1)
}
QWERTYKey.QWERTYKeySwitchDefaultLayout -> {
// Toggle between QWERTY and symbol layouts
keyboardView.switchLayout()
}
}
}
})
// Multi-touch is automatic - the view handles second-finger suppression
// via ACTION_POINTER_DOWN logic in QWERTYKeyboardView.kt
For tablet implementations, reference TabletKeyboardView.kt, which mirrors the same longPressJob and pointerButtonMap patterns with adjusted UI dimensions.
Summary
- Use
SparseArray<Job>to track per-pointer long-press coroutines, cancelling them immediately on pointer up or multi-touch detection. - Suppress the first pointer when a second finger touches down to prevent conflicting gestures and accidental long-press triggers.
- Implement flick-locking via a pointer ID set to separate flick gestures from standard tap actions.
- Check
isCursorModebefore processing normal touch logic to enable directional arrow key simulation. - Reference
LongPressListener.ktfor the callback contract andQWERTYKeyboardView.ktfor the complete touch pipeline implementation.
Frequently Asked Questions
How does the keyboard distinguish between a long-press and a flick gesture?
The system uses pointerStartCoords to track the initial touch position and calculates movement distance against flickThreshold. If the pointer moves beyond the threshold before longPressTimeout elapses, the long-press job is cancelled and the gesture is processed as a flick. The flickLockedPointers set then prevents the same pointer from triggering a key-up event once the flick completes.
What happens when a user touches a second key while holding the first?
When ACTION_POINTER_DOWN detects a second finger, the view identifies the first pointer ID from pointerButtonMap, cancels its long-press job via cancelLongPressForPointer, and assigns it to suppressedPointerId. The first pointer is removed from pointerButtonMap and ignored in subsequent move events, allowing the second finger to control input without interference from the held key.
Which keys support long-press actions without displaying a popup?
The longPressEnabledKeys set in QWERTYKeyboardView.kt defines keys that execute actions directly: QWERTYKeyDelete, QWERTYKeySpace, QWERTYKeySwitchDefaultLayout, QWERTYKeyCursorLeft, and QWERTYKeyCursorRight. When these keys trigger onLongPress, the view performs the associated action immediately rather than showing a variation picker popup.
How is cursor mode implemented differently from standard multi-touch?
When isCursorMode is true, QWERTYKeyboardView intercepts all motion events in the touch pipeline and translates X/Y movement into directional cursor commands (left, right, up, down). This mode bypasses the standard longPressJobs logic, flickLockedPointers checks, and key detection entirely, dedicating all touch processing to arrow key simulation until the mode is disabled.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →