Estados y animaciones en Styles

La API de Styles ofrece un enfoque declarativo y optimizado para administrar los cambios en la IU durante los estados de interacción, como hovered, focused y pressed. Con esta API, puedes reducir significativamente el código estándar que suele ser necesario cuando se usan modificadores.

Para facilitar el diseño reactivo, StyleState actúa como una interfaz estable de solo lectura que hace un seguimiento del estado activo de un elemento (como su estado habilitado, presionado o enfocado). Dentro de un StyleScope, puedes acceder a esto a través de la propiedad state para implementar lógica condicional directamente en tus definiciones de diseño.

Interacción basada en el estado: Enfoque, presión, selección, habilitación, activación o desactivación

Los estilos incluyen compatibilidad integrada para interacciones comunes:

  • Presionados
  • Flotaba
  • Seleccionado
  • Habilitado
  • Activado

También es posible admitir estados personalizados. Consulta la sección Diseño de estados personalizados con StyleState para obtener más información.

Cómo controlar estados de interacción con parámetros de diseño

En el siguiente ejemplo, se muestra cómo modificar background y borderColor en respuesta a los estados de interacción, específicamente, cambiar a morado cuando se coloca el cursor sobre el elemento y a azul cuando se enfoca:

@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. Cambia el color de fondo según los estados de enfoque y desplazamiento.

También puedes crear definiciones de estado anidadas. Por ejemplo, puedes definir un estilo específico para cuando se presiona y se coloca el cursor sobre un botón de forma simultánea:

@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 desplazamiento y presión juntos en un botón.

Elementos componibles personalizados con Modifier.styleable

Cuando crees tus propios componentes styleable, debes conectar un interactionSource a un styleState. Luego, pasa este estado a Modifier.styleable para utilizarlo.

Considera una situación en la que tu sistema de diseño incluye un GradientButton. Es posible que desees crear un LoginButton que herede de GradientButton, pero que altere sus colores durante las interacciones, como cuando se presiona.

  • Para habilitar las actualizaciones de estilo de interactionSource, incluye un interactionSource como parámetro dentro de tu elemento componible. Usa el parámetro proporcionado o, si no se proporciona uno, inicializa un nuevo MutableInteractionSource.
  • Inicializa el objeto styleState proporcionando el objeto interactionSource. Asegúrate de que el estado habilitado de styleState refleje el valor del parámetro habilitado proporcionado.
  • Asigna el interactionSource a los modificadores focusable y clickable. Por último, aplica el styleState al parámetro styleable del 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,
    )
}

Ahora puedes usar el estado interactionSource para controlar las modificaciones de estilo con las opciones pressed, focused y hovered dentro del bloque 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. Cómo cambiar el estado de un elemento componible personalizado según interactionSource

Cómo animar los cambios de estilo

Los cambios de estado de los diseños incluyen compatibilidad con animaciones integradas. Puedes incluir la nueva propiedad dentro de cualquier bloque de cambio de estado con animate para agregar automáticamente animaciones entre diferentes estados. Es similar a las APIs de animate*AsState. En el siguiente ejemplo, se anima el borderColor de negro a azul cuando el estado cambia a enfocado:

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. Se animan los cambios de color al presionar.

La API de animate acepta un animationSpec para cambiar la duración o la forma de la curva de animación. En el siguiente ejemplo, se anima el tamaño de la caja con una especificación 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: Se animan los cambios de tamaño y color al presionar.

Diseño de estados personalizados con StyleState

Según tu caso de uso componible, es posible que tengas diferentes estilos respaldados por estados personalizados. Por ejemplo, si tienes una app de contenido multimedia, es posible que desees tener un diseño diferente para los botones en tu elemento MediaPlayer componible según el estado de reproducción del reproductor. Sigue estos pasos para crear y usar tu propio estado personalizado:

  1. Define una clave personalizada
  2. Crea la extensión StyleState
  3. Vínculo a un estado personalizado

Define una clave personalizada

Para crear un diseño personalizado basado en el estado, primero crea un objeto StyleStateKey y pasa el valor de estado predeterminado. Cuando se inicia la app, el reproductor multimedia se encuentra en el estado Stopped, por lo que se inicializa de la siguiente manera:

enum class PlayerState {
    Stopped,
    Playing,
    Paused
}

val playerStateKey = StyleStateKey(PlayerState.Stopped)

Crea funciones de extensión de StyleState

Define una función de extensión en StyleState para consultar el playState actual. Luego, crea funciones de extensión en StyleScope con tus estados personalizados pasando playStateKey, una lambda con el estado específico y el 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 })
}

Define styleState en tu elemento componible y establece styleState.playState como igual al estado entrante. Pasa styleState a la función styleable en el modificador.

Dentro de la expresión lambda style, puedes aplicar un diseño basado en el estado para estados personalizados con las funciones de extensión definidas anteriormente.

El siguiente código es el fragmento completo de este ejemplo:

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)
}