Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-31 16:36:46 +03:00
parent 1a4405fd28
commit 815f6c2b25
2 changed files with 52 additions and 7 deletions

View file

@ -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(

View file

@ -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,