diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarNestedScroll.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarNestedScroll.kt index 0261d6857..5d2db649d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarNestedScroll.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarNestedScroll.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarState.kt index 50df41a08..d426b09e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarState.kt @@ -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( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ) + val Saver: Saver = Saver( save = { listOf(it.topHeightOffset, it.bottomHeightOffset) },