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:
+19
-32
@@ -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)
|
||||
|
||||
+39
-12
@@ -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) },
|
||||
|
||||
Reference in New Issue
Block a user