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:
+107
@@ -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() }
|
||||
-171
@@ -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)
|
||||
}
|
||||
-62
@@ -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,
|
||||
)
|
||||
}
|
||||
+145
-42
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-19
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user