Updated on 2026-08-14

This commit is contained in:
Tangem 2024-02-29 13:50:16 +00:00
commit 6d0b295ca3
219 changed files with 3720 additions and 1687 deletions

View file

@ -6,6 +6,10 @@ plugins {
id("configuration")
}
android {
namespace = "com.tangem.feature.wallet.impl"
}
dependencies {
/** AndroidX */
implementation(deps.androidx.activity.compose)

View file

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.tangem.feature.wallet.impl">
</manifest>

View file

@ -87,11 +87,13 @@ private fun PriceChangeIcon(type: PriceChangeType) {
id = when (animatedType) {
PriceChangeType.UP -> R.drawable.ic_arrow_up_8
PriceChangeType.DOWN -> R.drawable.ic_arrow_down_8
PriceChangeType.NEUTRAL -> R.drawable.ic_elipse_8
},
),
tint = when (animatedType) {
PriceChangeType.UP -> TangemTheme.colors.icon.accent
PriceChangeType.DOWN -> TangemTheme.colors.icon.warning
PriceChangeType.NEUTRAL -> TangemTheme.colors.icon.inactive
},
contentDescription = null,
)
@ -106,6 +108,7 @@ private fun PriceChangeText(type: PriceChangeType?, text: String?, modifier: Mod
color = when (type) {
PriceChangeType.UP -> TangemTheme.colors.text.accent
PriceChangeType.DOWN -> TangemTheme.colors.text.warning
PriceChangeType.NEUTRAL -> TangemTheme.colors.text.disabled
null -> TangemTheme.colors.text.tertiary
},
overflow = TextOverflow.Ellipsis,

View file

@ -3,11 +3,11 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers.convert
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.components.marketprice.PriceChangeState
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.components.marketprice.utils.PriceChangeConverter
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.utils.converter.Converter
import java.math.BigDecimal
internal class SingleWalletMarketPriceConverter(
private val status: CryptoCurrencyStatus.Status,
@ -61,8 +61,6 @@ internal class SingleWalletMarketPriceConverter(
}
private fun getPriceChangeType(status: CryptoCurrencyStatus.Status): PriceChangeType {
val priceChange = status.priceChange ?: return PriceChangeType.DOWN
return if (priceChange > BigDecimal.ZERO) PriceChangeType.UP else PriceChangeType.DOWN
return PriceChangeConverter.fromBigDecimal(status.priceChange)
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers.convert
import com.tangem.core.ui.components.currency.tokenicon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.components.marketprice.utils.PriceChangeConverter
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
@ -97,8 +98,6 @@ internal class TokenItemStateConverter(
priceChangePercent = BigDecimalFormatter.formatPercent(
percent = priceChange,
useAbsoluteValue = true,
maxFractionDigits = 1,
minFractionDigits = 1,
),
type = priceChange.getPriceChangeType(),
)
@ -117,6 +116,6 @@ internal class TokenItemStateConverter(
}
private fun BigDecimal.getPriceChangeType(): PriceChangeType {
return if (this > BigDecimal.ZERO) PriceChangeType.UP else PriceChangeType.DOWN
return PriceChangeConverter.fromBigDecimal(value = this)
}
}

View file

@ -1,7 +1,11 @@
package com.tangem.feature.wallet.presentation.wallet.ui
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.TweenSpec
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
@ -13,16 +17,19 @@ import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.Dp
import androidx.paging.compose.collectAsLazyPagingItems
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.tangem.core.ui.components.Keyboard
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.SystemBarsEffect
import com.tangem.core.ui.components.atoms.Hand
import com.tangem.core.ui.components.atoms.handComposableComponentHeight
import com.tangem.core.ui.components.bottomsheets.chooseaddress.ChooseAddressBottomSheet
@ -64,6 +71,13 @@ internal fun WalletScreen(
val snackbarHostState = remember(::SnackbarHostState)
val isAutoScroll = remember { mutableStateOf(value = false) }
var alertConfig by remember { mutableStateOf<WalletAlertState?>(value = null) }
val config = alertConfig
if (config != null) {
WalletAlert(state = config, onDismiss = { alertConfig = null })
}
WalletContent(
state = state,
walletsListState = walletsListState,
@ -72,14 +86,9 @@ internal fun WalletScreen(
onAutoScrollReset = { isAutoScroll.value = false },
bottomSheetHeaderHeightProvider = bottomSheetHeaderHeightProvider,
bottomSheetContent = bottomSheetContent,
alertConfig = alertConfig,
)
var alertConfig by remember { mutableStateOf<WalletAlertState?>(value = null) }
alertConfig?.let {
WalletAlert(state = it, onDismiss = { alertConfig = null })
}
WalletEventEffect(
event = state.event,
selectedWalletIndex = state.selectedWalletIndex,
@ -100,8 +109,9 @@ private fun WalletContent(
bottomSheetHeaderHeightProvider: () -> Dp,
onAutoScrollReset: () -> Unit,
bottomSheetContent: @Composable () -> Unit,
alertConfig: WalletAlertState?,
) {
var selectedWalletIndex by remember { mutableIntStateOf(state.selectedWalletIndex) }
var selectedWalletIndex by remember(state.selectedWalletIndex) { mutableIntStateOf(state.selectedWalletIndex) }
val selectedWallet = state.wallets[selectedWalletIndex]
val scaffoldContent: @Composable () -> Unit = {
@ -207,6 +217,7 @@ private fun WalletContent(
snackbarHostState = snackbarHostState,
bottomSheetHeaderHeightProvider = bottomSheetHeaderHeightProvider,
bottomSheetContent = bottomSheetContent,
alertConfig = alertConfig,
) {
scaffoldContent()
}
@ -222,7 +233,7 @@ private fun WalletContent(
}
@Suppress("LongParameterList", "LongMethod")
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
@Composable
private fun BaseScaffoldManageTokenRedesign(
state: WalletScreenState,
@ -230,49 +241,46 @@ private fun BaseScaffoldManageTokenRedesign(
snackbarHostState: SnackbarHostState,
bottomSheetHeaderHeightProvider: () -> Dp,
bottomSheetContent: @Composable () -> Unit,
alertConfig: WalletAlertState?,
content: @Composable () -> Unit,
) {
val scaffoldState = rememberBottomSheetScaffoldState()
// show the bottom sheet if there is at least one multicurrency wallet
val showManageTokensBottomSheet = remember(state.wallets) {
state.wallets.any { it is WalletState.MultiCurrency }
}
val bottomSheetState = rememberSheetStateEnhanced(
initialValue = if (showManageTokensBottomSheet) SheetValue.PartiallyExpanded else SheetValue.Hidden,
confirmValueChange = { sheetValue ->
when {
sheetValue == SheetValue.Hidden && showManageTokensBottomSheet -> false
sheetValue != SheetValue.Hidden && !showManageTokensBottomSheet -> false
else -> true
}
},
skipHiddenState = showManageTokensBottomSheet,
)
val keyboardShown = keyboardAsState()
BottomSheetStateEffects(
bottomSheetState = bottomSheetState,
showManageTokensBottomSheet = showManageTokensBottomSheet,
alertConfig = alertConfig,
keyboardShown = keyboardShown,
)
val scaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = bottomSheetState,
snackbarHostState = snackbarHostState,
)
val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() }
val statusBarHeight = with(LocalDensity.current) { WindowInsets.statusBars.getTop(this).toDp() }
val systemUiController = rememberSystemUiController()
val navigationBarColor = TangemTheme.colors.background.primary
val navigationBarColorWithout = TangemTheme.colors.background.secondary
DisposableEffect(
navigationBarColor,
navigationBarColorWithout,
) {
systemUiController.setNavigationBarColor(navigationBarColor)
onDispose {
systemUiController.setNavigationBarColor(navigationBarColorWithout)
}
}
val keyboardShown by keyboardAsState()
// expand bottom sheet when keyboard appears
LaunchedEffect(keyboardShown is Keyboard.Opened) {
if (keyboardShown is Keyboard.Opened) {
scaffoldState.bottomSheetState.expand()
}
}
val keyboardController = LocalSoftwareKeyboardController.current
val sheetHasBeenHidden = scaffoldState.bottomSheetState.targetValue == SheetValue.PartiallyExpanded
// hide keyboard when bottom sheet is about to be hidden
LaunchedEffect(sheetHasBeenHidden) {
if (sheetHasBeenHidden) {
keyboardController?.hide()
}
}
val peekHeight = bottomSheetHeaderHeightProvider() + handComposableComponentHeight + bottomBarHeight
val coroutineScope = rememberCoroutineScope()
BottomSheetScaffold(
topBar = {
WalletTopBar(config = state.topBarConfig)
},
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
},
@ -296,10 +304,10 @@ private fun BaseScaffoldManageTokenRedesign(
// hide bottom sheet when back pressed
BackHandler(
keyboardShown is Keyboard.Closed &&
scaffoldState.bottomSheetState.currentValue == SheetValue.Expanded,
keyboardShown.value is Keyboard.Closed &&
bottomSheetState.currentValue == SheetValue.Expanded,
) {
coroutineScope.launch { scaffoldState.bottomSheetState.partialExpand() }
coroutineScope.launch { bottomSheetState.partialExpand() }
}
},
content = { paddingValues ->
@ -308,23 +316,153 @@ private fun BaseScaffoldManageTokenRedesign(
onRefresh = selectedWallet.pullToRefreshConfig.onRefresh,
)
Box(
modifier = Modifier
.pullRefresh(pullRefreshState)
.padding(paddingValues),
Column(
modifier = Modifier.padding(paddingValues),
) {
content()
WalletTopBar(config = state.topBarConfig)
Box(
modifier = Modifier.pullRefresh(pullRefreshState),
) {
content()
WalletPullToRefreshIndicator(
isRefreshing = selectedWallet.pullToRefreshConfig.isRefreshing,
state = pullRefreshState,
modifier = Modifier.align(Alignment.TopCenter),
)
WalletPullToRefreshIndicator(
isRefreshing = selectedWallet.pullToRefreshConfig.isRefreshing,
state = pullRefreshState,
modifier = Modifier.align(Alignment.TopCenter),
)
}
}
BottomSheetScrim(
color = BottomSheetDefaults.ScrimColor,
visible = bottomSheetState.targetValue == SheetValue.Expanded,
onDismissRequest = { coroutineScope.launch { bottomSheetState.partialExpand() } },
)
},
)
}
@Composable
private fun BottomSheetScrim(color: Color, visible: Boolean, onDismissRequest: () -> Unit) {
val alpha by animateFloatAsState(
targetValue = if (visible) 1f else 0f,
animationSpec = TweenSpec(),
label = "scrim",
)
val dismissSheet = if (visible) {
Modifier
.pointerInput(onDismissRequest) {
detectTapGestures {
onDismissRequest()
}
}
.clearAndSetSemantics {}
} else {
Modifier
}
Canvas(
Modifier
.fillMaxSize()
.then(dismissSheet),
) {
drawRect(color = color, alpha = alpha)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun BottomSheetStateEffects(
bottomSheetState: SheetState,
showManageTokensBottomSheet: Boolean,
alertConfig: WalletAlertState?,
keyboardShown: State<Keyboard>,
) {
// Bottom sheet during initialization internally expand partially after its content was remeasured,
// therefore initialValue = SheetValue.Hidden in rememberStandardBottomSheetState doesn't work as expected
// so we have to manually restrict expansion in this case
LaunchedEffect(bottomSheetState.targetValue, bottomSheetState.currentValue) {
if (!showManageTokensBottomSheet &&
(bottomSheetState.targetValue != SheetValue.Hidden || bottomSheetState.currentValue != SheetValue.Hidden)
) {
bottomSheetState.hide()
}
}
// react to changes in wallet list
LaunchedEffect(showManageTokensBottomSheet) {
when {
showManageTokensBottomSheet && bottomSheetState.currentValue != SheetValue.PartiallyExpanded -> {
bottomSheetState.partialExpand()
}
!showManageTokensBottomSheet && bottomSheetState.targetValue != SheetValue.Hidden -> {
bottomSheetState.hide()
}
}
}
val systemUiController = rememberSystemUiController()
val navigationBarColor = TangemTheme.colors.background.primary
val navigationBarColorWithout = TangemTheme.colors.background.secondary
SystemBarsEffect {
if (showManageTokensBottomSheet) {
setNavigationBarColor(navigationBarColor)
}
}
DisposableEffect(
showManageTokensBottomSheet,
) {
onDispose {
if (showManageTokensBottomSheet) {
systemUiController.setNavigationBarColor(navigationBarColorWithout)
}
}
}
// expand bottom sheet when keyboard appears
LaunchedEffect(keyboardShown.value is Keyboard.Opened) {
if (keyboardShown.value is Keyboard.Opened && alertConfig == null) {
bottomSheetState.expand()
}
}
val keyboardController = LocalSoftwareKeyboardController.current
// hide keyboard when bottom sheet is about to be hidden
LaunchedEffect(Unit) {
snapshotFlow {
bottomSheetState.currentValue == SheetValue.Expanded &&
bottomSheetState.targetValue == SheetValue.PartiallyExpanded
}.collect { sheetHasBeenHidden ->
if (sheetHasBeenHidden) {
keyboardController?.hide()
}
}
}
}
/**
* Use a standard method when this is fixed https://issuetracker.google.com/issues/314796718
* Current material3 version: 1.2.0
*/
@Composable
@ExperimentalMaterial3Api
private fun rememberSheetStateEnhanced(
skipPartiallyExpanded: Boolean = false,
confirmValueChange: (SheetValue) -> Boolean = { true },
initialValue: SheetValue = SheetValue.Hidden,
skipHiddenState: Boolean = false,
): SheetState {
val density = LocalDensity.current
return remember(initialValue, skipPartiallyExpanded, confirmValueChange, skipHiddenState) {
SheetState(
skipPartiallyExpanded = skipPartiallyExpanded,
density = density,
initialValue = initialValue,
confirmValueChange = confirmValueChange,
skipHiddenState = skipHiddenState,
)
}
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun BaseScaffold(

View file

@ -5,6 +5,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.snapshotFlow
import com.tangem.feature.wallet.presentation.wallet.ui.utils.LazyListItemData
import com.tangem.feature.wallet.presentation.wallet.ui.utils.ScrollOffsetCollector
import com.tangem.feature.wallet.presentation.wallet.ui.utils.WalletsListInteractionsCollector
@ -19,7 +20,11 @@ internal fun WalletsListEffects(
onAutoScrollReset: () -> Unit,
) {
LaunchedEffect(key1 = lazyListState, key2 = onWalletChange) {
snapshotFlow { lazyListState.layoutInfo.visibleItemsInfo }
snapshotFlow {
lazyListState.layoutInfo.visibleItemsInfo.map {
LazyListItemData(it.index, it.size, it.offset)
}
}
.collect(
collector = ScrollOffsetCollector(
selectedWalletIndex = selectedWalletIndex,

View file

@ -0,0 +1,405 @@
package com.tangem.feature.wallet.presentation.wallet.ui.components
import androidx.compose.animation.core.*
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.FlingBehavior
import androidx.compose.foundation.gestures.ScrollScope
import androidx.compose.ui.MotionDurationScale
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.withContext
import kotlin.math.abs
import kotlin.math.absoluteValue
import kotlin.math.sign
@ExperimentalFoundationApi
class TangemSnapFlingBehavior(
private val snapLayoutInfoProvider: SnapLayoutInfoProvider,
private val lowVelocityAnimationSpec: AnimationSpec<Float>,
private val highVelocityAnimationSpec: DecayAnimationSpec<Float>,
private val snapAnimationSpec: AnimationSpec<Float>,
private val density: Density,
private val shortSnapVelocityThreshold: Dp = MinFlingVelocityDp,
) : FlingBehavior {
private val velocityThreshold = with(density) { shortSnapVelocityThreshold.toPx() }
private var motionScaleDuration = DefaultScrollMotionDurationScale
override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
return performFling(initialVelocity) {}
}
/**
* Perform a snapping fling animation with given velocity and suspend until fling has
* finished. This will behave the same way as [performFling] except it will report on
* each remainingOffsetUpdate using the [onSettlingDistanceUpdated] lambda.
*
* @param initialVelocity velocity available for fling in the orientation specified in
* [androidx.compose.foundation.gestures.scrollable] that invoked this method.
*
* @param onSettlingDistanceUpdated a lambda that will be called anytime the
* distance to the settling offset is updated. The settling offset is the final offset where
* this fling will stop and may change depending on the snapping animation progression.
*
* @return remaining velocity after fling operation has ended
*/
private suspend fun ScrollScope.performFling(
initialVelocity: Float,
onSettlingDistanceUpdated: (Float) -> Unit,
): Float {
val (remainingOffset, remainingState) = fling(initialVelocity, onSettlingDistanceUpdated)
// No remaining offset means we've used everything, no need to propagate velocity. Otherwise
// we couldn't use everything (probably because we have hit the min/max bounds of the
// containing layout) we should propagate the offset.
return if (remainingOffset == 0f) NoVelocity else remainingState.velocity
}
private suspend fun ScrollScope.fling(
initialVelocity: Float,
onRemainingScrollOffsetUpdate: (Float) -> Unit,
): AnimationResult<Float, AnimationVector1D> {
// If snapping from scroll (short snap) or fling (long snap)
val result = withContext(motionScaleDuration) {
if (abs(initialVelocity) <= abs(velocityThreshold)) {
shortSnap(initialVelocity, onRemainingScrollOffsetUpdate)
} else {
longSnap(initialVelocity, onRemainingScrollOffsetUpdate)
}
}
onRemainingScrollOffsetUpdate(0f) // Animation finished or was cancelled
return result
}
private suspend fun ScrollScope.shortSnap(
velocity: Float,
onRemainingScrollOffsetUpdate: (Float) -> Unit,
): AnimationResult<Float, AnimationVector1D> {
val closestOffset = with(snapLayoutInfoProvider) {
density.calculateSnappingOffset(0f)
}
var remainingScrollOffset = closestOffset
val animationState = AnimationState(NoDistance, velocity)
return animateSnap(
closestOffset,
closestOffset,
animationState,
snapAnimationSpec,
) { delta ->
remainingScrollOffset -= delta
onRemainingScrollOffsetUpdate(remainingScrollOffset)
}
}
private suspend fun ScrollScope.longSnap(
initialVelocity: Float,
onAnimationStep: (remainingScrollOffset: Float) -> Unit,
): AnimationResult<Float, AnimationVector1D> {
val initialOffset =
with(snapLayoutInfoProvider) { density.calculateApproachOffset(initialVelocity) }.let {
abs(it) * sign(initialVelocity) // ensure offset sign is correct
}
var remainingScrollOffset = initialOffset
onAnimationStep(remainingScrollOffset) // First Scroll Offset
val (remainingOffset, animationState) = runApproach(
initialOffset,
initialVelocity,
) { delta ->
remainingScrollOffset -= delta
onAnimationStep(remainingScrollOffset)
}
remainingScrollOffset = remainingOffset
return animateSnap(
remainingOffset,
remainingOffset,
animationState.copy(value = 0f),
snapAnimationSpec,
) { delta ->
remainingScrollOffset -= delta
onAnimationStep(remainingScrollOffset)
}
}
private suspend fun ScrollScope.runApproach(
initialTargetOffset: Float,
initialVelocity: Float,
onAnimationStep: (delta: Float) -> Unit,
): AnimationResult<Float, AnimationVector1D> {
val animation =
if (isDecayApproachPossible(offset = initialTargetOffset, velocity = initialVelocity)) {
HighVelocityApproachAnimation(highVelocityAnimationSpec)
} else {
LowVelocityApproachAnimation(
lowVelocityAnimationSpec,
snapLayoutInfoProvider,
density,
)
}
return approach(
initialTargetOffset,
initialVelocity,
animation,
snapLayoutInfoProvider,
density,
onAnimationStep,
)
}
/**
* If we can approach the target and still have velocity left
*/
private fun isDecayApproachPossible(offset: Float, velocity: Float): Boolean {
val decayOffset = highVelocityAnimationSpec.calculateTargetValue(NoDistance, velocity)
val snapStepSize = with(snapLayoutInfoProvider) { density.calculateSnapStepSize() }
return decayOffset.absoluteValue >= offset.absoluteValue + snapStepSize
}
override fun equals(other: Any?): Boolean {
return if (other is TangemSnapFlingBehavior) {
other.snapAnimationSpec == this.snapAnimationSpec &&
other.highVelocityAnimationSpec == this.highVelocityAnimationSpec &&
other.lowVelocityAnimationSpec == this.lowVelocityAnimationSpec &&
other.snapLayoutInfoProvider == this.snapLayoutInfoProvider &&
other.density == this.density &&
other.shortSnapVelocityThreshold == this.shortSnapVelocityThreshold
} else {
false
}
}
override fun hashCode(): Int = 0
.let { 31 * it + snapAnimationSpec.hashCode() }
.let { 31 * it + highVelocityAnimationSpec.hashCode() }
.let { 31 * it + lowVelocityAnimationSpec.hashCode() }
.let { 31 * it + snapLayoutInfoProvider.hashCode() }
.let { 31 * it + density.hashCode() }
.let { 31 * it + shortSnapVelocityThreshold.hashCode() }
}
@Suppress("LongParameterList")
@OptIn(ExperimentalFoundationApi::class)
private suspend fun ScrollScope.approach(
initialTargetOffset: Float,
initialVelocity: Float,
animation: ApproachAnimation<Float, AnimationVector1D>,
snapLayoutInfoProvider: SnapLayoutInfoProvider,
density: Density,
onAnimationStep: (delta: Float) -> Unit,
): AnimationResult<Float, AnimationVector1D> {
val (_, currentAnimationState) = animation.approachAnimation(
this,
initialTargetOffset,
initialVelocity,
onAnimationStep,
)
val remainingOffset = with(snapLayoutInfoProvider) {
density.calculateSnappingOffset(currentAnimationState.velocity)
}
// will snap the remainder
return AnimationResult(remainingOffset, currentAnimationState)
}
/**
* Runs a [AnimationSpec] to snap the list into [targetOffset]. Uses [cancelOffset] to stop this
* animation before it reaches the target.
*
* @param targetOffset The final target of this animation
* @param cancelOffset If we'd like to finish the animation earlier we use this value
* @param animationState The current animation state for continuation purposes
* @param snapAnimationSpec The [AnimationSpec] that will drive this animation
* @param onAnimationStep Called for each new scroll delta emitted by the animation cycle.
*/
@Suppress("MagicNumber")
private suspend fun ScrollScope.animateSnap(
targetOffset: Float,
cancelOffset: Float,
animationState: AnimationState<Float, AnimationVector1D>,
snapAnimationSpec: AnimationSpec<Float>,
onAnimationStep: (delta: Float) -> Unit,
): AnimationResult<Float, AnimationVector1D> {
var consumedUpToNow = 0f
val initialVelocity = animationState.velocity
animationState.animateTo(
targetOffset,
animationSpec = snapAnimationSpec,
sequentialAnimation = animationState.velocity != 0f,
) {
val realValue = value.coerceToTarget(cancelOffset)
val delta = realValue - consumedUpToNow
val consumed = scrollBy(delta)
onAnimationStep(consumed)
// stop when unconsumed or when we reach the desired value
if (abs(delta - consumed) > 0.5f || realValue != value) {
cancelAnimation()
}
consumedUpToNow += consumed
}
// Always course correct velocity so they don't become too large.
val finalVelocity = animationState.velocity.coerceToTarget(initialVelocity)
return AnimationResult(
targetOffset - consumedUpToNow,
animationState.copy(velocity = finalVelocity),
)
}
private class HighVelocityApproachAnimation(
private val decayAnimationSpec: DecayAnimationSpec<Float>,
) : ApproachAnimation<Float, AnimationVector1D> {
override suspend fun approachAnimation(
scope: ScrollScope,
offset: Float,
velocity: Float,
onAnimationStep: (delta: Float) -> Unit,
): AnimationResult<Float, AnimationVector1D> {
val animationState = AnimationState(initialValue = 0f, initialVelocity = velocity)
return with(scope) {
animateDecay(offset, animationState, decayAnimationSpec, onAnimationStep)
}
}
}
private class LowVelocityApproachAnimation @OptIn(ExperimentalFoundationApi::class) constructor(
private val lowVelocityAnimationSpec: AnimationSpec<Float>,
private val layoutInfoProvider: SnapLayoutInfoProvider,
private val density: Density,
) : ApproachAnimation<Float, AnimationVector1D> {
@OptIn(ExperimentalFoundationApi::class)
override suspend fun approachAnimation(
scope: ScrollScope,
offset: Float,
velocity: Float,
onAnimationStep: (delta: Float) -> Unit,
): AnimationResult<Float, AnimationVector1D> {
val animationState = AnimationState(initialValue = 0f, initialVelocity = velocity)
val targetOffset =
(abs(offset) + with(layoutInfoProvider) { density.calculateSnapStepSize() }) * sign(
velocity,
)
return with(scope) {
animateSnap(
targetOffset = targetOffset,
cancelOffset = offset,
animationState = animationState,
snapAnimationSpec = lowVelocityAnimationSpec,
onAnimationStep = onAnimationStep,
)
}
}
}
@Suppress("MagicNumber")
private suspend fun ScrollScope.animateDecay(
targetOffset: Float,
animationState: AnimationState<Float, AnimationVector1D>,
decayAnimationSpec: DecayAnimationSpec<Float>,
onAnimationStep: (delta: Float) -> Unit,
): AnimationResult<Float, AnimationVector1D> {
var previousValue = 0f
fun AnimationScope<Float, AnimationVector1D>.consumeDelta(delta: Float) {
val consumed = scrollBy(delta)
onAnimationStep(consumed)
if (abs(delta - consumed) > 0.5f) cancelAnimation()
}
animationState.animateDecay(
animationSpec = decayAnimationSpec,
sequentialAnimation = animationState.velocity != 0f,
) {
previousValue = if (abs(value) >= abs(targetOffset)) {
val finalValue = value.coerceToTarget(targetOffset)
val finalDelta = finalValue - previousValue
consumeDelta(finalDelta)
cancelAnimation()
finalValue
} else {
val delta = value - previousValue
consumeDelta(delta)
value
}
}
return AnimationResult(
targetOffset - previousValue,
animationState,
)
}
private interface ApproachAnimation<T, V : AnimationVector> {
suspend fun approachAnimation(
scope: ScrollScope,
offset: T,
velocity: T,
onAnimationStep: (delta: T) -> Unit,
): AnimationResult<T, V>
}
private fun Float.coerceToTarget(target: Float): Float {
if (target == 0f) return 0f
return if (target > 0) coerceAtMost(target) else coerceAtLeast(target)
}
private class AnimationResult<T, V : AnimationVector>(
val remainingOffset: T,
val currentAnimationState: AnimationState<T, V>,
) {
operator fun component1(): T = remainingOffset
operator fun component2(): AnimationState<T, V> = currentAnimationState
}
@Suppress("TopLevelPropertyNaming")
private const val DefaultScrollMotionDurationScaleFactor = 1f
@Suppress("TopLevelPropertyNaming")
val DefaultScrollMotionDurationScale = object : MotionDurationScale {
override val scaleFactor: Float
get() = DefaultScrollMotionDurationScaleFactor
}
@Suppress("TopLevelPropertyNaming")
internal val MinFlingVelocityDp = 400.dp
@Suppress("TopLevelPropertyNaming")
internal const val NoDistance = 0f
@Suppress("TopLevelPropertyNaming")
internal const val NoVelocity = 0f
@ExperimentalFoundationApi
interface SnapLayoutInfoProvider {
/**
* The minimum offset that snapping will use to animate.(e.g. an item size)
*/
fun Density.calculateSnapStepSize(): Float
/**
* Calculate the distance to navigate before settling into the next snapping bound.
*
* @param initialVelocity The current fling movement velocity. You can use this tho calculate a
* velocity based offset.
*/
fun Density.calculateApproachOffset(initialVelocity: Float): Float
/**
* Given a target placement in a layout, the snapping offset is the next snapping position
* this layout can be placed in. If this is a short snapping, [currentVelocity] is guaranteed
* to be 0.If it is a long snapping, this method will be called
* after [calculateApproachOffset].
*
* @param currentVelocity The current fling movement velocity. This may change throughout the
* fling animation.
*/
fun Density.calculateSnappingOffset(currentVelocity: Float): Float
}

View file

@ -0,0 +1,177 @@
package com.tangem.feature.wallet.presentation.wallet.ui.components
import androidx.compose.animation.core.DecayAnimationSpec
import androidx.compose.animation.core.calculateTargetValue
import androidx.compose.animation.splineBasedDecay
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.snapping.SnapFlingBehavior
import androidx.compose.foundation.lazy.LazyListLayoutInfo
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.ui.unit.Density
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
import kotlin.math.abs
import kotlin.math.absoluteValue
import kotlin.math.sign
/**
* A [SnapLayoutInfoProvider] for LazyLists.
*
* @param lazyListState The [LazyListState] with information about the current state of the list
* @param positionInLayout The desired positioning of the snapped item within the main layout.
* This position should be considered with regard to the start edge of the item and the placement
* within the viewport.
*
* @return A [SnapLayoutInfoProvider] that can be used with [SnapFlingBehavior]
*/
@Suppress("FunctionNaming")
@ExperimentalFoundationApi
fun TangemSnapLayoutInfoProvider(
lazyListState: LazyListState,
positionInLayout: SnapPositionInLayout = SnapPositionInLayout.CenterToCenter,
): SnapLayoutInfoProvider = object : SnapLayoutInfoProvider {
private val layoutInfo: LazyListLayoutInfo
get() = lazyListState.layoutInfo
// Decayed page snapping is the default
override fun Density.calculateApproachOffset(initialVelocity: Float): Float {
val decayAnimationSpec: DecayAnimationSpec<Float> = splineBasedDecay(this)
val offset =
decayAnimationSpec.calculateTargetValue(NoDistance, initialVelocity).absoluteValue
val finalDecayOffset = (offset - calculateSnapStepSize()).coerceAtLeast(0f)
return if (finalDecayOffset == 0f) {
finalDecayOffset
} else {
finalDecayOffset * initialVelocity.sign
}
}
override fun Density.calculateSnappingOffset(currentVelocity: Float): Float {
var lowerBoundOffset = Float.NEGATIVE_INFINITY
var upperBoundOffset = Float.POSITIVE_INFINITY
layoutInfo.visibleItemsInfo.fastForEach { item ->
val offset =
calculateDistanceToDesiredSnapPosition(
mainAxisViewPortSize = layoutInfo.singleAxisViewportSize,
beforeContentPadding = layoutInfo.beforeContentPadding,
afterContentPadding = layoutInfo.afterContentPadding,
itemSize = item.size,
itemOffset = item.offset,
itemIndex = item.index,
snapPositionInLayout = positionInLayout,
)
// Find item that is closest to the center
if (offset <= 0 && offset > lowerBoundOffset) {
lowerBoundOffset = offset
}
// Find item that is closest to center, but after it
if (offset >= 0 && offset < upperBoundOffset) {
upperBoundOffset = offset
}
}
return calculateFinalOffset(currentVelocity, lowerBoundOffset, upperBoundOffset)
}
override fun Density.calculateSnapStepSize(): Float = with(layoutInfo) {
if (visibleItemsInfo.isNotEmpty()) {
visibleItemsInfo.fastSumBy { it.size } / visibleItemsInfo.size.toFloat()
} else {
0f
}
}
}
@Suppress("BanInlineOptIn")
@OptIn(ExperimentalContracts::class)
inline fun <T> List<T>.fastSumBy(selector: (T) -> Int): Int {
contract { callsInPlace(selector) }
var sum = 0
fastForEach { element ->
sum += selector(element)
}
return sum
}
internal fun calculateFinalOffset(velocity: Float, lowerBound: Float, upperBound: Float): Float {
fun Float.isValidDistance(): Boolean {
return this != Float.POSITIVE_INFINITY && this != Float.NEGATIVE_INFINITY
}
val finalDistance = when (sign(velocity)) {
0f -> {
if (abs(upperBound) <= abs(lowerBound)) {
upperBound
} else {
lowerBound
}
}
1f -> upperBound
-1f -> lowerBound
else -> NoDistance
}
return if (finalDistance.isValidDistance()) {
finalDistance
} else {
NoDistance
}
}
internal val LazyListLayoutInfo.singleAxisViewportSize: Int
get() = if (orientation == Orientation.Vertical) viewportSize.height else viewportSize.width
@Suppress("BanInlineOptIn")
@OptIn(ExperimentalContracts::class)
inline fun <T> List<T>.fastForEach(action: (T) -> Unit) {
contract { callsInPlace(action) }
for (index in indices) {
val item = get(index)
action(item)
}
}
@Suppress("LongParameterList")
@OptIn(ExperimentalFoundationApi::class)
internal fun Density.calculateDistanceToDesiredSnapPosition(
mainAxisViewPortSize: Int,
beforeContentPadding: Int,
afterContentPadding: Int,
itemSize: Int,
itemOffset: Int,
itemIndex: Int,
snapPositionInLayout: SnapPositionInLayout,
): Float {
val containerSize = mainAxisViewPortSize - beforeContentPadding - afterContentPadding
val desiredDistance = with(snapPositionInLayout) {
position(containerSize, itemSize, itemIndex)
}.toFloat()
return itemOffset - desiredDistance
}
@ExperimentalFoundationApi
fun interface SnapPositionInLayout {
/**
* Calculates an offset positioning between a container and an element within this container.
* The offset calculation is the necessary diff that should be applied to the item offset to
* align the item with a position within the container. As a base line, if we wanted to align
* the start of the container and the start of the item, we would return 0 in this function.
*/
fun Density.position(layoutSize: Int, itemSize: Int, itemIndex: Int): Int
companion object {
/**
* Aligns the center of the item with the center of the containing layout.
*/
val CenterToCenter =
SnapPositionInLayout { layoutSize, itemSize, _ -> layoutSize / 2 - itemSize / 2 }
}
}

View file

@ -4,8 +4,6 @@ import androidx.compose.animation.core.*
import androidx.compose.animation.rememberSplineBasedDecay
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.snapping.SnapFlingBehavior
import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
@ -31,7 +29,7 @@ import com.tangem.feature.wallet.presentation.wallet.ui.components.common.Wallet
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
private const val SHORT_SNAP_ELEMENT_COUNT = 50
private const val SHORT_SNAP_ELEMENT_COUNT = 25
/**
* Wallets list component
@ -76,23 +74,20 @@ internal fun WalletsList(
/**
* Custom implementation of fling behaviour that overrides 'shortSnapVelocityThreshold'.
* Every user's drag action will similar to a short snap
* if drag offset is less than [SHORT_SNAP_ELEMENT_COUNT] * item width.
*
* @param lazyListState lazy list state
* @param itemWidth list item width
*
* @see rememberSnapFlingBehavior
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun rememberWalletsFlingBehaviour(lazyListState: LazyListState, itemWidth: Dp): SnapFlingBehavior {
val snappingLayout = remember(lazyListState) { SnapLayoutInfoProvider(lazyListState) }
private fun rememberWalletsFlingBehaviour(lazyListState: LazyListState, itemWidth: Dp): TangemSnapFlingBehavior {
val snappingLayout = remember(lazyListState) { TangemSnapLayoutInfoProvider(lazyListState) }
val density = LocalDensity.current
val highVelocityApproachSpec: DecayAnimationSpec<Float> = rememberSplineBasedDecay()
return remember(key1 = snappingLayout, key2 = highVelocityApproachSpec, key3 = density) {
SnapFlingBehavior(
TangemSnapFlingBehavior(
snapLayoutInfoProvider = snappingLayout,
lowVelocityAnimationSpec = tween(durationMillis = 1000, easing = LinearEasing),
highVelocityAnimationSpec = highVelocityApproachSpec,

View file

@ -1,6 +1,5 @@
package com.tangem.feature.wallet.presentation.wallet.ui.utils
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.LazyListState
import kotlinx.coroutines.flow.FlowCollector
import kotlin.math.abs
@ -20,14 +19,14 @@ internal class ScrollOffsetCollector(
selectedWalletIndex: Int,
private val lazyListState: LazyListState,
private val onWalletChange: (Int) -> Unit,
) : FlowCollector<List<LazyListItemInfo>> {
) : FlowCollector<List<LazyListItemData>> {
private val LazyListItemInfo.halfItemSize
private val LazyListItemData.halfItemSize
get() = size.div(other = 2)
private var currentIndex = selectedWalletIndex
override suspend fun emit(value: List<LazyListItemInfo>) {
override suspend fun emit(value: List<LazyListItemData>) {
if (!lazyListState.isScrollInProgress || value.size <= 1) return
val firstItem = value.firstOrNull() ?: return
@ -46,4 +45,10 @@ internal class ScrollOffsetCollector(
onWalletChange(newIndex)
}
}
}
}
internal data class LazyListItemData(
val index: Int,
val size: Int,
val offset: Int,
)