Updated on 2026-08-14
This commit is contained in:
parent
a2f57e10be
commit
6df93c1d67
5 changed files with 607 additions and 12 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Float> = 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue