mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-29 07:16:08 +00:00
fix(ui): re-implement spoiler effect with better performance and API<31 support
This commit is contained in:
parent
f71750630f
commit
3a67b4ea4e
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user