Add support for displaying spoiler text in compose.

This commit is contained in:
Alex Hart
2025-11-24 10:49:00 -04:00
committed by jeffrey-signal
parent b3b934e009
commit 7c9aa3de72
5 changed files with 709 additions and 3 deletions

View File

@@ -28,9 +28,18 @@ object SpoilerPaint {
*/ */
var shader: BitmapShader? = null var shader: BitmapShader? = null
private val WIDTH = if (Util.isLowMemory(AppDependencies.application)) 50.dp else 100.dp private val WIDTH = if (isLowMemory()) 50.dp else 100.dp
private val HEIGHT = if (Util.isLowMemory(AppDependencies.application)) 20.dp else 40.dp private val HEIGHT = if (isLowMemory()) 20.dp else 40.dp
private val PARTICLES_PER_PIXEL = if (Util.isLowMemory(AppDependencies.application)) 0.001f else 0.002f private val PARTICLES_PER_PIXEL = if (isLowMemory()) 0.001f else 0.002f
private fun isLowMemory(): Boolean {
return try {
Util.isLowMemory(AppDependencies.application)
} catch (e: Throwable) {
// In preview mode or when AppDependencies is not initialized, default to low memory mode
true
}
}
private var shaderBitmap: Bitmap = Bitmap.createBitmap(WIDTH, HEIGHT, Bitmap.Config.ALPHA_8) private var shaderBitmap: Bitmap = Bitmap.createBitmap(WIDTH, HEIGHT, Bitmap.Config.ALPHA_8)
private var bufferBitmap: Bitmap = Bitmap.createBitmap(WIDTH, HEIGHT, Bitmap.Config.ALPHA_8) private var bufferBitmap: Bitmap = Bitmap.createBitmap(WIDTH, HEIGHT, Bitmap.Config.ALPHA_8)

View File

@@ -0,0 +1,149 @@
package org.thoughtcrime.securesms.components.spoiler.compose
import android.graphics.Paint
import android.graphics.PorterDuff
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextLayoutResult
import org.thoughtcrime.securesms.components.spoiler.SpoilerPaint
/**
* Annotation tag used to mark spoiler text ranges in an AnnotatedString.
*/
const val SPOILER_ANNOTATION_TAG = "spoiler"
/**
* Modifier that draws the spoiler effect over specific text regions.
*
* @param spoilerState State holder for revealed spoilers
* @param annotatedString The text with spoiler annotations
* @param textLayoutResult The result of text layout measurement
* @param textColor Color to tint the particles
*/
fun Modifier.drawSpoilers(
spoilerState: SpoilerState,
annotatedString: AnnotatedString,
textLayoutResult: TextLayoutResult?,
textColor: Color
): Modifier = this.then(
Modifier.drawWithCache {
val paint = Paint()
val colorFilter = android.graphics.PorterDuffColorFilter(
textColor.toArgb(),
PorterDuff.Mode.SRC_IN
)
onDrawWithContent {
drawContent()
val layout = textLayoutResult ?: return@onDrawWithContent
// Get all spoiler annotations
val spoilerAnnotations = annotatedString.getStringAnnotations(
tag = SPOILER_ANNOTATION_TAG,
start = 0,
end = annotatedString.length
)
if (spoilerAnnotations.isEmpty()) {
return@onDrawWithContent
}
val shader = SpoilerPaint.shader
drawIntoCanvas { canvas ->
val nativeCanvas = canvas.nativeCanvas
// Update paint properties for this draw
if (shader != null) {
paint.shader = shader
paint.colorFilter = colorFilter
} else {
paint.shader = null
paint.color = android.graphics.Color.TRANSPARENT
}
for (annotation in spoilerAnnotations) {
if (spoilerState.isRevealed(annotation.item)) {
continue
}
val start = annotation.start
val end = annotation.end
if (start >= end) {
continue
}
val startLine = layout.getLineForOffset(start)
val endLine = layout.getLineForOffset(end)
if (startLine == endLine) {
val left = layout.getHorizontalPosition(start, true)
val right = layout.getHorizontalPosition(end, true)
val top = layout.getLineTop(startLine)
val bottom = layout.getLineBottom(startLine)
nativeCanvas.drawRect(
left.coerceAtMost(right),
top,
left.coerceAtLeast(right),
bottom,
paint
)
} else {
val firstLineLeft = layout.getHorizontalPosition(start, true)
val firstLineRight = if (layout.getParagraphDirection(startLine) == androidx.compose.ui.text.style.ResolvedTextDirection.Rtl) {
layout.getLineLeft(startLine)
} else {
layout.getLineRight(startLine)
}
nativeCanvas.drawRect(
firstLineLeft.coerceAtMost(firstLineRight),
layout.getLineTop(startLine),
firstLineLeft.coerceAtLeast(firstLineRight),
layout.getLineBottom(startLine),
paint
)
for (line in startLine + 1 until endLine) {
nativeCanvas.drawRect(
layout.getLineLeft(line),
layout.getLineTop(line),
layout.getLineRight(line),
layout.getLineBottom(line),
paint
)
}
val lastLineLeft = if (layout.getParagraphDirection(endLine) == androidx.compose.ui.text.style.ResolvedTextDirection.Rtl) {
layout.getLineRight(endLine)
} else {
layout.getLineLeft(endLine)
}
val lastLineRight = layout.getHorizontalPosition(end, true)
nativeCanvas.drawRect(
lastLineLeft.coerceAtMost(lastLineRight),
layout.getLineTop(endLine),
lastLineLeft.coerceAtLeast(lastLineRight),
layout.getLineBottom(endLine),
paint
)
}
}
}
}
}
)
/**
* Find which spoiler annotation (if any) contains the given offset in the text.
*/
fun AnnotatedString.findSpoilerAt(offset: Int): AnnotatedString.Range<String>? {
return getStringAnnotations(SPOILER_ANNOTATION_TAG, offset, offset).firstOrNull()
}

View File

@@ -0,0 +1,46 @@
package org.thoughtcrime.securesms.components.spoiler.compose
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
/**
* State holder for managing which spoilers have been revealed in Compose.
* Similar to the View-based SpoilerAnnotation, but uses Compose state management.
*/
@Stable
class SpoilerState {
private var revealedSpoilers by mutableStateOf(setOf<String>())
/**
* Check if a spoiler with the given ID has been revealed.
*/
fun isRevealed(spoilerId: String): Boolean {
return spoilerId in revealedSpoilers
}
/**
* Reveal a spoiler with the given ID.
*/
fun reveal(spoilerId: String) {
revealedSpoilers = revealedSpoilers + spoilerId
}
/**
* Reset all revealed spoilers.
*/
fun reset() {
revealedSpoilers = emptySet()
}
}
/**
* Remember a [SpoilerState] instance.
*/
@Composable
fun rememberSpoilerState(): SpoilerState {
return remember { SpoilerState() }
}

View File

@@ -0,0 +1,213 @@
package org.thoughtcrime.securesms.components.spoiler.compose
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.TextUnit
import org.thoughtcrime.securesms.components.spoiler.SpoilerPaint
/**
* A Text composable that supports spoiler annotations with particle effects.
*
* Text ranges marked with [SPOILER_ANNOTATION_TAG] annotations will be rendered with
* an animated particle effect until revealed by tapping.
*
* Example usage:
* ```
* val spoilerState = rememberSpoilerState()
* val text = buildAnnotatedString {
* append("This is normal text. ")
* withAnnotation(SPOILER_ANNOTATION_TAG, "spoiler-1") {
* append("This is a spoiler!")
* }
* append(" More normal text.")
* }
* SpoilerText(
* text = text,
* spoilerState = spoilerState
* )
* ```
*
* @param text The annotated string with optional spoiler annotations
* @param spoilerState State holder for managing revealed spoilers
* @param modifier Modifier to be applied to the Text
* @param color Color to apply to the text
* @param fontSize Font size to apply to the text
* @param textAlign Text alignment
* @param textDecoration Text decoration
* @param overflow How to handle text overflow
* @param softWrap Whether the text should break at soft line breaks
* @param maxLines Maximum number of lines
* @param minLines Minimum number of lines
* @param onTextLayout Callback for text layout results
* @param style Text style to apply
* @param inlineContent Map of inline content for the text
*/
@Composable
fun SpoilerText(
text: AnnotatedString,
spoilerState: SpoilerState,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
fontSize: TextUnit = TextUnit.Unspecified,
textAlign: TextAlign? = null,
textDecoration: TextDecoration? = null,
overflow: TextOverflow = TextOverflow.Clip,
softWrap: Boolean = true,
maxLines: Int = Int.MAX_VALUE,
minLines: Int = 1,
onTextLayout: (TextLayoutResult) -> Unit = {},
style: TextStyle = LocalTextStyle.current,
inlineContent: Map<String, InlineTextContent> = mapOf()
) {
var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
var frameTime by remember { mutableLongStateOf(0L) }
// Get all spoiler annotations
val spoilerAnnotations = remember(text) {
text.getStringAnnotations(SPOILER_ANNOTATION_TAG, 0, text.length)
}
// Check if there are any unrevealed spoilers
val hasUnrevealedSpoilers = remember(spoilerAnnotations, spoilerState, frameTime) {
spoilerAnnotations.any { !spoilerState.isRevealed(it.item) }
}
// Animate when there are unrevealed spoilers
LaunchedEffect(hasUnrevealedSpoilers) {
if (hasUnrevealedSpoilers) {
while (true) {
withFrameNanos { nanos ->
SpoilerPaint.update()
frameTime = nanos
}
}
}
}
val originalTextColor = if (color != Color.Unspecified) {
color
} else if (style.color != Color.Unspecified) {
style.color
} else {
LocalContentColor.current
}
val displayText = remember(text, spoilerAnnotations, spoilerState, frameTime) {
AnnotatedString.Builder(text).apply {
for (annotation in spoilerAnnotations) {
if (!spoilerState.isRevealed(annotation.item)) {
addStyle(
style = androidx.compose.ui.text.SpanStyle(color = Color.Transparent),
start = annotation.start,
end = annotation.end
)
}
}
}.toAnnotatedString()
}
Text(
text = displayText,
modifier = modifier
.drawSpoilers(
spoilerState = spoilerState,
annotatedString = text,
textLayoutResult = textLayoutResult,
textColor = originalTextColor
)
.pointerInput(text, spoilerState) {
detectTapGestures { offset ->
val layout = textLayoutResult ?: return@detectTapGestures
val position = layout.getOffsetForPosition(offset)
val spoiler = text.findSpoilerAt(position)
if (spoiler != null && !spoilerState.isRevealed(spoiler.item)) {
spoilerState.reveal(spoiler.item)
}
}
},
color = color,
fontSize = fontSize,
textAlign = textAlign,
textDecoration = textDecoration,
overflow = overflow,
softWrap = softWrap,
maxLines = maxLines,
minLines = minLines,
onTextLayout = { result ->
textLayoutResult = result
onTextLayout(result)
},
style = style,
inlineContent = inlineContent
)
}
/**
* A Text composable that supports spoiler annotations, using a simple string with builder.
*
* @param text The plain text to display
* @param spoilerState State holder for managing revealed spoilers
* @param modifier Modifier to be applied to the Text
* @param color Color to apply to the text
* @param fontSize Font size to apply to the text
* @param textAlign Text alignment
* @param textDecoration Text decoration
* @param overflow How to handle text overflow
* @param softWrap Whether the text should break at soft line breaks
* @param maxLines Maximum number of lines
* @param minLines Minimum number of lines
* @param onTextLayout Callback for text layout results
* @param style Text style to apply
*/
@Composable
fun SpoilerText(
text: String,
spoilerState: SpoilerState,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
fontSize: TextUnit = TextUnit.Unspecified,
textAlign: TextAlign? = null,
textDecoration: TextDecoration? = null,
overflow: TextOverflow = TextOverflow.Clip,
softWrap: Boolean = true,
maxLines: Int = Int.MAX_VALUE,
minLines: Int = 1,
onTextLayout: (TextLayoutResult) -> Unit = {},
style: TextStyle = LocalTextStyle.current
) {
SpoilerText(
text = AnnotatedString(text),
spoilerState = spoilerState,
modifier = modifier,
color = color,
fontSize = fontSize,
textAlign = textAlign,
textDecoration = textDecoration,
overflow = overflow,
softWrap = softWrap,
maxLines = maxLines,
minLines = minLines,
onTextLayout = onTextLayout,
style = style
)
}

View File

@@ -0,0 +1,289 @@
package org.thoughtcrime.securesms.components.spoiler.compose
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
@DayNightPreviews
@Composable
private fun SingleSpoilerPreview() {
val spoilerState = rememberSpoilerState()
val text = buildAnnotatedString {
append("This is some normal text and ")
val start = length
append("this part is hidden")
addStringAnnotation(SPOILER_ANNOTATION_TAG, "spoiler-1", start, length)
append(" until you tap it!")
}
Previews.Preview {
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
) {
SpoilerText(
text = text,
spoilerState = spoilerState,
style = MaterialTheme.typography.bodyLarge
)
}
}
}
@DayNightPreviews
@Composable
private fun MultipleSpoilersPreview() {
val spoilerState = rememberSpoilerState()
val text = buildAnnotatedString {
append("The answer to question 1 is ")
val start1 = length
append("42")
addStringAnnotation(SPOILER_ANNOTATION_TAG, "answer-1", start1, length)
append(" and the answer to question 2 is ")
val start2 = length
append("the meaning of life")
addStringAnnotation(SPOILER_ANNOTATION_TAG, "answer-2", start2, length)
append(".")
}
Previews.Preview {
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
) {
SpoilerText(
text = text,
spoilerState = spoilerState,
style = MaterialTheme.typography.bodyLarge
)
}
}
}
@DayNightPreviews
@Composable
private fun MultilineSpoilerPreview() {
val spoilerState = rememberSpoilerState()
val text = buildAnnotatedString {
append("Here's a really long spoiler: ")
val start = length
append("This is a very long piece of text that will definitely span multiple lines when displayed in a narrow container. It contains important plot details that you might not want to see until you're ready!")
addStringAnnotation(SPOILER_ANNOTATION_TAG, "long-spoiler", start, length)
append(" Tap to reveal.")
}
Previews.Preview {
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
) {
SpoilerText(
text = text,
spoilerState = spoilerState,
style = MaterialTheme.typography.bodyLarge
)
}
}
}
@DayNightPreviews
@Composable
private fun StyledSpoilersPreview() {
val spoilerState = rememberSpoilerState()
val text = buildAnnotatedString {
append("Normal text, ")
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append("bold text, ")
}
val start1 = length
withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontSize = 18.sp)) {
append("bold spoiler")
}
addStringAnnotation(SPOILER_ANNOTATION_TAG, "styled-spoiler-1", start1, length)
append(", and ")
val start2 = length
withStyle(SpanStyle(fontWeight = FontWeight.Light, fontSize = 14.sp)) {
append("light spoiler")
}
addStringAnnotation(SPOILER_ANNOTATION_TAG, "styled-spoiler-2", start2, length)
append(".")
}
Previews.Preview {
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
) {
SpoilerText(
text = text,
spoilerState = spoilerState,
style = MaterialTheme.typography.bodyLarge
)
}
}
}
@DayNightPreviews
@Composable
private fun ColoredSpoilersPreview() {
val spoilerState = rememberSpoilerState()
val text = buildAnnotatedString {
append("This text has ")
val start1 = length
withStyle(SpanStyle(color = Color.Red)) {
append("red spoiler")
}
addStringAnnotation(SPOILER_ANNOTATION_TAG, "red-spoiler", start1, length)
append(" and ")
val start2 = length
withStyle(SpanStyle(color = Color.Blue)) {
append("blue spoiler")
}
addStringAnnotation(SPOILER_ANNOTATION_TAG, "blue-spoiler", start2, length)
append(".")
}
Previews.Preview {
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
) {
SpoilerText(
text = text,
spoilerState = spoilerState,
style = MaterialTheme.typography.bodyLarge
)
}
}
}
@DayNightPreviews
@Composable
private fun SharedStatePreview() {
val spoilerState = rememberSpoilerState()
val text1 = buildAnnotatedString {
append("First message with ")
val start = length
append("shared spoiler")
addStringAnnotation(SPOILER_ANNOTATION_TAG, "shared-spoiler", start, length)
}
val text2 = buildAnnotatedString {
append("Second message with the same ")
val start = length
append("shared spoiler")
addStringAnnotation(SPOILER_ANNOTATION_TAG, "shared-spoiler", start, length)
}
Previews.Preview {
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
SpoilerText(
text = text1,
spoilerState = spoilerState,
style = MaterialTheme.typography.bodyLarge
)
SpoilerText(
text = text2,
spoilerState = spoilerState,
style = MaterialTheme.typography.bodyLarge
)
}
}
}
@DayNightPreviews
@Composable
private fun EntireMessageSpoilerPreview() {
val spoilerState = rememberSpoilerState()
val text = buildAnnotatedString {
val start = length
append("This entire message is a spoiler! You need to tap anywhere on it to reveal the content.")
addStringAnnotation(SPOILER_ANNOTATION_TAG, "entire-message", start, length)
}
Previews.Preview {
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
) {
SpoilerText(
text = text,
spoilerState = spoilerState,
style = MaterialTheme.typography.bodyLarge
)
}
}
}
@DayNightPreviews
@Composable
private fun DifferentSizesPreview() {
val spoilerState = rememberSpoilerState()
Previews.Preview {
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
SpoilerText(
text = buildAnnotatedString {
append("Small text with ")
val start = length
append("small spoiler")
addStringAnnotation(SPOILER_ANNOTATION_TAG, "small", start, length)
},
spoilerState = spoilerState,
fontSize = 12.sp
)
SpoilerText(
text = buildAnnotatedString {
append("Medium text with ")
val start = length
append("medium spoiler")
addStringAnnotation(SPOILER_ANNOTATION_TAG, "medium", start, length)
},
spoilerState = spoilerState,
fontSize = 16.sp
)
SpoilerText(
text = buildAnnotatedString {
append("Large text with ")
val start = length
append("large spoiler")
addStringAnnotation(SPOILER_ANNOTATION_TAG, "large", start, length)
},
spoilerState = spoilerState,
fontSize = 24.sp
)
}
}
}