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.Bitmap
import android.graphics.BitmapShader 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 android.graphics.Shader
import androidx.compose.animation.core.* import android.os.Build
import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.clickable
import androidx.compose.runtime.* 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.Modifier
import androidx.compose.ui.composed import androidx.compose.ui.composed
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.input.pointer.pointerInput 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 androidx.compose.ui.unit.dp
import kotlin.math.max
import kotlin.random.Random import kotlin.random.Random
/** /**
* Applies a spoiler effect to the content.
* *
* @param isOn Whether the spoiler is active (content hidden). * @param isVisible Whether the spoiler (obscuration) is active.
* @param onReveal Called when the user taps the spoiler to reveal the content. * @param onReveal Called when the user taps to reveal the content.
*/ */
fun Modifier.spoiler( fun Modifier.spoiler(
isOn: Boolean, isVisible: Boolean,
onReveal: () -> Unit onReveal: () -> Unit
): Modifier = composed { ): 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) // Icon Logic
val infiniteTransition = rememberInfiniteTransition(label = "spoiler_driver") val iconPainter = rememberVectorPainter(Icons.Filled.TouchApp)
val time by infiniteTransition.animateFloat( val iconColor = MaterialTheme.colorScheme.onSurface
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"
)
this this
.pointerInput(isOn) { .clickable(
awaitEachGesture { interactionSource = remember { MutableInteractionSource() },
val down = awaitFirstDown(requireUnconsumed = false) indication = null, // No ripple for the spoiler tap itself to keep it clean
touchPosition = down.position enabled = isVisible,
val up = waitForUpOrCancellation() onClick = onReveal
if (up != null && isOn) {
onReveal() // This will flip isOn -> false -> trigger reveal anim
}
}
}
.then(
Modifier.blur(blurRadius)
) )
// Order matters:
// 1. drawWithContent (Outer wrapper) -> Draws overlays ON TOP of everything else
// 2. graphicsLayer (Inner wrapper) -> Blurs the content (Image)
.drawWithContent { .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 (overlayAlpha > 0f) {
if (revealState != RevealState.Revealed) { // 1. Draw Fallback Overlay (Noise/Scrim)
drawIntoCanvas { canvas -> // On API < 31, this is the primary hiding mechanism.
spoilerState.draw(canvas.nativeCanvas, size, time, revealState) // 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 { private fun createNoiseShader(): Shader {
object Hidden : RevealState() val width = 128
data class Revealing(val start: Long, val origin: Offset) : RevealState() val height = 128
object Revealed : RevealState() val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
}
/**
* Efficient state holder using a cached BitmapShader pattern.
*/
private class SpoilerEffectState {
private val paint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG) // Generate static noise
private val shader: BitmapShader val pixels = IntArray(width * height)
private val matrix = Matrix() for (i in pixels.indices) {
private val ripplePath = Path() val alpha = Random.nextInt(255)
// Grayscale noise
init { val v = Random.nextInt(200, 256) // High value (whitish)
// Generate a small noise bitmap once pixels[i] = android.graphics.Color.argb(alpha, v, v, v)
// 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
} }
bitmap.setPixels(pixels, 0, width, 0, 0, width, height)
fun draw( return BitmapShader(bitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
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)
}
} }

View File

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