perf(ui): bar reveals on pre-scroll and rides the fling's velocity

Two follow-up tweaks to the disappearing scaffold to remove the last
sources of roughness users could notice:

- Reveal now happens in onPreScroll as well as hide. The previous
  version only re-showed the bars when the list had leftover delta (at
  the top), so pulling finger-down mid-list did nothing until you
  actually reached the top. Matches M3 enterAlways expectations and
  still returns the exact consumed amount so the list never loses
  pixels.
- Post-fling settle now picks up the fling's tail velocity as
  initialVelocity and a strong velocity also biases the snap target,
  so the bar continues the fling's motion rather than starting a
  separate animation after it. Spring softened from stiffness 600 to
  StiffnessMediumLow for a less abrupt finish, and we early-out when
  already at a resting edge. onPostFling still swallows residual
  velocity so parents don't get a phantom kick.

https://claude.ai/code/session_01M3Bj24jLc9aVhMuvn55jXa
This commit is contained in:
Claude
2026-04-18 03:35:46 +00:00
parent 724b1a353d
commit 6c7e2bd67f
2 changed files with 58 additions and 44 deletions
@@ -29,13 +29,12 @@ import androidx.compose.ui.unit.Velocity
* Single nested-scroll connection that hides/reveals the top and bottom bars together.
*
* Behaviour:
* - onPreScroll: on "hide" deltas, consumes exactly the amount used to shift the bars
* (never over-claims the available delta). This avoids the list "swallowing" pixels
* at the edge of the bars' travel range.
* - onPostScroll: on "reveal" deltas still available after the list consumed its share,
* moves the bars back in; again consumes only what it used.
* - onPostFling: snaps mid-way bars to the nearest edge. No additional decay. No
* phantom velocity is returned upward.
* - onPreScroll: consumes the portion of the scroll delta used to move the bars
* (both hide and reveal), returning the exact amount absorbed so the list never
* "loses pixels" at the edge of the bars' travel range.
* - onPostFling: snaps a mid-way bar to the nearest edge, continuing the fling's
* tail velocity so the settle motion feels like part of the fling, not a second
* animation after it. No velocity is returned upward.
*/
class DisappearingBarNestedScroll(
private val state: DisappearingBarState,
@@ -47,25 +46,9 @@ class DisappearingBarNestedScroll(
source: NestedScrollSource,
): Offset {
if (!canScroll()) return Offset.Zero
if (available.y == 0f) return Offset.Zero
val deltaY = if (reverseLayout) -available.y else available.y
// Only hide on "hide" direction in the pre-scroll phase.
if (deltaY >= 0f) return Offset.Zero
val consumed = applyDelta(deltaY)
if (consumed == 0f) return Offset.Zero
return Offset(0f, if (reverseLayout) -consumed else consumed)
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource,
): Offset {
if (!canScroll()) return Offset.Zero
val deltaY = if (reverseLayout) -available.y else available.y
// Only reveal on "reveal" direction in the post-scroll phase.
if (deltaY <= 0f) return Offset.Zero
val applied = applyDelta(deltaY)
if (applied == 0f) return Offset.Zero
return Offset(0f, if (reverseLayout) -applied else applied)
@@ -75,17 +58,23 @@ class DisappearingBarNestedScroll(
consumed: Velocity,
available: Velocity,
): Velocity {
if (canScroll()) state.snapToNearestEdge()
// Do not propagate phantom velocity back up the nested-scroll tree.
if (canScroll()) {
// Feed the fling's remaining velocity into the settle so the bar keeps
// moving in the same direction rather than starting a fresh animation.
val velocityY = if (reverseLayout) -available.y else available.y
state.settleToNearestEdge(initialVelocityY = velocityY)
}
// Swallow any residual velocity so parents don't get a phantom fling kick.
return Velocity.Zero
}
/**
* Applies the given delta (in "content-space" negative hides, positive reveals) to
* Applies the given delta (in content-space negative hides, positive reveals) to
* both bar offsets, clamped to their travel range.
*
* Returns the delta that was actually absorbed (in the same sign convention) so the
* caller can report accurate consumption upward.
* Returns the delta that was actually absorbed, using the side with the larger
* absorption so consumption reporting remains accurate when the two bars have
* different remaining travel.
*/
private fun applyDelta(deltaY: Float): Float {
val prevTop = state.topHeightOffset
@@ -98,8 +87,6 @@ class DisappearingBarNestedScroll(
state.topHeightOffset = newTop
state.bottomHeightOffset = newBottom
// If either bar moved we consider that portion consumed. Use the largest absorbed
// magnitude so we don't claim more than one bar's worth of pixels.
val topDelta = newTop - prevTop
val bottomDelta = newBottom - prevBottom
return if (deltaY < 0f) minOf(topDelta, bottomDelta) else maxOf(topDelta, bottomDelta)
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.ui.layouts
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
@@ -68,12 +69,15 @@ class DisappearingBarState(
/**
* Snaps both bars to the nearest edge (fully shown or fully hidden).
* Used after a fling to resolve the "mid-way" state without a decay animation.
*
* If [initialVelocityY] is non-zero the spring continues the fling's motion rather than
* starting from rest, avoiding the "extra animation at the end of the fling" feel. The
* velocity is in content-space (negative = hide direction, positive = reveal direction).
*/
suspend fun snapToNearestEdge() {
suspend fun settleToNearestEdge(initialVelocityY: Float = 0f) {
coroutineScope {
launch { snapOne(topHeightLimit, { topHeightOffset }) { topHeightOffset = it } }
launch { snapOne(bottomHeightLimit, { bottomHeightOffset }) { bottomHeightOffset = it } }
launch { settleOne({ topHeightOffset }, topHeightLimit, initialVelocityY) { topHeightOffset = it } }
launch { settleOne({ bottomHeightOffset }, bottomHeightLimit, initialVelocityY) { bottomHeightOffset = it } }
}
}
@@ -82,37 +86,60 @@ class DisappearingBarState(
*/
suspend fun resetToVisible() {
coroutineScope {
launch { animateOne({ topHeightOffset }, 0f) { topHeightOffset = it } }
launch { animateOne({ bottomHeightOffset }, 0f) { bottomHeightOffset = it } }
launch { animateOne({ topHeightOffset }, 0f, 0f) { topHeightOffset = it } }
launch { animateOne({ bottomHeightOffset }, 0f, 0f) { bottomHeightOffset = it } }
}
}
private suspend fun snapOne(
limit: Float,
private suspend fun settleOne(
get: () -> Float,
limit: Float,
initialVelocityY: Float,
set: (Float) -> Unit,
) {
if (limit <= 0f) return
val current = get()
if (current >= 0f || current <= -limit) return
val target = if (-current < limit / 2f) 0f else -limit
animateOne(get, target, set)
// Decide target edge from position by default, but let a strong velocity bias it.
val positionBiasToHide = -current > limit / 2f
val target =
when {
initialVelocityY < -VELOCITY_BIAS_THRESHOLD -> -limit
initialVelocityY > VELOCITY_BIAS_THRESHOLD -> 0f
positionBiasToHide -> -limit
else -> 0f
}
animateOne(get, target, initialVelocityY, set)
}
private suspend fun animateOne(
get: () -> Float,
target: Float,
initialVelocity: Float,
set: (Float) -> Unit,
) {
val start = get()
if (start == target) return
if (start == target && initialVelocity == 0f) return
Animatable(start)
.animateTo(target, animationSpec = spring(stiffness = 600f)) {
.animateTo(
targetValue = target,
animationSpec = SETTLE_SPRING,
initialVelocity = initialVelocity,
) {
set(value)
}
}
companion object {
private const val VELOCITY_BIAS_THRESHOLD = 200f
private val SETTLE_SPRING =
spring<Float>(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMediumLow,
)
val Saver: Saver<DisappearingBarState, *> =
Saver(
save = { listOf(it.topHeightOffset, it.bottomHeightOffset) },