Estados e animações em estilos

A API Styles oferece uma abordagem declarativa e simplificada para gerenciar mudanças na interface durante estados de interação como hovered, focused e pressed. Com essa API, é possível diminuir significativamente o código boilerplate normalmente necessário ao usar modificadores.

Para facilitar o estilo reativo, o StyleState atua como uma interface estável e somente leitura que rastreia o estado ativo de um elemento, como o status ativado, pressionado ou em foco. Em um StyleScope, você pode acessar isso pela propriedade state para implementar a lógica condicional diretamente nas definições de estilo.

Interação baseada em estado: passar o cursor, foco, pressionado, selecionado, ativado, alternado

Os estilos vêm com suporte integrado para interações comuns:

  • Pressionado
  • Passar o cursor do mouse
  • Selecionado
  • Ativado
  • Alternada

Também é possível oferecer suporte a estados personalizados. Consulte a seção Estilização de estado personalizada com StyleState para mais informações.

Processar estados de interação com parâmetros de estilo

O exemplo a seguir demonstra como modificar background e borderColor em resposta a estados de interação, especificamente mudando para roxo quando passa o cursor e azul quando está em foco:

@Preview
@Composable
private fun OpenButton() {
    BaseButton(
        style = outlinedButtonStyle then {
            background(Color.White)
            hovered {
                background(lightPurple)
                border(2.dp, lightPurple)
            }
            focused {
                background(lightBlue)
            }
        },
        onClick = {  },
        content = {
            BaseText("Open in Studio", style = {
                contentColor(Color.Black)
                fontSize(26.sp)
                textAlign(TextAlign.Center)
            })
        }
    )
}

Figura 1. Mudar a cor do plano de fundo com base nos estados de foco e passar o cursor.

Também é possível criar definições de estado aninhadas. Por exemplo, é possível definir um estilo específico para quando um botão é pressionado e passa o cursor sobre ele simultaneamente:

@Composable
private fun OpenButton_CombinedStates() {
    BaseButton(
        style = outlinedButtonStyle then {
            background(Color.White)
            hovered {
                // light purple
                background(lightPurple)
                pressed {
                    // When running on a device that can hover, whilst hovering and then pressing the button this would be invoked
                    background(lightOrange)
                }
            }
            pressed {
                // when running on a device without a mouse attached, this would be invoked as you wouldn't be in a hovered state only
                background(lightRed)
            }
            focused {
                background(lightBlue)
            }
        },
        onClick = {  },
        content = {
            BaseText("Open in Studio", style = {
                contentColor(Color.Black)
                fontSize(26.sp)
                textAlign(TextAlign.Center)
            })
        }
    )
}

Figura 2. Estado de passar o cursor e pressionar juntos em um botão.

Elementos combináveis personalizados com Modifier.styleable

Ao criar seus próprios componentes styleable, é necessário conectar um interactionSource a um styleState. Em seguida, transmita esse estado para Modifier.styleable para usá-lo.

Considere um cenário em que seu sistema de design inclui um GradientButton. Você pode criar um LoginButton que herda de GradientButton, mas altera as cores durante as interações, como quando é pressionado.

  • Para ativar as atualizações de estilo interactionSource, inclua um interactionSource como parâmetro no seu elemento combinável. Use o parâmetro fornecido ou, se nenhum for fornecido, inicialize um novo MutableInteractionSource.
  • Inicialize o styleState fornecendo o interactionSource. Verifique se o status ativado do styleState reflete o valor do parâmetro ativado fornecido.
  • Atribua o interactionSource aos modificadores focusable e clickable. Por fim, aplique o styleState ao parâmetro styleable do modificador.

@Composable
private fun GradientButton(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    style: Style = Style,
    enabled: Boolean = true,
    interactionSource: MutableInteractionSource? = null,
    content: @Composable RowScope.() -> Unit,
) {
    val interactionSource = interactionSource ?: remember { MutableInteractionSource() }
    val styleState = remember(interactionSource) { MutableStyleState(interactionSource) }
    styleState.isEnabled = enabled
    Row(
        modifier =
            modifier
                .clickable(
                    onClick = onClick,
                    enabled = enabled,
                    interactionSource = interactionSource,
                    indication = null,
                )
                .styleable(styleState, baseGradientButtonStyle then style),
        content = content,
    )
}

Agora você pode usar o estado interactionSource para acionar modificações de estilo com as opções pressionado, em foco e passar o cursor dentro do bloco de estilo:

@Preview
@Composable
fun LoginButton() {
    val loginButtonStyle = Style {
        pressed {
            background(
                Brush.linearGradient(
                    listOf(Color.Magenta, Color.Red)
                )
            )
        }
    }
    GradientButton(onClick = {
        // Login logic
    }, style = loginButtonStyle) {
        BaseText("Login")
    }
}

Figura 3. Mudar um estado combinável personalizado com base em interactionSource.

Animar mudanças de estilo

As mudanças de estado de estilos vêm com suporte integrado para animação. Você pode incluir a nova propriedade em qualquer bloco de mudança de estado com animate para adicionar animações automaticamente entre diferentes estados. Isso é semelhante às APIs animate*AsState. O exemplo a seguir anima o borderColor de preto para azul quando o estado muda para "em foco":

val animatingStyle = Style {
    externalPadding(48.dp)
    border(3.dp, Color.Black)
    background(Color.White)
    size(100.dp)

    pressed {
        animate {
            borderColor(Color.Magenta)
            background(Color(0xFFB39DDB))
        }
    }
}

@Preview
@Composable
private fun AnimatingStyleChanges() {
    val interactionSource = remember { MutableInteractionSource() }
    val styleState = remember(interactionSource) { MutableStyleState(interactionSource) }
    Box(modifier = Modifier
        .clickable(
            interactionSource,
            enabled = true,
            indication = null,
            onClick = {

            }
        )
        .styleable(styleState, animatingStyle)) {

    }
}

Figura 4. Animação de mudanças de cor ao pressionar.

A API animate aceita um animationSpec para mudar a duração ou o formato da curva de animação. O exemplo a seguir anima o tamanho da caixa com uma especificação de spring:

val animatingStyleSpec = Style {
    externalPadding(48.dp)
    border(3.dp, Color.Black)
    background(Color.White)
    size(100.dp)
    transformOrigin(TransformOrigin.Center)
    pressed {
        animate {
            borderColor(Color.Magenta)
            background(Color(0xFFB39DDB))
        }
        animate(spring(dampingRatio = Spring.DampingRatioMediumBouncy)) {
            scale(1.2f)
        }
    }
}

@Preview(showBackground = true)
@Composable
fun AnimatingStyleChangesSpec() {
    val interactionSource = remember { MutableInteractionSource() }
    val styleState = remember(interactionSource) { MutableStyleState(interactionSource) }
    Box(modifier = Modifier
        .clickable(
            interactionSource,
            enabled = true,
            indication = null,
            onClick = {

            }
        )
        .styleable(styleState, animatingStyleSpec))
}

Figura 5. Animação de mudanças de tamanho e cor ao pressionar.

Estilo de estado personalizado com StyleState

Dependendo do seu caso de uso combinável, você pode ter estilos diferentes que são compatíveis com estados personalizados. Por exemplo, se você tiver um app de mídia, talvez queira ter estilos diferentes para os botões no elemento combinável MediaPlayer dependendo do estado de reprodução do player. Siga estas etapas para criar e usar seu próprio estado personalizado:

  1. Definir chave personalizada
  2. Criar extensão StyleState
  3. Link para estado personalizado

Definir chave personalizada

Para criar um estilo personalizado com base no estado, primeiro crie um StyleStateKey e transmita o valor de estado padrão. Quando o app é iniciado, o player de mídia está no estado Stopped. Portanto, ele é inicializado desta forma:

enum class PlayerState {
    Stopped,
    Playing,
    Paused
}

val playerStateKey = StyleStateKey(PlayerState.Stopped)

Criar funções de extensão StyleState

Defina uma função de extensão em StyleState para consultar o playState atual. Em seguida, crie funções de extensão em StyleScope com seus estados personalizados transmitindo o playStateKey, uma expressão lambda com o estado específico e o estilo.

// Extension Function on MutableStyleState to query and set the current playState
var MutableStyleState.playerState
    get() = this[playerStateKey]
    set(value) { this[playerStateKey] = value }

fun StyleScope.playerPlaying(value: Style) {
    state(playerStateKey, value, { key, state -> state[key] == PlayerState.Playing })
}
fun StyleScope.playerPaused(value: Style) {
    state(playerStateKey, value, { key, state -> state[key] == PlayerState.Paused })
}

Defina o styleState no elemento combinável e defina o styleState.playState igual ao estado de entrada. Transmita styleState para a função styleable no modificador.

No lambda style, é possível aplicar estilos com base no estado para estados personalizados usando as funções de extensão definidas anteriormente.

Confira abaixo o snippet completo deste exemplo:

enum class PlayerState {
    Stopped,
    Playing,
    Paused
}
val playerStateKey = StyleStateKey<PlayerState>(PlayerState.Stopped)
var MutableStyleState.playerState
    get() = this[playerStateKey]
    set(value) { this[playerStateKey] = value }

fun StyleScope.playerPlaying(value: Style) {
    state(playerStateKey, value, { key, state -> state[key] == PlayerState.Playing })
}
fun StyleScope.playerPaused(value: Style) {
    state(playerStateKey, value, { key, state -> state[key] == PlayerState.Paused })

}

@Composable
fun MediaPlayer(
    url: String,
    modifier: Modifier = Modifier,
    style: Style = Style,
    state: PlayerState = remember { PlayerState.Paused }
) {
    // Hoist style state, set playstate as a parameter,
    val styleState = remember { MutableStyleState(null) }
    // Set equal to incoming state to link the two together
    styleState.playerState = state
    Box(
        modifier = modifier.styleable(styleState, Style {
            size(100.dp)
            border(2.dp, Color.Red)

        }, style, )) {

        ///..
    }
}
@Composable
fun StyleStateKeySample() {
    // Using the extension function to change the border color to green while playing
    val style = Style {
        borderColor(Color.Gray)
        playerPlaying {
            animate {
                borderColor(Color.Green)
            }
        }
        playerPaused {
            animate {
                borderColor(Color.Blue)
            }
        }
    }
    val styleState = remember { MutableStyleState(null) }
    styleState[playerStateKey] = PlayerState.Playing

    // Using the style in a composable that sets the state -> notice if you change the state parameter, the style changes. You can link this up to an ViewModel and change the state from there too.
    MediaPlayer(url = "https://example.com/media/video",
        style = style,
        state = PlayerState.Stopped)
}