Updated on 2026-08-14
This commit is contained in:
parent
0d4fd5874e
commit
8cd088188f
20 changed files with 1119 additions and 489 deletions
|
|
@ -81,6 +81,7 @@ fun BottomFadeWithBlur(backgroundColor: Color, modifier: Modifier = Modifier) {
|
|||
),
|
||||
),
|
||||
),
|
||||
backgroundColor = Color.Transparent,
|
||||
),
|
||||
) {
|
||||
progressive =
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ fun Chip(state: ChipUM, modifier: Modifier = Modifier) {
|
|||
@Preview(showBackground = true)
|
||||
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun ChipPreviewV() {
|
||||
private fun ChipPreview() {
|
||||
TangemThemePreview {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package com.tangem.core.ui.components.pager
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
|
|
@ -22,132 +21,147 @@ import androidx.compose.ui.unit.Dp
|
|||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.components.haze.hazeEffectTangem
|
||||
import com.tangem.core.ui.res.LocalRedesignEnabled
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import dev.chrisbanes.haze.HazeStyle
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val ANIMATION_DURATION = 300
|
||||
private const val MAX_VISIBLE_DOTS = 5
|
||||
internal const val ANIMATION_DURATION = 300
|
||||
internal const val MAX_VISIBLE_DOTS = 5
|
||||
internal val SPACING = 4.dp
|
||||
internal val HINT_DOT_SIZE = DpSize(6.dp, 6.dp)
|
||||
private const val MIN_HIDDEN_FOR_SMALL_DOT = 2
|
||||
private const val MIN_DISTANCE_FOR_SMALL_DOT = 3
|
||||
private const val MIN_DISTANCE_FOR_HINT_DOT = 2
|
||||
|
||||
private val SPACING = 4.dp
|
||||
private val BACKGROUND_SIZE = DpSize(92.dp, 32.dp)
|
||||
|
||||
private val CURRENT_DOT_SIZE = DpSize(16.dp, 8.dp)
|
||||
private val NORMAL_DOT_SIZE = DpSize(8.dp, 8.dp)
|
||||
private val HINT_DOT_SIZE = DpSize(6.dp, 6.dp)
|
||||
private val SMALL_DOT_SIZE = DpSize(4.dp, 4.dp)
|
||||
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
fun PagerIndicator(pagerState: PagerState, modifier: Modifier = Modifier) {
|
||||
val totalPages = pagerState.pageCount
|
||||
val currentIndex = pagerState.currentPage
|
||||
if (LocalRedesignEnabled.current) {
|
||||
PagerIndicatorV2(pagerState, modifier)
|
||||
} else {
|
||||
PagerIndicatorV1(pagerState, modifier)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PagerIndicatorV1(pagerState: PagerState, modifier: Modifier = Modifier) {
|
||||
val colors = PagerIndicatorColors(
|
||||
active = TangemTheme.colors.control.key,
|
||||
inactive = TangemTheme.colors.text.tertiary,
|
||||
overlay = TangemTheme.colors.overlay.secondary,
|
||||
)
|
||||
PagerIndicatorContent(
|
||||
pagerState = pagerState,
|
||||
colors = colors,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PagerIndicatorV2(pagerState: PagerState, modifier: Modifier = Modifier) {
|
||||
val colors = PagerIndicatorColors(
|
||||
active = TangemTheme.colors2.graphic.neutral.primary,
|
||||
inactive = TangemTheme.colors2.graphic.neutral.tertiary,
|
||||
overlay = TangemTheme.colors2.tabs.backgroundSecondary.copy(alpha = .1f),
|
||||
)
|
||||
PagerIndicatorContent(
|
||||
pagerState = pagerState,
|
||||
colors = colors,
|
||||
modifier = modifier,
|
||||
boxModifier = Modifier.hazeEffectTangem(style = HazeStyle(blurRadius = 22.dp, tint = null)),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberPagerIndicatorAnimationState(pagerState: PagerState): PagerIndicatorAnimationState {
|
||||
val density = LocalDensity.current
|
||||
return remember(pagerState.pageCount, density) {
|
||||
PagerIndicatorAnimationState(pagerState.pageCount, pagerState.currentPage, density)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
private fun calculateDotAlpha(
|
||||
isSliding: Boolean,
|
||||
slideDirection: Int,
|
||||
index: Int,
|
||||
displayLower: Int,
|
||||
displayUpper: Int,
|
||||
fadeProgress: Float,
|
||||
): Float {
|
||||
return when {
|
||||
!isSliding -> 1f
|
||||
slideDirection > 0 && index == displayLower -> 1f - fadeProgress
|
||||
slideDirection > 0 && index == displayUpper - 1 -> fadeProgress
|
||||
slideDirection < 0 && index == displayUpper - 1 -> 1f - fadeProgress
|
||||
slideDirection < 0 && index == displayLower -> fadeProgress
|
||||
else -> 1f
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PagerIndicatorContent(
|
||||
pagerState: PagerState,
|
||||
colors: PagerIndicatorColors,
|
||||
modifier: Modifier = Modifier,
|
||||
boxModifier: Modifier = Modifier,
|
||||
) {
|
||||
val totalPages = pagerState.pageCount
|
||||
if (totalPages == 0) return
|
||||
|
||||
val indicatorColor = TangemTheme.colors.control.key
|
||||
val overlayColor = TangemTheme.colors.overlay.secondary
|
||||
val inactiveIndicatorColor = TangemTheme.colors.text.tertiary
|
||||
|
||||
val density = LocalDensity.current
|
||||
|
||||
val (targetLower, targetUpper) = getWindowBounds(totalPages, currentIndex)
|
||||
|
||||
var displayLower by remember { mutableIntStateOf(targetLower) }
|
||||
var displayUpper by remember { mutableIntStateOf(targetUpper) }
|
||||
var prevTargetLower by remember { mutableIntStateOf(targetLower) }
|
||||
|
||||
val slideOffset = remember { Animatable(0f) }
|
||||
var isSliding by remember { mutableStateOf(false) }
|
||||
var slideDirection by remember { mutableIntStateOf(0) }
|
||||
val fadeProgress = remember { Animatable(0f) }
|
||||
var fadeJob by remember { mutableStateOf<Job?>(null) }
|
||||
val animState = rememberPagerIndicatorAnimationState(pagerState)
|
||||
val (targetLower, targetUpper) = getWindowBounds(pagerState.pageCount, pagerState.currentPage)
|
||||
|
||||
LaunchedEffect(targetLower) {
|
||||
if (targetLower != prevTargetLower && totalPages > MAX_VISIBLE_DOTS) {
|
||||
fadeJob?.cancel()
|
||||
slideOffset.stop()
|
||||
fadeProgress.stop()
|
||||
|
||||
val dir = if (targetLower > prevTargetLower) 1 else -1
|
||||
val edgeDotSize = with(density) { (HINT_DOT_SIZE.width + SPACING).toPx() }
|
||||
val halfEdge = edgeDotSize / 2
|
||||
|
||||
isSliding = true
|
||||
slideDirection = dir
|
||||
fadeProgress.snapTo(0f)
|
||||
|
||||
if (dir > 0) {
|
||||
displayLower = prevTargetLower
|
||||
displayUpper = targetUpper
|
||||
slideOffset.snapTo(halfEdge)
|
||||
} else {
|
||||
displayLower = targetLower
|
||||
displayUpper = prevTargetLower + MAX_VISIBLE_DOTS
|
||||
slideOffset.snapTo(-halfEdge)
|
||||
}
|
||||
|
||||
prevTargetLower = targetLower
|
||||
|
||||
fadeJob = launch {
|
||||
fadeProgress.animateTo(1f, tween(ANIMATION_DURATION))
|
||||
}
|
||||
slideOffset.animateTo(
|
||||
if (dir > 0) -halfEdge else halfEdge,
|
||||
tween(ANIMATION_DURATION),
|
||||
)
|
||||
|
||||
displayLower = targetLower
|
||||
displayUpper = targetUpper
|
||||
slideOffset.snapTo(0f)
|
||||
isSliding = false
|
||||
slideDirection = 0
|
||||
}
|
||||
animState.onBoundsChange(this, targetLower, targetUpper)
|
||||
}
|
||||
val visibleIndices = (displayLower until displayUpper).toList()
|
||||
|
||||
val visibleIndices = (animState.displayLower until animState.displayUpper).toList()
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.width(BACKGROUND_SIZE.width)
|
||||
.height(BACKGROUND_SIZE.height)
|
||||
.background(
|
||||
color = overlayColor,
|
||||
color = colors.overlay,
|
||||
shape = CircleShape,
|
||||
)
|
||||
.clip(CircleShape),
|
||||
.clip(CircleShape)
|
||||
.then(boxModifier),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.offset {
|
||||
IntOffset(slideOffset.value.roundToInt(), 0)
|
||||
IntOffset(animState.slideOffset.value.roundToInt(), 0)
|
||||
},
|
||||
horizontalArrangement = Arrangement.spacedBy(SPACING),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
visibleIndices.forEach { index ->
|
||||
val dotAlpha = when {
|
||||
!isSliding -> 1f
|
||||
slideDirection > 0 && index == displayLower -> 1f - fadeProgress.value
|
||||
slideDirection > 0 && index == displayUpper - 1 -> fadeProgress.value
|
||||
slideDirection < 0 && index == displayUpper - 1 -> 1f - fadeProgress.value
|
||||
slideDirection < 0 && index == displayLower -> fadeProgress.value
|
||||
else -> 1f
|
||||
}
|
||||
val dotAlpha = calculateDotAlpha(
|
||||
isSliding = animState.isSliding,
|
||||
slideDirection = animState.slideDirection,
|
||||
index = index,
|
||||
displayLower = animState.displayLower,
|
||||
displayUpper = animState.displayUpper,
|
||||
fadeProgress = animState.fadeProgress.value,
|
||||
)
|
||||
|
||||
key(index) {
|
||||
Dot(
|
||||
index = index,
|
||||
currentIndex = currentIndex,
|
||||
currentIndex = pagerState.currentPage,
|
||||
totalPages = totalPages,
|
||||
activeColor = indicatorColor,
|
||||
inactiveColor = inactiveIndicatorColor,
|
||||
activeColor = colors.active,
|
||||
inactiveColor = colors.inactive,
|
||||
modifier = Modifier.graphicsLayer { alpha = dotAlpha },
|
||||
)
|
||||
}
|
||||
|
|
@ -156,19 +170,6 @@ fun PagerIndicator(pagerState: PagerState, modifier: Modifier = Modifier) {
|
|||
}
|
||||
}
|
||||
|
||||
private fun getWindowBounds(totalPages: Int, currentIndex: Int): Pair<Int, Int> {
|
||||
if (totalPages <= MAX_VISIBLE_DOTS) {
|
||||
return 0 to totalPages
|
||||
}
|
||||
val lowerBound = when {
|
||||
currentIndex <= 1 -> 0
|
||||
currentIndex >= totalPages - 2 -> totalPages - MAX_VISIBLE_DOTS
|
||||
else -> currentIndex - 2
|
||||
}
|
||||
val upperBound = min(lowerBound + MAX_VISIBLE_DOTS, totalPages)
|
||||
return lowerBound to upperBound
|
||||
}
|
||||
|
||||
private fun getDotSize(index: Int, currentIndex: Int, totalPages: Int): DpSize {
|
||||
if (index == currentIndex) {
|
||||
return CURRENT_DOT_SIZE
|
||||
|
|
@ -180,6 +181,46 @@ private fun getDotSize(index: Int, currentIndex: Int, totalPages: Int): DpSize {
|
|||
return params.calculateSize()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Dot(
|
||||
index: Int,
|
||||
currentIndex: Int,
|
||||
totalPages: Int,
|
||||
activeColor: Color,
|
||||
inactiveColor: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val isActive = index == currentIndex
|
||||
val size = getDotSize(index, currentIndex, totalPages)
|
||||
|
||||
val animSpec = tween<Dp>(ANIMATION_DURATION)
|
||||
val colorSpec = tween<Color>(ANIMATION_DURATION)
|
||||
|
||||
val animatedWidth by animateDpAsState(size.width, animSpec, label = "w$index")
|
||||
val animatedHeight by animateDpAsState(size.height, animSpec, label = "h$index")
|
||||
val animatedColor by animateColorAsState(
|
||||
targetValue = if (isActive) activeColor else inactiveColor,
|
||||
animationSpec = colorSpec,
|
||||
label = "c$index",
|
||||
)
|
||||
|
||||
val shape = RoundedCornerShape(animatedHeight / 2)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.width(animatedWidth)
|
||||
.height(animatedHeight)
|
||||
.background(animatedColor, shape),
|
||||
)
|
||||
}
|
||||
|
||||
@Immutable
|
||||
private data class PagerIndicatorColors(
|
||||
val active: Color,
|
||||
val inactive: Color,
|
||||
val overlay: Color,
|
||||
)
|
||||
|
||||
private class DotSizeParams private constructor(
|
||||
val posInWindow: Int,
|
||||
val currentPosInWindow: Int,
|
||||
|
|
@ -248,43 +289,27 @@ private class DotSizeParams private constructor(
|
|||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun Dot(
|
||||
index: Int,
|
||||
currentIndex: Int,
|
||||
totalPages: Int,
|
||||
activeColor: Color,
|
||||
inactiveColor: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val isActive = index == currentIndex
|
||||
val size = getDotSize(index, currentIndex, totalPages)
|
||||
|
||||
val animSpec = tween<Dp>(ANIMATION_DURATION)
|
||||
val colorSpec = tween<Color>(ANIMATION_DURATION)
|
||||
|
||||
val animatedWidth by animateDpAsState(size.width, animSpec, label = "w$index")
|
||||
val animatedHeight by animateDpAsState(size.height, animSpec, label = "h$index")
|
||||
val animatedColor by animateColorAsState(
|
||||
targetValue = if (isActive) activeColor else inactiveColor,
|
||||
animationSpec = colorSpec,
|
||||
label = "c$index",
|
||||
)
|
||||
|
||||
val shape = RoundedCornerShape(animatedHeight / 2)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.width(animatedWidth)
|
||||
.height(animatedHeight)
|
||||
.background(animatedColor, shape),
|
||||
)
|
||||
private fun PagerIndicatorPreviewV1() {
|
||||
TangemThemePreview {
|
||||
Column(
|
||||
Modifier
|
||||
.background(TangemTheme.colors.background.primary)
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
listOf(0, 1, 2, 3, 4).forEach { page ->
|
||||
PagerIndicator(rememberPagerState(page) { 5 })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun PagerIndicatorPreview() {
|
||||
TangemThemePreview {
|
||||
private fun PagerIndicatorPreviewV2() {
|
||||
TangemThemePreviewRedesign {
|
||||
Column(
|
||||
Modifier
|
||||
.background(TangemTheme.colors.background.primary)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
package com.tangem.core.ui.components.pager
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.unit.Density
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.min
|
||||
|
||||
@Stable
|
||||
internal class PagerIndicatorAnimationState(
|
||||
private val totalPages: Int,
|
||||
initialCurrentPage: Int,
|
||||
private val density: Density,
|
||||
) {
|
||||
var displayLower by mutableIntStateOf(0)
|
||||
private set
|
||||
var displayUpper by mutableIntStateOf(0)
|
||||
private set
|
||||
|
||||
val slideOffset = Animatable(0f)
|
||||
var isSliding by mutableStateOf(false)
|
||||
private set
|
||||
var slideDirection by mutableIntStateOf(0)
|
||||
private set
|
||||
val fadeProgress = Animatable(0f)
|
||||
private var fadeJob by mutableStateOf<Job?>(null)
|
||||
|
||||
private var prevTargetLower by mutableIntStateOf(0)
|
||||
|
||||
init {
|
||||
val (lower, upper) = getWindowBounds(totalPages, initialCurrentPage)
|
||||
displayLower = lower
|
||||
displayUpper = upper
|
||||
prevTargetLower = lower
|
||||
}
|
||||
|
||||
suspend fun onBoundsChange(scope: CoroutineScope, targetLower: Int, targetUpper: Int) {
|
||||
if (targetLower == prevTargetLower || totalPages <= MAX_VISIBLE_DOTS) {
|
||||
return
|
||||
}
|
||||
|
||||
fadeJob?.cancel()
|
||||
slideOffset.stop()
|
||||
fadeProgress.stop()
|
||||
|
||||
val dir = if (targetLower > prevTargetLower) 1 else -1
|
||||
val edgeDotSize = with(density) { (HINT_DOT_SIZE.width + SPACING).toPx() }
|
||||
val halfEdge = edgeDotSize / 2
|
||||
|
||||
isSliding = true
|
||||
slideDirection = dir
|
||||
fadeProgress.snapTo(0f)
|
||||
|
||||
if (dir > 0) {
|
||||
displayUpper = targetUpper
|
||||
slideOffset.snapTo(halfEdge)
|
||||
} else {
|
||||
displayLower = targetLower
|
||||
displayUpper = prevTargetLower + MAX_VISIBLE_DOTS
|
||||
slideOffset.snapTo(-halfEdge)
|
||||
}
|
||||
|
||||
prevTargetLower = targetLower
|
||||
|
||||
fadeJob = scope.launch {
|
||||
fadeProgress.animateTo(1f, tween(ANIMATION_DURATION))
|
||||
}
|
||||
slideOffset.animateTo(
|
||||
if (dir > 0) -halfEdge else halfEdge,
|
||||
tween(ANIMATION_DURATION),
|
||||
)
|
||||
|
||||
displayLower = targetLower
|
||||
displayUpper = targetUpper
|
||||
slideOffset.snapTo(0f)
|
||||
isSliding = false
|
||||
slideDirection = 0
|
||||
}
|
||||
}
|
||||
|
||||
internal fun getWindowBounds(totalPages: Int, currentIndex: Int): Pair<Int, Int> {
|
||||
if (totalPages <= MAX_VISIBLE_DOTS) {
|
||||
return 0 to totalPages
|
||||
}
|
||||
val lowerBound = when {
|
||||
currentIndex <= 1 -> 0
|
||||
currentIndex >= totalPages - 2 -> totalPages - MAX_VISIBLE_DOTS
|
||||
else -> currentIndex - 2
|
||||
}
|
||||
val upperBound = min(lowerBound + MAX_VISIBLE_DOTS, totalPages)
|
||||
return lowerBound to upperBound
|
||||
}
|
||||
|
|
@ -80,7 +80,7 @@ internal fun TangemButtonInternal(
|
|||
visible = iconRes != null && iconPosition == TangemButtonIconPosition.Start,
|
||||
modifier = Modifier.size(size = size.toContentSize()),
|
||||
) {
|
||||
val wrappedIconRes = remember(this) { requireNotNull(iconRes) }
|
||||
val wrappedIconRes = remember(this, iconRes) { requireNotNull(iconRes) }
|
||||
TangemButtonIcon(iconRes = wrappedIconRes, state = state, iconColor = contentColor, size = size)
|
||||
}
|
||||
|
||||
|
|
|
|||
9
core/ui/src/main/res/drawable/ic_like_20.xml
Normal file
9
core/ui/src/main/res/drawable/ic_like_20.xml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="20dp"
|
||||
android:height="20dp"
|
||||
android:viewportWidth="20"
|
||||
android:viewportHeight="20">
|
||||
<path
|
||||
android:pathData="M13.013,2.167C16.34,2.167 18.666,4.886 18.666,8.23C18.666,10.929 17.218,13.156 15.803,14.715C14.361,16.302 12.716,17.457 11.829,18.033L11.785,18.062L11.74,18.088C11.685,18.12 11.46,18.256 11.252,18.357C11.082,18.441 10.608,18.667 10.019,18.667C9.455,18.667 8.985,18.458 8.816,18.381C8.583,18.275 8.371,18.153 8.259,18.088L8.214,18.062L8.171,18.033C7.284,17.457 5.639,16.303 4.196,14.715C2.781,13.156 1.333,10.929 1.333,8.23C1.333,4.886 3.659,2.167 6.986,2.167C8.182,2.167 9.19,2.546 10,3.118C10.81,2.545 11.819,2.167 13.013,2.167Z"
|
||||
android:fillColor="#ffffff"/>
|
||||
</vector>
|
||||
Loading…
Add table
Add a link
Reference in a new issue