!
This article is compiled from development conversations with Gemini and was generated by it (with only stylistic adjustments to the intro).
In the chat interface of instant messaging (IM) apps, smoothly switching the input bar (Composer) between the soft keyboard (IME) and an attachment panel (emoji, tools, camera) is a fundamental requirement.
While building this component for the imlog project, I hit plenty of synchronization snags and animation handoff issues. I even uncovered a subtle race condition deep inside Android’s window inset dispatching when keyboard animations are interrupted. Here is a breakdown of the design rationale, architecture, and bug-hunting process.
1. Why Modifier.imePadding() Doesn’t Cut It
The intuitive first thought for many developers is to slap Modifier.imePadding() onto the composer container. But when you need bidirectional transitions between a keyboard and an extension panel, this modifier is the root cause of flickering and layout collapse:
- Disconnected Lifecycles:
imePadding() strictly mirrors system IME Insets. When you switch from the keyboard to the panel, the keyboard slides down and the system shrinks the IME inset from ~300dp to 0dp, while your panel animates up from 0dp to 300dp. Even if both animation curves match, cross-process IPC timing discrepancies cause the bottom container to collapse and spring back upβcreating an annoying jump. - Lack of Arbitration: Who actually owns the bottom space? The keyboard, the panel, or the navigation bar?
imePadding() has zero awareness of your custom panels.
The Core Solution: Height Arbitration with a Single Placeholder Box
The key to solving this is to drop imePadding() entirely and place an independent, dynamically measured placeholder Box beneath the input bar.
1
2
3
4
5
6
7
8
9
10
| ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Input Bar (Composer) β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Adaptive Height Container (Arbitration Box) β
β β
β finalHeight = maxOf(NavBar, SafeIme, PanelAnimation) β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β AttachmentPanel (Fixed at cachedHeight) β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββ β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
The final height of this container follows a Max-of Arbitration Rule:
$$\text{finalContainerHeight} = \max(\text{NavigationBarPx}, \text{CurrentImePx}, \text{PanelAnimationPx})$$
- Keyboard to Panel: The panel’s animation state immediately snaps to the cached keyboard height with zero animation, and the keyboard is dismissed. The bottom container remains pinned at ~300dp, so as the keyboard slides down, it simply unveils the panel sitting right behind it.
- Panel to Keyboard: The panel stays put at full height, waiting for the keyboard to rise. As soon as the keyboard surpasses the panel’s height, the keyboard takes over sizing. Once fully extended, the panel height is reset to zero.
2. Core Architecture and Optimization Strategies
The solution rests on three main pillars:
Keyboard transitions run at high display refresh rates (up to 120Hz). Reading WindowInsets.ime.getBottom(density) at the root Composable and passing it to Modifier.height(dp) will trigger over a hundred recompositions in 300ms, resulting in dropped frames.
Instead, we consume raw insets during the measurement and layout phase using Modifier.layout:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| Box(
modifier = Modifier
.fillMaxWidth()
.layout { measurable, constraints ->
// Computed directly in pixels during layoutβzero recompositions even on 120Hz displays!
val navigationBarPx = navigationBars.getBottom(this)
val currentImePx = imeInsets.getBottom(this)
val safeCurrentImePx = imeJitterGuard.sanitizeImeHeight(currentImePx, currentCachedHeightPx)
val currentPanelPx = panelHeightAnimation.value.toInt()
// Height arbitration: pick the largest to smooth over transitions
val finalContainerHeight = maxOf(navigationBarPx, safeCurrentImePx, currentPanelPx)
val placeable = measurable.measure(
constraints.copy(
minHeight = finalContainerHeight,
maxHeight = finalContainerHeight,
)
)
layout(placeable.width, finalContainerHeight) {
placeable.place(0, 0)
}
}
)
|
The nested AttachmentPanel is always pinned to the historical cached keyboard height (currentCachedHeightPx). The outer Box clips it as needed, preventing internal components from repeatedly recalculating their layout during transition animations.
2. State-Driven Transitions: The State Decision Matrix
Switching between the panel and the keyboard is not a binary toggle. Here is the decision matrix:
| Current State | Trigger Action | Target State | Transition Action | Key Technique |
|---|
| None | Tap Attachment Button | Panel | Smooth Expansion (animateTo) | Standard spring animation sliding up |
| IME | Tap Attachment Button | Panel | Instant Swap (snapTo) | Panel snaps to cached height; IME slides down to unveil it |
| Panel | Tap Input Field | IME | Delayed Handoff (Wait & Snap) | Panel holds position until IME reaches top, then snaps to 0 |
| Panel | Tap Outside (Back/List) | None | Smooth Collapse (animateTo(0)) | Returns space cleanly to the chat timeline |
Bridging System IPC Latency
When a user switches from the panel back to the text field, there is a roughly 140msβ200ms IPC latency between requesting keyboard focus and Android updating WindowInsets.imeAnimationTarget.
If you only observe the IME state, your code will assume the keyboard isn’t coming and dismiss the panel immediately, causing the bottom container to collapse.
The Fix: A focus change (isTextFieldFocused) fires much earlier than the IME Inset event. We use focus as an intent signal to open a 250ms golden wait window:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| val shouldKeepHeight = isTextFieldFocused
if (shouldKeepHeight) {
// 200β250ms window: covers system IPC latency while waiting for the target height
val targetHeight = withTimeoutOrNull(250.milliseconds) {
snapshotFlow { imeAnimationTarget.getBottom(density) }.first { it > 0 }
}
if (targetHeight != null && targetHeight > 0) {
// Wait until the keyboard reaches or exceeds target height, then snap the panel to 0
snapshotFlow {
val current = imeInsets.getBottom(density)
val target = imeAnimationTarget.getBottom(density)
current to target
}.first { (current, target) -> target == 0 || current >= target }
panelHeightAnimation.snapTo(0f)
} else {
// Timeout fallback (e.g., physical keyboard or soft input rejected)
panelHeightAnimation.animateTo(0f, spring(stiffness = Spring.StiffnessMediumLow))
}
}
|
3. The Deep End: Android’s System-Level IME Race Condition (Jitter)
Once the core logic was in place and subjected to stress testing, an elusive system-level bug emerged:
1. Repro Steps
- Tap the attachment button while the keyboard is open (keyboard starts sliding down, invoking
clearFocus()). - Just before the keyboard finishes sliding down, quickly tap the text field again.
- The keyboard pops back up, but the bottom composer jumps up by ~30dp, then abruptly drops back down with a visible jitter.
2. Log Analysis
Inspecting logcat uncovered an unexpected sequence:
1
2
3
4
5
6
7
| 13:01:08.416 InsetsController: show(ime())
13:01:08.417 WindowInsets changed: ime:[0,0,0,955] <-- Expected 872px, but received 955px!
13:01:08.442 ime px = 41, final = 872
...
13:01:08.727 ime px = 954, final = 954 <-- The container expanded to 954px
13:01:08.743 ImeTracker: onShown <-- IME process finishes measuring
13:01:08.744 WindowInsets changed: ime:[0,0,0,872] <-- Drops straight back to 872px (-82px drop!)
|
3. Root Cause in Android’s Architecture
Looking at the display metrics: the test device has a display density of 2.875 (872px corresponds to 303.3dp).
$955\text{px} - 872\text{px} = 83\text{px} \approx \mathbf{28.87\text{dp}}$.
In Android’s source code, an input method window has two distinct dimensions:
- Content / Visible Insets (872px): The actual usable interactive area calculated by the IME (e.g., Gboard) via
onComputeInsets(). - Window Frame Bounds (955px): The overall physical window boundaries registered with WindowManagerService (WMS). The extra 83px (~28dpβ32dp) accounts for the top elevation shadow or candidate bar padding.
Why does the race condition happen?
- Switching to the panel invokes
clearFocus(), tearing down the client app’s InputConnection to the IME. - Tapping the text field before the dismiss animation finishes forces the system to run
startInput again. - To preserve touch responsiveness, Android doesn’t wait for the IME process to remeasure its content insetsβit immediately grabs the stale physical Window Frame (955px) as the animation target and starts interpolating.
- Once the animation completes (
onShown), the IME asynchronously delivers the true content height (872px), prompting the system to dispatch an abrupt correction that snaps the layout back down.
4. Graceful Degradation: Building ImeJitterGuard
Since this behavior is baked into the OS during interrupted animations, our app needs an application-level guardrail:
- Track Unfocus Events: Record a timestamp whenever the text field loses focus.
- Guard Window: If the text field regains focus within a short window (the race condition period) and we already have a valid historical cached height:
- Block Cache Writes: Reject the temporary target (955px) so it doesn’t taint our cached height.
- Clamp Layout Height: Clamp the system’s
imeInsets to the known cached height (minOf(currentImePx, cachedHeightPx)) during layout.
To keep timing monotonic and immune to NTP time adjustments or daylight saving time shifts, we use Kotlin’s standard TimeSource.Monotonic:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
| import androidx.compose.runtime.*
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlin.time.TimeMark
import kotlin.time.TimeSource
const val RACE_CONDITION_DEBOUNCE_WINDOW_SECONDS = 1.5
/**
* IME Jitter Guard:
* Mitigates sudden height spikes and cache pollution when the soft keyboard
* is re-invoked before its dismissal animation has fully finished.
*/
@Stable
internal class ImeJitterGuard(
private val debounceWindow: Duration = RACE_CONDITION_DEBOUNCE_WINDOW_SECONDS.seconds,
private val timeSource: TimeSource = TimeSource.Monotonic,
) {
private var lastUnfocusMark by mutableStateOf<TimeMark?>(null)
fun onFocusChanged(focused: Boolean) {
if (!focused) {
lastUnfocusMark = timeSource.markNow()
}
}
val isInRaceConditionWindow: Boolean
get() {
val mark = lastUnfocusMark ?: return false
return mark.elapsedNow() < debounceWindow
}
/** Defense 1: Clamp runaway insets during layout */
fun sanitizeImeHeight(currentImePx: Int, cachedHeightPx: Int): Int {
return if (isInRaceConditionWindow && cachedHeightPx > 0) {
minOf(currentImePx, cachedHeightPx)
} else {
currentImePx
}
}
/** Defense 2: Reject dirty cache updates */
fun canUpdateCache(): Boolean = !isInRaceConditionWindow
}
@Composable
internal fun rememberImeJitterGuard(
debounceWindow: Duration = RACE_CONDITION_DEBOUNCE_WINDOW_SECONDS.seconds
): ImeJitterGuard = remember(debounceWindow) { ImeJitterGuard(debounceWindow) }
|
5. Complete Implementation
Here is the complete implementation of ClassicComposerDock.kt integrating height arbitration, layout phase measurement, focus anticipation, and the ImeJitterGuard:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
| package top.fseasy.imlog.features.home.topiclog.composer2.classic
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
import top.fseasy.imlog.domain.model.ScreenOrientation
import top.fseasy.imlog.ui.util.toScreenConfiguration
import kotlin.time.Duration.Companion.milliseconds
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun ClassicComposerDock(
state: ComposerDockState,
textFieldState: TextFieldState,
onUpdateCachedImeHeightInDp: (ScreenOrientation, heightValueInDp: Float) -> Unit,
onToggleAttachment: () -> Unit,
onSendText: () -> Unit,
onTextFieldFocusChanged: (focused: Boolean) -> Unit,
onHoldToRecordAction: (ComposerHoldToRecordAction) -> Unit,
modifier: Modifier = Modifier,
) {
val density = LocalDensity.current
val keyboardController = LocalSoftwareKeyboardController.current
val orientation = LocalConfiguration.current.toScreenConfiguration()
val focusManager = LocalFocusManager.current
var isTextFieldFocused by remember { mutableStateOf(false) }
val imeJitterGuard = rememberImeJitterGuard()
val currentCachedHeightPx = with(density) {
state.cachedImeHeightInDp.get(orientation).dp.roundToPx()
}
val imeAnimationTarget = WindowInsets.imeAnimationTarget
val imeInsets = WindowInsets.ime
val navigationBars = WindowInsets.navigationBars
// 1. Update cached height only when the animation settles (current == target) and guard is inactive
LaunchedEffect(orientation, density) {
snapshotFlow {
val current = imeInsets.getBottom(density)
val target = imeAnimationTarget.getBottom(density)
if (target > 0 && current == target) target else null
}
.filterNotNull()
.distinctUntilChanged()
.collect { heightPx ->
if (!imeJitterGuard.canUpdateCache()) return@collect
val heightDp = with(density) { heightPx.toDp() }
onUpdateCachedImeHeightInDp(orientation, heightDp.value)
}
}
val panelHeightAnimation = remember { Animatable(0f) }
// 2. State-driven panel animation
LaunchedEffect(state.activePanel) {
when (state.activePanel) {
ActivePanel.ATTACHMENT -> {
val shouldSkipAnimate = imeInsets.getBottom(density) > 0f
if (shouldSkipAnimate) {
// Keyboard is active: snap panel immediately with zero animation
panelHeightAnimation.snapTo(currentCachedHeightPx.toFloat())
} else {
// Keyboard is closed: animate panel smoothly upward
panelHeightAnimation.animateTo(
targetValue = currentCachedHeightPx.toFloat(),
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
)
}
keyboardController?.hide()
focusManager.clearFocus()
}
ActivePanel.NONE -> {
if (panelHeightAnimation.value <= 1e-6f) return@LaunchedEffect
val shouldKeepHeight = isTextFieldFocused
if (shouldKeepHeight) {
// Panel to keyboard: retain current height, then snap to 0 once IME takes over
val targetHeight = withTimeoutOrNull(250.milliseconds) {
snapshotFlow { imeAnimationTarget.getBottom(density) }.first { it > 0 }
}
if (targetHeight != null && targetHeight > 0) {
snapshotFlow {
val current = imeInsets.getBottom(density)
val target = imeAnimationTarget.getBottom(density)
current to target
}.first { (current, target) ->
target == 0 || current >= target
}
panelHeightAnimation.snapTo(0f)
} else {
// Timeout fallback (e.g., hardware keyboard or failed show request)
panelHeightAnimation.animateTo(
targetValue = 0f,
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
)
}
} else {
// Standard panel dismiss
panelHeightAnimation.animateTo(
targetValue = 0f,
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
)
}
}
}
}
Surface(
modifier = modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface,
) {
Column(modifier = Modifier.fillMaxWidth()) {
// Top input bar
DockFloatingContent(
mode = state.mode,
onAttachmentClick = onToggleAttachment,
onTextFieldFocusChanged = { focused ->
isTextFieldFocused = focused
imeJitterGuard.onFocusChanged(focused)
onTextFieldFocusChanged(focused)
},
textFieldState = textFieldState,
onSendText = onSendText,
onHoldToRecordAction = onHoldToRecordAction,
)
// Dynamic arbitration container
Box(
modifier = Modifier
.fillMaxWidth()
.layout { measurable, constraints ->
val navigationBarPx = navigationBars.getBottom(this)
val currentImePx = imeInsets.getBottom(this)
// Sanitize against inflated insets during race conditions
val safeCurrentImePx = imeJitterGuard.sanitizeImeHeight(
currentImePx,
currentCachedHeightPx
)
val currentPanelPx = panelHeightAnimation.value.toInt()
// Height arbitration
val finalContainerHeight = maxOf(
navigationBarPx,
safeCurrentImePx,
currentPanelPx
)
val placeable = measurable.measure(
constraints.copy(
minHeight = finalContainerHeight,
maxHeight = finalContainerHeight,
)
)
layout(placeable.width, finalContainerHeight) {
placeable.place(0, 0)
}
}
) {
val isPanelVisible = state.activePanel == ActivePanel.ATTACHMENT || panelHeightAnimation.value > 0f
if (isPanelVisible) {
AttachmentPanel(
modifier = Modifier
.fillMaxWidth()
.height(with(density) { currentCachedHeightPx.toDp() })
.navigationBarsPadding()
)
}
}
}
}
}
|
6. Takeaways and Known Trade-Offs
To recap, the transition holds together thanks to four key mechanisms:
- A Single Dynamic Placeholder Box: Replacing uncoordinated
imePadding with explicit maximum-height arbitration. - Calculations in
Modifier.layout: Avoiding continuous UI recompositions during high-refresh-rate keyboard animations. - Focus-Based Intent Handoff: Using a 250ms golden window to compensate for IPC latency between app and input method.
ImeJitterGuard Protection: Catching the system’s inflated Window Frame insets during rapid dismiss/show interruptions.
Known Edge Cases & Trade-offs:
Cold Starts: When the app is freshly launched and the keyboard has never opened, currentCachedHeightPx has to rely on a sensible fallback. If the user taps the attachment button right away, the panel will open using that estimated height until an actual keyboard opening calibrates it.
Split Screen & Multi-Window: In foldable split-screen or freeform window modes, WindowInsets can fluctuate dramatically. A purely monotonic-timer-based debounce may need resetting across configuration changes.
Extremely Fast Taps: Checking val shouldSkipAnimate = imeInsets.getBottom(density) > 0f alone does not cover every rapid-input edge case.
1
2
3
4
5
6
| Scenario: The user taps the text field, and before the keyboard surfaces from the bottom of the screen, immediately taps the "+" button to open attachments.
Symptom:
Because Target > 0 is already true, the code assumes the keyboard is already on its way and invokes snapTo(fullHeight).
From the user's perspective, the bottom of the screen was completely flat, and suddenly the input bar and panel teleport 300dp into the air with zero animation.
It feels as if the frame dropped or the transition animation was swallowed entirely.
|
Despite these niche edge cases, this approach keeps the code self-contained, sidesteps invasive reflection or private API hooks, and delivers a clean, production-grade transition for everyday messaging workflows.