Skip to content

fix(ui): prevent auto-animate from overriding user-initiated sheet animations#3740

Draft
kairosci wants to merge 1 commit into
MetrolistGroup:mainfrom
kairosci:feat/queue-not-opening
Draft

fix(ui): prevent auto-animate from overriding user-initiated sheet animations#3740
kairosci wants to merge 1 commit into
MetrolistGroup:mainfrom
kairosci:feat/queue-not-opening

Conversation

@kairosci
Copy link
Copy Markdown
Contributor

@kairosci kairosci commented May 14, 2026

Problem

The queue sheet occasionally becomes unresponsive — tapping the queue button shows the tap animation but the queue panel does not open. The issue appears randomly during normal usage and requires a full app restart to recover.

Cause

The remember(dismissedBound, expandedBound, collapsedBound, coroutineScope) block in rememberBottomSheetState launches animatable.animateTo(initialValue, NavigationBarAnimationSpec) as a side effect every time any key changes. The queue sheet's expandedBound key tracks state.expandedBound (the Player sheet's animatable.upperBound!!) and dismissedBound depends on WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(). When these values fluctuate during normal usage (inset changes, player bound updates), the auto-animate cancels any in-progress user animation from expandSoft() mid-flight, creating a race condition that can leave the sheet in a broken state.

Solution

  • Added an activeAnimations counter (MutableIntState) that tracks whether user-initiated expand/collapse/dismiss animations are in progress
  • Introduced a launchAnimation wrapper that increments/decrements the counter around all user-initiated animation coroutines
  • The remember block's auto-animate now checks activeAnimations == 0 before launching — if the user is actively animating, the auto-animate is skipped and the user's animation continues uninterrupted
  • Passed launchAnimation to BottomSheetState as a constructor parameter (with a safe default for backward compatibility)

Testing

  • Build succeeds with ./gradlew :app:assembleFossDebug
  • No functional changes to the BottomSheet API — all existing callers continue to work unchanged

Related Issues

Summary by CodeRabbit

  • Refactor
    • Improved bottom sheet animation handling with centralized animation launching and better concurrent animation management for more reliable and smoother transitions.

Review Change Stack

The remember(dismissedBound, expandedBound, collapsedBound, coroutineScope)
block in rememberBottomSheetState launched animatable.animateTo() every
time any key changed, which could cancel a user-initiated expand animation
in progress. This race condition made the queue sheet appear unresponsive
after certain window inset or bound changes during normal usage.

Fixed by tracking active user animations with a counter and only running
the remember-block auto-animate when no user animation is in progress.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 14, 2026

📝 Walkthrough

Walkthrough

This PR centralizes animation launching in BottomSheetState by introducing a launchAnimation parameter that wraps coroutine launching. rememberBottomSheetState implements a custom wrapper with activeAnimations counter tracking and refactors initialization to only trigger animations when no concurrent animations are running.

Changes

Animation Launch Centralization

Layer / File(s) Summary
launchAnimation parameter contract
app/src/main/kotlin/com/metrolist/music/ui/component/BottomSheet.kt
BottomSheetState constructor adds a new launchAnimation lambda parameter with default implementation delegating to coroutineScope.launch to standardize animation launching.
State methods using launchAnimation
app/src/main/kotlin/com/metrolist/music/ui/component/BottomSheet.kt
collapse, expand, and dismiss methods updated to schedule animatable.animateTo through the launchAnimation wrapper instead of directly using coroutineScope.launch.
rememberBottomSheetState animation tracking
app/src/main/kotlin/com/metrolist/music/ui/component/BottomSheet.kt
rememberBottomSheetState introduces activeAnimations state and custom launchAnimation wrapper incrementing/decrementing counter. Initialization refactored to compute anchor target in animateToAnchor() and only trigger animation when activeAnimations is zero, replacing prior unconditional launch.
launchAnimation passed to constructor
app/src/main/kotlin/com/metrolist/music/ui/component/BottomSheet.kt
BottomSheetState constructed in rememberBottomSheetState now receives the custom launchAnimation wrapper as constructor argument.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Suggested reviewers

  • nyxiereal
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main fix: preventing auto-animate from canceling user-initiated sheet animations, which directly addresses the core problem in #3711.
Linked Issues check ✅ Passed The code changes directly address issue #3711 by implementing an activeAnimations counter to prevent auto-animate from canceling user-initiated animations, resolving the unresponsive queue sheet issue.
Out of Scope Changes check ✅ Passed All changes are scoped to fixing the race condition in BottomSheetState; no unrelated modifications or features are present outside the problem scope.
Description check ✅ Passed The pull request description fully matches the required template with all sections completed: Problem, Cause, Solution, Testing, and Related Issues clearly documented.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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 and usage tips.

@kairosci kairosci marked this pull request as draft May 14, 2026 16:44
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

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 `@app/src/main/kotlin/com/metrolist/music/ui/component/BottomSheet.kt`:
- Around line 345-354: The race is caused by incrementing
activeAnimations.intValue inside the coroutine body of the launchAnimation
lambda so recompositions can see 0 before the coroutine starts; move the
increment so it happens synchronously before coroutineScope.launch is invoked
(keep the decrement in the finally block inside the launched coroutine), i.e.
update the launchAnimation closure that references activeAnimations.intValue and
coroutineScope.launch so it increments activeAnimations.intValue immediately
prior to calling coroutineScope.launch (while leaving the existing try/finally
decrement inside the launched coroutine), ensuring calls from
state.expandSoft()/collapseSoft()/dismiss() are observed by the remember(...)
logic that checks activeAnimations.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: bcb241f7-1287-4e44-bf77-6f8de35670dd

📥 Commits

Reviewing files that changed from the base of the PR and between 15bf160 and 85acddd.

📒 Files selected for processing (1)
  • app/src/main/kotlin/com/metrolist/music/ui/component/BottomSheet.kt

Comment on lines +345 to +354
val launchAnimation: (suspend () -> Unit) -> Unit = { block ->
coroutineScope.launch {
activeAnimations.intValue++
try {
block()
} finally {
activeAnimations.intValue--
}
}
}
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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Race window: increment activeAnimations before launch, not inside the coroutine.

The counter is incremented inside the launched coroutine body, but coroutineScope.launch on AndroidUiDispatcher.Main is dispatched, not immediate. Between the call to state.expandSoft()/collapseSoft()/dismiss() and the moment the coroutine body runs, activeAnimations.intValue is still 0. If dismissedBound/expandedBound/collapsedBound change in that same frame (e.g., a bottom-inset update fired as part of the same gesture), the remember(dismissedBound, expandedBound, collapsedBound, coroutineScope) block at Line 369 re-runs, sees the counter as 0, and calls animateToAnchor() — which then cancels the user's still-pending animateTo on the same Animatable. That is the exact race #3711 is meant to close; this implementation only narrows the window, it doesn't close it.

Move the increment out of the launched block so it's observed synchronously by any recomposition triggered after the user call returns:

🛠️ Proposed fix
     val launchAnimation: (suspend () -> Unit) -> Unit = { block ->
+        activeAnimations.intValue++
         coroutineScope.launch {
-            activeAnimations.intValue++
             try {
                 block()
             } finally {
                 activeAnimations.intValue--
             }
         }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
val launchAnimation: (suspend () -> Unit) -> Unit = { block ->
coroutineScope.launch {
activeAnimations.intValue++
try {
block()
} finally {
activeAnimations.intValue--
}
}
}
val launchAnimation: (suspend () -> Unit) -> Unit = { block ->
activeAnimations.intValue++
coroutineScope.launch {
try {
block()
} finally {
activeAnimations.intValue--
}
}
}
🤖 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 `@app/src/main/kotlin/com/metrolist/music/ui/component/BottomSheet.kt` around
lines 345 - 354, The race is caused by incrementing activeAnimations.intValue
inside the coroutine body of the launchAnimation lambda so recompositions can
see 0 before the coroutine starts; move the increment so it happens
synchronously before coroutineScope.launch is invoked (keep the decrement in the
finally block inside the launched coroutine), i.e. update the launchAnimation
closure that references activeAnimations.intValue and coroutineScope.launch so
it increments activeAnimations.intValue immediately prior to calling
coroutineScope.launch (while leaving the existing try/finally decrement inside
the launched coroutine), ensuring calls from
state.expandSoft()/collapseSoft()/dismiss() are observed by the remember(...)
logic that checks activeAnimations.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Queue not opening

1 participant