Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-24 15:38:13 +03:00
parent c0fbd241f4
commit af84319092
9 changed files with 821 additions and 5 deletions

View file

@ -0,0 +1,297 @@
@file:Suppress("MagicNumber")
package com.tangem.core.ui.ds2.glowring
import android.os.Build
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
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 com.tangem.core.ui.res.generated.TangemColors3
/**
* Design-system v2 (DS3) **Glow Ring** an animated angular-gradient halo that runs around a
* rounded-rect outline, like lights chasing along the border.
*
* [Figma](https://www.figma.com/design/AsnJ5CPHib4Qxw12gszjMS/%F0%9F%92%A0-DS-Components?node-id=4933-126&m=dev)
*
* @param modifier Modifier for the whole component; also defines its size when there is no [content].
* @param variant Color theme of the gradient see [TangemGlowRing.Variant].
* @param cornerRadius Corner radius of the ring; should match the radius of the wrapped surface.
* @param animated When `false`, the ring is rendered static (no rotation).
* @param quality Rendering strategy; defaults to [TangemGlowRing.Quality.Auto] (device-appropriate).
* Force [TangemGlowRing.Quality.LayeredStrokes] to preview the pre-Android-12 fallback on any device.
* @param contentDescription Accessibility label; pass a value when the ring conveys state (e.g. error),
* leave `null` when it is purely decorative.
* @param content Optional content drawn inside/over the ring.
*/
@Composable
fun TangemGlowRing(
modifier: Modifier = Modifier,
variant: TangemGlowRing.Variant = TangemGlowRing.Variant.Magic,
cornerRadius: Dp = 24.dp,
animated: Boolean = true,
quality: TangemGlowRing.Quality = TangemGlowRing.Quality.Auto,
contentDescription: String? = null,
content: @Composable BoxScope.() -> Unit = {},
) {
val resolved = remember(quality) { resolveQuality(quality) }
val stops = rememberGlowRingStops(variant, animated)
val metrics = remember {
GlowRingMetrics(coreWidth = 2.dp, ringWidth = 4.dp, blurMid = 8.dp, blurBottom = 16.dp)
}
val angle = if (animated) {
val transition = rememberInfiniteTransition(label = "glowRing")
val rotation by transition.animateFloat(
initialValue = GLOW_RING_START_ANGLE,
targetValue = 270f,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = 24_000,
easing = CubicBezierEasing(a = 0.1f, b = 0f, c = 0.9f, d = 1f),
),
repeatMode = RepeatMode.Restart,
),
label = "angle",
)
rotation
} else {
GLOW_RING_START_ANGLE
}
Box(
modifier = if (contentDescription != null) {
modifier.semantics { this.contentDescription = contentDescription }
} else {
modifier
},
) {
val ringModifier = Modifier.matchParentSize()
when (resolved) {
ResolvedGlowRingQuality.Blur -> BlurGlowRing(
angle = angle,
stops = stops,
cornerRadius = cornerRadius,
metrics = metrics,
modifier = ringModifier,
)
ResolvedGlowRingQuality.LayeredStrokes -> LayeredStrokesGlowRing(
angle = angle,
stops = stops,
cornerRadius = cornerRadius,
metrics = metrics,
modifier = ringModifier,
)
}
content()
}
}
/** Sweep start angle, also reused as the static angle when [TangemGlowRing] is not animated (Figma: -90°). */
private const val GLOW_RING_START_ANGLE = -90f
/**
* Resolves the gradient stops for [variant] from the DS3 `colors3.glow` tokens. The
* [TangemGlowRing.Variant.Magic] variant continuously ping-pongs between gradient A (`glow.magic`) and
* gradient B (`glow.magicBlend`) while [animated] is `true`; every other variant has a single static
* gradient.
*/
@Composable
private fun rememberGlowRingStops(variant: TangemGlowRing.Variant, animated: Boolean): List<Pair<Float, Color>> {
val glow = TangemTheme.colors3.glow
if (variant != TangemGlowRing.Variant.Magic || !animated) {
return variant.stops(glow)
}
val morphTransition = rememberInfiniteTransition(label = "glowRingMorph")
val mix by morphTransition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
// 6s A→B half-period; Reverse makes a 12s ping-pong (Figma morphDur = 12s).
animation = tween(durationMillis = 6_000, easing = LinearEasing),
repeatMode = RepeatMode.Reverse,
),
label = "morphMix",
)
return morphedMagicStops(glow.magic.steps(), glow.magicBlend.steps(), mix)
}
/** Public API surface of [TangemGlowRing]. */
object TangemGlowRing {
/** Color theme of the glow ring gradient. */
enum class Variant {
/**
* Multi-color "magic" gradient that continuously auto-morphs (ping-pongs) between separated
* orange / blue / purple arcs and a continuous fully-saturated blend.
*/
Magic,
/** Green success glow. */
Success,
/** Red error glow. */
Error,
/** Orange/amber warning glow. */
Warning,
/** Blue informational glow. */
Info,
}
/**
* Rendering strategy for the glow.
*
* [Auto] picks the best renderer for the current device a real Gaussian blur on Android 12+
* (API 31) and a layered-stroke approximation on older versions. The explicit values force one
* renderer regardless of API level; they exist mainly for previews / Storybook so the
* pre-Android-12 fallback can be inspected on a modern device. Product code should use [Auto].
*/
enum class Quality {
/** Auto-detect the renderer from the device API level (recommended). */
Auto,
/** Force the Android 12+ real-blur renderer. */
Blur,
/** Force the pre-Android-12 layered-stroke fallback. */
LayeredStrokes,
}
}
/**
* Resolves [quality] to a concrete renderer. [TangemGlowRing.Quality.Auto] picks a real blur on
* Android 12+ (API 31) and falls back to stacked translucent strokes on older versions; the explicit
* values force their renderer regardless of API level.
*/
private fun resolveQuality(quality: TangemGlowRing.Quality): ResolvedGlowRingQuality = when (quality) {
TangemGlowRing.Quality.Blur -> ResolvedGlowRingQuality.Blur
TangemGlowRing.Quality.LayeredStrokes -> ResolvedGlowRingQuality.LayeredStrokes
TangemGlowRing.Quality.Auto -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
ResolvedGlowRingQuality.Blur
} else {
ResolvedGlowRingQuality.LayeredStrokes
}
}
/** Concrete rendering strategy chosen by [resolveQuality]. */
private enum class ResolvedGlowRingQuality { Blur, LayeredStrokes }
/**
* Builds the angular-gradient stops for [this] variant from its DS3 `colors3.glow` token group. Every
* variant token exposes the same 10 [steps] solid arcs at steps 1/4/7, a faint arc at 9 and transparent
* gaps elsewhere which [glowStops] lays out as evenly-spaced, seamlessly-looping stops.
*/
private fun TangemGlowRing.Variant.stops(glow: TangemColors3.Glow): List<Pair<Float, Color>> = glowStops(
when (this) {
TangemGlowRing.Variant.Magic -> glow.magic.steps()
TangemGlowRing.Variant.Success -> glow.success.steps()
TangemGlowRing.Variant.Error -> glow.error.steps()
TangemGlowRing.Variant.Warning -> glow.warning.steps()
TangemGlowRing.Variant.Info -> glow.info.steps()
},
)
/**
* Blends the Magic gradients A ([magic] = `glow.magic`) and B ([magicBlend] = `glow.magicBlend`) at
* [mix] (`0` = A, `1` = B). Both token groups share the same stop positions, so the morph is a direct
* per-step color lerp. Mirrors the reference rig's auto-morph (ping-pong) between gradient A and B.
*/
private fun morphedMagicStops(magic: List<Color>, magicBlend: List<Color>, mix: Float): List<Pair<Float, Color>> {
val m = mix.coerceIn(0f, 1f)
return glowStops(List(magic.size) { lerp(magic[it], magicBlend[it], m) })
}
/**
* Lays the glow [steps] out as an angular gradient: evenly spaced from `0`, with step 1 repeated at `1.0`
* so the rotation loops seamlessly. Transparent steps create the gaps between the glowing arcs.
*/
private fun glowStops(steps: List<Color>): List<Pair<Float, Color>> {
val count = steps.size
return steps.mapIndexed { index, color -> index.toFloat() / count to color } + (1f to steps.first())
}
private fun TangemColors3.Glow.Magic.steps(): List<Color> =
listOf(step1, step2, step3, step4, step5, step6, step7, step8, step9, step10)
private fun TangemColors3.Glow.MagicBlend.steps(): List<Color> =
listOf(step1, step2, step3, step4, step5, step6, step7, step8, step9, step10)
private fun TangemColors3.Glow.Success.steps(): List<Color> =
listOf(step1, step2, step3, step4, step5, step6, step7, step8, step9, step10)
private fun TangemColors3.Glow.Error.steps(): List<Color> =
listOf(step1, step2, step3, step4, step5, step6, step7, step8, step9, step10)
private fun TangemColors3.Glow.Warning.steps(): List<Color> =
listOf(step1, step2, step3, step4, step5, step6, step7, step8, step9, step10)
private fun TangemColors3.Glow.Info.steps(): List<Color> =
listOf(step1, step2, step3, step4, step5, step6, step7, step8, step9, step10)
@Preview(name = "Light", showBackground = true)
@Preview(name = "Dark", uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES, showBackground = true)
@Composable
private fun TangemGlowRingPreview() {
TangemThemePreviewRedesign {
Column(
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) {
TangemGlowRing(
modifier = Modifier.size(120.dp, 72.dp),
variant = TangemGlowRing.Variant.Magic,
)
TangemGlowRing(
modifier = Modifier.size(120.dp, 72.dp),
variant = TangemGlowRing.Variant.Success,
)
}
Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) {
TangemGlowRing(
modifier = Modifier.size(120.dp, 72.dp),
variant = TangemGlowRing.Variant.Error,
)
TangemGlowRing(
modifier = Modifier.size(120.dp, 72.dp),
variant = TangemGlowRing.Variant.Warning,
)
}
Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) {
TangemGlowRing(
modifier = Modifier.size(120.dp, 72.dp),
variant = TangemGlowRing.Variant.Info,
)
}
}
}
}

View file

@ -0,0 +1,231 @@
@file:Suppress("MagicNumber")
package com.tangem.core.ui.ds2.glowring
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.BlurredEdgeTreatment
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathFillType
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.drawscope.withTransform
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.unit.Dp
import kotlin.math.cos
import kotlin.math.max
import kotlin.math.min
/**
* Token-driven measurements shared by both renderers, mirroring the Figma component anatomy:
* a crisp [coreWidth] core line plus two wider, blurred glow bands ([ringWidth] stroked, blurred by
* [blurMid] and [blurBottom]).
*/
internal data class GlowRingMetrics(
val coreWidth: Dp, // crisp core stroke (top layer)
val ringWidth: Dp, // glow band stroke (mid + bottom layers)
val blurMid: Dp, // mid glow blur radius
val blurBottom: Dp, // widest glow blur radius
)
/**
* Tier 1 works on every API level, no blur or shader. Approximates the blurred glow by stacking the
* same breathing angular-gradient ring several times: progressively wider + fainter bands under a crisp
* core. Everything is clipped to the rounded box, so only the inner half of each band shows inner glow.
*/
@Composable
internal fun LayeredStrokesGlowRing(
angle: Float,
stops: List<Pair<Float, Color>>,
cornerRadius: Dp,
metrics: GlowRingMetrics,
modifier: Modifier = Modifier,
) {
Box(modifier.clip(RoundedCornerShape(cornerRadius))) {
Canvas(Modifier.fillMaxSize()) {
val r = cornerRadius.toPx()
// widest & faintest first, crisp core last
drawBreathingRing(
stops = stops,
angleDeg = angle,
cornerRadiusPx = r,
strokePx = (metrics.ringWidth + metrics.blurBottom).toPx(),
alpha = 0.06f,
)
drawBreathingRing(
stops = stops,
angleDeg = angle,
cornerRadiusPx = r,
strokePx = (metrics.ringWidth + metrics.blurMid).toPx(),
alpha = 0.12f,
)
drawBreathingRing(
stops = stops,
angleDeg = angle,
cornerRadiusPx = r,
strokePx = metrics.ringWidth.toPx(),
alpha = 0.30f,
)
drawBreathingRing(
stops = stops,
angleDeg = angle,
cornerRadiusPx = r,
strokePx = metrics.coreWidth.toPx(),
alpha = 1.0f,
)
}
}
}
/**
* Tier 2 Android 12+ (API 31). Reproduces the Figma anatomy directly: three stacked breathing
* angular-gradient rings with real blur (bottom widest, mid, top crisp). Each layer bleeds with
* [BlurredEdgeTreatment.Unbounded]; the surrounding [clip] to the rounded box keeps only the inner
* bloom, producing the inner glow.
*/
@Composable
internal fun BlurGlowRing(
angle: Float,
stops: List<Pair<Float, Color>>,
cornerRadius: Dp,
metrics: GlowRingMetrics,
modifier: Modifier = Modifier,
) {
Box(modifier.clip(RoundedCornerShape(cornerRadius))) {
// bottom — widest halo
BreathingRing(
angle = angle,
stops = stops,
cornerRadius = cornerRadius,
strokeWidth = metrics.ringWidth,
modifier = Modifier
.fillMaxSize()
.blur(radius = metrics.blurBottom, edgeTreatment = BlurredEdgeTreatment.Unbounded),
)
// mid
BreathingRing(
angle = angle,
stops = stops,
cornerRadius = cornerRadius,
strokeWidth = metrics.ringWidth,
modifier = Modifier
.fillMaxSize()
.blur(radius = metrics.blurMid, edgeTreatment = BlurredEdgeTreatment.Unbounded),
)
// top — crisp core
BreathingRing(
angle = angle,
stops = stops,
cornerRadius = cornerRadius,
strokeWidth = metrics.coreWidth,
modifier = Modifier.fillMaxSize(),
)
}
}
@Composable
private fun BreathingRing(
angle: Float,
stops: List<Pair<Float, Color>>,
cornerRadius: Dp,
strokeWidth: Dp,
modifier: Modifier = Modifier,
) {
Canvas(modifier) {
drawBreathingRing(
stops = stops,
angleDeg = angle,
cornerRadiusPx = cornerRadius.toPx(),
strokePx = strokeWidth.toPx(),
alpha = 1f,
)
}
}
/**
* Draws one angular-gradient ring band clipped to the rounded-rect stroke outline. The gradient is a
* sweep whose colour seam is rotated by [angleDeg] (via [rotatedStops]) and whose vertical squish
* breathes between W/2 and W/8 over the rotation (`rxM = mid + amp·cos(2φ)`), reproducing the morphing
* arcs of the reference rig.
*/
private fun DrawScope.drawBreathingRing(
stops: List<Pair<Float, Color>>,
angleDeg: Float,
cornerRadiusPx: Float,
strokePx: Float,
alpha: Float,
) {
val w = size.width
val h = size.height
if (w <= 0f || h <= 0f) return
val center = Offset(w / 2f, h / 2f)
// Breathing horizontal radius of the gradient ellipse → vertical squish of the angle sampling.
val maxRx = w / 2f
val minRx = w / 8f
val mid = (maxRx + minRx) / 2f
val amp = max((maxRx - minRx) / 2f, 0f)
val phaseRad = Math.toRadians(angleDeg.toDouble()).toFloat()
val rxM = mid + amp * cos(2f * phaseRad)
val scaleY = h / 2f / max(rxM, 1f)
val r = min(cornerRadiusPx, min(w, h) / 2f)
val o = strokePx / 2f
val ring = Path().apply {
fillType = PathFillType.EvenOdd
addRoundRect(
RoundRect(rect = Rect(Offset(-o, -o), Size(w + 2f * o, h + 2f * o)), cornerRadius = CornerRadius(r + o)),
)
addRoundRect(
RoundRect(
rect = Rect(Offset(o, o), Size(w - 2f * o, h - 2f * o)),
cornerRadius = CornerRadius(max(r - o, 0f)),
),
)
}
val brush = Brush.sweepGradient(colorStops = rotatedStops(stops, angleDeg), center = center)
val big = max(w, h) * 4f
clipPath(ring) {
withTransform({ scale(scaleX = 1f, scaleY = scaleY, pivot = center) }) {
drawRect(
brush = brush,
topLeft = Offset(center.x - big / 2f, center.y - big / 2f),
size = Size(big, big),
alpha = alpha,
)
}
}
}
/**
* Compose's [Brush.sweepGradient] has no start-angle parameter, so the colour seam is rotated by
* shifting every stop position by `deg/360` (wrapping around the loop) and re-anchoring boundary stops
* at 0 and 1 with the interpolated wrap colour. Mirrors `rotatedStops` from the reference rig.
*/
private fun rotatedStops(base: List<Pair<Float, Color>>, deg: Float): Array<Pair<Float, Color>> {
val d = (deg / 360f % 1f + 1f) % 1f
val uniq = base.dropLast(1) // drop the duplicate wrap stop at 1.0
val shifted = uniq
.map { (p, c) -> ((p + d) % 1f + 1f) % 1f to c }
.sortedBy { it.first }
val first = shifted.first()
val last = shifted.last()
val span = first.first + 1f - last.first
val wrapFraction = if (span > 1e-6f) (1f - last.first) / span else 0f
val wrapColor = lerp(last.second, first.second, wrapFraction)
return (listOf(0f to wrapColor) + shifted + listOf(1f to wrapColor)).toTypedArray()
}

View file

@ -190,11 +190,13 @@ object TangemTheme {
@ReadOnlyComposable
get() = LocalTangemTypography3.current
@Deprecated("Use plain dp")
val dimens: TangemDimens
@Composable
@ReadOnlyComposable
get() = LocalTangemDimens.current
@Deprecated("Use plain dp")
val dimens2: TangemDimens2
@Composable
@ReadOnlyComposable