Skip to content

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

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

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

@kairosci kairosci commented May 14, 2026

Copy link
Copy Markdown
Contributor

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 and drag snapping for smoother, more reliable transitions.
    • Made initial open/close animations more resilient to interruptions so the sheet recovers gracefully.
  • Bug Fixes

    • Prevented rare animation failures from leaving the sheet in an inconsistent state.

@coderabbitai

coderabbitai Bot commented May 14, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Collapse, initial anchor animation, and drag snap now coerce computed targets into the animatable's current bounds. Initial animateTo and drag snap calls are wrapped to rethrow CancellationException but ignore other animation errors.

Changes

BottomSheet bounds-coercion & resilient animations

Layer / File(s) Summary
Collapse uses bounds-coerced target
app/src/main/kotlin/com/metrolist/music/ui/component/BottomSheet.kt
collapse(animationSpec) animates to collapsedBound coerced into the animatable's current bounds instead of directly using collapsedBound.
Initial animateTo with bounds coercion and error handling
app/src/main/kotlin/com/metrolist/music/ui/component/BottomSheet.kt
rememberBottomSheetState computes a targetValue from previousAnchor coerced into current bounds and wraps the initial animatable.animateTo(..., NavigationBarAnimationSpec) in a try/catch that rethrows CancellationException and ignores other exceptions.
Drag snap coerced and exception-handled
app/src/main/kotlin/com/metrolist/music/ui/component/BottomSheet.kt
Drag handling now coerces the computed snap target into bounds and wraps animatable.snapTo(...) in the same CancellationException-preserving try/catch pattern.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

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 describes the main fix: preventing auto-animate from overriding user-initiated sheet animations, which directly addresses the queue sheet unresponsiveness issue.
Description check ✅ Passed The description thoroughly covers all required template sections: Problem, Cause, Solution, Testing, and Related Issues with specific technical details and implementation strategy.
Linked Issues check ✅ Passed The code changes directly address the requirements from issue #3711 by implementing the activeAnimations counter and launchAnimation wrapper to prevent auto-animate from canceling user animations.
Out of Scope Changes check ✅ Passed All changes are focused on fixing the BottomSheet animation race condition; no unrelated modifications are present in the changeset.

✏️ 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

@coderabbitai coderabbitai Bot 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.

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 thread app/src/main/kotlin/com/metrolist/music/ui/component/BottomSheet.kt Outdated
@kairosci kairosci marked this pull request as ready for review June 6, 2026 13:37
The previous approach used a workaround (activeAnimations counter)
to prevent the remember block from overriding user animations.
However, the true root cause of the queue sheet becoming permanently
unresponsive (until app restart) was the CoroutineScope getting cancelled.
During layout passes, the bounds could momentarily collapse (e.g., expandedBound < collapsedBound).
When the remember block triggered animatable.animateTo() with an
out-of-bounds initialValue, it threw an IllegalArgumentException.
Since this exception was unhandled, it propagated and killed the
rememberCoroutineScope, silently breaking all future interactions.

This commit resolves the root cause by clamping the target values
within the Animatable's bounds and catching non-cancellation exceptions
to keep the CoroutineScope alive. It also removes the activeAnimations
workaround, which caused regressions by ignoring valid layout updates
during active animations and manual drags.
@kairosci kairosci force-pushed the feat/queue-not-opening branch from 85acddd to aa989ba Compare June 6, 2026 13:42

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 2

🧹 Nitpick comments (2)
app/src/main/kotlin/com/metrolist/music/ui/component/BottomSheet.kt (2)

364-368: ⚡ Quick win

Add logging for ignored snapTo exceptions.

Same concern as the animateTo exception handling: silently ignoring non-cancellation exceptions during drag gestures makes debugging difficult.

📝 Proposed enhancement: log ignored exceptions
+import android.util.Log
+
+private const val TAG = "BottomSheet"
+
 // ... in DraggableState
                     try {
                         animatable.snapTo(target)
                     } catch (e: Exception) {
                         if (e is kotlinx.coroutines.CancellationException) throw e
+                        Log.w(TAG, "Drag snap animation failed, ignoring to prevent scope death", e)
                     }
🤖 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 364 - 368, The catch block around animatable.snapTo in BottomSheet.kt
currently swallows all non-cancellation exceptions; update the catch to rethrow
kotlinx.coroutines.CancellationException but log any other Exception (include
the exception message and stacktrace) before continuing. Specifically, in the
try/catch that calls animatable.snapTo(target) inside the BottomSheet
composable/drag handler, keep the "if (e is CancellationException) throw e"
check and add a logging call (e.g., Log.e or the project's logger/Timber) such
as Log.e("BottomSheet", "snapTo failed during drag", e) to record the ignored
exception. Ensure you import the logger you use.

351-356: ⚡ Quick win

Add logging for ignored animation exceptions.

While the CancellationException-preserving pattern is correct and consistent with the codebase, silently ignoring non-cancellation exceptions could hide bugs and make debugging difficult. Consider logging ignored exceptions.

📝 Proposed enhancement: log ignored exceptions
+import android.util.Log
+
+private const val TAG = "BottomSheet"
+
 // ... in rememberBottomSheetState
         coroutineScope.launch {
             try {
                 animatable.animateTo(targetValue, NavigationBarAnimationSpec)
             } catch (e: Exception) {
                 if (e is kotlinx.coroutines.CancellationException) throw e
-                // Ignore unexpected animation errors to prevent coroutine scope death
+                Log.w(TAG, "Initial anchor animation failed, ignoring to prevent scope death", e)
             }
         }
🤖 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 351 - 356, The catch block around animatable.animateTo(targetValue,
NavigationBarAnimationSpec) currently swallows non-cancellation exceptions;
update it to log the caught exception (while still rethrowing
kotlinx.coroutines.CancellationException) so unexpected animation errors are
recorded for debugging—add a warning/error log call (e.g., via Android
Log/Timber or the project logger) inside the catch branch for
non-CancellationException cases and include the exception and a short contextual
message mentioning animatable.animateTo/NavigationBarAnimationSpec.
🤖 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 340-341: The current logic uses val lowerBound =
dismissedBound.coerceAtMost(expandedBound) which can invert semantics when
expandedBound < dismissedBound; change this to use the dismissed bound directly
and call animatable.updateBounds(dismissedBound, expandedBound) so the lower
bound always represents the dismissed (bottom) position, or alternatively add an
explicit validation that throws/ logs if expandedBound < dismissedBound before
calling animatable.updateBounds to fail fast; update references: dismissedBound,
expandedBound, lowerBound and animatable.updateBounds accordingly.
- Around line 186-187: The call to Dp.coerceIn uses animatable.lowerBound and
animatable.upperBound which are nullable (Dp?), so update the expression to
mirror the class's existing pattern by force-unwrapping the bounds (use
animatable.lowerBound!! and animatable.upperBound!!) when computing target
before calling animatable.animateTo(collapsedBound.coerceIn(...), animationSpec)
so the non-nullable overload of coerceIn is used; reference symbols:
collapsedBound, animatable, lowerBound, upperBound, coerceIn, animateTo.

---

Nitpick comments:
In `@app/src/main/kotlin/com/metrolist/music/ui/component/BottomSheet.kt`:
- Around line 364-368: The catch block around animatable.snapTo in
BottomSheet.kt currently swallows all non-cancellation exceptions; update the
catch to rethrow kotlinx.coroutines.CancellationException but log any other
Exception (include the exception message and stacktrace) before continuing.
Specifically, in the try/catch that calls animatable.snapTo(target) inside the
BottomSheet composable/drag handler, keep the "if (e is CancellationException)
throw e" check and add a logging call (e.g., Log.e or the project's
logger/Timber) such as Log.e("BottomSheet", "snapTo failed during drag", e) to
record the ignored exception. Ensure you import the logger you use.
- Around line 351-356: The catch block around animatable.animateTo(targetValue,
NavigationBarAnimationSpec) currently swallows non-cancellation exceptions;
update it to log the caught exception (while still rethrowing
kotlinx.coroutines.CancellationException) so unexpected animation errors are
recorded for debugging—add a warning/error log call (e.g., via Android
Log/Timber or the project logger) inside the catch branch for
non-CancellationException cases and include the exception and a short contextual
message mentioning animatable.animateTo/NavigationBarAnimationSpec.
🪄 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: 9c62c2f9-f6d0-4724-89c5-7a188c703a9e

📥 Commits

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

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

Comment on lines +186 to +187
val target = collapsedBound.coerceIn(animatable.lowerBound, animatable.upperBound)
animatable.animateTo(target, animationSpec)

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

Verify nullable bounds handling in coerceIn.

animatable.lowerBound and animatable.upperBound are Dp? (nullable), but Dp.coerceIn expects non-null parameters. The existing pattern in this class (lines 160, 163) uses !! to force-unwrap when accessing bounds. Either the nullable bounds should be force-unwrapped here, or there's a special API overload that handles nullables.

While the PR states the build succeeds, this appears to be a type mismatch that warrants verification.

Run the following script to verify the Compose Animatable API for coerceIn with nullable bounds:

#!/bin/bash
# Description: Check how lowerBound/upperBound are used with coerceIn in the codebase

# Check the pattern of accessing animatable bounds
rg -n -C3 'animatable\.(lowerBound|upperBound)' --type=kt
Does Compose Animatable's Dp.coerceIn method accept nullable Dp parameters for minimumValue and maximumValue?
🛡️ Proposed fix if force-unwrap is needed
-            val target = collapsedBound.coerceIn(animatable.lowerBound, animatable.upperBound)
+            val target = collapsedBound.coerceIn(animatable.lowerBound!!, animatable.upperBound!!)
             animatable.animateTo(target, animationSpec)
🤖 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 186 - 187, The call to Dp.coerceIn uses animatable.lowerBound and
animatable.upperBound which are nullable (Dp?), so update the expression to
mirror the class's existing pattern by force-unwrapping the bounds (use
animatable.lowerBound!! and animatable.upperBound!!) when computing target
before calling animatable.animateTo(collapsedBound.coerceIn(...), animationSpec)
so the non-nullable overload of coerceIn is used; reference symbols:
collapsedBound, animatable, lowerBound, upperBound, coerceIn, animateTo.

Comment on lines +340 to +341
val lowerBound = dismissedBound.coerceAtMost(expandedBound)
animatable.updateBounds(lowerBound, expandedBound)

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

Logic error: coerceAtMost produces semantically incorrect lowerBound when bounds are inverted.

If expandedBound < dismissedBound (invalid configuration), this code sets lowerBound = expandedBound, meaning the "lower" bound would be the expanded position—semantically backwards. The animatable's lower bound should represent the dismissed (bottom) position, not the expanded (top) position.

🔧 Proposed fix: use dismissed bound directly or validate configuration

Option 1 (preferred): Use dismissedBound directly as lower bound

-        val lowerBound = dismissedBound.coerceAtMost(expandedBound)
-        animatable.updateBounds(lowerBound, expandedBound)
+        animatable.updateBounds(dismissedBound, expandedBound)

Option 2: Validate configuration and fail fast

+        require(dismissedBound <= expandedBound) {
+            "Invalid BottomSheet bounds: dismissedBound ($dismissedBound) must be <= expandedBound ($expandedBound)"
+        }
         val lowerBound = dismissedBound.coerceAtMost(expandedBound)
         animatable.updateBounds(lowerBound, expandedBound)
📝 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 lowerBound = dismissedBound.coerceAtMost(expandedBound)
animatable.updateBounds(lowerBound, expandedBound)
animatable.updateBounds(dismissedBound, expandedBound)
🤖 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 340 - 341, The current logic uses val lowerBound =
dismissedBound.coerceAtMost(expandedBound) which can invert semantics when
expandedBound < dismissedBound; change this to use the dismissed bound directly
and call animatable.updateBounds(dismissedBound, expandedBound) so the lower
bound always represents the dismissed (bottom) position, or alternatively add an
explicit validation that throws/ logs if expandedBound < dismissedBound before
calling animatable.updateBounds to fail fast; update references: dismissedBound,
expandedBound, lowerBound and animatable.updateBounds accordingly.

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