From 0689c189e9b90557c917fcd669cde907d8d648e3 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 3 Jul 2026 14:01:20 +0200 Subject: [PATCH] compose: Prevent MessageList item jump during IME animation Co-Authored-By: Claude --- .../compose/ui/theme/ChatComponentFactory.kt | 14 +++++-- .../android/compose/util/IsImeAnimating.kt | 42 +++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/util/IsImeAnimating.kt diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt index e9a21cd26b8..1527f1ab9af 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt @@ -169,6 +169,7 @@ import io.getstream.chat.android.compose.ui.threads.ThreadItem import io.getstream.chat.android.compose.ui.util.StreamSnackbar import io.getstream.chat.android.compose.ui.util.bottomBorder import io.getstream.chat.android.compose.ui.util.topBorder +import io.getstream.chat.android.compose.util.isImeAnimating import io.getstream.chat.android.compose.viewmodel.messages.AudioPlayerViewModelFactory import io.getstream.chat.android.models.ConnectionState import io.getstream.chat.android.models.Message @@ -857,14 +858,19 @@ public interface ChatComponentFactory { /** * The default message list item modifier for styling. * + * Uses `Modifier.animateItem` for fade in/out and placement transitions. The placement + * animation is disabled while the IME is animating so items don't slide during the + * keyboard open/close window; it resumes for normal list changes (insertions, deletions, + * height changes). + * * @param params Parameters for this component. */ @Composable public fun LazyItemScope.messageListItemModifier(params: MessageListItemModifierParams): Modifier = - if (LocalInspectionMode.current) { - Modifier - } else { - Modifier.animateItem() + when { + LocalInspectionMode.current -> Modifier + isImeAnimating() -> Modifier.animateItem(placementSpec = null) + else -> Modifier.animateItem() } /** diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/util/IsImeAnimating.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/util/IsImeAnimating.kt new file mode 100644 index 00000000000..1afc16efe4d --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/util/IsImeAnimating.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.util + +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.imeAnimationSource +import androidx.compose.foundation.layout.imeAnimationTarget +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalDensity + +/** + * Returns `true` while an IME animation is in progress; `false` when it's fully settled + * (either open or closed). + * + * `imeAnimationSource` and `imeAnimationTarget` diverge for the whole duration of the + * animation and converge again once it settles. Reading them synchronously in composition + * means the value flips on the same frame the animation starts, avoiding a one-frame lag + * that a coroutine-based debounce approach would incur at the transition edges. + */ +@Composable +@OptIn(ExperimentalLayoutApi::class) +internal fun isImeAnimating(): Boolean { + val density = LocalDensity.current + val source = WindowInsets.imeAnimationSource.getBottom(density) + val target = WindowInsets.imeAnimationTarget.getBottom(density) + return source != target +}