Why Android Keyboard Animation Matters
When your app shows a text input and the soft keyboard slides up, that moment sets the tone for user experience. A jarring jump or a laggy animation feels cheap. A smooth, synchronized transition where your content slides up in perfect sync with the IME (Input Method Editor) feels polished and intentional. The WindowInsetsAnimation API is Android’s tool for creating exactly that experience — and it’s simpler than you might think.
The old way was to listen for keyboard events and manually animate your views. The new way lets you tap directly into the system’s keyboard animation frame-by-frame, so your app animates in lockstep with the IME. This post shows you how to use WindowInsetsAnimation.Callback, hook into onProgress, and build a chat screen that feels responsive and native.
Understanding WindowInsetsAnimation and WindowInsetsAnimationCallback
WindowInsets is Android’s way of describing the system UI “insets” — the keyboard, status bar, navigation bar, and cutouts that eat into your app’s usable space. Starting in Android 11, the WindowInsetsAnimation API lets you observe and react to animations of those insets in real time.
The key class is WindowInsetsAnimationCallback. You set it on a view, and Android calls your callback with progress updates (0.0 to 1.0) as the keyboard animates. You can use that progress to animate your own views in sync. For example, if your chat input field should rise as the keyboard rises, you move it by the same distance at the same animation progress.
Here’s a minimal example in Kotlin using the AndroidX insets library:
view.setWindowInsetsAnimationCallback(
object : WindowInsetsAnimation.Callback(DISPATCH_MODE_STOP) {
override fun onProgress(
insets: WindowInsets,
runningAnimations: MutableList
): WindowInsets {
val imeAnimation = runningAnimations.find {
it.typeMask == WindowInsets.Type.ime()
} ?: return insets
val progress = imeAnimation.fraction // 0.0 to 1.0
val translationY = -imeAnimation.translationY
chatInputField.translationY = translationY * progress
return insets
}
override fun onEnd(animation: WindowInsetsAnimation) {
// Clean up if needed
}
}
)
The fraction tells you how far through the animation you are. The translationY tells you how far the keyboard has moved. Multiply them together and you get a value you can pass to your view’s animation.
A Practical Chat Screen Example: Jetpack Compose
If you’re using Jetpack Compose for your UI, you can hook into WindowInsetsAnimation using Modifier.imePadding() and a custom callback on the root surface. Here’s a chat screen that animates naturally:
@Composable
fun ChatScreen() {
var messageText by remember { mutableStateOf("") }
var animatedInsetHeight by remember { mutableStateOf(0.dp) }
val localDensity = LocalDensity.current
val view = LocalView.current
LaunchedEffect(Unit) {
view.setWindowInsetsAnimationCallback(
object : WindowInsetsAnimation.Callback(DISPATCH_MODE_STOP) {
override fun onProgress(
insets: WindowInsets,
runningAnimations: MutableList
): WindowInsets {
val imeAnimation = runningAnimations.find {
it.typeMask == WindowInsets.Type.ime()
} ?: return insets
val insetHeightPx = imeAnimation.translationY.toInt()
val insetHeightDp = with(localDensity) {
insetHeightPx.toDp()
}
animatedInsetHeight = insetHeightDp
return insets
}
}
)
}
Column(
modifier = Modifier
.fillMaxSize()
.imePadding()
) {
ChatMessages()
Spacer(modifier = Modifier.height(animatedInsetHeight))
ChatInputField(
value = messageText,
onValueChange = { messageText = it }
)
}
}
The key parts: LocalView.current gives you access to the view hierarchy from Compose, and Modifier.imePadding() automatically reserves space for the keyboard. The animatedInsetHeight state flows into a Spacer that grows and shrinks with the keyboard animation.
Using WindowInsetsAnimation With Traditional Views
If you’re still using XML layouts and traditional Views, the setup is even more straightforward. Set the callback on your root view or on the specific input field:
class ChatActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ChatActivityBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.root.setWindowInsetsAnimationCallback(
object : WindowInsetsAnimation.Callback(DISPATCH_MODE_STOP) {
override fun onProgress(
insets: WindowInsets,
runningAnimations: MutableList
): WindowInsets {
val imeAnimation = runningAnimations.find {
it.typeMask == WindowInsets.Type.ime()
} ?: return insets
val translationY = imeAnimation.translationY
binding.chatInputContainer.translationY = -translationY
binding.messagesList.translationY = -translationY
return insets
}
}
)
}
}
Both the message list and the input field slide up together, maintaining their relative spacing. This creates the illusion that your entire screen is moving in sync with the keyboard.
Combining With Other Animations
The WindowInsetsAnimation.Callback approach pairs nicely with other UI animations. For example, you might want to fade in a “scroll to latest” button as the user scrolls up while the keyboard is animated. Because onProgress is called on every animation frame, you can tie multiple animations to the same progress value:
override fun onProgress(
insets: WindowInsets,
runningAnimations: MutableList
): WindowInsets {
val imeAnimation = runningAnimations.find {
it.typeMask == WindowInsets.Type.ime()
} ?: return insets
val progress = imeAnimation.fraction
// Slide input field up
chatInputContainer.translationY = -imeAnimation.translationY
// Fade in accent color
chatInputContainer.setBackgroundColor(
interpolateColor(
startColor, endColor,
progress
)
)
// Scale send button
sendButton.scaleX = 0.8f + (0.2f * progress)
sendButton.scaleY = 0.8f + (0.2f * progress)
return insets
}
All three animations — position, color, and scale — move in lockstep with the keyboard, creating a cohesive, responsive experience.
Performance Considerations
WindowInsetsAnimation callbacks are called on every frame, so keep your onProgress implementation lightweight. Avoid allocating new objects, avoid complex layout calculations, and avoid triggering recompositions if you’re in Compose. If you need to do heavy work, offload it to a background thread and post results back to the main thread.
Also, be aware that setWindowInsetsAnimationCallback was added in Android 11 (API 30). For older devices, fall back to traditional ViewTreeObserver listeners or layout change callbacks. Most chat apps can afford to hide the advanced animation on older devices without harming the core experience.
Connecting to Jetpack Compose Components
For a deeper dive into building responsive UIs in Compose, check out Jetpack Compose for Beginners — it covers how to structure composables and state management for smooth animations like this.
And if you’re interested in custom animation systems (like particle effects or frame-by-frame control), the concepts in Animating the Seasons: Building a Particle System with Android Canvas show how to synchronize multiple drawing operations in the same frame, which pairs well with keyboard animations.
Summary
Android’s WindowInsetsAnimation API removes the guesswork from keyboard animations. Instead of fighting the system’s timing or trying to detect keyboard visibility indirectly, you hook directly into the animation stream and animate your UI in perfect sync. For chat apps, messaging screens, or any form-heavy UI, this small investment pays off immediately in perceived polish and responsiveness.
For more details, check the WindowInsetsAnimation API reference.
This post was written by a human with the help of Claude, an AI assistant by Anthropic.
