Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-21 14:12:01 +02:00
parent 4ae70b5391
commit 4e53603fdf
6 changed files with 527 additions and 31 deletions

View file

@ -0,0 +1,223 @@
package com.tangem.core.ui.ds2.shimmers
import android.content.res.Configuration
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer
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.core.ui.res.TangemThemePreviewRedesign
import kotlin.math.cos
import kotlin.math.sin
/**
* Design-system rectangle shimmer placeholder.
*
* A rounded rectangle painted with `bg.opaque.secondary`. A tilted band sweeps across it where
* the base color's alpha is gradually dimmed toward the center of the band and restored at the
* edges, producing a soft "blade" highlight passing through the placeholder. The alpha profile
* matches [com.tangem.core.ui.components.text.BladeAnimation].
*
* Cycle: 1.5s hold 0.8s linear sweep restart.
*
* Version 1.0
* [Figma](https://www.figma.com/design/AsnJ5CPHib4Qxw12gszjMS/%F0%9F%92%A0-DS-Components?node-id=3398-625&p=f&m=dev)
*
* Sizing is the caller's responsibility set width and height via [modifier].
*
* @param modifier Modifier applied to the shimmer's root.
* @param radius Corner radius of the rectangle.
*/
@Composable
fun RectangleShimmer(modifier: Modifier = Modifier, radius: Dp = 6.dp) {
val baseColor = TangemTheme.colors3.bg.opaque.secondary
val progress = LocalTangemShimmerProgress.current ?: rememberShimmerProgressInstance()
val colorStops = remember(baseColor) { buildColorStops(baseColor) }
Box(
modifier = modifier
.clip(RoundedCornerShape(radius))
.drawWithCache {
// Stable per layout — recomputed only when size or density changes.
val shimmerWidthPx = SHIMMER_WIDTH.toPx()
val coverage = size.width * SHIMMER_DX + size.height * SHIMMER_DY
val travel = coverage + shimmerWidthPx
val halfWidth = shimmerWidthPx / 2f
onDrawBehind {
val center = -halfWidth + progress.value * travel
drawRect(
brush = Brush.linearGradient(
colorStops = colorStops,
start = Offset(
x = (center - halfWidth) * SHIMMER_DX,
y = (center - halfWidth) * SHIMMER_DY,
),
end = Offset(
x = (center + halfWidth) * SHIMMER_DX,
y = (center + halfWidth) * SHIMMER_DY,
),
),
)
}
},
)
}
/**
* Text-sized shimmer placeholder. Sizes itself to the bounding box of the [text] measured in the
* typography preset selected by [style], plus the preset's vertical padding (top + bottom).
*
* @param text Text used to determine the shimmer's size. Not drawn.
* @param style Typography preset drives both the measurement style and the vertical padding.
* @param radius Corner radius of the rectangle.
* @param modifier Modifier applied to the shimmer's root.
*/
@Composable
fun TextShimmer(text: String, style: TextShimmerStyle, radius: Dp, modifier: Modifier = Modifier) {
val textStyle = style.toTextStyle()
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
val (widthDp, heightDp) = remember(text, textStyle, measurer, density) {
val measured = measurer.measure(text = text, style = textStyle)
with(density) { measured.size.width.toDp() to measured.size.height.toDp() }
}
RectangleShimmer(
modifier = modifier.size(
width = widthDp,
height = heightDp + style.verticalPadding * 2,
),
radius = radius,
)
}
/**
* Typography preset for [TextShimmer]. Each preset maps to a [TangemTheme.typography3] style
* and contributes additional [verticalPadding] applied to both top and bottom the shimmer
* block ends up `2 * verticalPadding` taller than the raw measured text.
*/
enum class TextShimmerStyle(val verticalPadding: Dp) {
DISPLAY(verticalPadding = 4.dp),
HEADING_MEDIUM(verticalPadding = 2.dp),
HEADING_SMALL(verticalPadding = 2.dp),
BODY(verticalPadding = 2.dp),
SUBHEADING(verticalPadding = 2.dp),
CAPTION(verticalPadding = 2.dp),
}
@Composable
@ReadOnlyComposable
private fun TextShimmerStyle.toTextStyle(): TextStyle = when (this) {
TextShimmerStyle.DISPLAY -> TangemTheme.typography3.display.medium
TextShimmerStyle.HEADING_MEDIUM -> TangemTheme.typography3.heading.medium
TextShimmerStyle.HEADING_SMALL -> TangemTheme.typography3.heading.small
TextShimmerStyle.BODY -> TangemTheme.typography3.body.medium
TextShimmerStyle.SUBHEADING -> TangemTheme.typography3.subheading.medium
TextShimmerStyle.CAPTION -> TangemTheme.typography3.caption.medium
}
/**
* Wraps [content] so every [RectangleShimmer] / [TextShimmer] inside reuses a single shimmer
* animation driver. Without this provider each shimmer creates its own
* [rememberInfiniteTransition] that scales poorly in lists and lets sweeps drift out of phase.
* Safe to nest; safe to omit (each shimmer falls back to its own driver).
*/
@Composable
fun ProvideTangemShimmer(content: @Composable () -> Unit) {
CompositionLocalProvider(
LocalTangemShimmerProgress provides rememberShimmerProgressInstance(),
content = content,
)
}
private val LocalTangemShimmerProgress = compositionLocalOf<State<Float>?> { null }
@Composable
private fun rememberShimmerProgressInstance(): State<Float> {
val transition = rememberInfiniteTransition(label = "TangemShimmer")
return transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = SHIMMER_DURATION_MS,
delayMillis = SHIMMER_DELAY_MS,
easing = LinearEasing,
),
repeatMode = RepeatMode.Restart,
),
label = "TangemShimmerProgress",
)
}
private fun buildColorStops(baseColor: Color): Array<Pair<Float, Color>> = SHIMMER_ALPHA_STOPS
.map { (position, factor) -> position to baseColor.copy(alpha = baseColor.alpha * factor) }
.toTypedArray()
private val SHIMMER_WIDTH: Dp = 400.dp
private const val SHIMMER_DURATION_MS = 800
private const val SHIMMER_DELAY_MS = 1_500
private const val SHIMMER_ROTATION_DEG = 15.0
private val SHIMMER_DX = cos(Math.toRadians(SHIMMER_ROTATION_DEG)).toFloat()
private val SHIMMER_DY = sin(Math.toRadians(SHIMMER_ROTATION_DEG)).toFloat()
/** Alpha profile borrowed from BladeAnimation — a wide, gradual dim through the band's center. */
private val SHIMMER_ALPHA_STOPS: List<Pair<Float, Float>> = listOf(
0f to 1f,
0.15f to 0.75f,
0.35f to 0.45f,
0.5f to 0.3f,
0.65f to 0.45f,
0.85f to 0.75f,
1f to 1f,
)
// region Previews
@Preview(name = "Light", showBackground = true)
@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true)
@Composable
private fun TangemShimmerPreview() {
TangemThemePreviewRedesign {
Column(
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
RectangleShimmer(
modifier = Modifier.size(width = 200.dp, height = 24.dp),
radius = 6.dp,
)
RectangleShimmer(
modifier = Modifier.size(width = 120.dp, height = 16.dp),
radius = 4.dp,
)
TextShimmer(
text = "Account balance",
style = TextShimmerStyle.BODY,
radius = 4.dp,
)
TextShimmer(
text = "$12,345.67",
style = TextShimmerStyle.HEADING_MEDIUM,
radius = 6.dp,
)
}
}
}
// endregion