Pantrix UI

Putting the keyboard away

Why a field’s software keyboard cannot be put away on an iPhone in Compose Multiplatform, what the library gives for it, and how it is tested. The problem is not Pantrix’s own: every screen with a field of several lines, or of numbers, has it.

1. The problem

What is seen

2. The fix

A listener for taps goes on the screen’s root, under everything it shows. On a tap that nothing under it takes, it clears the focus (FocusManager.clearFocus()): the field gives its focus up, and the keyboard goes with it.

Who takes the press
The userComposeWhat follows
A tap on a buttonThe button takes itIt does its work; the focus stays where it was
A tap in the fieldThe field takes itThe caret moves; the keyboard stays
A drag that scrollsWhat scrolls takes itThe focus stays
A tap on empty roomNothing takes itclearFocus(): the keyboard goes

3. With Pantrix

One modifier, in com.pantrixui.foundation.keyboard, on the screen’s root or the app’s:

ClearFocusOnTap.kt

public fun Modifier.clearFocusOnTap(): Modifier

CommentScreen.kt

Column(Modifier.fillMaxSize().clearFocusOnTap()) {
    PantrixTextArea(state = comment, label = stringResource(Res.string.comment))
    PantrixButton(text = stringResource(Res.string.send), onClick = ::send)
}
iOS
The way the keyboard is put away.
Android
A way beside the system’s back gesture.
Desktop and web
A click on empty room lets the focus go, as in a browser.

4. Without Pantrix

A modifier node with pointerInput and LocalFocusManager, in common code. A project that would rather not write a node does the same inside a composable.

A modifier node

fun Modifier.clearFocusOnTap(): Modifier = this then ClearFocusOnTapElement

private data object ClearFocusOnTapElement : ModifierNodeElement<ClearFocusOnTapNode>() {
    override fun create() = ClearFocusOnTapNode()
    override fun update(node: ClearFocusOnTapNode) = Unit
}

private class ClearFocusOnTapNode : DelegatingNode(), CompositionLocalConsumerModifierNode {
    init {
        delegate(SuspendingPointerInputModifierNode {
            detectTapGestures(onTap = { currentValueOf(LocalFocusManager).clearFocus() })
        })
    }
}

Inside a composable

val focusManager = LocalFocusManager.current
Column(
    Modifier
        .fillMaxSize()
        .pointerInput(focusManager) { detectTapGestures(onTap = { focusManager.clearFocus() }) }
) {
    // the screen's content
}

detectTapGestures is in androidx.compose.foundation.gestures; the node’s types are in androidx.compose.ui.node and …ui.input.pointer.

5. What the helper does not do

It does not take the keyboard down on a scroll.
iOS’s keyboardDismissMode has no counterpart here: a scroll leaves the focus where it is.
It puts no “Done” bar above the keyboard.
That is native iOS’s other way (inputAccessoryView). It was not tried here.
It does not hear the taps of a layer with a window of its own.
A Popup or Dialog that holds a field takes the modifier on its own content too.
It does not take the focus back from a button that takes it.
On a desktop and on the web a pressed button may take the focus itself: that is the button’s doing.

6. Testing

A unit test, on five targets

ClearFocusOnTapTest: a tap on empty room takes the focus away; a tap in the field, by a finger or a mouse, keeps it; a button that takes no focus does its work; a drag that scrolls keeps the focus.

Maestro, in place of hideKeyboard

On a screen whose root has the modifier, a step that taps empty room does the same.

- tapOn: "A note for the courier"  # empty room: the keyboard goes
- tapOn:
    id: "Send"

That the keyboard really goes is only seen on a device or a simulator.

7. Verification

Seen on a simulator, an emulator and the web

Read in the sources

Not checked

8. Where it is in the repository

The modifierfoundation/keyboard/ClearFocusOnTap.kt
TestsClearFocusOnTapTest, in commonTest
Who uses itThe demo app’s root; PantrixTextArea and PantrixFilledTextArea name it
Demo“A tap outside puts the keyboard away” on the Text Area page