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

11

49

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?

Cobelligerent answered 19/8, 2021 at 12:4 Comment(2)
WindowInsets.isImeVisibleDalessandro
WindowInsets.isImeVisible works for me.Crowther
C
2

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()
      }
    }
  }
}
Cobelligerent answered 19/8, 2021 at 13:50 Comment(0)
L
117

Update

With the new WindowInsets API, it gets easier

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

WindowCompat.setDecorFitsSystemWindows(window, false)

Then to use Keyboard as a state:

@Composable
fun keyboardAsState(): State<Boolean> {
    val isImeVisible = WindowInsets.ime.getBottom(LocalDensity.current) > 0
    return rememberUpdatedState(isImeVisible)
}

use example:

val isKeyboardOpen by keyboardAsState() // true or false

ps: I've tried to use WindowInsets.isImeVisible, but it returns true in the first call.


Without an experimental API

if you want with the 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 
Leucocratic answered 11/10, 2021 at 23:47 Comment(15)
very nice solution. thanks.Withdrew
very nicely done!Hamil
It stops working when I lock screen and unlock it again. Any idea why?Yetah
The state starts as closed but remains opened no matter the keyboard is shown or hidden.Milligan
Weird behavior oh my end. When first time whos the compose, WindowInsets.isImeVisible show true, but there is no keyboard shows, when tap on an input field, nothing is triggered. After I close the keyboard, WindowInsets.isImeVisible shows false. And I can get values change foward.Moina
Hey @Arst, I just updated the answer, could you try again? Please make sure that you set setDecorFitsSystemWindows correctly tooLeucocratic
I also pushed a sample here: github.com/ujizin/Compose-stackoverflow/blob/main/app/src/main/…Leucocratic
@Leucocratic Thanks for the update. I ended up using imePadding for my case. I just needed to find way to adjust UI based on keyboard show/hide and found this one.Moina
I get Unresolved reference: ime error and even after importing the recommended, this doesn't work.Pettifogger
I do keep getting wrong states when going back from the screen with open keyboard. Needs to be improvedEdouard
Personaly the solution "Without an experimental API" works perfectly then the other is not working on my 29 api devices. May is some configuration that i'm using. Ty !Tithing
Caution! Non experimental example has memory leak: when view/viewtreeobserver changes removeOnGlobalLayoutListener is called from incorrect object and listener isn't deleted, that causes view leak. Use fixed version: gist.github.com/MaxMyalkin/b63f4d4050e4ff882cf3a30abcab448fHypochromia
The first option did work but it changes the screen size (edge-to-edge) which is not intended here, but the second option works, even though it shows a bit of lagginess but not really harmfulElector
do not forget to set android:softInputMode to 'adjustResize' in AndroidManifest/yourActivity.Supinator
I found this github project link that indicates WindowInsets.isImeVisible needs enableEdgetoEdge() and found it worked for me as well. Still trying to understand why....Hydrography
M
33

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
val viewTreeObserver = view.viewTreeObserver
DisposableEffect(viewTreeObserver) {
    val listener = ViewTreeObserver.OnGlobalLayoutListener {
        val isKeyboardOpen = ViewCompat.getRootWindowInsets(view)
            ?.isVisible(WindowInsetsCompat.Type.ime()) ?: true
        // ... do anything you want here with `isKeyboardOpen`
    }

    viewTreeObserver.addOnGlobalLayoutListener(listener)
    onDispose {
        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.
Madrepore answered 7/9, 2022 at 18:57 Comment(6)
This is pure geniusMilligan
I can't thank you enough for this solution.Milligan
Working as expected and very well writter. Thank you!Hunsinger
Caution! This example has memory leak: when view/viewtreeobserver changes removeOnGlobalLayoutListener is called from incorrect object and listener isn't deleted, that causes view leak. Use fixed version: gist.github.com/MaxMyalkin/b63f4d4050e4ff882cf3a30abcab448fHypochromia
@Hypochromia the difference is that you only store view.viewTreeObserver instead of the whole view, isn't it? If you confirm I will update my answerMadrepore
@Madrepore Also use DisposableEffect(viewTreeObserver). From docs: The returned ViewTreeObserver observer is not guaranteed to remain valid for the lifetime of this View. I.e. observer can changeHypochromia
M
11

To handle the keyboard opening and closing behavior in Jetpack Compose without altering the WindowCompat.setDecorFitsSystemWindows(window, false) setting in your Activity, especially when working with a single activity architecture containing Fragments and Composables, you can use the following approach:

@Composable
fun keyboardAsState(): State<Boolean> {
    val view = LocalView.current
    var isImeVisible by remember { mutableStateOf(false) }

    DisposableEffect(LocalWindowInfo.current) {
        val listener = ViewTreeObserver.OnPreDrawListener {
            isImeVisible = ViewCompat.getRootWindowInsets(view)
                ?.isVisible(WindowInsetsCompat.Type.ime()) == true
            true
        }
        view.viewTreeObserver.addOnPreDrawListener(listener)
        onDispose {
            view.viewTreeObserver.removeOnPreDrawListener(listener)
        }
    }
    return rememberUpdatedState(isImeVisible)
}

This code snippet provides a Composable function keyboardAsState that allows you to observe and respond to changes in the keyboard's visibility status. It does so by leveraging the DisposableEffect and ViewTreeObserver.OnPreDrawListener to detect when the keyboard opens or closes.

To use this function, simply access its value as val isKeyboardOpen by keyboardAsState() within your Composable where you need to track the keyboard state. It will return a State<Boolean> that reflects whether the keyboard is currently visible or not.

Misrepresent answered 2/9, 2023 at 12:33 Comment(1)
Tried a few others, but this works great for me!Laforge
M
4

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://mcmap.net/q/356064/-how-can-i-show-a-composable-on-top-of-the-visible-keyboard

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
Malfeasance answered 8/9, 2022 at 14:42 Comment(0)
T
4

New Experimental solution:

val isImeVisible = WindowInsets.isImeVisible
Tubbs answered 6/3 at 12:45 Comment(1)
At least in a bottom sheet this is not working, don't know why.Johnjohna
M
3

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

Molnar answered 8/9, 2022 at 9:31 Comment(0)
C
2

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()
      }
    }
  }
}
Cobelligerent answered 19/8, 2021 at 13:50 Comment(0)
S
2

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
}
Selfgoverned answered 15/8, 2022 at 10:20 Comment(2)
Doesn't work. The state remains same whether or not keyboard is visible.Milligan
Because you have to Set WindowCompat.setDecorFitsSystemWindows(window, false) in mainActivityHagy
B
1

In Jetpack compose:

@Composable
fun isKeyboardVisible(): Boolean = WindowInsets.ime.getBottom(LocalDensity.current) > 0

It will return true or false,

True -> Keyboard Visible

False -> Keyboard Not Visible

Beating answered 26/9, 2022 at 5:47 Comment(2)
What if monitoring is required?Satem
Can you explain in detail ?Beating
C
1

There you go:

@Composable
fun OnKeyboardClosedEffect(block: () -> Unit) {
    val isKeyboardVisible = WindowInsets.ime.getBottom(LocalDensity.current) > 130
    var keyboardListenerHaBeenSet by remember { mutableStateOf(false) }
    if (isKeyboardVisible || keyboardListenerHaBeenSet) {
        if (!isKeyboardVisible) {
            block()
            keyboardListenerHaBeenSet = false // clear
        }
        keyboardListenerHaBeenSet = true
    }
}
Cannes answered 29/6, 2023 at 13:48 Comment(0)
E
0

Here's how you can observe the keyboard visibility:

    val keyboardHeight = WindowInsets.ime.getBottom(LocalDensity.current)

    LaunchedEffect(key1 = keyboardHeight) {
        if (keyboardHeight > 0) {
            // keyboard showing
        } else {
            // keyboard closed
        }
    }
Ewall answered 12/5 at 0:37 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.