How can I detect keyboard opening and closing in jetpack compose?

Viewed 4375

The only way I've found in compose is to use accompanist-insets and that removes window insets. And such causes other problems with my app's layout.

The Android way seems to be this and I could pass that into my compose app and act accordingly.

Is there another way in jetpack compose?

6 Answers

Update

With the new WindowInsets.isImeVisible Experimental API, it gets easier, but it's important to check the lifecycle to not start as an opened state.

First, to return the correct values, you need to set:

WindowCompat.setDecorFitsSystemWindows(window, false)

Then to use Keyboard as a state:

enum class Keyboard {
    Opened, Closed
}

/** if you want, you can use boolean instead to return an enum. */
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun keyboardAsState(): State<Keyboard> {
    val lifecycle = LocalLifecycleOwner.current.lifecycle
    val isResumed = lifecycle.currentState == Lifecycle.State.RESUMED
    return rememberUpdatedState(if (WindowInsets.isImeVisible && isResumed) Keyboard.Opened else Keyboard.Closed)
}

use example:

val keyboardState by keyboardAsState() // Keyboard.Opened or Keyboard.Closed 

ps: WindowInsets.isImeVisible has been experimental yet.


Without an experimental API

if you want with statement, I found this solution:

enum class Keyboard {
    Opened, Closed
}

@Composable
fun keyboardAsState(): State<Keyboard> {
    val keyboardState = remember { mutableStateOf(Keyboard.Closed) }
    val view = LocalView.current
    DisposableEffect(view) {
        val onGlobalListener = ViewTreeObserver.OnGlobalLayoutListener {
            val rect = Rect()
            view.getWindowVisibleDisplayFrame(rect)
            val screenHeight = view.rootView.height
            val keypadHeight = screenHeight - rect.bottom
            keyboardState.value = if (keypadHeight > screenHeight * 0.15) {
                Keyboard.Opened
            } else {
                Keyboard.Closed
            }
        }
        view.viewTreeObserver.addOnGlobalLayoutListener(onGlobalListener)

        onDispose {
            view.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalListener)
        }
    }

    return keyboardState
}

and to detect/check the value you'll only need this:

val isKeyboardOpen by keyboardAsState() // Keyboard.Opened or Keyboard.Closed 

I found a way with Android's viewTreeObserver. It essentially is the Android version but it calls a callback that can be used in compose.

class MainActivity : ComponentActivity() {

  var kbGone = false
  var kbOpened: () -> Unit = {}
  var kbClosed: () -> Unit = {}

  override fun onCreate(state: Bundle?) {
    super.onCreate(state)
    setContent {
      kbClosed = {
        // dismiss the keyboard with LocalFocusManager for example
      }
      kbOpened = {
        // something
      }
      MyComponent()
    }
    setupKeyboardDetection(findViewById<View>(android.R.id.content))
  }

  fun setupKeyboardDetection(contentView: View) {
    contentView.viewTreeObserver.addOnGlobalLayoutListener {
      val r = Rect()
      contentView.getWindowVisibleDisplayFrame(r)
      val screenHeight = contentView.rootView.height
      val keypadHeight = screenHeight - r.bottom
      if (keypadHeight > screenHeight * 0.15) { // 0.15 ratio is perhaps enough to determine keypad height.
        kbGone = false
        kbOpened()
      } else(!kbGone) {
        kbGone = true
        kbClosed()
      }
    }
  }
}

Here is a solution that uses OnGlobalLayoutListener to listen to changes to the layout and uses the new window insets APIs to perform calculations, as recommended by the documentation. You can place this code anywhere inside a @Composable function and handle the isKeyboardOpen as you wish. I tested and it works on API 21 and above.

val view = LocalView.current
DisposableEffect(view) {
    val listener = ViewTreeObserver.OnGlobalLayoutListener {
        val isKeyboardOpen = ViewCompat.getRootWindowInsets(view)
            ?.isVisible(WindowInsetsCompat.Type.ime()) ?: true
        // ... do anything you want here with `isKeyboardOpen`
    }

    view.viewTreeObserver.addOnGlobalLayoutListener(listener)
    onDispose {
        view.viewTreeObserver.removeOnGlobalLayoutListener(listener)
    }
}

For me the other solutions wouldn't work well: the keyboard would result as always closed.

  • In OnGlobalLayoutListener-based answers, the used formula does not seem to behave as it should, and old APIs are used.
  • In the WindowInsetListener-based answer, since view is not the root view, no window insets would be applied on it. I tried replacing view with view.rootView, and although the keyboard-detection code would then work, passing the root view to setOnApplyWindowInsetsListener replaces any listener set by components, which is obviously unwanted.

Also we can use WindowInsetListener, something like this

@Composable
fun keyboardAsState(): State<Boolean> {
val keyboardState = remember { mutableStateOf(false) }
val view = LocalView.current
    LaunchedEffect(view) {
        ViewCompat.setOnApplyWindowInsetsListener(view) { _, insets ->
            keyboardState.value = insets.isVisible(WindowInsetsCompat.Type.ime())
            insets
        }
    }
    return keyboardState
}

Now, with the new WindowInsets api, WindowInsets.isImeVisible can be used. For reference, see this.

Detecting whether keyboard is opening or closing can be inspected with WindowInsest.ime

Set WindowCompat.setDecorFitsSystemWindows(window, false)

To check whether it's open or close use

WindowInsets.isImeVisible

Check if it's going up or opening with using bottom offset however it's not always reliable you need to do extra steps to check if it's opening or closing

val offsetY = WindowInsets.ime.getBottom(density)

you can compare a previous value and detect if it's opening closing, open or close

https://stackoverflow.com/a/73358604/5457853

When it opens it returns values such as

17:40:21.429  I  OffsetY: 1017
17:40:21.446  I  OffsetY: 38
17:40:21.463  I  OffsetY: 222
17:40:21.479  I  OffsetY: 438
17:40:21.496  I  OffsetY: 586
17:40:21.513  I  OffsetY: 685
17:40:21.530  I  OffsetY: 764
17:40:21.546  I  OffsetY: 825
17:40:21.562  I  OffsetY: 869
17:40:21.579  I  OffsetY: 907
17:40:21.596  I  OffsetY: 937
17:40:21.613  I  OffsetY: 960
17:40:21.631  I  OffsetY: 979
17:40:21.646  I  OffsetY: 994
17:40:21.663  I  OffsetY: 1004
17:40:21.679  I  OffsetY: 1010
17:40:21.696  I  OffsetY: 1014
17:40:21.713  I  OffsetY: 1016
17:40:21.730  I  OffsetY: 1017
17:40:21.746  I  OffsetY: 1017

While closing

17:40:54.276  I  OffsetY: 0
17:40:54.288  I  OffsetY: 972
17:40:54.303  I  OffsetY: 794
17:40:54.320  I  OffsetY: 578
17:40:54.337  I  OffsetY: 430
17:40:54.354  I  OffsetY: 331
17:40:54.371  I  OffsetY: 252
17:40:54.387  I  OffsetY: 191
17:40:54.404  I  OffsetY: 144
17:40:54.421  I  OffsetY: 109
17:40:54.437  I  OffsetY: 79
17:40:54.454  I  OffsetY: 55
17:40:54.471  I  OffsetY: 37
17:40:54.487  I  OffsetY: 22
17:40:54.504  I  OffsetY: 12
17:40:54.521  I  OffsetY: 6
17:40:54.538  I  OffsetY: 2
17:40:54.555  I  OffsetY: 0
17:40:54.571  I  OffsetY: 0
Related