Como usar efeitos de renderização no Jetpack Compose para obter visuais impressionantes

Tempo de leitura: 9 minutes

Plano de fundo

O Jetpack Compose oferece uma ampla variedade de ferramentas e componentes para criar interfaces de usuário envolventes, e uma das joias menos conhecidas do Compose é o RenderEffect.

Nesta postagem do blog, exploraremos o RenderEffect criando alguns exemplos interessantes com um efeito de renderização.

O que é RenderEffect?

O RenderEffect permite que você aplique efeitos visuais aos componentes da interface do usuário. Esses efeitos podem incluir borrões, sombreadores personalizados ou quaisquer outras transformações visuais que você possa imaginar. No entanto, ele está disponível para a API 31 e superior.

Em nosso exemplo, usaremos o RenderEffect para criar um efeito de desfoque e sombreamento para o nosso botão flutuante expansível e alguns componentes de bônus.

O que implementaremos neste blog?

Comece a usar…

O BlurContainer

Na primeira etapa, vamos começar apresentando o “BlurContainer”. Esse componente exclusivo acrescenta uma camada extra de elegância visual e cativação à nossa interface de usuário, criando um efeito visual impressionante.

Ele abriga um modificador de desfoque personalizado que leva nosso efeito de renderização ao próximo nível.

@Composable
fun BlurContainer(
    modifier: Modifier = Modifier,
    blur: Float = 60f,
    component: @Composable BoxScope.() -> Unit,
    content: @Composable BoxScope.() -> Unit = {},
) {
    Box(modifier, contentAlignment = Alignment.Center) {
        Box(
            modifier = Modifier
                .customBlur(blur),
            content = component,
        )
        Box(
            contentAlignment = Alignment.Center
        ) {
            content()
        }
    }
}

fun Modifier.customBlur(blur: Float) = this.then(
    graphicsLayer {
        if (blur > 0f)
            renderEffect = RenderEffect
                .createBlurEffect(
                    blur,
                    blur,
                    Shader.TileMode.DECAL,
                )
                .asComposeRenderEffect()
    }
)

 

  • A extensão do modificador ‘customBlur‘ usa um parâmetro de desfoque, que especifica a intensidade do efeito de desfoque.
  • Ela é usada para aplicar um graphicsLayer ao Composable, que, por sua vez, aplica um efeito de desfoque usando RenderEffect.createBlurEffect. O graphicsLayer é usado para aplicar efeitos de renderização ao Composable.

Veja como fica o efeito de desfoque:

Com esse modificador, podemos adicionar facilmente efeitos de desfoque a qualquer Composable, encadeando-o aos modificadores existentes.

Aplicação do efeito de renderização ao contêiner principal

Para isso, usaremos um shader personalizado, o RuntimeShader, e o graphicsLayer do Jetpack Compose para obter o efeito visual desejado no contêiner pai.

Antes de nos aprofundarmos em como o efeito de renderização é aplicado, vamos entender como o RuntimeShader é inicializado.

@Language("AGSL")
const val ShaderSource = """
    uniform shader composable;
    
    uniform float visibility;
    
    half4 main(float2 cord) {
        half4 color = composable.eval(cord);
        color.a = step(visibility, color.a);
        return color;
    }
"""

val runtimeShader = remember {
    RuntimeShader(ShaderSource)
}

Neste trecho de código, criamos uma instância do RuntimeShader. A função remember garante que o shader seja inicializado apenas uma vez, evitando sobrecarga desnecessária. Passamos nosso código-fonte de shader personalizado (ShaderSource) para o construtor do RuntimeShader.

Nosso ShaderSource é uma parte crucial do efeito de renderização. Ele foi escrito em uma linguagem de sombreamento chamada AGSL (Android Graphics Shading Language). Vamos dar uma olhada mais de perto:

  • shader uniforme composable: Essa linha declara uma variável de shader uniforme chamada “composable”. Essa variável variável é usada para amostrar as cores dos elementos Composable se quisermos quisermos aplicar o efeito de renderização.
  • uniform float visibility (visibilidade de flutuação uniforme): Declaramos uma variável de flutuação uniforme chamada “visibility” (visibilidade). Essa variável controla a intensidade do efeito do sombreador especificando um limite.
  • half4 main(float2 cord): A função principal é o ponto de entrada do sombreador. Ela recebe uma coordenada 2D (cord) e retorna uma cor na forma de half4, que representa uma cor com componentes vermelho, verde, azul e alfa.
  • half4 color = composable.eval(cord): Aqui, obtemos uma amostra da cor da variável uniforme do shader “composable” na coordenada fornecida.
  • color.a = step(visibility, color.a): Aplicamos o efeito do sombreador definindo o componente alfa (color.a) como 0 ou 1 com base no limite de “visibilidade”.
  • return color: Por fim, retornamos a cor modificada.

Confira o AGSL Shader no aplicativo JetLagged da compose-samples.

 

Aplicação do efeito de renderização

Com nosso RuntimeShader e ShaderSource prontos, agora podemos aplicar o efeito de renderização usando o graphicsLayer:

Box(
    modifier
        .graphicsLayer {
            runtimeShader.setFloatUniform("visibility", 0.2f)
            renderEffect = RenderEffect
                .createRuntimeShaderEffect(
                    runtimeShader, "composable"
                )
                .asComposeRenderEffect()
        },
    content = content,
)

Veja a seguir um detalhamento de como isso funciona:

  • runtimeShader.setFloatUniform("visibility", 0.2f): Definimos a variável uniforme “visibility” em nosso shader para controlar a intensidade do efeito. Nesse caso, nós a definimos como 0,2f, mas você pode ajustar esse valor para obter o efeito desejado.
  • renderEffect = RenderEffect.createRuntimeShaderEffect(...): Criamos um RenderEffect usando o método createRuntimeShaderEffect. Esse método usa nosso runtimeShader e o nome “composable”, que corresponde à variável do shader em nosso ShaderSource.
  • .asComposeRenderEffect(): Convertemos o RenderEffect em um formato compatível com o Compose usando asComposeRenderEffect().

Ao aplicar esse efeito de renderização no graphicsLayer, obtemos o efeito de sombreamento nos componentes da interface do usuário contidos na caixa.

Para reunir todos esses elementos e aplicar nosso efeito de renderização sem problemas, criaremos um ShaderContainer componível como este:

@Language("AGSL")
const val Source = """
    uniform shader composable;
    
    uniform float visibility;
    
    half4 main(float2 cord) {
        half4 color = composable.eval(cord);
        color.a = step(visibility, color.a);
        return color;
    }
"""

@Composable
fun ShaderContainer(
    modifier: Modifier = Modifier,
    content: @Composable BoxScope.() -> Unit,
) {
    val runtimeShader = remember {
        RuntimeShader(Source)
    }
    Box(
        modifier
            .graphicsLayer {
                runtimeShader.setFloatUniform("visibility", 0.2f)
                renderEffect = RenderEffect
                    .createRuntimeShaderEffect(
                        runtimeShader, "composable"
                    )
                    .asComposeRenderEffect()
            },
        content = content
    )
}

 

Aqui está o efeito visual do BlurContainer envolvido pelo ShaderContainer:

Agora que construímos com sucesso a base para o nosso efeito de renderização com o ShaderContainer e o BlurContainer, é hora de reunir tudo isso criando o ExtendedFabRenderEffect. Esse Composable será a peça central do nosso botão flutuante expansível com efeitos de renderização dinâmica.

 

ExtendedFabRenderEffect

O composable ExtendedFabRenderEffect é responsável por orquestrar toda a interface do usuário, animar a expansão do botão e lidar com o efeito de renderização. Vamos ver como ele funciona e como cria uma experiência de usuário visualmente atraente.

Animação suave

Criar uma animação suave e fluida é essencial para uma experiência de usuário refinada. Para isso, aplicamos a animação alfa:

A animação alfa gerencia a transparência dos botões. Quando expandido é verdadeiro, os botões se tornam totalmente opacos; caso contrário, eles desaparecem. Assim como a animação de deslocamento, usamos a função animateFloatAsState com parâmetros apropriados para garantir transições suaves.

var expanded: Boolean by remember {
    mutableStateOf(false)
}

val alpha by animateFloatAsState(
  targetValue = if (expanded) 1f else 0f,
  animationSpec = tween(durationMillis = 1000, easing = LinearEasing),
  label = ""
)

 

 

Combinação de efeitos

Agora, combinamos o efeito de renderização, o ShaderContainer, com nossos botões para criar uma interface de usuário coerente. Dentro do ShaderContainer, colocamos vários ButtonComponent Composables, cada um representando um botão com um ícone e uma interação específicos.

ShaderContainer(
    modifier = Modifier.fillMaxSize()
) {

    ButtonComponent(
        Modifier.padding(
            paddingValues = PaddingValues(
                bottom = 80.dp
            ) * FastOutSlowInEasing
                .transform((alpha))
        ),
        onClick = {
            expanded = !expanded
        }
    ) {
        Icon(
            imageVector = Icons.Default.Edit,
            contentDescription = null,
            tint = Color.White,
            modifier = Modifier.alpha(alpha)
        )
    }

    ButtonComponent(
        Modifier.padding(
            paddingValues = PaddingValues(
                bottom = 160.dp
            ) * FastOutSlowInEasing.transform(alpha)
        ),
        onClick = {
            expanded = !expanded
        }
    ) {
        Icon(
            imageVector = Icons.Default.LocationOn,
            contentDescription = null,
            tint = Color.White,
            modifier = Modifier.alpha(alpha)
        )
    }

    ButtonComponent(
        Modifier.padding(
            paddingValues = PaddingValues(
                bottom = 240.dp
            ) * FastOutSlowInEasing.transform(alpha)
        ),
        onClick = {
            expanded = !expanded
        }
    ) {
        Icon(
            imageVector = Icons.Default.Delete,
            contentDescription = null,
            tint = Color.White,
            modifier = Modifier.alpha(alpha)
        )
    }

    ButtonComponent(
        Modifier.align(Alignment.BottomEnd),
        onClick = {
            expanded = !expanded
        },
    ) {
        val rotation by animateFloatAsState(
            targetValue = if (expanded) 45f else 0f,
            label = "",
            animationSpec = tween(1000, easing = FastOutSlowInEasing)
        )
        Icon(
            imageVector = Icons.Default.Add,
            contentDescription = null,
            modifier = Modifier.rotate(rotation),
            tint = Color.White
        )
    }
}

Com essa configuração, o ShaderContainer atua como um pano de fundo para os nossos botões e o efeito de renderização é perfeitamente aplicado aos botões por meio do ButtonComponent Composables. O modificador alpha garante que os botões se tornem visíveis ou invisíveis com base no estado de expansão, criando uma interface de usuário polida e dinâmica.

 

Anatomia do ButtonComponent

O ButtonComponent foi projetado para encapsular cada botão dentro do menu expansível. Ele oferece a flexibilidade de personalizar a aparência e o comportamento do botão.

Veja a seguir como o ButtonComponent é estruturado:

@Composable
fun BoxScope.ButtonComponent(
    modifier: Modifier = Modifier,
    background: Color = Color.Black,
    onClick: () -> Unit,
    content: @Composable BoxScope.() -> Unit
) {
    // Applying the Blur Effect with the BlurContainer
    BlurContainer(
        modifier = modifier
            .clickable(
                interactionSource = remember {
                    MutableInteractionSource()
                },
                indication = null,
                onClick = onClick,
            )
            .align(Alignment.BottomEnd),
        component = {
            Box(
                Modifier
                    .size(40.dp)
                    .background(color = background, CircleShape)
            )
        }
    ) {
        // Content (Icon or other elements) inside the button
        Box(
            Modifier.size(80.dp),
            content = content,
            contentAlignment = Alignment.Center,
        )
    }
}

E é isso, conseguimos o efeito desejado com o código acima!

 

TextRenderEffect

O coração do TextRenderEffect é a exibição dinâmica do texto. Usaremos uma lista de frases e citações motivadoras que serão apresentadas ao usuário. Essas frases incluirão sentimentos como “Alcance seus objetivos”, “Realize seus sonhos” e outros.

val animateTextList =
    listOf(
        "\"Reach your goals\"",
        "\"Achieve your dreams\"",
        "\"Be happy\"",
        "\"Be healthy\"",
        "\"Get rid of depression\"",
        "\"Overcome loneliness\""
    )

Criaremos a variável de estado textToDisplay para manter e exibir essas frases, criando uma sequência animada.

 

Animação do texto

Para tornar a exibição do texto atraente, utilizaremos algumas animações importantes:

  1. Blur Effect: Aplicaremos um efeito blur ao texto. O valor do desfoque é animado de 0 a 30 e de volta a 0, usando uma animação de atenuação linear. Isso cria um efeito visual sutil e hipnotizante que aprimora a aparência do texto.
  2. Text Transition: Usaremos o LaunchedEffect para percorrer a lista de frases, exibindo cada uma delas por um determinado período. Quando o textToDisplay muda, ocorre uma animação scaleIn, apresentando o novo texto com um efeito scale-in e, quando ele sai da transição, é aplicado um efeito scaleOut. Isso proporciona uma maneira visualmente agradável de introduzir e encerrar o texto.

Integração completa com o ShaderContainer

@Composable
fun TextRenderEffect() {

    val animateTextList =
        listOf(
            "\"Reach your goals\"",
            "\"Achieve your dreams\"",
            "\"Be happy\"",
            "\"Be healthy\"",
            "\"Get rid of depression\"",
            "\"Overcome loneliness\""
        )

    var index by remember {
        mutableIntStateOf(0)
    }

    var textToDisplay by remember {
        mutableStateOf("")
    }
    
    val blur = remember { Animatable(0f) }

    LaunchedEffect(textToDisplay) {
        blur.animateTo(30f, tween(easing = LinearEasing))
        blur.animateTo(0f, tween(easing = LinearEasing))
    }

    LaunchedEffect(key1 = animateTextList) {
        while (index <= animateTextList.size) {
            textToDisplay = animateTextList[index]
            delay(3000)
            index = (index + 1) % animateTextList.size
        }
    }

    ShaderContainer(
        modifier = Modifier.fillMaxSize()
    ) {
        BlurContainer(
            modifier = Modifier.fillMaxSize(),
            blur = blur.value,
            component = {
                AnimatedContent(
                    targetState = textToDisplay,
                    modifier = Modifier
                        .fillMaxWidth(),
                    transitionSpec = {
                        (scaleIn()).togetherWith(
                            scaleOut()
                        )
                    }, label = ""
                ) { text ->
                    Text(
                        modifier = Modifier
                            .fillMaxWidth(),
                        text = text,
                        style = MaterialTheme.typography.headlineLarge,
                        color = MaterialTheme.colorScheme.onPrimaryContainer,
                        textAlign = TextAlign.Center
                    )
                }
            }
        ) {}
    }
}

 

ImageRenderEffect

Nossa exploração do RenderEffect no Jetpack Compose continua com o intrigante ImageRenderEffect. Esse Composable leva a renderização de imagens a um novo patamar, introduzindo transições de imagens dinâmicas e efeitos de renderização cativantes. Vamos nos aprofundar em como ele é construído e como aprimora a experiência visual.

 

Transições dinâmicas de imagens

O núcleo do ImageRenderEffect está em sua capacidade de fazer a transição entre imagens de forma visualmente atraente. Para demonstrar isso, configuraremos um cenário básico em que duas imagens, ic_first e ic_second, serão alternadas em um evento de clique.

var image by remember {
    mutableIntStateOf(R.drawable.ic_first)
}

A variável de estado image contém a imagem exibida no momento e, com um simples clique no botão, os usuários podem alternar entre as duas.

 

Criação de efeitos envolventes

  • Blur Effect: Assim como em nossos exemplos anteriores, aplicamos um efeito de desfoque às imagens. O valor do desfoque é animado de 0 a 100 e volta a 0, criando um efeito visual hipnotizante que aprimora a transição da imagem.
val blur = remember { Animatable(0f) }
LaunchedEffect(image) {
    blur.animateTo(100f, tween(easing = LinearEasing))
    blur.animateTo(0f, tween(easing = LinearEasing))
}
  • Image Transition: O coração da transição de imagens é o AnimatedContent Composable. Ele lida com a transição suave entre as imagens, combinando um efeito de fadeIn e scaleIn para a imagem que entra na cena e um efeito de fadeOut e scaleOut para a imagem que sai da cena.
AnimatedContent(
    targetState = image,
    modifier = Modifier.fillMaxWidth(),
    transitionSpec = {
        (fadeIn(tween(easing = LinearEasing)) + scaleIn(
            tween(1_000, easing = LinearEasing)
        )).togetherWith(
            fadeOut(
                tween(1_000, easing = LinearEasing)
            ) + scaleOut(
                tween(1_000, easing = LinearEasing)
            )
        )
    }, label = ""
) { image ->
    Image(
        painter = painterResource(id = image),
        modifier = Modifier.size(200.dp),
        contentDescription = ""
    )
}

Integração perfeita com o ShaderContainer

Assim como em nossos exemplos anteriores, o ImageRenderEffect está integrado em um ShaderContainer. Isso nos permite combinar as transições de imagem e os efeitos de renderização, criando uma experiência visual cativante e envolvente.

 

@Composable
fun ImageRenderEffect() {

    var image by remember {
        mutableIntStateOf(R.drawable.ic_first)
    }

    val blur = remember { Animatable(0f) }

    LaunchedEffect(image) {
        blur.animateTo(100f, tween(easing = LinearEasing))
        blur.animateTo(0f, tween(easing = LinearEasing))
    }

    Column(
        modifier = Modifier
            .wrapContentSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center,
    ) {

        ShaderContainer(
            modifier = Modifier
                .animateContentSize()
                .clipToBounds()
                .fillMaxWidth()
        ) {

            BlurContainer(
                modifier = Modifier.fillMaxWidth(),
                blur = blur.value,
                component = {
                    AnimatedContent(
                        targetState = image,
                        modifier = Modifier
                            .fillMaxWidth(),
                        transitionSpec = {
                            (fadeIn(tween(easing = LinearEasing)) + scaleIn(
                                tween(
                                    1_000,
                                    easing = LinearEasing
                                )
                            )).togetherWith(
                                fadeOut(
                                    tween(
                                        1_000,
                                        easing = LinearEasing
                                    )
                                ) + scaleOut(
                                    tween(
                                        1_000,
                                        easing = LinearEasing
                                    )
                                )
                            )
                        }, label = ""
                    ) { image ->
                        Image(
                            painter = painterResource(id = image),
                            modifier = Modifier
                                .size(200.dp),
                            contentDescription = ""
                        )
                    }
                }) {}
        }

        Spacer(modifier = Modifier.height(20.dp))

        Button(
            onClick = {
                image =
                    if (image == R.drawable.ic_first) R.drawable.ic_second else R.drawable.ic_first
            },
            colors = ButtonDefaults.buttonColors(
                containerColor = Color.Black
            )
        ) {
            Text("Change Image")
        }
    }
}

 

 

Conclusão

Ao compreender o ShaderContainer, o BlurContainer, o ShaderSource e o modificador customBlur, você tem as ferramentas para criar efeitos de renderização impressionantes em seus aplicativos Jetpack Compose. Esses elementos fornecem uma base para explorar e experimentar vários efeitos visuais e shaders personalizados, abrindo um mundo de possibilidades criativas para seus designs de UI.

Boa codificação!

Segue o código fonte completo (Github)