Updated on 2026-08-14
This commit is contained in:
parent
1a4405fd28
commit
815f6c2b25
2 changed files with 52 additions and 7 deletions
|
|
@ -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.conditionalCompose
|
||||||
import com.tangem.core.ui.extensions.softLayerShadow
|
import com.tangem.core.ui.extensions.softLayerShadow
|
||||||
import com.tangem.core.ui.res.LocalHazeState
|
import com.tangem.core.ui.res.LocalHazeState
|
||||||
|
import com.tangem.core.ui.res.LocalRootBackgroundColor
|
||||||
import com.tangem.core.ui.res.TangemTheme
|
import com.tangem.core.ui.res.TangemTheme
|
||||||
import dev.chrisbanes.haze.HazeStyle
|
import dev.chrisbanes.haze.HazeStyle
|
||||||
import dev.chrisbanes.haze.HazeTint
|
import dev.chrisbanes.haze.HazeTint
|
||||||
|
|
@ -38,7 +39,8 @@ import dev.chrisbanes.haze.HazeTint
|
||||||
* Rendering modes:
|
* Rendering modes:
|
||||||
* - **Flat** (`isMaterial = false`, default): a solid [color] background clipped to [shape].
|
* - **Flat** (`isMaterial = false`, default): a solid [color] background clipped to [shape].
|
||||||
* - **Material** (`isMaterial = true`): the [color] parameter is ignored. The surface adds a soft
|
* - **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.
|
* 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
|
* previews, low-end devices), the surface falls back to opaque `material.fill.solid` overlaid
|
||||||
* with translucent `material.tint.solid` so both layers remain visible.
|
* 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 onClick Click handler. `null` makes the surface non-interactive.
|
||||||
* @param enabled Forwarded to the click handler.
|
* @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.
|
* @param content Content rendered inside the clipped surface.
|
||||||
*/
|
*/
|
||||||
@Suppress("UnsafeCallOnNullableType", "")
|
@Suppress("UnsafeCallOnNullableType", "")
|
||||||
|
|
@ -118,10 +122,15 @@ fun TangemSurface(
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun Modifier.materialShadow(shape: Shape, radius: Dp): Modifier {
|
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
|
val isBlurEnabled = LocalHazeState.current.blurEnabled
|
||||||
return softLayerShadow(
|
return softLayerShadow(
|
||||||
radius = radius,
|
radius = radius,
|
||||||
color = Color.Black.copy(alpha = 0.12f),
|
color = Color.Black.copy(alpha = MATERIAL_SHADOW_ALPHA),
|
||||||
shape = shape,
|
shape = shape,
|
||||||
spread = 0.dp,
|
spread = 0.dp,
|
||||||
offset = DpOffset(x = 0.dp, y = 8.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. */
|
/** Diagonal gradient stroke that wraps the material variant. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun Modifier.materialBorder(shape: Shape): Modifier = border(
|
private fun Modifier.materialBorder(shape: Shape): Modifier = border(
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,21 @@ import androidx.compose.ui.graphics.drawscope.clipPath
|
||||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.DpOffset
|
import androidx.compose.ui.unit.DpOffset
|
||||||
import androidx.compose.ui.unit.LayoutDirection
|
|
||||||
import androidx.compose.ui.unit.dp
|
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(
|
fun Modifier.softLayerShadow(
|
||||||
radius: Dp = 8.dp,
|
radius: Dp = 8.dp,
|
||||||
color: Color = Color.Black.copy(alpha = .23f),
|
color: Color = Color.Black.copy(alpha = .23f),
|
||||||
|
|
@ -19,10 +31,11 @@ fun Modifier.softLayerShadow(
|
||||||
offset: DpOffset = DpOffset(x = 0.dp, y = 2.dp),
|
offset: DpOffset = DpOffset(x = 0.dp, y = 2.dp),
|
||||||
isAlphaContentClip: Boolean = false,
|
isAlphaContentClip: Boolean = false,
|
||||||
): Modifier = this.drawWithCache {
|
): Modifier = this.drawWithCache {
|
||||||
val radiusPx = radius.toPx()
|
val radiusPx = designBlurToShadowRadiusPx(radius.toPx())
|
||||||
require(radiusPx > 0.0F)
|
if (radiusPx <= 0f) return@drawWithCache onDrawBehind {}
|
||||||
|
|
||||||
val paint = Paint().apply {
|
val paint = Paint().apply {
|
||||||
this.color = color
|
this.color = color.copy(alpha = 0f)
|
||||||
|
|
||||||
asFrameworkPaint().apply {
|
asFrameworkPaint().apply {
|
||||||
isDither = true
|
isDither = true
|
||||||
|
|
@ -38,7 +51,7 @@ fun Modifier.softLayerShadow(
|
||||||
}
|
}
|
||||||
val shapeOutline = shape.createOutline(
|
val shapeOutline = shape.createOutline(
|
||||||
size = size,
|
size = size,
|
||||||
layoutDirection = LayoutDirection.Rtl,
|
layoutDirection = layoutDirection,
|
||||||
density = this,
|
density = this,
|
||||||
)
|
)
|
||||||
val shapePath = Path().apply {
|
val shapePath = Path().apply {
|
||||||
|
|
@ -86,6 +99,18 @@ fun Modifier.softLayerShadow(
|
||||||
@Suppress("UnnecessaryParentheses")
|
@Suppress("UnnecessaryParentheses")
|
||||||
private fun spreadScale(spread: Float, size: Float): Float = 1.0F + ((spread / size) * 2.0F)
|
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) {
|
private fun DrawScope.clipShadowByPath(path: Path, block: DrawScope.() -> Unit) {
|
||||||
clipPath(
|
clipPath(
|
||||||
path = path,
|
path = path,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue