Skip to content

Add Mob.Keyboard.dismiss/1 and the dismiss_keyboard NIF - #173

Merged
GenericJam merged 4 commits into
GenericJam:masterfrom
tmepple:feat/keyboard-dismiss
Oct 24, 2026
Merged

GenericJam merged 4 commits into
GenericJam:masterfrom
tmepple:feat/keyboard-dismiss

Conversation

@tmepple

@tmepple tmepple commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

What

Mob.Keyboard.dismiss/1 and a dismiss_keyboard/0 NIF: take the on-screen keyboard down from a screen's own handler.

  • iOS: resigns the first responder found in any visible window of a connected scene (the walk delete_backward / key_press use). Fire-and-forget — dispatch_async, returns ok at once — shaped like haptic/1 / torch/1, not like the observing harness NIFs; key_press(:escape) remains the observing form.
  • Android: calls an optional MobBridge.dismissKeyboard() (cached with cacheOptional); a bridge without it returns {:error, :not_loaded}, which dismiss/1 swallows. No generated template defines it yet. The paired mob_new change is InputMethodManager.hideSoftInputFromWindow on the activity's focused view, a dozen lines next to haptic — happy to open that PR as well.
  • dismiss/1 is guarded on the host (unlike Mob.Haptic) so a screen that chains it stays testable under Mob.ScreenCase.
  • Docs: moduledoc, guides/device_capabilities.md → Keyboard, CHANGELOG [Unreleased], and decisions/2026-09-24-keyboard-dismiss-is-a-device-api.md. No version bump — per RELEASE.md that's yours to call.

Why

A screen has no way to dismiss the keyboard from its own handler — its own Done or Save button, a tap on blank space, a committed value — short of removing the field from the tree. The built-in exits are the return key (which the decimal / number pads don't have, so on_submit never fires for them), another field taking focus, and the iOS keyboard-toolbar Done. Hit in a downstream app (Alctrak) with a weight field on a decimal pad.

Verification

  • Static: mix format --check-formatted, mix credo --strict, mix compile --warnings-as-errors, mix erlfmt --check src/, xcrun clang-format --dry-run -Werror ios/mob_nif.m android/jni/mob_beam.h.
  • Host: mix test, 1812 passed. nif_scheduling_completeness_test classifies the NIF as returns-promptly.
  • iOS 26.5 simulator (iPhone 17 Pro), over dist against a native build: Mob.Test.type_text/2 succeeded before the call and returned {:error, :no_first_responder} after it, with the field still in the tree; full-screen simctl captures agree. A value: assigned in the same handler as the dismiss reached the field, so SwiftUI's focus state had already flipped when MobTextField's initialText sync ran.
  • Not climbed: physical device. Android: zig ast-check passes on the NIF, but it was not compiled or run (no Android toolchain here, and CI doesn't build the Android NIF) — please build it once before merging. The only Android path reachable today is the missing-method branch ({:error, :not_loaded}), the same shape as nif_clear_text; the JNI call itself can't execute until the template defines the method.

One thing worth your eye

The field's on_blur arrives when the NIF runs with no re-render, and via key_press(:escape), but did not arrive when the dismissing handler also re-rendered — consistent with the blur firing after the new render generation committed and being dropped as stale. Documented as "act in the dismissing handler, not in on_blur". If you'd rather the resign go through Mob.Sender so the blur lands in the new generation, I can do that instead.

No Linear issue yet — happy to rename the branch and title once one is assigned.

🤖 Generated with Claude Code

@GenericJam

Copy link
Copy Markdown
Owner

Thanks @tmepple, this is a clean addition and the write-up made the review easy. I rebased onto master (Zig 0.17.0) and added one commit on top. Summary:

Changed

  • iOS dismiss_keyboard now waits for the resign (IO_BOUND, 2 s bound, {:error, :timeout} past it). On an iPhone Air simulator (iOS 27.0), after the user had typed, the value: assigned in the same handler was dropped in nearly every run: the re-render reached the field while SwiftUI still had it focused, and a focused field keeps what the user typed. With the wait, it landed 22/22.
  • Your on_blur question. On current master, iOS already delivers blur through the identity-tolerant final-text path (MOB-406), so it arrived 10/10 even when the handler re-rendered. Android did lose it: the paired bridge clears focus, and the strict mob_send_blur dropped 6 of 8 blurs. Android blur now resolves by identity, like change events, and 10/10 arrive. So the "act in the handler, not in on_blur" caveat is gone from the docs.
  • The Kotlin half is Android MobBridge.dismissKeyboard() for Mob.Keyboard.dismiss/1 mob_new#113. It hides the IME and clears focus in the focused field's own window, so a field inside a Mob.UI.sheet works too.
  • Docs, the decision record and the CHANGELOG are updated to match, and there's an Upgrading note for existing Android apps.

Verified

  • The Android NIF compiles with Zig 0.17.0 (android_libc_free_test). mix test 2108 passed; format, credo and --warnings-as-errors are clean.
  • On a Moto G Power 2021 (Android 11) and the iPhone Air simulator, with a decimal field: the keyboard goes down (mInputShown=false / no keyboard in the screenshots), the value assigned alongside reaches the field, on_blur arrives, and nothing crashes.
  • An Android bridge without the method logs dismissKeyboard not found (optional), returns {:error, :not_loaded} and leaves the keyboard up, with no crash.

tmepple and others added 4 commits October 23, 2026 21:11
Verified: static (format, credo --strict, compile --warnings-as-errors,
erlfmt, clang-format), host (mix test), and the iOS 26.5 simulator over
dist: type_text succeeded before the call and returned no_first_responder
after it with the field still in the tree; a value assigned in the same
handler reached the field; on_blur arrives without a re-render and is
dropped with one (decisions/2026-09-24-keyboard-dismiss-is-a-device-api.md).
Android: host tests only. The bridge method is optional and no generated
template defines it yet, so Android is a documented no-op until mob_new
gains MobBridge.dismissKeyboard().
…blur

iOS dismiss_keyboard now waits (IO_BOUND, 2 s bound) for the main thread to
resign the first responder. Queued async, the handler's re-render reached a
field SwiftUI still had focused, and a value: assigned next to the dismiss
was dropped after an edit in nearly every run on the iPhone Air simulator;
22 of 22 landed with the wait.

Android mob_send_blur resolves by identity, like change events and the iOS
final-text blur: clearFocus() fires after the BEAM committed the handler's
new tap table, and the strict check dropped 6 of 8 such blurs on a Moto G
Power 2021 (10 of 10 arrive now).

Docs, guide, decision record and CHANGELOG describe the paired mob_new
MobBridge.dismissKeyboard() (hide IME + clear focus).
@GenericJam
GenericJam force-pushed the feat/keyboard-dismiss branch from 63eb0c1 to cdb71ae Compare October 24, 2026 03:12
@GenericJam
GenericJam merged commit 49daaf1 into GenericJam:master Oct 24, 2026
4 checks passed
GenericJam added a commit to GenericJam/mob_new that referenced this pull request Oct 24, 2026
* Android MobBridge.dismissKeyboard(): the Android half of Mob.Keyboard.dismiss/1

Hides the soft keyboard (InputMethodManager.hideSoftInputFromWindow) and
clears focus on the UI thread, so the field's on_blur fires as on iOS, where
dismissing resigns the first responder. mob's dismiss_keyboard NIF looks it
up with cacheOptional by name and ()V (GenericJam/mob#173).

Verified on a Moto G Power 2021: the decimal keyboard goes down
(dumpsys input_method mInputShown=false), a value assigned in the same
handler reaches the field, on_blur arrives, and the next tap focuses the
field again.

* dismissKeyboard targets the focused field's own window (sheets)

A field in a Mob.UI.sheet renders in ModalBottomSheet's separate window, so
activity.currentFocus carried the wrong window token: the IME ignored the
hide and the field kept focus (verified on a Moto G Power 2021). MobTextField
now records its LocalView on focus and dismissKeyboard targets it.

* Test the hostView wiring; name the hostView line in the upgrade note
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants