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 5f0dd97e507..e7fccf1b4df 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 +}