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.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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user