Skip to content

Prevent MessageList item jump during IME animation#6539

Open
VelikovPetar wants to merge 3 commits into
developfrom
port/v6-to-develop/prevent-message-list-ime-jump
Open

Prevent MessageList item jump during IME animation#6539
VelikovPetar wants to merge 3 commits into
developfrom
port/v6-to-develop/prevent-message-list-ime-jump

Conversation

@VelikovPetar

@VelikovPetar VelikovPetar commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Goal

Port of v6 commit 13a8be56c7Prevent MessageList item jump during IME animation (#6535) — to develop.

Fixes AND-1285. When opening the on-screen keyboard on MessagesScreen with only a few messages (items fit in the viewport), the Compose MessageList items visibly "hop" as the keyboard animates in — and again, more subtly, when it animates out.

Root cause: Modifier.animateItem() animates item placement changes. With reverseLayout = true + Arrangement.Top, items shift position slightly on every frame while the LazyColumn viewport shrinks/grows in step with the IME animation; the placement spring lays its own animation on top of those per-frame deltas, which surfaces as the visible hop.

Implementation

Suppress only the placement portion of Modifier.animateItem() during the IME animation window; fade in/out and normal list-change placement animations (insertions, deletions, height changes) are untouched.

  • New internal isImeAnimating() @Composable in util/IsImeAnimating.kt. It reads WindowInsets.imeAnimationSource / imeAnimationTarget (@ExperimentalLayoutApi), which diverge for the full IME animation and converge once it settles — a synchronous "is animating" boolean that flips on the same frame the animation starts (a coroutine-debounced variant lagged by one frame at the close-start edge).
  • ChatComponentFactory.messageListItemModifier(params) now returns Modifier.animateItem(placementSpec = null) while isImeAnimating() is true, the original Modifier.animateItem() otherwise, and plain Modifier under LocalInspectionMode.current (Paparazzi).

Port note: This was a manual port, not a clean cherry-pick. On develop, messageListItemModifier takes a MessageListItemModifierParams argument that v6 lacks, so a raw git cherry-pick conflicts on the method signature line. The fix body is identical to v6; IsImeAnimating.kt was added verbatim.

No public API change: isImeAnimating() is internal and the messageListItemModifier(params) signature is unchanged. ./gradlew :stream-chat-android-compose:apiDump produced no diff.

UI Changes

No UI changes at rest. The visible difference is that message items no longer hop during keyboard open/close on channels where items fit in the viewport. See the before/after videos on the original v6 PR #6535.

Testing

  • ./gradlew :stream-chat-android-compose:detekt — green
  • ./gradlew :stream-chat-android-compose:spotlessApply — no reformatting
  • ./gradlew :stream-chat-android-compose:apiDump — no API diff
  • ./gradlew :stream-chat-android-compose:testDebugUnitTest — green (includes Paparazzi snapshots, unchanged)

Manual: open a channel with 2–5 messages via MessagesScreen, tap the composer → keyboard opens smoothly with no item hop; tap outside → keyboard closes with no hop. Repeat on a channel with a full viewport (behaviour unchanged), and send a new message on a small channel (placement animation still works at rest).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Improved message list animations during keyboard transitions for a smoother chat experience.
    • Keyboard-aware behavior now adjusts item motion while the on-screen keyboard is opening or closing.
  • Bug Fixes
    • Reduced awkward list movement when the keyboard changes height, especially during animated transitions.
    • Kept animations stable in inspection mode for more predictable preview behavior.

Co-Authored-By: Claude <noreply@anthropic.com>
@VelikovPetar VelikovPetar added the pr:bug Bug fix label Jul 3, 2026
@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

PR checklist ✅

All required conditions are satisfied:

  • Title length is OK (or ignored by label).
  • At least one pr: label exists.
  • Sections ### Goal, ### Implementation, and ### Testing are filled, or the PR is bot-authored.
  • An issue is linked (Linear ticket or GitHub issue), or the PR is bot-authored.

🎉 Great job! This PR is ready for review.

@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

SDK Size Comparison 📏

SDK Before After Difference Status
stream-chat-android-client 5.93 MB 5.93 MB 0.00 MB 🟢
stream-chat-android-ui-components 11.20 MB 11.20 MB 0.00 MB 🟢
stream-chat-android-compose 12.66 MB 12.66 MB 0.00 MB 🟢

@sonarqubecloud

sonarqubecloud Bot commented Jul 6, 2026

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
20.0% Coverage on New Code (required ≥ 80%)

See analysis details on SonarQube Cloud

@VelikovPetar VelikovPetar marked this pull request as ready for review July 6, 2026 13:34
@VelikovPetar VelikovPetar requested a review from a team as a code owner July 6, 2026 13:34
@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Adds a new isImeAnimating() Compose utility that detects keyboard animation state by comparing IME animation source and target insets. ChatComponentFactory is updated to use this utility, disabling item placement animation during IME transitions while preserving other animation behavior.

Changes

IME-aware Message List Item Animation

Layer / File(s) Summary
isImeAnimating utility
stream-chat-android-compose/.../util/IsImeAnimating.kt
New internal composable computes IME animation state by comparing imeAnimationSource and imeAnimationTarget bottom insets via LocalDensity.
Apply IME state to item modifier
stream-chat-android-compose/.../ui/theme/ChatComponentFactory.kt
Imports isImeAnimating and updates messageListItemModifier to a 3-way branch: disable animations in inspection mode, disable placement transitions during IME animation, otherwise keep full item placement animations.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

  • GetStream/stream-chat-android#6535: Both PRs modify ChatComponentFactory.messageListItemModifier() to use the shared isImeAnimating() helper to suppress placement animations during IME transitions.

Suggested labels: pr:improvement

Suggested reviewers: gpunto

Poem

A keyboard rises, soft and slow,
No jumpy hops, no jittery show,
The rabbit checks each inset's edge,
Source and target, insets' pledge,
Now items glide, calm as still snow. 🐇⌨️

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main change: preventing MessageList item jumps during IME animation.
Description check ✅ Passed The description covers Goal, Implementation, UI Changes, and Testing, with only non-critical template sections missing.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch port/v6-to-develop/prevent-message-list-ime-jump

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/util/IsImeAnimating.kt`:
- Around line 35-42: Hoist the IME animation read out of isImeAnimating() usage
in messageListItemModifier so individual message items do not subscribe to
WindowInsets.imeAnimationSource and WindowInsets.imeAnimationTarget during
composition. Move the isImeAnimating() call to a shared ancestor composable,
cache the boolean there, and pass it down to the message item modifier or
related UI path so only one composable tracks the inset animation state.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: c9eae097-c7ca-4b35-af98-53513d826f51

📥 Commits

Reviewing files that changed from the base of the PR and between 48f5ab2 and f94852d.

📒 Files selected for processing (2)
  • stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt
  • stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/util/IsImeAnimating.kt

Comment on lines +35 to +42
@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
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

set -euo pipefail

# Locate relevant files and usages
git ls-files | rg 'stream-chat-android-compose/.*/IsImeAnimating\.kt|stream-chat-android-compose/.*/Messages|stream-chat-android-compose/.*/ChatComponentFactory|stream-chat-android-compose/.*/MessageList|stream-chat-android-compose/.*/ChannelList'
printf '\n--- isImeAnimating usages ---\n'
rg -n "isImeAnimating\(" stream-chat-android-compose || true
printf '\n--- imeAnimationSource/Target usages ---\n'
rg -n "imeAnimation(Source|Target)" stream-chat-android-compose || true

printf '\n--- target file ---\n'
nl -ba stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/util/IsImeAnimating.kt | sed -n '1,200p'

Repository: GetStream/stream-chat-android

Length of output: 4619


🏁 Script executed:

set -euo pipefail

# Map the target file and likely caller sites
git ls-files | rg 'stream-chat-android-compose/.*/IsImeAnimating\.kt|stream-chat-android-compose/.*/Messages|stream-chat-android-compose/.*/ChatComponentFactory|stream-chat-android-compose/.*/MessageList|stream-chat-android-compose/.*/ChannelList'
printf '\n--- isImeAnimating usages ---\n'
rg -n "isImeAnimating\(" stream-chat-android-compose || true
printf '\n--- imeAnimationSource/Target usages ---\n'
rg -n "imeAnimation(Source|Target)" stream-chat-android-compose || true

printf '\n--- target file ---\n'
nl -ba stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/util/IsImeAnimating.kt | sed -n '1,200p'

Repository: GetStream/stream-chat-android

Length of output: 4619


🏁 Script executed:

set -euo pipefail

git ls-files | rg 'stream-chat-android-compose/.*/IsImeAnimating\.kt|stream-chat-android-compose/.*/Messages|stream-chat-android-compose/.*/ChatComponentFactory|stream-chat-android-compose/.*/MessageList|stream-chat-android-compose/.*/ChannelList'
printf '\n--- isImeAnimating usages ---\n'
rg -n "isImeAnimating\(" stream-chat-android-compose || true
printf '\n--- imeAnimationSource/Target usages ---\n'
rg -n "imeAnimation(Source|Target)" stream-chat-android-compose || true

printf '\n--- target file ---\n'
nl -ba stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/util/IsImeAnimating.kt | sed -n '1,200p'

Repository: GetStream/stream-chat-android

Length of output: 4619


🌐 Web query:

Jetpack Compose WindowInsets documentation should not be read during composition avoid doing composition for every frame of an animation

💡 Result:

In Jetpack Compose, WindowInsets values should not be read directly during the composition phase [1][2]. Doing so causes the composition to re-execute whenever the insets change, which—during an animation—results in triggering composition for every single frame [1][2]. This can lead to significant performance degradation and potential UI lag [1][2][3]. To avoid this, you should use built-in Compose modifiers and APIs designed to handle insets efficiently without triggering unnecessary recomposition [1][2]. Recommended practices include: Use specialized modifiers: Utilize modifiers such as Modifier.windowInsetsPadding, Modifier.systemBarsPadding, Modifier.imePadding, or Modifier.windowInsetsTopHeight [1][2]. These modifiers are designed to apply inset changes during the layout phase rather than the composition phase, ensuring performance efficiency even during animations [1][2]. Avoid reading values in composition: Do not access properties like WindowInsets.getTop(density) or WindowInsets.getBottom(density) within your Composable functions if those values change frequently [1][2]. If you have complex requirements that necessitate manual handling, look into APIs like onConsumedWindowInsetsChanged or MutableWindowInsets, though these should be used with care to maintain proper performance patterns [4][5]. Generally, sticking to the provided layout modifiers is the most performant and recommended approach [1][2].

Citations:


🏁 Script executed:

set -uo pipefail

printf '%s\n' '--- files ---'
git ls-files | rg 'stream-chat-android-compose/.*/IsImeAnimating\.kt|stream-chat-android-compose/.*/Messages|stream-chat-android-compose/.*/ChatComponentFactory|stream-chat-android-compose/.*/MessageList|stream-chat-android-compose/.*/ChannelList' || true

printf '\n%s\n' '--- isImeAnimating usages ---'
rg -n "isImeAnimating\(" stream-chat-android-compose || true

printf '\n%s\n' '--- imeAnimationSource/Target usages ---'
rg -n "imeAnimation(Source|Target)" stream-chat-android-compose || true

printf '\n%s\n' '--- target file ---'
nl -ba stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/util/IsImeAnimating.kt | sed -n '1,200p'

Repository: GetStream/stream-chat-android

Length of output: 4633


🏁 Script executed:

set -euo pipefail

python3 - <<'PY'
from pathlib import Path

paths = [
    Path("stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/util/IsImeAnimating.kt"),
    Path("stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt"),
]
for path in paths:
    print(f"\n--- {path} ---")
    text = path.read_text()
    for i, line in enumerate(text.splitlines(), 1):
        if path.name == "IsImeAnimating.kt" or (860 <= i <= 885):
            print(f"{i:4d}: {line}")
PY

Repository: GetStream/stream-chat-android

Length of output: 3631


🏁 Script executed:

set -euo pipefail

python3 - <<'PY'
from pathlib import Path
path = Path("stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt")
text = path.read_text().splitlines()
for start in [840, 860, 868, 880]:
    print(f"\n--- lines {start}-{start+25} ---")
    for i in range(start, min(start+25, len(text))+1):
        print(f"{i:4d}: {text[i-1]}")
PY

Repository: GetStream/stream-chat-android

Length of output: 5014


🏁 Script executed:

set -euo pipefail

rg -n "messageListItemModifier|animateItem\(placementSpec = null\)|isImeAnimating\(\)" stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/Messages.kt stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageList.kt

Repository: GetStream/stream-chat-android

Length of output: 759


🏁 Script executed:

set -euo pipefail

python3 - <<'PY'
from pathlib import Path
path = Path("stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/util/IsImeAnimating.kt")
for i, line in enumerate(path.read_text().splitlines(), 1):
    print(f"{i:4d}: {line}")
PY

Repository: GetStream/stream-chat-android

Length of output: 2196


🏁 Script executed:

set -euo pipefail

python3 - <<'PY'
from pathlib import Path
path = Path("stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt")
lines = path.read_text().splitlines()
for i in range(850, 881):
    print(f"{i:4d}: {lines[i-1]}")
PY

Repository: GetStream/stream-chat-android

Length of output: 1549


🏁 Script executed:

set -euo pipefail

python3 - <<'PY'
from pathlib import Path

files = [
    "stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt",
    "stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/util/IsImeAnimating.kt",
]
for file in files:
    path = Path(file)
    print(f"\n=== {file} ===")
    lines = path.read_text().splitlines()
    if file.endswith("ChatComponentFactory.kt"):
        for start, end in [(820, 890), (1, 120)]:
            print(f"\n--- lines {start}-{end} ---")
            for i in range(start, min(end, len(lines)) + 1):
                print(f"{i:4d}: {lines[i-1]}")
    else:
        print("\n--- full file ---")
        for i, line in enumerate(lines, 1):
            print(f"{i:4d}: {line}")
PY

Repository: GetStream/stream-chat-android

Length of output: 14306


Hoist IME animation state out of messageListItemModifier

isImeAnimating() reads WindowInsets.imeAnimationSource/imeAnimationTarget during composition, so each visible message item subscribes to IME inset updates and can recompose every frame while the keyboard animates. Move this check to a shared ancestor (or pass a cached boolean down) to avoid repeating the read per item.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/util/IsImeAnimating.kt`
around lines 35 - 42, Hoist the IME animation read out of isImeAnimating() usage
in messageListItemModifier so individual message items do not subscribe to
WindowInsets.imeAnimationSource and WindowInsets.imeAnimationTarget during
composition. Move the isImeAnimating() call to a shared ancestor composable,
cache the boolean there, and pass it down to the message item modifier or
related UI path so only one composable tracks the inset animation state.

@andremion andremion left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. One optional note below.

*/
@Composable
@OptIn(ExperimentalLayoutApi::class)
internal fun isImeAnimating(): Boolean {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The neighbouring isKeyboardVisibleAsState() avoids WindowInsets.ime because it can report wrong values on some OEM ROMs (Xiaomi/MIUI), see its KDoc. This helper reads imeAnimationSource / imeAnimationTarget from the same IME inset channel, so on those ROMs (or when the host window isn't set up for edge-to-edge inset dispatch) it may never report source != target and the fix just won't apply. It degrades gracefully, so not blocking. Worth a one-line note in the KDoc to document the dependency?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr:bug Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants