스타일의 상태 및 애니메이션

Styles API는 hovered, focused, pressed와 같은 상호작용 상태에서 UI 변경사항을 관리하는 선언적이고 간소화된 접근 방식을 제공합니다. 이 API를 사용하면 수정자를 사용할 때 일반적으로 필요한 상용구 코드를 크게 줄일 수 있습니다.

반응형 스타일 지정을 용이하게 하기 위해 StyleState는 요소의 활성 상태 (예: 사용 설정, 눌림 또는 포커스 상태)를 추적하는 안정적인 읽기 전용 인터페이스로 작동합니다. StyleScope 내에서 state 속성을 통해 이에 액세스하여 스타일 정의에 직접 조건부 로직을 구현할 수 있습니다.

상태 기반 상호작용: 마우스 오버, 포커스, 누름, 선택, 사용 설정, 전환

스타일에는 일반적인 상호작용을 위한 기본 지원이 제공됩니다.

  • 누름
  • 마우스 오버됨
  • 선택됨
  • 사용 설정됨
  • 전환됨

맞춤 상태를 지원할 수도 있습니다. 자세한 내용은 StyleState를 사용한 맞춤 상태 스타일 지정 섹션을 참고하세요.

스타일 매개변수로 상호작용 상태 처리

다음 예에서는 상호작용 상태에 따라 background 및 borderColor를 수정하는 방법을 보여줍니다. 구체적으로 마우스를 가져가면 보라색으로 전환되고 포커스가 지정되면 파란색으로 전환됩니다.

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

그림 1. 마우스를 가져가거나 포커스가 있는 상태에 따라 배경 색상을 변경합니다.

중첩된 상태 정의를 만들 수도 있습니다. 예를 들어 버튼이 동시에 눌리고 마우스가 올라갔을 때의 특정 스타일을 정의할 수 있습니다.

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

그림 2. 버튼에서 호버 상태와 누름 상태가 동시에 발생했습니다.

Modifier.styleable을 사용한 맞춤 컴포저블

자체 styleable 구성요소를 만들 때는 interactionSource을 styleState에 연결해야 합니다. 그런 다음 이 상태를 Modifier.styleable에 전달하여 활용합니다.

디자인 시스템에 GradientButton이 포함된 시나리오를 고려해 보세요. GradientButton에서 상속받지만 상호작용(예: 눌림) 중에 색상을 변경하는 LoginButton를 만들 수 있습니다.

  • interactionSource 스타일 업데이트를 사용 설정하려면 컴포저블 내에 interactionSource을 매개변수로 포함하세요. 제공된 매개변수를 사용하거나 제공되지 않은 경우 새 MutableInteractionSource을 초기화합니다.
  • interactionSource를 제공하여 styleState를 초기화합니다. styleState의 사용 설정 상태가 제공된 사용 설정 매개변수의 값을 반영하는지 확인합니다.
  • interactionSource을 focusable 및 clickable 수정자에 할당합니다. 마지막으로 styleState를 수정자의 styleable 매개변수에 적용합니다.

@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 = rememberUpdatedStyleState(interactionSource) {
        it.isEnabled = enabled
    }
    Row(
        modifier =
            modifier
                .clickable(
                    onClick = onClick,
                    enabled = enabled,
                    interactionSource = interactionSource,
                    indication = null,
                )
                .styleable(styleState, baseGradientButtonStyle then style),
        content = content,
    )
}

이제 interactionSource 상태를 사용하여 스타일 블록 내의 pressed, focused, hovered 옵션으로 스타일 수정사항을 적용할 수 있습니다.

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

그림 3. interactionSource에 따라 맞춤 컴포저블 상태를 변경합니다.

스타일 변경에 애니메이션 적용

스타일 상태 변경에는 기본 애니메이션 지원이 제공됩니다. animate를 사용하여 상태 변경 블록 내에 새 속성을 래핑하여 서로 다른 상태 간에 애니메이션을 자동으로 추가할 수 있습니다. 이는 animate*AsState API와 유사합니다. 다음 예에서는 상태가 포커스로 변경되면 borderColor를 검은색에서 파란색으로 애니메이션 처리합니다.

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

    }
}

그림 4. 누를 때 색상 변경 애니메이션 적용

animate API는 animationSpec를 허용하여 애니메이션 곡선의 지속 시간이나 모양을 변경합니다. 다음 예에서는 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))
}

그림 5. 누를 때 크기와 색상 변경을 애니메이션으로 표시합니다.

StyleState를 사용한 맞춤 상태 스타일 지정

컴포저블 사용 사례에 따라 맞춤 상태로 지원되는 스타일이 다를 수 있습니다. 예를 들어 미디어 앱이 있는 경우 플레이어의 재생 상태에 따라 MediaPlayer 컴포저블의 버튼에 다른 스타일을 적용할 수 있습니다. 다음 단계에 따라 맞춤 상태를 만들고 사용하세요.

  1. 맞춤 키 정의
  2. StyleState 확장 프로그램 만들기
  3. 맞춤 상태 링크

맞춤 키 정의

맞춤 상태 기반 스타일을 만들려면 먼저 StyleStateKey를 만들고 기본 상태 값을 전달합니다. 앱이 실행되면 미디어 플레이어가 Stopped 상태이므로 다음과 같이 초기화됩니다.

enum class PlayerState {
    Stopped,
    Playing,
    Paused
}

val playerStateKey = StyleStateKey(PlayerState.Stopped)

StyleState 확장 함수 만들기

StyleState에서 확장 함수를 정의하여 현재 playState를 쿼리합니다. 그런 다음 playStateKey, 특정 상태가 있는 람다, 스타일을 전달하는 맞춤 상태를 사용하여 StyleScope에 확장 함수를 만듭니다.

// 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(block: () -> Unit) {
    state(playerStateKey, block, { key, state -> state[key] == PlayerState.Playing })
}
fun StyleScope.playerPaused(block: () -> Unit) {
    state(playerStateKey, block, { key, state -> state[key] == PlayerState.Paused })
}

컴포저블에서 styleState를 정의하고 styleState.playState을 수신 상태와 동일하게 설정합니다. 수정자에서 styleable 함수에 styleState를 전달합니다.

style 람다 내에서 이전에 정의한 확장 함수를 사용하여 맞춤 상태에 상태 기반 스타일을 적용할 수 있습니다.

다음 코드는 이 예의 전체 스니펫입니다.

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(block: () -> Unit) {
    state(playerStateKey, block, { key, state -> state[key] == PlayerState.Playing })
}
fun StyleScope.playerPaused(block: () -> Unit) {
    state(playerStateKey, block, { 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)
}