From 6df93c1d67a08b8ce0ec5cae56e4f76e381074b2 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 21 Feb 2024 14:26:26 +0700 Subject: [PATCH] Updated on 2026-08-14 --- .../wallet/ui/WalletsListEffects.kt | 7 +- .../ui/components/TangemSnapFlingBehavior.kt | 405 ++++++++++++++++++ .../TangemSnapLayoutInfoProvider.kt | 177 ++++++++ .../wallet/ui/components/WalletsList.kt | 15 +- .../wallet/ui/utils/ScrollOffsetCollector.kt | 15 +- 5 files changed, 607 insertions(+), 12 deletions(-) create mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/TangemSnapFlingBehavior.kt create mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/TangemSnapLayoutInfoProvider.kt diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletsListEffects.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletsListEffects.kt index 5765897931..d2173369f4 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletsListEffects.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletsListEffects.kt @@ -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, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/TangemSnapFlingBehavior.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/TangemSnapFlingBehavior.kt new file mode 100644 index 0000000000..4ce45d17f4 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/TangemSnapFlingBehavior.kt @@ -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, + private val highVelocityAnimationSpec: DecayAnimationSpec, + private val snapAnimationSpec: AnimationSpec, + 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 { + // 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 { + 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 { + 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 { + 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, + snapLayoutInfoProvider: SnapLayoutInfoProvider, + density: Density, + onAnimationStep: (delta: Float) -> Unit, +): AnimationResult { + 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, + snapAnimationSpec: AnimationSpec, + onAnimationStep: (delta: Float) -> Unit, +): AnimationResult { + 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, +) : ApproachAnimation { + override suspend fun approachAnimation( + scope: ScrollScope, + offset: Float, + velocity: Float, + onAnimationStep: (delta: Float) -> Unit, + ): AnimationResult { + 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, + private val layoutInfoProvider: SnapLayoutInfoProvider, + private val density: Density, +) : ApproachAnimation { + @OptIn(ExperimentalFoundationApi::class) + override suspend fun approachAnimation( + scope: ScrollScope, + offset: Float, + velocity: Float, + onAnimationStep: (delta: Float) -> Unit, + ): AnimationResult { + 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, + decayAnimationSpec: DecayAnimationSpec, + onAnimationStep: (delta: Float) -> Unit, +): AnimationResult { + var previousValue = 0f + + fun AnimationScope.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 { + suspend fun approachAnimation( + scope: ScrollScope, + offset: T, + velocity: T, + onAnimationStep: (delta: T) -> Unit, + ): AnimationResult +} + +private fun Float.coerceToTarget(target: Float): Float { + if (target == 0f) return 0f + return if (target > 0) coerceAtMost(target) else coerceAtLeast(target) +} + +private class AnimationResult( + val remainingOffset: T, + val currentAnimationState: AnimationState, +) { + operator fun component1(): T = remainingOffset + operator fun component2(): AnimationState = 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 +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/TangemSnapLayoutInfoProvider.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/TangemSnapLayoutInfoProvider.kt new file mode 100644 index 0000000000..1d68cf0349 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/TangemSnapLayoutInfoProvider.kt @@ -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 = 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 List.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 List.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 } + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/WalletsList.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/WalletsList.kt index bd417d7a27..c02a45e56e 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/WalletsList.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/WalletsList.kt @@ -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 @@ -22,6 +20,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.tangem.core.ui.res.TangemTheme import com.tangem.feature.wallet.presentation.common.WalletPreviewData @@ -30,6 +29,8 @@ 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 = 25 + /** * Wallets list component * @@ -53,7 +54,7 @@ internal fun WalletsList( state = lazyListState, contentPadding = PaddingValues(horizontal = TangemTheme.dimens.spacing16), horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8), - flingBehavior = rememberWalletsFlingBehaviour(lazyListState = lazyListState), + flingBehavior = rememberWalletsFlingBehaviour(lazyListState = lazyListState, itemWidth = itemWidth), ) { items( items = wallets, @@ -80,17 +81,19 @@ internal fun WalletsList( */ @OptIn(ExperimentalFoundationApi::class) @Composable -private fun rememberWalletsFlingBehaviour(lazyListState: LazyListState): 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 = rememberSplineBasedDecay() return remember(key1 = snappingLayout, key2 = highVelocityApproachSpec, key3 = density) { - SnapFlingBehavior( + TangemSnapFlingBehavior( snapLayoutInfoProvider = snappingLayout, lowVelocityAnimationSpec = tween(durationMillis = 1000, easing = LinearEasing), highVelocityAnimationSpec = highVelocityApproachSpec, snapAnimationSpec = spring(stiffness = Spring.StiffnessMediumLow), + density = density, + shortSnapVelocityThreshold = itemWidth * SHORT_SNAP_ELEMENT_COUNT, ) } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/utils/ScrollOffsetCollector.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/utils/ScrollOffsetCollector.kt index 939886db4a..63fbab40fd 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/utils/ScrollOffsetCollector.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/utils/ScrollOffsetCollector.kt @@ -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> { +) : FlowCollector> { - private val LazyListItemInfo.halfItemSize + private val LazyListItemData.halfItemSize get() = size.div(other = 2) private var currentIndex = selectedWalletIndex - override suspend fun emit(value: List) { + override suspend fun emit(value: List) { if (!lazyListState.isScrollInProgress || value.size <= 1) return val firstItem = value.firstOrNull() ?: return @@ -46,4 +45,10 @@ internal class ScrollOffsetCollector( onWalletChange(newIndex) } } -} \ No newline at end of file +} + +internal data class LazyListItemData( + val index: Int, + val size: Int, + val offset: Int, +) \ No newline at end of file