fix(ui): re-implement spoiler effect with better performance and API<31 support

This commit is contained in:
callebtc 2026-01-15 23:08:30 +07:00
parent f71750630f
commit 3a67b4ea4e
No known key found for this signature in database
2 changed files with 120 additions and 149 deletions

View File

@ -2,179 +2,150 @@ package com.bitchat.android.core.ui.utils
import android.graphics.Bitmap
import android.graphics.BitmapShader
import android.graphics.Color
import android.graphics.Matrix
import android.graphics.Path
import android.graphics.Region
import android.graphics.Shader
import androidx.compose.animation.core.*
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.runtime.*
import android.os.Build
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.TouchApp
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.asComposeRenderEffect
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.unit.dp
import kotlin.math.max
import kotlin.random.Random
/**
* Applies a spoiler effect to the content.
*
* @param isOn Whether the spoiler is active (content hidden).
* @param onReveal Called when the user taps the spoiler to reveal the content.
* @param isVisible Whether the spoiler (obscuration) is active.
* @param onReveal Called when the user taps to reveal the content.
*/
fun Modifier.spoiler(
isOn: Boolean,
isVisible: Boolean,
onReveal: () -> Unit
): Modifier = composed {
val spoilerState = remember { SpoilerEffectState() }
val revealProgress by animateFloatAsState(
targetValue = if (isVisible) 0f else 1f,
animationSpec = tween(durationMillis = 400),
label = "revealProgress"
)
// If fully revealed, just draw content to avoid overdraw/performance hit
if (revealProgress == 1f) {
return@composed this
}
val noiseShader = remember { createNoiseShader() }
// Animate the shader offset (Time-based constant movement)
val infiniteTransition = rememberInfiniteTransition(label = "spoiler_driver")
val time by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(4000, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "time"
)
// Logic for Reveal Transition
var revealState by remember {
mutableStateOf(if (isOn) RevealState.Hidden else RevealState.Revealed)
}
var lastIsOn by remember { mutableStateOf(isOn) }
var touchPosition by remember { mutableStateOf(Offset.Zero) }
if (isOn != lastIsOn) {
if (!isOn) {
revealState = RevealState.Revealing(System.currentTimeMillis(), touchPosition)
} else {
revealState = RevealState.Hidden
}
lastIsOn = isOn
}
val blurRadius by animateDpAsState(
targetValue = if (revealState is RevealState.Hidden) 46.dp else 0.dp,
animationSpec = tween(350),
label = "blur"
)
// Icon Logic
val iconPainter = rememberVectorPainter(Icons.Filled.TouchApp)
val iconColor = MaterialTheme.colorScheme.onSurface
this
.pointerInput(isOn) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
touchPosition = down.position
val up = waitForUpOrCancellation()
if (up != null && isOn) {
onReveal() // This will flip isOn -> false -> trigger reveal anim
}
}
}
.then(
Modifier.blur(blurRadius)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null, // No ripple for the spoiler tap itself to keep it clean
enabled = isVisible,
onClick = onReveal
)
// Order matters:
// 1. drawWithContent (Outer wrapper) -> Draws overlays ON TOP of everything else
// 2. graphicsLayer (Inner wrapper) -> Blurs the content (Image)
.drawWithContent {
drawContent()
drawContent() // Calls the next modifier (graphicsLayer -> Image)
// Calculate overlay opacity (fades out as revealed)
val overlayAlpha = (1f - revealProgress).coerceIn(0f, 1f)
// Only draw if we have something to hide
if (revealState != RevealState.Revealed) {
drawIntoCanvas { canvas ->
spoilerState.draw(canvas.nativeCanvas, size, time, revealState)
if (overlayAlpha > 0f) {
// 1. Draw Fallback Overlay (Noise/Scrim)
// On API < 31, this is the primary hiding mechanism.
// On API 31+, it adds texture to the blur.
drawRect(
color = Color.Black.copy(alpha = 0.2f * overlayAlpha) // Dimming
)
// Draw Noise Texture
drawRect(
brush = ShaderBrush(noiseShader),
alpha = 1f * overlayAlpha,
blendMode = androidx.compose.ui.graphics.BlendMode.Screen // Blend noise lightly
)
// 2. Stronger Scrim for API < 31 (since no blur)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
drawRect(
color = Color.LightGray.copy(alpha = 0.95f * overlayAlpha) // Almost opaque
)
// Re-apply noise on top of gray for texture
drawRect(
brush = ShaderBrush(noiseShader),
alpha = 0.3f * overlayAlpha
)
}
// 3. Draw "TouchApp" Icon
val iconSize = 48.dp.toPx()
val iconX = (size.width - iconSize) / 2
val iconY = (size.height - iconSize) / 2
translate(left = iconX, top = iconY) {
with(iconPainter) {
draw(
size = Size(iconSize, iconSize),
alpha = overlayAlpha,
colorFilter = ColorFilter.tint(iconColor)
)
}
}
}
}
// For API 31+, we can use RenderEffect/graphicsLayer to blur the content efficiently
// This is applied "inside" the drawWithContent above.
.graphicsLayer {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// Blur radius fades out as we reveal (40.dp -> 0.dp)
val blurRadius = (40f * (1f - revealProgress)).coerceAtLeast(0f) * density
if (blurRadius > 0) {
renderEffect = android.graphics.RenderEffect
.createBlurEffect(
blurRadius,
blurRadius,
android.graphics.Shader.TileMode.CLAMP
)
.asComposeRenderEffect()
}
}
}
}
private sealed class RevealState {
object Hidden : RevealState()
data class Revealing(val start: Long, val origin: Offset) : RevealState()
object Revealed : RevealState()
}
/**
* Efficient state holder using a cached BitmapShader pattern.
*/
private class SpoilerEffectState {
private fun createNoiseShader(): Shader {
val width = 128
val height = 128
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
private val paint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG)
private val shader: BitmapShader
private val matrix = Matrix()
private val ripplePath = Path()
init {
// Generate a small noise bitmap once
// Size 128x128 is usually sufficient for repeating noise
val size = 128
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
// Fill with randomized "sand" (white dots with varying alpha)
for (i in 0 until size) {
for (j in 0 until size) {
if (Random.nextFloat() > 0.85f) { // ~15% fill rate
val alpha = Random.nextInt(50, 255)
// White color with random alpha
bitmap.setPixel(i, j, Color.argb(alpha, 255, 255, 255))
} else {
bitmap.setPixel(i, j, Color.TRANSPARENT)
}
}
}
shader = BitmapShader(bitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
paint.shader = shader
// Generate static noise
val pixels = IntArray(width * height)
for (i in pixels.indices) {
val alpha = Random.nextInt(255)
// Grayscale noise
val v = Random.nextInt(200, 256) // High value (whitish)
pixels[i] = android.graphics.Color.argb(alpha, v, v, v)
}
bitmap.setPixels(pixels, 0, width, 0, 0, width, height)
fun draw(
canvas: android.graphics.Canvas,
size: Size,
time: Float,
revealState: RevealState
) {
// 1. Handle Ripple Reveal Clipping
if (revealState is RevealState.Revealing) {
val elapsed = System.currentTimeMillis() - revealState.start
val progress = (elapsed / 400f).coerceIn(0f, 1f)
val maxRadius = max(size.width, size.height) * 1.5f
val radius = maxRadius * progress
ripplePath.reset()
ripplePath.addCircle(revealState.origin.x, revealState.origin.y, radius, Path.Direction.CW)
try {
canvas.clipPath(ripplePath, Region.Op.DIFFERENCE)
} catch (e: Exception) {
// Ignore clip failures
}
}
// 2. Draw Background Overlay (Dim)
// This ensures white sparks are visible on light content
canvas.drawColor(Color.argb(80, 0, 0, 0))
// 3. Update Shader Matrix for "Swarming" Animation
// Move diagonally over time
val offsetX = time * size.width * 0.5f // Move half screen width over cycle
val offsetY = time * size.height * 0.2f // Move slightly vertical
matrix.reset()
matrix.setTranslate(offsetX, offsetY)
// Optional: Scale slightly to make texture independent of bitmap resolution
// But 1:1 pixel mapping usually looks crispest for noise
shader.setLocalMatrix(matrix)
// 4. Draw the Noise Shader
canvas.drawRect(0f, 0f, size.width, size.height, paint)
}
return BitmapShader(bitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
}

View File

@ -114,7 +114,7 @@ fun ImageMessageItem(
val currentIndex = imagePaths.indexOf(path)
onImageClick?.invoke(path, imagePaths, currentIndex)
}
.spoiler(isOn = isSpoilerVisible) { isSpoilerVisible = false }
.spoiler(isVisible = isSpoilerVisible) { isSpoilerVisible = false }
)
} else {
// Fully revealed image
@ -129,7 +129,7 @@ fun ImageMessageItem(
val currentIndex = imagePaths.indexOf(path)
onImageClick?.invoke(path, imagePaths, currentIndex)
}
.spoiler(isOn = isSpoilerVisible) { isSpoilerVisible = false },
.spoiler(isVisible = isSpoilerVisible) { isSpoilerVisible = false },
contentScale = ContentScale.Fit
)
}