Marquee Text Effect in Jetpack Compose

Issue

In the past, a kind of animation could be included in the text, in which if the text exceeded the limits, it would automatically scroll horizontally. This was done by including: android:ellipsize"marquee", and the result was something similar to the one shown here:

enter image description here

The problem is that in Jetpack Compose I don’t see a way to include that option inside the Composable Text, there is the TextOverflow that includes the Clip, Ellipsis or Visible options, but I don’t know if there is a way to include or use the “Marquee” option in Jetpack Compose. Is there any way to do it?

Solution

This is not yet supported by Compose, but it’s not too hard to implement. You will need TargetBasedAnimation, which will update the text offset, and SubcomposeLayout, which lies under most collections. Inside you can define the size of the text, and also place the second similar Text, which will appear from the right edge.

@Composable
fun MarqueeText(
    text: String,
    modifier: Modifier  Modifier,
    gradientEdgeColor: Color  Color.White,
    color: Color  Color.Unspecified,
    fontSize: TextUnit  TextUnit.Unspecified,
    fontStyle: FontStyle?  null,
    fontWeight: FontWeight?  null,
    fontFamily: FontFamily?  null,
    letterSpacing: TextUnit  TextUnit.Unspecified,
    textDecoration: TextDecoration?  null,
    textAlign: TextAlign?  null,
    lineHeight: TextUnit  TextUnit.Unspecified,
    overflow: TextOverflow  TextOverflow.Clip,
    softWrap: Boolean  true,
    onTextLayout: (TextLayoutResult) -> Unit  {},
    style: TextStyle  LocalTextStyle.current,
) {
    val createText  @Composable { localModifier: Modifier ->
        Text(
            text,
            textAlign  textAlign,
            modifier  localModifier,
            color  color,
            fontSize  fontSize,
            fontStyle  fontStyle,
            fontWeight  fontWeight,
            fontFamily  fontFamily,
            letterSpacing  letterSpacing,
            textDecoration  textDecoration,
            lineHeight  lineHeight,
            overflow  overflow,
            softWrap  softWrap,
            maxLines  1,
            onTextLayout  onTextLayout,
            style  style,
        )
    }
    var offset by remember { mutableStateOf(0) }
    val textLayoutInfoState  remember { mutableStateOf<TextLayoutInfo?>(null) }
    LaunchedEffect(textLayoutInfoState.value) {
        val textLayoutInfo  textLayoutInfoState.value ?: return@LaunchedEffect
        if (textLayoutInfo.textWidth < textLayoutInfo.containerWidth) return@LaunchedEffect
        val duration  7500 * textLayoutInfo.textWidth / textLayoutInfo.containerWidth
        val delay  1000L

        do {
            val animation  TargetBasedAnimation(
                animationSpec  infiniteRepeatable(
                    animation  tween(
                        durationMillis  duration,
                        delayMillis  1000,
                        easing  LinearEasing,
                    ),
                    repeatMode  RepeatMode.Restart
                ),
                typeConverter  Int.VectorConverter,
                initialValue  0,
                targetValue  -textLayoutInfo.textWidth
            )
            val startTime  withFrameNanos { it }
            do {
                val playTime  withFrameNanos { it } - startTime
                offset  (animation.getValueFromNanos(playTime))
            } while (!animation.isFinishedFromNanos(playTime))
            delay(delay)
        } while (true)
    }

    SubcomposeLayout(
        modifier  modifier.clipToBounds()
    ) { constraints ->
        val infiniteWidthConstraints  constraints.copy(maxWidth  Int.MAX_VALUE)
        var mainText  subcompose(MarqueeLayers.MainText) {
            createText(Modifier)
        }.first().measure(infiniteWidthConstraints)

        var gradient: Placeable?  null

        var secondPlaceableWithOffset: Pair<Placeable, Int>?  null
        if (mainText.width < constraints.maxWidth) {
            mainText  subcompose(MarqueeLayers.SecondaryText) {
                createText(Modifier.fillMaxWidth())
            }.first().measure(constraints)
            textLayoutInfoState.value  null
        } else {
            val spacing  constraints.maxWidth * 2 / 3
            textLayoutInfoState.value  TextLayoutInfo(
                textWidth  mainText.width + spacing,
                containerWidth  constraints.maxWidth
            )
            val secondTextOffset  mainText.width + offset + spacing
            val secondTextSpace  constraints.maxWidth - secondTextOffset
            if (secondTextSpace > 0) {
                secondPlaceableWithOffset  subcompose(MarqueeLayers.SecondaryText) {
                    createText(Modifier)
                }.first().measure(infiniteWidthConstraints) to secondTextOffset
            }
            gradient  subcompose(MarqueeLayers.EdgesGradient) {
                Row {
                    GradientEdge(gradientEdgeColor, Color.Transparent)
                    Spacer(Modifier.weight(1f))
                    GradientEdge(Color.Transparent, gradientEdgeColor)
                }
            }.first().measure(constraints.copy(maxHeight  mainText.height))
        }

        layout(
            width  constraints.maxWidth,
            height  mainText.height
        ) {
            mainText.place(offset, 0)
            secondPlaceableWithOffset?.let {
                it.first.place(it.second, 0)
            }
            gradient?.place(0, 0)
        }
    }
}

@Composable
private fun GradientEdge(
    startColor: Color, endColor: Color,
) {
    Box(
        modifier  Modifier
            .width(10.dp)
            .fillMaxHeight()
            .background(
                brush  Brush.horizontalGradient(
                    0f to startColor, 1f to endColor,
                )
            )
    )
}

private enum class MarqueeLayers { MainText, SecondaryText, EdgesGradient }
private data class TextLayoutInfo(val textWidth: Int, val containerWidth: Int)

Usage:

MarqueeText("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt")

Result:

Answered By – Philip Dukhov

Leave a Comment