From 815f6c2b2571020faa1e56381d8ff1c0596bd655 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 31 Jul 2026 16:36:46 +0300 Subject: [PATCH] Updated on 2026-08-14 --- .../core/ui/ds2/surface/TangemSurface.kt | 24 +++++++++++-- .../com/tangem/core/ui/extensions/Shadow.kt | 35 ++++++++++++++++--- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/surface/TangemSurface.kt b/core/ui/src/main/java/com/tangem/core/ui/ds2/surface/TangemSurface.kt index 9cab348f29..8cf038d8d0 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds2/surface/TangemSurface.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds2/surface/TangemSurface.kt @@ -26,6 +26,7 @@ import com.tangem.core.ui.components.haze.hazeEffectTangem import com.tangem.core.ui.extensions.conditionalCompose import com.tangem.core.ui.extensions.softLayerShadow import com.tangem.core.ui.res.LocalHazeState +import com.tangem.core.ui.res.LocalRootBackgroundColor import com.tangem.core.ui.res.TangemTheme import dev.chrisbanes.haze.HazeStyle import dev.chrisbanes.haze.HazeTint @@ -38,7 +39,8 @@ import dev.chrisbanes.haze.HazeTint * Rendering modes: * - **Flat** (`isMaterial = false`, default): a solid [color] background clipped to [shape]. * - **Material** (`isMaterial = true`): the [color] parameter is ignored. The surface adds a soft - * drop shadow and a gradient stroke (`material.border`), then renders a haze-blurred backdrop + * drop shadow (omitted on dark backdrops, where it is not representable in 8 bits and would show + * as a banding ring) and a gradient stroke (`material.border`), then renders a haze-blurred backdrop * tinted with `material.fill.blur`. When `LocalHazeState.blurEnabled` is `false` (e.g. * previews, low-end devices), the surface falls back to opaque `material.fill.solid` overlaid * with translucent `material.tint.solid` so both layers remain visible. @@ -57,6 +59,8 @@ import dev.chrisbanes.haze.HazeTint * @param onClick Click handler. `null` makes the surface non-interactive. * @param enabled Forwarded to the click handler. + * @param shadowRadius Blur size of the material drop shadow, expressed as a Figma `box-shadow` + * blur. Ignored when [isMaterial] is `false`. * @param content Content rendered inside the clipped surface. */ @Suppress("UnsafeCallOnNullableType", "") @@ -118,10 +122,15 @@ fun TangemSurface( */ @Composable private fun Modifier.materialShadow(shape: Shape, radius: Dp): Modifier { + // Approximates the backdrop: a material surface sitting on a non-root background keeps the + // root's verdict, which is accurate enough since only near-black backgrounds are rejected. + val backdrop by LocalRootBackgroundColor.current + if (!backdrop.canRenderShadow(MATERIAL_SHADOW_ALPHA)) return this + val isBlurEnabled = LocalHazeState.current.blurEnabled return softLayerShadow( radius = radius, - color = Color.Black.copy(alpha = 0.12f), + color = Color.Black.copy(alpha = MATERIAL_SHADOW_ALPHA), shape = shape, spread = 0.dp, offset = DpOffset(x = 0.dp, y = 8.dp), @@ -129,6 +138,17 @@ private fun Modifier.materialShadow(shape: Shape, radius: Dp): Modifier { ) } +/** + * Whether a black shadow of [shadowAlpha] is representable over this color, i.e. whether it spans + * at least [MIN_SHADOW_QUANTIZATION_STEPS] steps of the 8-bit channel range. + */ +private fun Color.canRenderShadow(shadowAlpha: Float): Boolean = + maxOf(red, green, blue) * shadowAlpha * MAX_CHANNEL_VALUE >= MIN_SHADOW_QUANTIZATION_STEPS + +private const val MATERIAL_SHADOW_ALPHA = 0.12f +private const val MIN_SHADOW_QUANTIZATION_STEPS = 3f +private const val MAX_CHANNEL_VALUE = 255f + /** Diagonal gradient stroke that wraps the material variant. */ @Composable private fun Modifier.materialBorder(shape: Shape): Modifier = border( diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/Shadow.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/Shadow.kt index bc1ce31389..a5f8b41021 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/extensions/Shadow.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/Shadow.kt @@ -8,9 +8,21 @@ import androidx.compose.ui.graphics.drawscope.clipPath import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpOffset -import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp +/** + * Draws a soft drop shadow behind the content. + * + * @param radius Blur size as authored in the design tool — a Figma / CSS `box-shadow` blur, i.e. + * twice the Gaussian sigma. It is converted to the framework's blur radius internally. Values + * too small to produce a visible blur render nothing. + * @param color Shadow color. Only its alpha and hue matter; the shape itself is never filled. + * @param shape Shape the shadow is cast from. + * @param spread Grows (positive) or shrinks (negative) the shadow relative to [shape]. + * @param offset Shadow displacement. + * @param isAlphaContentClip Cuts [shape]'s own area out of the shadow. Enable it when the content + * drawn on top is translucent, otherwise the blur shows through it. + */ fun Modifier.softLayerShadow( radius: Dp = 8.dp, color: Color = Color.Black.copy(alpha = .23f), @@ -19,10 +31,11 @@ fun Modifier.softLayerShadow( offset: DpOffset = DpOffset(x = 0.dp, y = 2.dp), isAlphaContentClip: Boolean = false, ): Modifier = this.drawWithCache { - val radiusPx = radius.toPx() - require(radiusPx > 0.0F) + val radiusPx = designBlurToShadowRadiusPx(radius.toPx()) + if (radiusPx <= 0f) return@drawWithCache onDrawBehind {} + val paint = Paint().apply { - this.color = color + this.color = color.copy(alpha = 0f) asFrameworkPaint().apply { isDither = true @@ -38,7 +51,7 @@ fun Modifier.softLayerShadow( } val shapeOutline = shape.createOutline( size = size, - layoutDirection = LayoutDirection.Rtl, + layoutDirection = layoutDirection, density = this, ) val shapePath = Path().apply { @@ -86,6 +99,18 @@ fun Modifier.softLayerShadow( @Suppress("UnnecessaryParentheses") private fun spreadScale(spread: Float, size: Float): Float = 1.0F + ((spread / size) * 2.0F) +/** + * Converts a design-tool blur value to the blur radius expected by `Paint.setShadowLayer`. + * + * A Figma / CSS `box-shadow` blur of `B` describes a Gaussian with `sigma = B / 2`, whereas the + * framework derives `sigma = 0.57735 * radius + 0.5`. Passing `B` straight through renders the + * shadow ~16% wider than designed. + */ +private fun designBlurToShadowRadiusPx(blurPx: Float): Float = (blurPx / 2f - BLUR_SIGMA_INTERCEPT) / BLUR_SIGMA_SLOPE + +private const val BLUR_SIGMA_SLOPE = 0.57735f +private const val BLUR_SIGMA_INTERCEPT = 0.5f + private fun DrawScope.clipShadowByPath(path: Path, block: DrawScope.() -> Unit) { clipPath( path = path,