From 3a67b4ea4e38f6240a075e145090e934db21ea27 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Thu, 15 Jan 2026 23:08:30 +0700 Subject: [PATCH] fix(ui): re-implement spoiler effect with better performance and API<31 support --- .../android/core/ui/utils/SpoilerEffect.kt | 265 ++++++++---------- .../android/ui/media/ImageMessageItem.kt | 4 +- 2 files changed, 120 insertions(+), 149 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/core/ui/utils/SpoilerEffect.kt b/app/src/main/java/com/bitchat/android/core/ui/utils/SpoilerEffect.kt index a77c528a..3b38c220 100644 --- a/app/src/main/java/com/bitchat/android/core/ui/utils/SpoilerEffect.kt +++ b/app/src/main/java/com/bitchat/android/core/ui/utils/SpoilerEffect.kt @@ -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) } diff --git a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt index 80124896..f01f1702 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt @@ -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 ) }