How to Debug Keyboard Trap in Mobile Apps
How to Debug Keyboard Trap in Mobile Apps: a hands‑on guide for developers and QA engineers
How to Debug Keyboard Trap in Mobile Apps: a hands‑on guide for developers and QA engineers
A keyboard trap occurs when focus becomes stuck inside an interactive element, preventing users—especially those who rely on keyboards, switch controls, or assistive technologies—from moving away. In mobile apps this usually manifests as the soft keyboard staying on screen, the user being unable to dismiss it, or navigation gestures failing to leave a modal or custom view. The result is a broken accessibility experience, failed WCAG 2.1 §2.1.2 (No Keyboard Trap) compliance, and frustrated users who may abandon the app.
This guide walks you through the entire lifecycle of a keyboard‑trap bug: from reliable reproduction to root‑cause analysis, tooling, step‑by‑step diagnosis, concrete fixes, and preventive practices. Each section contains practical commands, log snippets, and tables you can copy into your workflow. The techniques apply to native Android, native iOS, and hybrid apps that embed WebViews or third‑party SDKs.
---
Understanding Keyboard Trap in Mobile Apps
What is a keyboard trap?
In accessibility terminology a keyboard trap is a state where keyboard focus cannot be moved out of a component using standard navigation keys (Tab, Shift‑Tab, arrow keys, or equivalent swipe gestures). On mobile devices the “keyboard” is the soft input method, but the principle is identical: focus is locked inside a view, dialog, or web‑page overlay, and the system’s focus‑change events are ignored or swallowed.
Why it matters for accessibility and UX
- WCAG compliance – Failure to meet 2.1.2 results in automatic non‑conformance for any accessibility audit.
- Assistive technology users – TalkBack, VoiceOver, Switch Control, and external keyboards rely on predictable focus movement. A trap forces users to restart the app or lose input.
- General usability – Even power users who prefer gesture navigation encounter a stuck keyboard, leading to accidental taps, data loss, or perception of low quality.
Common symptoms
| Symptom | Typical trigger | Observable effect |
|---|---|---|
| Soft keyboard remains visible after tapping outside an input | Modal dialog that does not release focus | Keyboard overlaps background, back button does nothing |
| Focus cycles between two EditTexts | Custom view that overrides onKeyPreIme incorrectly | User cannot navigate to other fields or the app bar |
| TalkBack/VoiceOver reads same element repeatedly | Focus loop in a recycler view item | Screen reader stuck, user hears same announcement |
| Switch Control cannot advance | A full‑screen loading spinner that captures touch events | Switch scan pauses, user must force‑close app |
| Hardware keyboard arrows do not move focus | Activity set to windowIsFloating without proper focus init | Arrow keys behave as if no focusable view exists |
---
Root Causes of Keyboard Trap
Focus management failures
The most frequent cause is a custom view or activity that incorrectly consumes focus‑change events. Examples:
- Overriding
dispatchKeyEventPreImeand returningtruewithout forwarding the event. - Calling
requestFocus()on a non‑focusable view inside a focus‑change listener, creating a feedback loop. - Using
setFocusable(false)on a parent while a child remains focusable, causing the system to bounce focus back to the child.
Modal dialogs and custom views
Dialogs that appear as separate windows (e.g., AlertDialog, bottom sheets) must explicitly set android:windowIsFloating and ensure the decorated view hierarchy includes at least one focusable element that can receive the initial focus. If the dialog’s decor view is set to notFocusable or its background consumes all touch events, the IME may stay anchored to the underlying activity.
Input method editor (IME) interactions
IME state changes (onStartInputView, onFinishInputView) are sometimes mishandled:
- An app hides the keyboard programmatically but fails to clear the focused
EditText. - A custom IME (e.g., a language‑switching keyboard) swallows the
KEYCODE_BACKevent, preventing the system from dismissing the soft keyboard.
Third‑party libraries and webviews
- Advertising SDKs that insert a full‑screen interstitial with its own
Windowcan steal focus and never release it. - A
WebViewloading a page with a trapped focus (e.g., a modal built withdiv tabindex="-1"and JavaScript focus loops) propagates the trap to the host app. - Analytics or crash‑reporting tools that initialize a foreground service showing a persistent notification with a custom action view can inadvertently receive focus.
---
Reproducing Keyboard Trap Reliably
Manual reproduction steps on Android
- Launch the app on a device or emulator (API 21+).
- Navigate to the screen suspected of containing the trap (e.g., login form).
- Tap an
EditTextto bring up the soft keyboard. - Perform the action that should dismiss the keyboard (tap outside, press back, close a modal).
- Observe whether the keyboard remains visible and whether you can move focus to another element using Tab (if a hardware keyboard is connected) or swipe gestures with TalkBack.
Manual reproduction steps on iOS
- Run the app on a physical device or simulator (iOS 13+).
- Activate a text field to show the keyboard.
- Trigger the UI flow that should hide the keyboard (e.g., tap “Done”, swipe down, dismiss a pop‑up).
- Check if the keyboard stays on screen and whether VoiceOver can move focus away using the rotor or swipe gestures.
Automated reproduction with SUSA
SUSA’s autonomous explorer can surface keyboard traps without scripts:
# Install the agent
pip install susatest-agent
# Point SUSA at an APK or app URL
susatest run --app ./myapp.apk --personas curious impatient --max-depth 5
During exploration SUSA logs any instance where the soft keyboard remains visible for >2 seconds after a tap outside an input field, flagging it as a potential trap. The resulting report includes a video clip, the exact UI hierarchy, and the sequence of actions that led to the lock.
Creating a minimal repro case
When the trap is intermittent, strip the app down to the smallest possible activity:
- Keep only the layout that contains the suspect view.
- Remove unrelated dependencies, especially analytics and ads.
- If using a WebView, load a local HTML file that reproduces the focus loop (see the “WebView focus trapping solutions” section).
A minimal repro makes log analysis faster and helps confirm whether the issue lies in your code or a third‑party component.
---
Diagnostic Tools and Signals
Android Logcat and debug bridge
Focus‑related logs are emitted by the ViewRootImpl and InputMethodManager services. Enable verbose tags:
adb logcat ViewRootImpl:V InputMethodManager:V ActivityManager:V *:S
Look for lines like:
I/ViewRootImpl: [focusChanged] oldFocus=android.widget.EditText{b42c1a0 VFED..C.. .......I. 0,0-720,1200} newFocus=null
W/InputMethodManager: Ignoring event: focus not gained within 2000ms
If newFocus stays null while the keyboard is still shown, the system thinks no view has focus.
iOS Console and Instruments
On iOS, the UIFocusSystem posts notifications. In Console, filter for UIFocusSystem:
predicate: processName contains "MyApp" && eventMessage contains "UIFocusSystem"
Key messages:
<UIFocusSystem: 0x102800c00> Focus update: focusedView changed from <UITextField: 0x10480a200> to <UIView: 0x10480b400> (reason: UIFocusUpdateReasonUserInitiated)
If the focused view never changes after a tap outside, the system is stuck.
Use Instruments → Automation to record a script that taps an input field, waits, then taps elsewhere, and check the focusedElement property.
Accessibility inspection tools (TalkBack, VoiceOver)
- TalkBack – Enable “Show focus outline” in Developer options → Accessibility. A red rectangle indicates the current focus. If the rectangle never leaves the EditText after you tap elsewhere, you have a trap.
- VoiceOver – Use the Accessibility Inspector in Xcode to see the
AXFocusedattribute.
Profilers: CPU, memory, input event tracing
- Android Studio Profiler → Timeline → Input events. Look for a burst of
MotionEvent.ACTION_DOWNon the EditText followed by noACTION_UPon other views. - iOS Instruments → Core Animation → Event Trace. Check for repeated
touchesBeganon the same view without a correspondingtouchesEnded.
Using adb shell input events and uiautomator
You can synthesize key events to test focus escape:
# Send a TAB key event (keycode 61) to move focus forward
adb shell input keyevent 61
# Send BACK to dismiss keyboard
adb shell input keyevent 4
If after a series of TAB events the focus never leaves the EditText, the trap is confirmed.
uiautomator dump provides the current view hierarchy:
adb shell uiautomator dump /tmp/window.xml && cat /tmp/window.xml
Search for focused="true" attributes; if they remain on the same node after you issue a tap elsewhere, focus is locked.
Using Xcode's accessibility inspector
Open Xcode → Open Developer Tool → Accessibility Inspector. Point the inspector at the running app; the “Focused Element” field updates in real time. If it stays on a UITextField after you tap a button that should dismiss the keyboard, you have a trap.
---
Step‑by‑Step Diagnosis Workflow
1. Gather reproduction steps
Document the exact sequence: screen, input field, action that should dismiss keyboard, and any preceding dialogs or asynchronous calls (e.g., network request that shows a loading spinner).
2. Capture logs around focus change events
Start a timed logcat capture that begins before the reproduction and ends a few seconds after the expected dismissal:
adb logcat -c # clear buffer
adb logcat ViewRootImpl:V InputMethodManager:V ActivityManager:V > focus_log.txt &
# perform reproduction
sleep 5
kill %1 # stop background logcat
Search focus_log.txt for focusChanged lines. Note the timestamps of the last successful focus change and the first occurrence where newFocus is null while the keyboard is still shown (you can correlate with InputMethodManager: showing soft input).
3. Identify the offending view or window
From the log, locate the package/class name of the view that last had focus. Then use uiautomator dump at the moment the trap is active to see the full hierarchy. Look for:
- A view with
focusable="true"that is obscured by a sibling withclickable="true"consuming all touch events. - A dialog window (
type="APPLICATION_STARTING"ortype="APPLICATION") that lacks any focusable child.
4. Verify focus escapes via accessibility service
Enable TalkBack and use the “Explore by touch” gesture to move focus manually. If TalkBack can move focus away, the trap is limited to touch navigation; if not, the issue is deeper in the focus manager.
5. Isolate the problematic code path
Set breakpoints or log statements in:
onFocusChangeListenerof the suspected EditText.dispatchKeyEventPreIme/dispatchKeyEventof any custom view.onWindowFocusChangedof the activity or dialog.
If you observe that onFocusChange is called with hasFocus = false but the view immediately calls requestFocus() again, you have found the loop.
6. Fix and verify with regression matrix
Apply the fix (see next section), then run the reproduction steps again. Use the same log capture to confirm that a proper focusChanged event with a non‑null newFocus occurs after the dismissal action. Add the scenario to your automated UI test suite (e.g., Espresso or XCTest) to prevent regression.
---
Fixing Common Causes
Fixing focus loops in custom views
Problem: A custom view overrides dispatchKeyEventPreIme and returns true for KEYCODE_BACK, preventing the IME from receiving the back key.
Solution:
class CustomEditText @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = None, defStyle: Int = 0
) : AppCompatEditText(context, attrs, defStyle) {
override fun dispatchKeyEventPreIme(event: KeyEvent): Boolean {
// Let the system handle BACK first; only consume if we have custom logic
if (event.keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_DOWN) {
// Example: hide custom popup, then allow system to proceed
if (popupIsShowing) {
dismissPopup()
return true // we consumed it, but we already handled popup
}
}
return super.dispatchKeyEventPreIme(event)
}
}
Key points: call super for cases you don’t handle, and never swallow the event without a clear reason.
Properly dismissing modal dialogs
Problem: A full‑screen dialog created with DialogFragment uses setCancelable(false) and never calls dismiss() when the user taps outside, leaving the dialog window as the focused window.
Solution: Ensure the dialog’s decor view has at least one focusable element and that the dialog is dismissed on outside taps:
<!-- dialog_layout.xml -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/dialog_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:focusable="true"
android:focusableInTouchMode="true">
<!-- content -->
</LinearLayout>
class MyDialog : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return requireContext().let { ctx ->
AlertDialog.Builder(ctx)
.setView(R.layout.dialog_layout)
.setCancelable(true) // allow back press to dismiss
.create()
}.apply {
window?.setBackgroundDrawableResource(android.R.color.transparent)
// optional: set focus to first EditText when shown
setOnShowListener { dialog ->
dialog.window?.currentFocus?.clearFocus()
val firstEdit = dialog.findViewById<EditText>(R.id.first_input)
firstEdit?.requestFocus()
}
}
}
}
Handling IME state changes
Problem: An app hides the keyboard with inputMethodManager.hideSoftInputFromWindow(window.attributes.token, 0) but never clears the focused EditText. When the user taps another field, the IME thinks focus is still on the original field and does not show the keyboard.
Solution: Pair hide with clear focus:
fun hideKeyboardAndClearFocus(view: View?) {
view?.clearFocus()
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view?.windowToken, 0)
}
Call this function whenever you programmatically dismiss the keyboard (e.g., after a successful form submit).
Updating third‑party SDKs
If logs show that a class from com.thirdparty.sdk appears in the focus change stack trace, check the SDK’s changelog for known focus‑trap issues. Often a newer version fixes the problem by correctly setting windowIsFloating or by not overriding dispatchKeyEvent.
Procedure:
- Identify the SDK version in
build.gradle. - Review the SDK’s issue tracker or release notes for keywords “focus”, “IME”, “window”.
- Upgrade to the latest stable version.
- If upgrade is not possible, wrap the SDK’s activity in a transparent proxy that forwards focus events:
class FocusProxyActivity : AppCompatActivity() {
private lateinit var sdkDelegate: ThirdPartySdkActivity
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
sdkDelegate = ThirdPartySdkActivity()
sdkDelegate.delegate = this // custom interface to forward callbacks
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
// forward to SDK then to super
return sdkDelegate.dispatchKeyEvent(event) || super.dispatchKeyEvent(event)
}
}
WebView focus trapping solutions
Problem: A page loaded in a WebView contains a modal Solution: --- Add these tests to your CI pipeline so any regression is caught on pull request. Schedule a nightly SUSA run against your latest build artifact: The report includes a “Keyboard Trap” section with video evidence and a stack trace. Treat any finding as a blocker for release. --- A fintech app implemented a PIN entry as a row of six Fix: In the backspace case, explicitly request focus on the previous box (if any) or on the parent container that can receive focus. A travel app loaded a third‑party payment gateway inside a Fix: Added a JavaScript interface that called An analytics library initialized a foreground service that displayed a persistent notification with a custom action view ( Fix: Updated the SDK to version 2.4.1, which corrected the window flags. When an immediate upgrade wasn’t possible, the app added a On a Samsung Galaxy Fold in multi‑window active mode, a dialog that used Fix: Replaced the deprecated call with A feature flag rolled out a new “quick‑reply” bar that appeared as a transient view at the bottom of the screen. The bar was implemented as a Fix: Ensured the view was added to the hierarchy before any enable/disable calls, and added a unit test that asserts the view’s --- Run this matrix on every release candidate. Automate the steps with Espresso/UI Tests and attach the logcat/syslog capture as an artifact for audit. --- --- Keyboard traps are a subtle but high‑impact accessibility bug that can slip through functional testing because they often appear only under specific interaction sequences or device configurations. By combining reliable reproduction steps, targeted logging, a disciplined diagnosis workflow, and concrete fixes for the most common sources—focus loops, modal mishandling, IME state errors, and third‑party or WebView issues—you can eliminate these traps before they reach users. Make focus‑escape verification a regular part of your test matrix, leverage automated explorers like SUSA to surface traps early in development, and embed accessibility checks in your CI pipeline. With these practices in place, you’ll not only satisfy WCAG 2.1.2 but also deliver a smoother, more predictable experience for every user, whether they rely on a touchscreen, a keyboard, switch control, or a screen reader. --- *References* Upload your APK or URL. SUSA explores like 10 real users — finds bugs, accessibility violations, and security issues. No scripts.tabindex="-1" that traps focus when the user tabs. The WebView forwards the trap to the host app because the WebView itself never loses focus.
<script>
function trapFocus() {
const focusableEls = modal.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
const first = focusableEls[0];
const last = focusableEls[focusableEls.length - 1];
modal.addEventListener('keydown', e => {
if (e.key === 'Tab') {
if (e.shiftKey) { // shift + tab
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else { // tab
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
});
}
</script>
onCreateWindow to provide a custom WebChromeClient that ensures the WebView does not steal focus when a dialog is shown:
webView.webChromeClient = object : WebChromeClient() {
override fun onCreateWindow(view: WebView?, isDialog: Boolean, isUserGesture: Boolean, resultMsg: Message?): Boolean {
val newWebView = WebView(context)
newWebView.layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
// add to a container that is not focusable
container.addView(newWebView)
WebView.WebViewTransport transport = obj
transport.webView = newWebView
resultMsg.obj = transport
resultMsg.sendToTarget()
return true
}
}
configuration.allowsInlineMediaPlayback = true and evaluate JavaScript to reset focus after a modal dismissal:
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
webView.evaluateJavaScript("document.activeElement?.blur();") { _, _ in }
}
Prevention Strategies
Coding guidelines for focus management
super for unhandled cases.android:focusableInTouchMode="true" on a container if needed.hideSoftInputFromWindow with clearFocus() on the previously focused view.onWindowFocusChanged for logic that should live in lifecycle callbacks – misuse can cause focus to be reset incorrectly.Unit and UI tests for focus escape
isFocused() matcher to assert focus after an action:
onView(withId(R.id.username))
.perform(click())
.closeSoftKeyboard()
onView(withId(R.id.submit))
.perform(pressKey(KeyEvent.KEYCODE_ENTER))
assertThat(onView(withId(R.id.username)), isNot(isFocused()))
UIApplication.shared.keyWindow?.firstResponder:
XCTAssertFalse(usernameField.isFirstResponder, "Keyboard should be dismissed")
Integrating accessibility checks in CI
./gradlew connectedAndroidTest with the androidx.test.espresso:accessibility module enabled, which will fail on any WCAG 2.1.2 violation.xcodebuild test with the AccessibilityInspector tool or run axe-core via fastlane to scan for keyboard traps.Using automated exploration tools like SUSA
susatest run --app ./app-release.apk --personas elderly power_user --timeout 300 --output ./susa_report.json
Code review checklist
Item What to look for Focus listeners Does any OnFocusChangeListener immediately call requestFocus() on the same view?Key event overrides Are dispatchKeyEventPreIme, dispatchKeyEvent, or onKeyDown returning true without calling super?Dialog creation Is the dialog’s decor view focusable? Is setCancelable(true) used unless explicitly needed?IME hide calls Is hideSoftInputFromWindow always paired with clearFocus()?WebView content Does any loaded HTML contain a focus trap (modal with tabindex="-1" and no escape mechanism)?Third‑party init Does any SDK launch a full‑screen activity or window that lacks a focusable initial view? Real‑World Examples and Edge Cases
Example 1: Custom PIN entry view causing trap
EditText widgets, each limited to one character. The custom widget overrode onKeyPreIme to move focus to the next box when a digit was entered, but when the user pressed the backspace on the first box it called clearFocus() without moving focus elsewhere. The result: focus stayed on the PIN container still‑ show. Fix: In the InputMethodManager` never received a focus change, so the keyboard remained visible.Example 2: In‑app webview with login modal
WebView. The gateway’s HTML used a Bootstrap modal that trapped focus inside the modal when the user tabbed. Because the WebView itself never lost focus, the host app appeared to have a keyboard trap after the payment sheet closed.webView.evaluateJavascript("document.activeElement.blur();") after the modal’s hidden event, returning focus to the WebView’s host view.Example 3: Third‑party analytics SDK stealing focus
RemoteViews) containing a button. The notification’s window was flagged as type="TYPE_APPLICATION_OVERLAY" and set focusable="true" but had no focusable children. When the notification appeared, the system transferred focus to this empty window, causing the soft keyboard to stay locked on the last EditText.BroadcastReceiver for NotificationListenerService that called cancelAllNotifications() on the problematic package during app start.Example 4: Edge case – multi‑window mode on foldables
getWindow().setType(WindowManager.LayoutParams.TYPE_PHONE) (deprecated) was not re‑laid out when the window was resized. The dialog’s decor view retained focus even though its bounds were off‑screen, making the keyboard appear stuck.getWindow().setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) and ensured the dialog used WindowManager.LayoutParams.TYPE_APPLICATION.Example 5: Production‑only trap due to remote config
FrameLayout with android:windowIsTranslucent="true" and android:background="@null". Under certain remote‑config values, the bar’s setEnabled(false) was called before it was added to the hierarchy, leaving the view in a disabled but focusable state. The IME could not find a focusable target and stayed visible.isEnabled state after inflation.Test Matrix for Keyboard Trap Verification
Scenario Android (API) iOS (version) Expected result Pass/Fail criteria Tap EditText → press back 21‑33 13‑17 Keyboard dismisses, focus moves to previous view or activity bar focusChanged logs show newFocus ≠ null within 500 msTap EditText → tap outside (outside any modal) 21‑33 13‑17 Keyboard dismisses, focus clears No focused="true" on any EditText after tapOpen modal dialog → tap EditText inside → dismiss dialog 21‑33 13‑17 Keyboard remains visible while dialog open, dismisses after dialog closed While dialog open: keyboard visible; after dismiss: keyboard gone Custom PIN view → enter digit → backspace on first field 21‑33 13‑17 Focus moves to previous field or parent; keyboard stays No focus loop; focusChanged shows movementWebView loads page with modal trap → tap inside → close modal 21‑33 13‑17 Keyboard dismisses, focus returns to host app After modal close, focused host view ≠ nullThird‑party SDK shows full‑screen interstitial → tap EditText → close interstitial 21‑33 13‑17 Keyboard dismisses, focus returns No stuck keyboard; interstial removal triggers focus change Device rotated while keyboard visible 21‑33 13‑17 Keyboard remains visible, focus adapts to new orientation No loss of focus; orientationChange logs presentTalkBack enabled → swipe to leave EditText 21‑33 13‑17 Focus moves away, keyboard may stay (expected) TalkBack announces new element; no crash Quick Reference Checklist
adb logcat or Console) focused on ViewRootImpl, InputMethodManager, UIFocusSystem.return true.hideSoftInputFromWindow call with clearFocus() on the previously focused view.tabindex=-1 and no escape).Closing Takeaways
Test Your App Autonomously