Smoothly reacting to keyboard visibility changes in Android

When people use Dank, one of the terms they use to describe it is fluid. The app makes a good use of motion to indicate changes on the screen, but I think one of my favorite reasons why the app feels fluid is because it handles keyboard changes smoothly.

When the soft keyboard is shown, Android forces the content to resize immediately (unless the windowSoftInputMode is set otherwise). This results in a jarring change in the layout. Unfortunately, neither the platform nor the vast majority of apps handle this, so it looks normal to the user. Here’s a video from Google Keep:

In comparison, here’s how Dank reacts to keyboard by smoothly resizing the content upwards.

If you’re curious, you can watch the same screen in Dank but with the resize animation turned off here.

To achieve this, I learned a small trick. I learned that, when the keyboard is shown, the Activity’s entire window does not get resized. The root layout in the Activity’s View hierarchy, also referred to as the DecorView, stays unaffected. The layout that actually gets resized, is the content layout with the ID, android.R.id.content. What this means, is that the space in DecorView occupied by the keyboard can be reclaimed.

The Activity View tree usually looks like this,

DecorView
- LinearLayout
-- FrameLayout <- gets resized
--- LinearLayout
---- Activity content

I wrote a utility class that takes advantage of this. It observes changes to the content layout’s size. When a resize is detected, it immediately snaps the content back to full height and then manually resizes it, this time with an animator.

val decorView = activity.window.decorView

decorView.viewTreeObserver.addOnPreDrawListener { 
  val contentHeight = contentViewFrame.height
  val sizeChanged = contentHeight != previousHeight

  if (sizeChanged) {
    animateSizeChange(from = previousHeight, to = contentHeight)
  }

  previousHeight = contentHeight
}
fun animateSizeChange(from: Int, to: Int) {
  // Immediately snap back to the original size.
  contentView.setHeight(from)
  
  ObjectAnimator.ofInt(from, to)
    .addUpdateListener { 
      val h = it.animatedValue as Int
      contentView.setHeight(h) 
    }
    .start()
}

// Transitions API would be much more efficient than 
// using ObjectAnimator, but for some reason it skips 
// the first animation and I cannot figure out why.

You can find the full source here: https://github.com/saket/FluidKeyboardResize

Writing this was one of the many things that required low effort, but made a big impact to the motion of the app. I understand that this is not a perfect solution and is more of a workaround, but I’ve been using it for many months now and I haven’t noticed any issues.

Thanks to Alex Styl, Victoria Gonda, Mike Wolfson, Blake Meike, Nish Tahir and Vasilis Charalampakis for proofreading this post. Cover photo from material.io.