perf(ui): rewrite DisappearingScaffold for smoother scroll chrome

The previous implementation shrank the bar slots via Modifier.layout on
every scroll pixel, which forced Scaffold to re-measure its content and
the inner LazyColumn/HorizontalPager to reflow every frame. It also
over-claimed consumed offsets in onPreScroll (the list lost pixels at
the edge of the bars' travel range) and ran an extra decay+snap after a
fling, producing a visible "double animation".

New approach:
- Custom SubcomposeLayout keeps the bar slots at their natural height
  and moves them via Modifier.graphicsLayer { translationY = ... },
  which is a compositor-only operation. The content placeable is
  measured at full parent size with a stable PaddingValues so the inner
  lists never re-measure while the bars slide.
- Unified DisappearingBarState and DisappearingBarNestedScroll hide/
  reveal both bars together and return the exact consumed delta, so no
  pixels are swallowed at the transition.
- onPostFling snaps mid-way bars to the nearest edge without running a
  second decay, and returns Velocity.Zero so no phantom velocity leaks
  into parent nested-scroll containers.
- Drops the Modifier.draggable on both bars, re-enables reset-on-resume
  for the top bar, and simplifies the FAB to a graphicsLayer-only
  holder that rides along with the bottom bar.

https://claude.ai/code/session_01M3Bj24jLc9aVhMuvn55jXa
This commit is contained in:
Claude
2026-04-18 03:09:17 +00:00
parent 898dca26af
commit 724b1a353d
7 changed files with 379 additions and 586 deletions
@@ -0,0 +1,107 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.layouts
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
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.
*/
class DisappearingBarNestedScroll(
private val state: DisappearingBarState,
private val canScroll: () -> Boolean,
private val reverseLayout: Boolean,
) : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource,
): Offset {
if (!canScroll()) 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)
}
override suspend fun onPostFling(
consumed: Velocity,
available: Velocity,
): Velocity {
if (canScroll()) state.snapToNearestEdge()
// Do not propagate phantom velocity back up the nested-scroll tree.
return Velocity.Zero
}
/**
* 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.
*/
private fun applyDelta(deltaY: Float): Float {
val prevTop = state.topHeightOffset
val prevBottom = state.bottomHeightOffset
val topLimit = state.topHeightLimit
val bottomLimit = state.bottomHeightLimit
val newTop = (prevTop + deltaY).coerceIn(-topLimit, 0f)
val newBottom = (prevBottom + deltaY).coerceIn(-bottomLimit, 0f)
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)
}
}
@@ -0,0 +1,125 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.layouts
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.spring
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
/**
* Shared state for the disappearing top / bottom bar chrome.
*
* Both offsets are negative-or-zero. 0 = fully visible; -limit = fully hidden.
*
* Limits are updated by the layout pass once it measures the bar slots. The nested-scroll
* connection reads both limits and offsets to clamp movement to the visible travel range.
*/
@Stable
class DisappearingBarState(
initialTopHeightOffset: Float = 0f,
initialBottomHeightOffset: Float = 0f,
) {
var topHeightOffset by mutableFloatStateOf(initialTopHeightOffset)
var bottomHeightOffset by mutableFloatStateOf(initialBottomHeightOffset)
var topHeightLimit: Float = 0f
set(value) {
field = value
if (topHeightOffset < -value) topHeightOffset = -value
}
var bottomHeightLimit: Float = 0f
set(value) {
field = value
if (bottomHeightOffset < -value) bottomHeightOffset = -value
}
val topCollapsedFraction: Float
get() = if (topHeightLimit <= 0f) 0f else (-topHeightOffset / topHeightLimit).coerceIn(0f, 1f)
val bottomCollapsedFraction: Float
get() = if (bottomHeightLimit <= 0f) 0f else (-bottomHeightOffset / bottomHeightLimit).coerceIn(0f, 1f)
/**
* 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.
*/
suspend fun snapToNearestEdge() {
coroutineScope {
launch { snapOne(topHeightLimit, { topHeightOffset }) { topHeightOffset = it } }
launch { snapOne(bottomHeightLimit, { bottomHeightOffset }) { bottomHeightOffset = it } }
}
}
/**
* Animates both bars back to the fully visible resting state. Used on lifecycle resume.
*/
suspend fun resetToVisible() {
coroutineScope {
launch { animateOne({ topHeightOffset }, 0f) { topHeightOffset = it } }
launch { animateOne({ bottomHeightOffset }, 0f) { bottomHeightOffset = it } }
}
}
private suspend fun snapOne(
limit: Float,
get: () -> 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)
}
private suspend fun animateOne(
get: () -> Float,
target: Float,
set: (Float) -> Unit,
) {
val start = get()
if (start == target) return
Animatable(start)
.animateTo(target, animationSpec = spring(stiffness = 600f)) {
set(value)
}
}
companion object {
val Saver: Saver<DisappearingBarState, *> =
Saver(
save = { listOf(it.topHeightOffset, it.bottomHeightOffset) },
restore = { DisappearingBarState(it[0], it[1]) },
)
}
}
@Composable
fun rememberDisappearingBarState(): DisappearingBarState = rememberSaveable(saver = DisappearingBarState.Saver) { DisappearingBarState() }
@@ -1,171 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.layouts
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.AnimationState
import androidx.compose.animation.core.DecayAnimationSpec
import androidx.compose.animation.core.animateDecay
import androidx.compose.animation.core.animateTo
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.BottomAppBarScrollBehavior
import androidx.compose.material3.BottomAppBarState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.layout
import androidx.compose.ui.unit.Velocity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DisappearingBottomBar(
scrollBehavior: BottomAppBarScrollBehavior,
content: @Composable (ColumnScope.() -> Unit),
) {
// Set up support for resizing the bottom app bar when vertically dragging the bar itself.
val appBarDragModifier =
if (!scrollBehavior.isPinned) {
Modifier.draggable(
orientation = Orientation.Vertical,
state = rememberDraggableState { delta -> scrollBehavior.state.heightOffset -= delta },
onDragStopped = { velocity ->
settleAppBarBottom(
scrollBehavior.state,
velocity,
scrollBehavior.flingAnimationSpec,
scrollBehavior.snapAnimationSpec,
)
},
)
} else {
Modifier
}
ResetDisappearingOnResume(scrollBehavior)
Column(
modifier =
Modifier
.fillMaxWidth()
.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
scrollBehavior.state.heightOffsetLimit = -placeable.height.toFloat()
val height = placeable.height + scrollBehavior.state.heightOffset
layout(placeable.width, height.roundToInt().coerceAtLeast(0)) { placeable.place(0, 0) }
}.then(appBarDragModifier),
content = content,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ResetDisappearingOnResume(scrollBehavior: BottomAppBarScrollBehavior) {
// resets bar on resume
val lifeCycleOwner = LocalLifecycleOwner.current
val scope = rememberCoroutineScope()
DisposableEffect(lifeCycleOwner) {
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME && scrollBehavior.state.heightOffset != 0f) {
val spec = scrollBehavior.snapAnimationSpec
if (spec != null) {
scope.launch {
AnimationState(initialValue = scrollBehavior.state.heightOffset)
.animateTo(0f, animationSpec = spec) {
scrollBehavior.state.heightOffset = value
}
}
} else {
scrollBehavior.state.heightOffset = 0f
}
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) }
}
}
@OptIn(ExperimentalMaterial3Api::class)
private suspend fun settleAppBarBottom(
state: BottomAppBarState,
velocity: Float,
flingAnimationSpec: DecayAnimationSpec<Float>?,
snapAnimationSpec: AnimationSpec<Float>?,
): Velocity {
// Check if the app bar is completely collapsed/expanded. If so, no need to settle the app bar,
// and just return Zero Velocity.
// Note that we don't check for 0f due to float precision with the collapsedFraction
// calculation.
if (state.collapsedFraction < 0.01f || state.collapsedFraction == 1f) {
return Velocity.Zero
}
var remainingVelocity = velocity
// In case there is an initial velocity that was left after a previous user fling, animate to
// continue the motion to expand or collapse the app bar.
if (flingAnimationSpec != null && abs(velocity) > 1f) {
var lastValue = 0f
AnimationState(
initialValue = 0f,
initialVelocity = velocity,
).animateDecay(flingAnimationSpec) {
val delta = value - lastValue
val initialHeightOffset = state.heightOffset
state.heightOffset = initialHeightOffset + delta
val consumed = abs(initialHeightOffset - state.heightOffset)
lastValue = value
remainingVelocity = this.velocity
// avoid rounding errors and stop if anything is unconsumed
if (abs(delta - consumed) > 0.5f) this.cancelAnimation()
}
}
// Snap if animation specs were provided.
if (snapAnimationSpec != null) {
if (state.heightOffset < 0 && state.heightOffset > state.heightOffsetLimit) {
AnimationState(initialValue = state.heightOffset).animateTo(
if (state.collapsedFraction < 0.5f) {
0f
} else {
state.heightOffsetLimit
},
animationSpec = snapAnimationSpec,
) {
state.heightOffset = value
}
}
}
return Velocity(0f, remainingVelocity)
}
@@ -1,62 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.layouts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.material3.BottomAppBarScrollBehavior
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.layout
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DisappearingFloatingButton(
scrollBehavior: BottomAppBarScrollBehavior,
content: @Composable (BoxScope.() -> Unit),
) {
// We calculate the scale/alpha based on how much the bar is expanded
// 1.0 = fully visible, 0.0 = fully hidden
val progress = (1f - scrollBehavior.state.collapsedFraction).coerceAtLeast(0.001f)
Box(
modifier =
Modifier
.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
// Adjust the height of the layout so the FAB doesn't leave a "hole"
val currentHeight = (placeable.height * progress).toInt()
layout(placeable.width, currentHeight) {
placeable.placeRelative(0, 0)
}
}.graphicsLayer {
this.scaleX = progress
this.scaleY = progress
this.alpha = progress
clip = false
},
contentAlignment = Alignment.TopCenter,
content = content,
)
}
@@ -20,20 +20,32 @@
*/
package com.vitorpamplona.amethyst.ui.layouts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.imePadding
import androidx.compose.material3.BottomAppBarDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import kotlinx.coroutines.launch
private enum class DisappearingSlot { Top, Bottom, Content, Fab }
private val FabEdgePadding = 16.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DisappearingScaffold(
isInvertedLayout: Boolean,
@@ -44,50 +56,141 @@ fun DisappearingScaffold(
isActive: () -> Boolean = { true },
mainContent: @Composable (padding: PaddingValues) -> Unit,
) {
val topBehavior =
enterAlwaysScrollBehavior(
canScroll = {
topBar != null && isActive() && accountViewModel.settings.isImmersiveScrollingActive()
},
reverseLayout = isInvertedLayout,
)
val bottomBehavior =
BottomAppBarDefaults.exitAlwaysScrollBehavior(
canScroll = {
(bottomBar != null || floatingButton != null) && isActive() && accountViewModel.settings.isImmersiveScrollingActive()
},
)
val state = rememberDisappearingBarState()
Scaffold(
val canScroll = {
isActive() && accountViewModel.settings.isImmersiveScrollingActive()
}
val connection =
remember(state, isInvertedLayout) {
DisappearingBarNestedScroll(
state = state,
canScroll = canScroll,
reverseLayout = isInvertedLayout,
)
}
ResetBarsOnResume(state)
SubcomposeLayout(
modifier =
Modifier
.imePadding()
.nestedScroll(topBehavior.nestedScrollConnection)
.nestedScroll(bottomBehavior.nestedScrollConnection),
bottomBar = {
bottomBar?.let {
DisappearingBottomBar(bottomBehavior) {
it()
}
}
},
topBar = {
topBar?.let {
DisappearingTopBar(topBehavior) {
Column {
it()
.nestedScroll(connection),
) { constraints ->
val layoutWidth = constraints.maxWidth
val layoutHeight = constraints.maxHeight
val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0)
val topPlaceable =
topBar?.let { bar ->
subcompose(DisappearingSlot.Top) {
Column(
modifier =
Modifier.graphicsLayer {
translationY = state.topHeightOffset
},
) {
bar()
HorizontalDivider(thickness = DividerThickness)
}
}.firstOrNull()?.measure(looseConstraints)
}
val topHeight = topPlaceable?.height ?: 0
val bottomPlaceable =
bottomBar?.let { bar ->
subcompose(DisappearingSlot.Bottom) {
Column(
modifier =
Modifier.graphicsLayer {
translationY = -state.bottomHeightOffset
},
) {
bar()
}
}.firstOrNull()?.measure(looseConstraints)
}
val bottomHeight = bottomPlaceable?.height ?: 0
// Publish the measured limits so the nested-scroll connection can clamp correctly.
state.topHeightLimit = topHeight.toFloat()
state.bottomHeightLimit = bottomHeight.toFloat()
val contentPadding =
PaddingValues(
top = topHeight.toDp(),
bottom = bottomHeight.toDp(),
)
val contentPlaceable =
subcompose(DisappearingSlot.Content) {
mainContent(contentPadding)
}.firstOrNull()?.measure(
Constraints.fixed(layoutWidth, layoutHeight),
)
val fabPlaceable =
floatingButton?.let { fab ->
subcompose(DisappearingSlot.Fab) {
FloatingButtonHolder(state = state) { fab() }
}.firstOrNull()?.measure(looseConstraints)
}
val fabEdgePx = FabEdgePadding.roundToPx()
layout(layoutWidth, layoutHeight) {
contentPlaceable?.place(0, 0)
topPlaceable?.place(0, 0)
bottomPlaceable?.place(0, layoutHeight - bottomHeight)
if (fabPlaceable != null) {
val x = layoutWidth - fabPlaceable.width - fabEdgePx
val yBase = layoutHeight - bottomHeight - fabPlaceable.height - fabEdgePx
fabPlaceable.place(x, yBase)
}
}
}
}
/**
* Holds the floating button. Scales / fades it with the bottom-bar collapse fraction and
* rides along with the bar via graphicsLayer, so the layout pass is untouched while
* the bar animates.
*/
@Composable
private fun FloatingButtonHolder(
state: DisappearingBarState,
content: @Composable () -> Unit,
) {
Box(
modifier =
Modifier.graphicsLayer {
val visible = (1f - state.bottomCollapsedFraction).coerceAtLeast(0f)
scaleX = visible
scaleY = visible
alpha = visible
translationY = -state.bottomHeightOffset
},
) {
content()
}
}
@Composable
private fun ResetBarsOnResume(state: DisappearingBarState) {
val lifecycleOwner = LocalLifecycleOwner.current
val scope = rememberCoroutineScope()
DisposableEffect(lifecycleOwner, state) {
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
if (state.topHeightOffset != 0f || state.bottomHeightOffset != 0f) {
scope.launch { state.resetToVisible() }
}
}
}
},
floatingActionButton = {
floatingButton?.let {
DisappearingFloatingButton(bottomBehavior) {
it()
}
}
},
content = mainContent,
)
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
}
@@ -1,292 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.layouts
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.AnimationState
import androidx.compose.animation.core.DecayAnimationSpec
import androidx.compose.animation.core.animateDecay
import androidx.compose.animation.core.animateTo
import androidx.compose.animation.core.spring
import androidx.compose.animation.rememberSplineBasedDecay
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.TopAppBarState
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.layout.layout
import androidx.compose.ui.unit.Velocity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.roundToInt
@Composable
fun DisappearingTopBar(
scrollBehavior: CustomEnterAlwaysScrollBehavior,
content: @Composable (ColumnScope.() -> Unit),
) {
// ResetDisappearingOnResume(scrollBehavior)
// Set up support for resizing the top app bar when vertically dragging the bar itself.
val appBarDragModifier =
Modifier
.draggable(
orientation = Orientation.Vertical,
state =
rememberDraggableState { delta ->
scrollBehavior.state.heightOffset += delta
},
onDragStopped = { velocity ->
settleAppBar(
scrollBehavior.state,
velocity,
scrollBehavior.flingAnimationSpec,
scrollBehavior.snapAnimationSpec,
)
},
)
Column(
Modifier
.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
// Sets the app bar's height offset to collapse the entire bar's height when
// content is scrolled.
scrollBehavior.state.heightOffsetLimit = -placeable.height.toFloat()
val height = placeable.height + scrollBehavior.state.heightOffset
layout(placeable.width, height.roundToInt().coerceAtLeast(0)) {
// slides up together with the reduce in height
placeable.place(0, scrollBehavior.state.heightOffset.roundToInt())
}
}.then(appBarDragModifier),
content = content,
)
}
@Composable
fun ResetDisappearingOnResume(scrollBehavior: CustomEnterAlwaysScrollBehavior) {
// resets bar on resume
val lifeCycleOwner = LocalLifecycleOwner.current
val scope = rememberCoroutineScope()
DisposableEffect(lifeCycleOwner) {
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME && scrollBehavior.state.heightOffset != 0f) {
val spec = scrollBehavior.snapAnimationSpec
if (spec != null) {
scope.launch {
AnimationState(initialValue = scrollBehavior.state.heightOffset)
.animateTo(0f, animationSpec = spec) {
scrollBehavior.state.heightOffset = value
}
AnimationState(initialValue = scrollBehavior.state.contentOffset)
.animateTo(0f, animationSpec = spec) {
scrollBehavior.state.contentOffset = value
}
}
} else {
scrollBehavior.state.heightOffset = 0f
scrollBehavior.state.contentOffset = 0f
}
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) }
}
}
private suspend fun settleAppBar(
state: TopAppBarState,
velocity: Float,
flingAnimationSpec: DecayAnimationSpec<Float>?,
snapAnimationSpec: AnimationSpec<Float>?,
): Velocity {
// Check if the app bar is completely collapsed/expanded. If so, no need to settle the app bar,
// and just return Zero Velocity.
// Note that we don't check for 0f due to float precision with the collapsedFraction
// calculation.
if (state.collapsedFraction < 0.01f || state.collapsedFraction == 1f) {
return Velocity.Zero
}
var remainingVelocity = velocity
// In case there is an initial velocity that was left after a previous user fling, animate to
// continue the motion to expand or collapse the app bar.
if (flingAnimationSpec != null && abs(velocity) > 1f) {
var lastValue = 0f
AnimationState(
initialValue = 0f,
initialVelocity = velocity,
).animateDecay(flingAnimationSpec) {
val delta = value - lastValue
val initialHeightOffset = state.heightOffset
state.heightOffset = initialHeightOffset + delta
val consumed = abs(initialHeightOffset - state.heightOffset)
lastValue = value
remainingVelocity = this.velocity
// avoid rounding errors and stop if anything is unconsumed
if (abs(delta - consumed) > 0.5f) this.cancelAnimation()
}
}
// Snap if animation specs were provided.
if (snapAnimationSpec != null) {
if (state.heightOffset < 0 && state.heightOffset > state.heightOffsetLimit) {
AnimationState(initialValue = state.heightOffset).animateTo(
if (state.collapsedFraction < 0.5f) {
0f
} else {
state.heightOffsetLimit
},
animationSpec = snapAnimationSpec,
) {
state.heightOffset = value
}
}
}
return Velocity(0f, remainingVelocity)
}
/**
* EnterAlwaysScrollBehavior uses internal Material 3 animation specs
*/
val defaultMaterial3StandardSnap =
spring<Float>(
dampingRatio = 1.0f,
stiffness = 1600.0f,
)
/**
* Copy of enterAlwaysScrollBehavior to use CustomEnterAlwaysScrollBehavior
*/
@Composable
fun enterAlwaysScrollBehavior(
state: TopAppBarState = rememberTopAppBarState(),
canScroll: () -> Boolean = { true },
// TODO Load the motionScheme tokens from the component tokens file
snapAnimationSpec: AnimationSpec<Float>? = defaultMaterial3StandardSnap,
flingAnimationSpec: DecayAnimationSpec<Float>? = rememberSplineBasedDecay(),
reverseLayout: Boolean = false,
): CustomEnterAlwaysScrollBehavior =
remember(state, canScroll, snapAnimationSpec, flingAnimationSpec) {
CustomEnterAlwaysScrollBehavior(
state = state,
snapAnimationSpec = snapAnimationSpec,
flingAnimationSpec = flingAnimationSpec,
canScroll = canScroll,
reverseLayout = reverseLayout,
)
}
/**
* Custom copy of EnterAlwaysScrollBehavior that correctly handles reversed layouts
*/
@OptIn(ExperimentalMaterial3Api::class)
@Stable
class CustomEnterAlwaysScrollBehavior(
override val state: TopAppBarState,
override val snapAnimationSpec: AnimationSpec<Float>?,
override val flingAnimationSpec: DecayAnimationSpec<Float>?,
val canScroll: () -> Boolean = { true },
val reverseLayout: Boolean = false,
) : TopAppBarScrollBehavior {
override val isPinned: Boolean = false
override var nestedScrollConnection =
object : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource,
): Offset {
if (!canScroll()) return Offset.Zero
val prevHeightOffset = state.heightOffset
state.heightOffset +=
if (reverseLayout) {
-available.y
} else {
available.y
}
// The state's heightOffset is coerce in a minimum value of heightOffsetLimit and a
// maximum value 0f, so we check if its value was actually changed after the
// available.y was added to it in order to tell if the top app bar is currently
// collapsing or expanding.
// Note that when the content was set with a revered layout, we always return a
// zero offset.
return if (!reverseLayout && prevHeightOffset != state.heightOffset) {
available.copy(x = 0f)
} else {
Offset.Zero
}
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource,
): Offset {
if (!canScroll()) return Offset.Zero
state.contentOffset += consumed.y
state.heightOffset +=
if (reverseLayout) {
-consumed.y
} else {
consumed.y
}
return Offset.Zero
}
override suspend fun onPostFling(
consumed: Velocity,
available: Velocity,
): Velocity {
val hasVelocityLeft = if (reverseLayout) available.y < 0f else available.y > 0f
if (
hasVelocityLeft &&
(state.heightOffset == 0f || state.heightOffset == state.heightOffsetLimit)
) {
// Reset the total content offset to zero when scrolling all the way down.
// This will eliminate some float precision inaccuracies.
state.contentOffset = 0f
}
val superConsumed = super.onPostFling(consumed, available)
return superConsumed + settleAppBar(state, available.y, flingAnimationSpec, snapAnimationSpec)
}
}
}
@@ -21,11 +21,8 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.BottomAppBarDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -36,7 +33,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.layouts.DisappearingFloatingButton
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -46,25 +42,12 @@ import com.vitorpamplona.amethyst.ui.theme.Size26Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
@OptIn(ExperimentalMaterial3Api::class)
@Preview
@Composable
fun NewNoteButtonPreview() {
val bottomBehavior =
BottomAppBarDefaults.exitAlwaysScrollBehavior(
canScroll = { true },
)
ThemeComparisonRow {
Column {
Box(Modifier.size(200.dp), contentAlignment = Alignment.Center) {
DisappearingFloatingButton(bottomBehavior) {
NewNoteButton(EmptyNav())
}
}
Box(Modifier.size(200.dp), contentAlignment = Alignment.Center) {
NewNoteButton(EmptyNav())
}
Box(Modifier.size(200.dp), contentAlignment = Alignment.Center) {
NewNoteButton(EmptyNav())
}
}
}