Updated on 2026-08-14

This commit is contained in:
Tangem 2026-02-27 17:52:48 +05:00
parent 128faaec8d
commit 31e0591ef0
20 changed files with 1242 additions and 20 deletions

View file

@ -1,6 +1,7 @@
package com.tangem.core.ui.ds.button
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Stable
import com.tangem.core.ui.extensions.TextReference
/**
@ -19,6 +20,7 @@ import com.tangem.core.ui.extensions.TextReference
*
[REDACTED_AUTHOR]
*/
@Stable
data class TangemButtonUM(
val text: TextReference? = null,
val descriptionText: TextReference? = null,

View file

@ -2,7 +2,7 @@ package com.tangem.core.ui.ds.topbar
import android.content.res.Configuration
import androidx.annotation.DrawableRes
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Icon
@ -55,6 +55,7 @@ fun TangemTopBar(
modifier = modifier,
content = {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x0_5),
) {
@ -95,11 +96,17 @@ private fun TangemTopBarTitle(title: TextReference?, @DrawableRes titleIconRes:
AnimatedVisibility(
visible = title != null,
label = "Title Visibility",
enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(),
) {
val wrappedTitle = remember(this) { requireNotNull(title) }
Row(
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1),
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(
space = TangemTheme.dimens2.x1,
alignment = Alignment.CenterHorizontally,
),
verticalAlignment = Alignment.CenterVertically,
) {
AnimatedVisibility(

View file

@ -0,0 +1,126 @@
package com.tangem.core.ui.ds.topbar.collapsing
import android.content.res.Configuration
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.DecayAnimationSpec
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.ds.topbar.collapsing.entity.TangemCollapsingAppBarState
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import kotlin.math.max
import kotlin.math.roundToInt
@Composable
fun TangemCollapsingTopBar(
state: TangemCollapsingAppBarState,
collapsingPart: @Composable () -> Unit,
body: @Composable () -> Unit,
) {
Layout(
modifier = Modifier.fillMaxSize(),
content = {
collapsingPart()
body()
},
) { measurables, constraints ->
val collapsingConstraints = constraints.copy(
minWidth = 0,
minHeight = 0,
)
val collapsingPlaceable = measurables[0].measure(collapsingConstraints)
val bodyConstraints = constraints.copy(
minWidth = 0,
minHeight = 0,
maxHeight = (constraints.maxHeight - collapsingConstraints.minHeight).coerceAtLeast(0),
)
val bodyPlaceable = measurables[1].measure(bodyConstraints)
val minHeight = 0.dp.roundToPx()
val maxHeight = collapsingPlaceable.height + minHeight
val offset = state.heightOffset.roundToInt().coerceAtLeast(-maxHeight)
val width = max(
collapsingPlaceable.width,
bodyPlaceable.width,
).coerceIn(constraints.minWidth, constraints.maxWidth)
val height = max(
collapsingPlaceable.height,
bodyPlaceable.height,
).coerceIn(constraints.minHeight, constraints.maxHeight)
layout(width = width, height = height) {
bodyPlaceable.placeRelative(0, collapsingPlaceable.height + offset)
collapsingPlaceable.placeRelative(0, offset)
}
}
}
/**
* A scroll behavior for a collapsing top app bar that collapses when scrolling up and expands when scrolling down.
*
* @property state The state of the collapsing app bar.
* @property snapAnimationSpec The animation spec used for snapping the app bar to its collapsed or
* expanded state after a fling. If null, no snapping will occur.
* @property flingAnimationSpec The decay animation spec used for fling gestures.
* If null, fling gestures will not be handled.
* @property nestedScrollConnection Nested scroll connection
*/
@Stable
data class TangemCollapsingAppBarBehavior(
val state: TangemCollapsingAppBarState,
val snapAnimationSpec: AnimationSpec<Float>?,
val flingAnimationSpec: DecayAnimationSpec<Float>?,
val nestedScrollConnection: NestedScrollConnection,
)
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun TangemCollapsingTopBar_Preview() {
TangemThemePreviewRedesign {
val collapsingHeight = 200.dp
val behavior = rememberTangemExitUntilCollapsedScrollBehavior(
expandedHeight = collapsingHeight,
)
TangemCollapsingTopBar(
state = behavior.state,
collapsingPart = {
Box(
modifier = Modifier
.fillMaxWidth()
.height(collapsingHeight)
.background(Color.Red),
)
},
body = {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Blue)
.nestedScroll(behavior.nestedScrollConnection)
.verticalScroll(rememberScrollState()),
)
},
)
}
}
// endregion

View file

@ -0,0 +1,211 @@
package com.tangem.core.ui.ds.topbar.collapsing
import androidx.compose.animation.core.*
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.runtime.Composable
import androidx.compose.runtime.remember
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.unit.Dp
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.ds.topbar.collapsing.entity.TangemCollapsingAppBarState
import com.tangem.core.ui.ds.topbar.collapsing.entity.TopBapScrollDirection
import com.tangem.core.ui.ds.topbar.collapsing.entity.rememberTangemCollapsingAppBarState
import com.tangem.core.ui.utils.toPx
import kotlin.math.abs
import kotlin.math.absoluteValue
/**
* A scroll behavior for a collapsing top app bar that collapses when scrolling up and expands when scrolling down.
* When the user stops scrolling, the app bar will settle to either fully collapsed or fully expanded state
* based on the current collapsed fraction and scroll direction.
*
* @param expandedHeight The height of the app bar when it is fully expanded.
* @param partialCollapsedHeight The height of the app bar when it is partially collapsed.
* @param snapAnimationSpec The animation spec for snapping the app bar to the collapsed or expanded state when the
* user stops scrolling. If null, no snapping will occur.
* @param flingAnimationSpec The decay animation spec for the fling behavior when the user flings the app bar.
* If null, no fling behavior will occur.
*/
@Composable
fun rememberTangemExitUntilCollapsedScrollBehavior(
expandedHeight: Dp = -Int.MAX_VALUE.dp,
partialCollapsedHeight: Dp = expandedHeight,
snapAnimationSpec: AnimationSpec<Float>? = spring(),
flingAnimationSpec: DecayAnimationSpec<Float>? = rememberSplineBasedDecay(),
): TangemCollapsingAppBarBehavior {
val topBarState = rememberTangemCollapsingAppBarState(
heightOffsetLimit = -expandedHeight.toPx(),
partialHeightLimit = partialCollapsedHeight.toPx(),
)
return exitUntilCollapsedScrollBehavior(
state = topBarState,
snapAnimationSpec = snapAnimationSpec,
flingAnimationSpec = flingAnimationSpec,
)
}
/**
* A scroll behavior for a collapsing top app bar that collapses when scrolling up and expands when scrolling down.
* When the user stops scrolling, the app bar will settle to either fully collapsed or fully expanded state
* based on the current collapsed fraction and scroll direction.
*
* @param state The state of the collapsing app bar, which controls the height offset and scroll behavior.
* @param snapAnimationSpec The animation spec for snapping the app bar to the collapsed or expanded state when the
* user stops scrolling. If null, no snapping will occur.
* @param flingAnimationSpec The decay animation spec for the fling behavior when the user flings the app bar.
* If null, no fling behavior will occur.
*/
@Composable
private fun exitUntilCollapsedScrollBehavior(
state: TangemCollapsingAppBarState = rememberTangemCollapsingAppBarState(),
snapAnimationSpec: AnimationSpec<Float>? = spring(),
flingAnimationSpec: DecayAnimationSpec<Float>? = rememberSplineBasedDecay(),
): TangemCollapsingAppBarBehavior {
val nestedScrollConnection = remember(state) {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val dy = available.y
val consume = if (dy < 0) {
state.direction = TopBapScrollDirection.Collapsing
state.dispatchRawDelta(dy)
} else {
0f
}
return Offset(0f, consume)
}
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
val dy = available.y
val consume = if (dy > 0) {
state.direction = TopBapScrollDirection.Expanding
state.dispatchRawDelta(dy)
} else {
state.direction = TopBapScrollDirection.Collapsing
0f
}
return Offset(0f, consume)
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
val superConsumed = super.onPostFling(consumed, available)
return superConsumed + settleAppBar(
state = state,
velocity = available.y,
flingAnimationSpec = flingAnimationSpec,
snapAnimationSpec = snapAnimationSpec,
)
}
}
}
return remember(state, nestedScrollConnection, snapAnimationSpec, flingAnimationSpec) {
TangemCollapsingAppBarBehavior(
state = state,
snapAnimationSpec = snapAnimationSpec,
flingAnimationSpec = flingAnimationSpec,
nestedScrollConnection = nestedScrollConnection,
)
}
}
@Composable
fun Modifier.snapToExitUntilCollapsed(behavior: TangemCollapsingAppBarBehavior): Modifier {
return draggable(
orientation = Orientation.Vertical,
state = rememberDraggableState { delta ->
behavior.state.heightOffset += delta
},
onDragStopped = { velocity ->
settleAppBar(
state = behavior.state,
velocity = velocity,
flingAnimationSpec = behavior.flingAnimationSpec,
snapAnimationSpec = behavior.snapAnimationSpec,
)
},
)
}
/**
* Settles the app bar to either fully collapsed or fully expanded state
* based on the current collapsed fraction and scroll direction.
*/
@Suppress("MagicNumber", "CyclomaticComplexMethod")
private suspend fun settleAppBar(
state: TangemCollapsingAppBarState,
velocity: Float,
flingAnimationSpec: DecayAnimationSpec<Float>?,
snapAnimationSpec: AnimationSpec<Float>?,
snapCollapseThreshold: Float = 0.3f,
snapExpandThreshold: Float = 0.7f,
): Velocity {
val partialLimit = state.heightOffsetLimit + state.partialHeightLimit
var remainingVelocity = 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
}
// Fling
if (flingAnimationSpec != null && velocity.absoluteValue > 1f) {
var lastValue = 0f
AnimationState(
initialValue = 0f,
initialVelocity = velocity,
).animateDecay(flingAnimationSpec) {
val delta = value - lastValue
val initialHeightOffset = state.heightOffset
val availableDelta = partialLimit - initialHeightOffset
state.heightOffset = if (delta < 0f && initialHeightOffset > partialLimit) {
(initialHeightOffset + delta).coerceAtLeast(partialLimit)
} else {
initialHeightOffset + delta
}
val consumed = abs(initialHeightOffset - state.heightOffset)
lastValue = value
remainingVelocity = this.velocity
// avoid rounding errors and stop if anything is unconsumed
if (abs(maxOf(delta, availableDelta) - consumed) > 0.5f) this.cancelAnimation()
}
}
// Snap
if (snapAnimationSpec != null && state.heightOffset > partialLimit && state.heightOffset < 0f) {
AnimationState(initialValue = state.heightOffset).animateTo(
when (state.direction) {
TopBapScrollDirection.Collapsing -> if (state.collapsedFraction > snapCollapseThreshold) {
partialLimit
} else {
0f
}
TopBapScrollDirection.Expanding -> if (state.collapsedFraction < snapExpandThreshold) {
0f
} else {
partialLimit
}
TopBapScrollDirection.Idle -> 0f
},
animationSpec = snapAnimationSpec,
) {
state.heightOffset = value
}
}
return Velocity(0f, remainingVelocity)
}

View file

@ -0,0 +1,142 @@
package com.tangem.core.ui.ds.topbar.collapsing.entity
import androidx.compose.animation.core.AnimationState
import androidx.compose.animation.core.animateTo
import androidx.compose.animation.core.tween
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.gestures.ScrollScope
import androidx.compose.foundation.gestures.ScrollableState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import com.tangem.core.ui.ds.topbar.collapsing.entity.TangemCollapsingAppBarState.Companion.Saver
import kotlin.math.absoluteValue
import kotlin.math.max
import kotlin.math.min
/**
* State of the collapsing top app bar.
* It contains the current height offset, the limits for collapsing and expanding, and the scroll direction.
*
* @property initialHeightOffset The initial height offset of the app bar. Default is 0f.
* @property heightOffsetLimit The height offset limit for full collapse.
* @property partialHeightLimit The height offset limit for partial collapse. Default is the same as [heightOffsetLimit]
*/
@Stable
class TangemCollapsingAppBarState(
val initialHeightOffset: Float = 0f,
val heightOffsetLimit: Float = 0f,
val partialHeightLimit: Float = heightOffsetLimit,
) : ScrollableState {
private val _heightOffset = mutableFloatStateOf(initialHeightOffset)
private var deferredConsumption: Float = 0f
/**
* The current height offset of the app bar.
* This value is updated as the user scrolls, and is constrained between [heightOffsetLimit] and 0f.
*/
var heightOffset: Float
get() = _heightOffset.floatValue
set(newOffset) {
_heightOffset.floatValue =
newOffset.coerceIn(minimumValue = heightOffsetLimit, maximumValue = 0f)
}
/**
* The fraction of the app bar that is collapsed, calculated as the ratio of [heightOffset] to [heightOffsetLimit].
*/
val collapsedFraction: Float
get() =
if (heightOffsetLimit != 0f) {
heightOffset / heightOffsetLimit
} else {
0f
}
/**
* The current scroll direction of the app bar, which can be Collapsing, Expanding, or Idle.
*/
var direction: TopBapScrollDirection = TopBapScrollDirection.Idle
private val scrollableState = ScrollableState { value ->
val consume = if (value < 0) {
max(heightOffsetLimit - heightOffset, value)
} else {
min(0f - heightOffset, value)
}
val current = consume + deferredConsumption
val currentInt = current.toInt()
if (current.absoluteValue > 0) {
heightOffset += currentInt
deferredConsumption = current - currentInt
}
consume
}
override val isScrollInProgress: Boolean
get() = scrollableState.isScrollInProgress
/**
*
*/
suspend fun collapse() {
AnimationState(initialValue = heightOffset).animateTo(
targetValue = heightOffsetLimit + partialHeightLimit,
animationSpec = tween(),
) {
heightOffset = value
}
}
override suspend fun scroll(scrollPriority: MutatePriority, block: suspend ScrollScope.() -> Unit) =
scrollableState.scroll(scrollPriority, block)
override fun dispatchRawDelta(delta: Float) = scrollableState.dispatchRawDelta(delta)
companion object {
/** The default [Saver] implementation for [TangemCollapsingAppBarState]. */
val Saver: Saver<TangemCollapsingAppBarState, *> =
listSaver(
save = { state -> listOf(state.heightOffsetLimit, state.heightOffset, state.partialHeightLimit) },
restore = { state ->
TangemCollapsingAppBarState(
heightOffsetLimit = state[0],
partialHeightLimit = state[2],
initialHeightOffset = state[1],
)
},
)
}
}
/**
* Remembers and saves the state of the collapsing top app bar across recompositions and configuration changes.
*/
@Composable
fun rememberTangemCollapsingAppBarState(
heightOffsetLimit: Float = -Float.MAX_VALUE,
partialHeightLimit: Float = -Float.MAX_VALUE,
initialHeightOffset: Float = 0f,
): TangemCollapsingAppBarState {
return rememberSaveable(saver = Saver) {
TangemCollapsingAppBarState(
initialHeightOffset = initialHeightOffset,
partialHeightLimit = partialHeightLimit,
heightOffsetLimit = heightOffsetLimit,
)
}
}
/**
* The scroll direction of the top app bar, which can be Collapsing, Expanding, or Idle.
*/
enum class TopBapScrollDirection {
Collapsing, Expanding, Idle
}