DEV Community

Angel G. Olloqui for Playtomic

Posted on • Edited on • Originally published at angelolloqui.com

Keyboard observer in Android with LiveData

It is very common in mobile apps to need to update UI when keyboard is shown or dismissed. It might be to make some extra space in the screen, to scroll a list to a particular position or just to show some hints to the user while typing. Nevertheless, no matter what your reason is, there is no easy way to detect keyboard opening or closing in Android. Let's explore some ideas:

Option 1: Focus listener ❌

A first approach would be to detect a focus changes on a particular EditText. Code looks like:

editText.setOnFocusChangeListener { _, hasFocus ->
    if (hasFocus) {
        keyboardOpen()
    } else {
        keyboardClosed()
    }
}
Enter fullscreen mode Exit fullscreen mode

However, this solution does not solve the problem completely because when the keyboard is displayed (and field has focus), if the user presses the phone's back button, then the keyboard is dismissed but the focus remains in the EditText. You may think that you can solve this by overriding and detecting onKeyDown or onBackPressed on your activity, but in fact the events are not sent to your activity and there is no way to either detect the dismissal or to force the EditText to lose the focus with it.

Option 2: ViewTree listener ⚠️

A second approach would be add a global view tree layout listener, so every time you have a layout change then you can compute the height difference between your activity root view and the visible display frame for it. Code looks like:

val globalLayoutListener = ViewTreeObserver.OnGlobalLayoutListener {
    val displayRect = Rect().apply { contentView.getWindowVisibleDisplayFrame(this) }
    val keypadHeight = contentView.rootView.height - displayRect.bottom
    if (keypadHeight > minKeyboardHeight) {
        keyboardOpen()
    } else {
        keyboardClosed()
    }
}
contentView.viewTreeObserver.addOnGlobalLayoutListener(globalLayoutListener)
Enter fullscreen mode Exit fullscreen mode

Althought this solution works, it is quite cumbersome and leaves up to the developer big responsibilities like removing the observer when fragment/activity is destroyed. Besides, if you need to do this in many different views, you will end up with a lot of repetition of code that is not trivial. Lastly, the global layout listener will be fired multiples times, so your keyboardOpen and keyboardClosed methods will be fired lots of times with no need.

Solution: LiveData + ViewTree listener ✅

If we iterate on Option 2, we can see that the layout listener can in fact be encapsulated in a component. If we extend that idea, and we make use of LiveData, we can come with a very elegant solution.

Create a small behavior class encapsulating the logic in a LiveData subclass

open class KeyboardTriggerBehavior(activity: Activity, val minKeyboardHeight: Int = 0) : LiveData<KeyboardTriggerBehavior.Status>() {
    enum class Status {
        OPEN, CLOSED
    }

    val contentView = activity.findViewById<View>(android.R.id.content)

    val globalLayoutListener = ViewTreeObserver.OnGlobalLayoutListener {
        val displayRect = Rect().apply { contentView.getWindowVisibleDisplayFrame(this) }
        val keypadHeight = contentView.rootView.height - displayRect.bottom
        if (keypadHeight > minKeyboardHeight) {
            setDistinctValue(Status.OPEN)
        } else {
            setDistinctValue(Status.CLOSED)
        }
    }

    override fun observe(owner: LifecycleOwner, observer: Observer<in Status>) {
        super.observe(owner, observer)
        observersUpdated()
    }

    override fun observeForever(observer: Observer<in Status>) {
        super.observeForever(observer)
        observersUpdated()
    }

    override fun removeObservers(owner: LifecycleOwner) {
        super.removeObservers(owner)
        observersUpdated()
    }

    override fun removeObserver(observer: Observer<in Status>) {
        super.removeObserver(observer)
        observersUpdated()
    }

    private fun setDistinctValue(newValue: KeyboardTriggerBehavior.Status) {
        if (value != newValue) {
            value = newValue
        }
    }

    private fun observersUpdated() {
        if (hasObservers()) {
            contentView.viewTreeObserver.addOnGlobalLayoutListener(globalLayoutListener)
        } else {
            contentView.viewTreeObserver.removeOnGlobalLayoutListener(globalLayoutListener)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

And then use it everywhere just like:

    // Some Fragment, but it works equally on an activity
    private var keyboardTriggerBehavior: KeyboardTriggerBehavior? = null
    ...
    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        val activity = this.activity ?: return
        keyboardTriggerBehavior = KeyboardTriggerBehavior(activity).apply {
            observe(viewLifecycleOwner, Observer {
                when (it) {
                    KeyboardTriggerBehavior.Status.OPEN -> keyboardOpen()
                    KeyboardTriggerBehavior.Status.CLOSED -> keyboardClosed()
                }
            })
        }
    }
Enter fullscreen mode Exit fullscreen mode

There are a number of benefits of this solution:

  • It is highly reusable, you can include this in any of your views very easily
  • It automatically detects the lifecycle events thanks to the usage of LiveData, which means that it is secure to use since it will remove listeners automatically when your fragment/activity is destroyed
  • It behaves like any other observable of your ViewModels, and it can even be passed down to the view's databinding
  • It will just fire updates when there is a keyboard state change and not when any layout happens.

We have even added a minKeyboardHeight property in case you need to use this in a splitted view.

Magic!

Top comments (0)