Animação da máquina de escrever do Jetpack Compose com textos destacados

Tempo de leitura: 5 minutes

Background

As animações de máquina de escrever são uma ótima maneira de adicionar personalidade e interatividade à interface de usuário do seu aplicativo. Com o Jetpack Compose, o moderno kit de ferramentas do Google para a criação de UIs nativas do Android, criar animações de máquina de escrever está mais fácil do que nunca.

Nesta publicação do blog, mostraremos como usar as APIs de animação do Jetpack Compose para criar um efeito de máquina de escrever, em que o texto aparece como se estivesse sendo digitado letra por letra. Cobriremos tudo, desde a configuração do layout até a criação da animação, portanto, quer você seja novo no Jetpack Compose ou um profissional experiente, poderá acompanhar o processo.

Então, vamos começar e dar mais vida à interface de usuário do seu aplicativo com uma animação de máquina de escrever!

 

Implementar TypewriterText

Vamos começar criando um composable personalizado.

@Composable
fun TypewriterText(
    baseText: String,
    highlightedText: String,
    parts: List<String>
) { 

  Text(
        text = "",
        style = TextStyle(
            fontWeight = FontWeight.SemiBold,
            fontSize = 40.sp,
            letterSpacing = -(1.6).sp,
            lineHeight = 52.sp
        ),
        color = Color.Black,
    )
}

Ele recebe três parâmetros: baseText – o texto principal, que no nosso exemplo é “Everything you need to” (Tudo o que você precisa), highlightedText – o texto a ser destacado e parts – texto animado que muda constantemente.

Vamos primeiro implementar a lógica para ter o efeito de digitação.

@Composable
fun TypewriterText(
    ...
) {

    var partIndex by remember { mutableStateOf(0) }
    var partText by remember { mutableStateOf("") }
    val textToDisplay = "$baseText $partText"

    LaunchedEffect(key1 = parts) {
        while (partIndex <= parts.size) {

            val part = parts[partIndex]

            part.forEachIndexed { charIndex, _ ->
                partText = part.substring(startIndex = 0, endIndex = charIndex + 1)
                delay(100)
            }

            delay(1000)

            partIndex = (partIndex + 1) % parts.size
        }
    }
   .... 
}

Nada sofisticado, mas fácil de entender, certo?

partIndex mantém o controle das partes animadas. partText é o texto da parte animada selecionada no momento. Um loop while básico para processar todas as partes e pronto.

Ok, ótimo 👍.

No entanto, não queremos que nosso texto pule diretamente para a próxima parte. Antes de digitar a próxima parte, nosso texto anterior deve ser removido primeiro. Vamos modificar um pouco o código acima para fazer o mesmo.

LaunchedEffect(key1 = parts) {
    while (partIndex <= parts.size) {

        val part = parts[partIndex]

        part.forEachIndexed { charIndex, _ ->
            partText = part.substring(startIndex = 0, endIndex = charIndex + 1)
            delay(100)
        }

        delay(1000)

        part.forEachIndexed { charIndex, _ ->
            partText = part
                .substring(startIndex = 0, endIndex = part.length - (charIndex + 1))
            delay(30)
        }

        delay(500)

        partIndex = (partIndex + 1) % parts.size
    }
}

Agora, vamos ver o resultado.

Legal 👌. E temos uma bela máquina de escrever.

 

Implementar destaques no texto

Agora vamos decorar o texto para torná-lo mais atraente. Vamos destacar algumas partes importantes do texto.

Com Modifier.drawBehind, desenharemos linhas atrás do texto que queremos destacar, mas, antes disso, precisamos encontrar a posição do texto e o limite para desenhar as linhas.

 

Localizar o limite para desenhar as linhas

Durante a implementação, experimentei primeiro TextLayoutResult.getPathForRange(), que retornará o caminho que envolve o intervalo fornecido.

O resultado não foi o esperado quando temos vários textos de linha para destacar.

Encontrei a solução para desenhar o plano de fundo atrás do texto selecionado no StackOverflow. Apenas copiei e colei a lógica para encontrar o limite do texto selecionado.

fun TextLayoutResult.getBoundingBoxesForRange(start: Int, end: Int): List<Rect> {
    var prevRect: Rect? = null
    var firstLineCharRect: Rect? = null
    val boundingBoxes = mutableListOf<Rect>()
    for (i in start..end) {
        val rect = getBoundingBox(i)
        val isLastRect = i == end

        // caso de caractere único
        if (isLastRect && firstLineCharRect == null) {
            firstLineCharRect = rect
            prevRect = rect
        }

        if (!isLastRect && rect.right == 0f) continue

        if (firstLineCharRect == null) {
            firstLineCharRect = rect
        } else if (prevRect != null) {
            if (prevRect.bottom != rect.bottom || isLastRect) {
                boundingBoxes.add(
                    firstLineCharRect.copy(right = prevRect.right)
                )
                firstLineCharRect = rect
            }
        }
        prevRect = rect
    }
    return boundingBoxes
}

Inicialmente, ele parece um pouco complexo.

Por trás disso,

– Estamos apenas encontrando o retângulo de cada caractere em um determinado intervalo usando getBoundingBox(i)

– Se tivermos um texto com várias linhas em um determinado intervalo, ele retornará caixas numéricas com base nas linhas.

Vamos usar a função de extensão acima e encontrar uma lista de rect para desenhar na chamada de retorno onTextLayout.

val highlightStart = baseText.indexOf(highlightText)

Text(
....
onTextLayout = { layoutResult ->
    val start = baseText.length
    val end = textToDisplay.count()
    selectedPartRects = if (start < end) {
        layoutResult.getBoundingBoxesForRange(start = start, end = end - 1)
    } else { emptyList() }
    
    if (highlightStart >= 0) {
        selectedPartRects = selectedPartRects + layoutResult
            .getBoundingBoxesForRange(start = highlightStart,
                end = highlightStart + highlightText.length
            )
    }
})

 

Desenhar linhas atrás do texto

Agora que temos um número de retângulos, vamos desenhar a linha com Modifier.drawBehind{}

modifier = Modifier.drawBehind {
    val borderSize = 20.sp.toPx()

    selectedPartRects.forEach { rect ->
        val selectedRect = rect.translate(0f, -borderSize / 1.5f)
        drawLine(
            color = Color(0x408559DA),
            start = Offset(selectedRect.left, selectedRect.bottom),
            end = selectedRect.bottomRight,
            strokeWidth = borderSize
        )
    }
}

Nada sofisticado, certo? Se quisermos que nosso rect se pareça com um sublinhado, nós o traduzimos ligeiramente.

E nosso TypewriterText final terá a seguinte aparência,

@Composable
fun AnimateTypewriterText(baseText: String, highlightText: String, parts: List<String>) {

    val highlightStart = baseText.indexOf(highlightText)
    var partIndex by remember { mutableStateOf(0) }
    var partText by remember { mutableStateOf("") }
    val textToDisplay = "$baseText$partText"
    var selectedPartRects by remember { mutableStateOf(listOf<Rect>()) }

    LaunchedEffect(key1 = parts) {
        while (partIndex <= parts.size) {
            val part = parts[partIndex]
            part.forEachIndexed { charIndex, _ ->
                partText = part.substring(startIndex = 0, endIndex = charIndex + 1)
                delay(100)
            }
            delay(1000)
            part.forEachIndexed { charIndex, _ ->
                partText = part
                    .substring(startIndex = 0, endIndex = part.length - (charIndex + 1))
                delay(30)
            }
            delay(500)
            partIndex = (partIndex + 1) % parts.size
        }
    }
    
    Text(
        text = textToDisplay,
        style = AppTheme.typography.introHeaderTextStyle,
        color = colors.textPrimary,
        modifier = Modifier.drawBehind {
            val borderSize = 20.sp.toPx()
            selectedPartRects.forEach { rect ->
                val selectedRect = rect.translate(0f, -borderSize / 1.5f)
                drawLine(
                    color = Color(0x408559DA),
                    start = Offset(selectedRect.left, selectedRect.bottom),
                    end = selectedRect.bottomRight,
                    strokeWidth = borderSize
                )
            }
        },
        onTextLayout = { layoutResult ->
            val start = baseText.length
            val end = textToDisplay.count()
            selectedPartRects = if (start < end) {
                layoutResult.getBoundingBoxesForRange(start = start, end = end - 1)
            } else {
                emptyList()
            }
            
            if (highlightStart >= 0) {
                selectedPartRects = selectedPartRects + layoutResult
                    .getBoundingBoxesForRange(
                        start = highlightStart,
                        end = highlightStart + highlightText.length
                    )
            }
        }
    )
}

E o resultado está aqui,

É isso, terminamos a implementação 👏.

O código-fonte completo da implementação acima está disponível no GitHub.

 

Conclusão

Espero que este tutorial sobre como criar animações de máquina de escrever usando o Jetpack Compose tenha sido útil e informativo. Seguindo as etapas descritas neste post, você pode adicionar um elemento envolvente e interativo à interface do usuário do seu aplicativo, tornando-o mais amigável e divertido de usar.

Você pode usar essa animação para mostrar diferentes recursos do seu aplicativo, por exemplo, confira o aplicativo Justly, que usou a mesma animação para apresentar aos usuários os recursos do aplicativo.

Obrigado!!!