Alexey
Alexey

Reputation: 66

How to persist keyboard across several composable screens?

Hello!

I have an issue with TextField in Jetpack Compose, Android.
We have a sequence of screens where each screen has TextField, and I want to keep the keyboard open when the screen is changed to the next or previous one. But now when I change the screen, the keyboard is closed and opens what looks terribly bad to the user.

Video: https://youtube.com/shorts/RmSPGT2Rteo


Example

In original, I have separate ViewModels connected to these screens, a lot of other components on them and navigation library to make the navigation concise. This is a very simplified sample of the issue I suffer from:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    setContent {
        var screenIndex by remember { mutableStateOf(0) }

        when (screenIndex) {
            0 -> Screen(0) { screenIndex = 1 }
            1 -> Screen(1) { screenIndex = 0 }
        }
    }
}

@Composable
fun Screen(
    index: Int,
    onButtonClick: () -> Unit,
) {
    Column(
        modifier = Modifier.fillMaxSize().imePadding(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center,
    ) {
        val focusRequester = remember { FocusRequester() }

        LaunchedEffect(Unit) {
            focusRequester.requestFocus()
        }

        var value by remember { mutableStateOf("$index") }
        TextField(
            modifier = Modifier.focusRequester(focusRequester),
            value = value,
            onValueChange = { value = it },
        )

        Button(onClick = onButtonClick) {
            Text("Change screen")
        }
    }
}

What I've tried to do

I've read the source code of the CoreTextField and learnt the following: There are special function which disposes the TextInputSession when TextField is removed from the composition.

Source from CoreTextField.kt, line 316. Compose Foundation version is 1.3.1

// Hide the keyboard if made disabled or read-only while focused (b/237308379).
if (enabled && !readOnly) {
    // TODO(b/230536793) This is a workaround since we don't get an explicit focus blur event
    //  when the text field is removed from the composition entirely.
    DisposableEffect(state) {
        onDispose {
            if (state.hasFocus) {
                onBlur(state)
            }
        }
    }
}

Also, I've tried the following things:

  1. Add the delay before opening the keyboard
  2. Disable TextField before changing the screen (partially works but in my project screen navigation happens in the ViewModel level and it's impossible to synchronize that processes)
  3. Use InputMethodManager to open the keyboard, especially it's toggleSoftInput method but it's deprecated.

How can I keep the keyboard opened and move focus to the new TextField when the screen is changed?

Upvotes: 3

Views: 844

Answers (1)

Jolan DAUMAS
Jolan DAUMAS

Reputation: 1374

You can trick the IME by starting an input session as soon as the screen is displayed. Doing this, the IME will not have the time to close and will stay opened.

Starting an input session

Starting an input session is achieved by using LocalTextInputService.current which retained a reference of the following class TextInputService.

Using the TextInputService.startInput(...) method will launch the session.

The following code will launch it :

 val textInputService = LocalTextInputService.current

 textInputService?.startInput(
    value = TextFieldValue(""),
    imeOptions = ImeOptions.Default,
    onEditCommand = {},
    onImeActionPerformed = {}
 )

Wrapping inside a modifier extension

To have a cleaner code, we can wrap this piece of code inside a modifier extension to allow reusability.

fun Modifier.startInputSession() = composed {
    val textInputService = LocalTextInputService.current

    textInputService?.startInput(
        value = TextFieldValue(""),
        imeOptions = ImeOptions.Default,
        onEditCommand = {},
        onImeActionPerformed = {}
    )
    
    this
}

Use of composed {} is mandatory because we need to access LocalTextInputService.current

Example

Using it is simple, you just need to wrap your screens inside a box who use the Modifier.startInputSession() extension method

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    setContent {
         Box(
                modifier = Modifier.startKeyboardSession()
            ) {
                var screenIndex by remember { mutableStateOf(0) }

                when (screenIndex) {
                    0 -> Screen(0) { screenIndex = 1 }
                    1 -> Screen(1) { screenIndex = 0 }
                }
            }
    }
}

Issues

Don't forget that we are faking an input session EACH time the screen is recomposed. So be sure that each time you are navigating you are calling the startInputSession() method again, otherwise it will not work.

Upvotes: 4

Related Questions