Jetpack Compose의 실험적 Styles API 소개
Jetpack Compose의 실험적 Styles API 소개
Jetpack Compose의 Modifier 시스템은 컴포저블에 시각적 속성을 적용하는 핵심 수단이었습니다. background(), padding(), border() 등의 Modifier를 체이닝하여 UI 요소의 외관과 동작을 구성하는 방식인데, 이 접근법은 강력하면서도 인터랙티브한 상태를 다룰 때 한계가 존재합니다. 가령, 버튼을 눌렀을 때 색상을 변경하려면 상태를 수동으로 추적하고, 애니메이션 값을 생성한 뒤, 조건에 따라 서로 다른 Modifier를 적용해야 합니다. 새롭게 소개된 실험적 Styles API는 상태 기반 스타일링(state-dependent styling)을 선언적으로 정의하고, 애니메이션 전환까지 자동으로 처리하여 이러한 문제를 해결하는 것을 목표로 합니다.
이 글에서는 Styles API가 내부적으로 어떻게 동작하는지 살펴봅니다. Style 객체가 시각적 속성을 컴포저블 람다로 캡슐화하는 방법, StyleScope가 레이아웃, 드로잉, 텍스트 속성에 대한 접근을 제공하는 방법, StyleState가 pressed, hovered, focused 같은 인터랙션 상태를 노출하는 방법, 수동으로 Animatable을 관리하지 않아도 시스템이 자동으로 상태 간 전환을 애니메이션하는 원리, 그리고 투 노드(two-node) Modifier 아키텍처가 무효화를 최소화하면서 효율적으로 스타일을 적용하는 방법을 다룹니다. 이 글은 기본적인 Compose 스타일링 가이드가 아니라, 인터랙티브하고 상태에 따라 달라지는 UI 외관을 정의하는 새로운 패러다임을 깊이 있게 탐구하는 글입니다.
상태 기반 스타일링의 문제점
hover 상태와 press 상태에 따라 색상이 바뀌는 버튼을 구현하는 경우를 생각해 보겠습니다. 현재의 Modifier 접근 방식에서는 이를 직접 관리해야 합니다.
@Composable
fun InteractiveButton(onClick: () -> Unit) {
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val isHovered by interactionSource.collectIsHoveredAsState()
val backgroundColor by animateColorAsState(
targetValue = when {
isPressed -> Color.Red
isHovered -> Color.Yellow
else -> Color.Green
}
)
Box(
modifier = Modifier
.clickable(interactionSource = interactionSource, indication = null) { onClick() }
.background(backgroundColor)
.size(150.dp)
)
}
이 패턴에는 여러 가지 요소가 필요합니다. 인터랙션을 추적하기 위한 InteractionSource, 각 인터랙션 유형에 대한 상태 파생, 부드러운 전환을 위한 애니메이션 값, 그리고 현재 외관을 결정하는 조건부 로직이 그것입니다. 코드가 장황해지고, 관심사가 여러 선언에 걸쳐 분산되는 문제가 있습니다.
Styles API는 이 모든 것을 하나의 선언적 정의로 통합합니다.
@Composable
fun InteractiveButton(onClick: () -> Unit) {
ClickableStyleableBox(
onClick = onClick,
style = {
background(Color.Green)
size(150.dp)
hovered { animate { background(Color.Yellow) } }
pressed { animate { background(Color.Red) } }
}
)
}
스타일 블록 안에서 기본 외관과 각 상태별 변화를 한꺼번에 선언할 수 있습니다. animate 래퍼는 해당 상태에 진입하거나 빠져나올 때 부드럽게 전환하도록 시스템에 지시합니다. 수동 상태 추적도, 명시적 애니메이션 값도, 조건부 Modifier 체이닝도 필요하지 않습니다. 즉, 기존 방식 대비 보일러플레이트 코드를 크게 줄이면서도 동일한 인터랙티브 동작을 달성할 수 있습니다.
Style: 시각적 속성을 위한 함수형 인터페이스
Style 인터페이스는 이 API의 기반이 되는 핵심 요소입니다. StyleScope 위에서 동작하는 함수형 인터페이스(functional interface)로 정의되어 있습니다.
@ExperimentalFoundationStyleApi
public fun interface Style {
public fun StyleScope.applyStyle()
}
이러한 설계 덕분에 람다 구문으로 스타일을 생성할 수 있습니다. style = { background(Color.Green) }과 같이 작성하면, applyStyle 함수가 수신 StyleScope에서 background(Color.Green)을 호출하는 Style 인스턴스가 만들어집니다.
스타일은 then 중위 함수(infix function)나 Style() 팩토리 함수를 사용하여 조합할 수 있습니다.
val baseStyle = Style {
background(Color.White)
contentPadding(16.dp)
}
val borderedStyle = Style {
borderWidth(1.dp)
borderColor(Color.Gray)
}
val combinedStyle = baseStyle then borderedStyle
스타일을 조합할 때, 뒤에 오는 스타일의 속성이 앞선 스타일의 동일 속성을 프로퍼티 단위로 덮어씁니다. 이는 Modifier 체이닝과 다른 중요한 차이점입니다. Modifier 체이닝에서는 두 Modifier가 모두 적용되며 순서에 따라 시각적 중첩이 결정되지만, Styles에서는 baseStyle과 borderedStyle이 모두 background를 설정하면 두 번째 값만 사용됩니다.
이 조합은 내부적으로 CombinedStyle이라는 클래스를 통해 구현되며, 스타일 배열을 순차적으로 적용합니다.
internal class CombinedStyle(vararg val styles: Style) : Style {
override fun StyleScope.applyStyle() {
for (style in styles) {
with(style) { applyStyle() }
}
}
}
StyleScope: 속성 설정의 표면
StyleScope는 스타일 내에서 설정할 수 있는 모든 속성을 제공하는 sealed 인터페이스입니다. CompositionLocalAccessorScope를 확장하여 테마 값에 접근할 수 있고, Density를 확장하여 dp에서 픽셀로의 변환도 가능합니다.
@ExperimentalFoundationStyleApi
public sealed interface StyleScope : CompositionLocalAccessorScope, Density {
public val state: StyleState
// ... 속성 함수들
}
여기서 state 프로퍼티가 특히 중요한데, 현재 인터랙션 상태에 접근할 수 있게 해주므로 요소가 pressed, hovered, focused 상태인지 등에 따라 조건부 스타일링을 적용할 수 있기 때문입니다.
이 스코프는 여러 카테고리에 걸쳐 함수를 제공합니다. 레이아웃 속성으로는 명시적 치수를 설정하는 width(), height(), size(), 비율 기반 크기를 설정하는 fillWidth(), fillHeight(), fillSize(), 제약 조건을 설정하는 minWidth(), maxWidth(), minHeight(), maxHeight(), 콘텐츠 안쪽과 바깥쪽 여백을 설정하는 contentPadding()과 externalPadding()이 있습니다.
드로잉 속성으로는 채우기 색상을 설정하는 background(Color) 및 background(Brush), 포그라운드 레이어를 위한 foreground(Color) 및 foreground(Brush), 외곽선을 위한 borderWidth(), borderColor(), border(), 모서리 라운딩 및 커스텀 도형을 위한 shape(), 그리고 그림자 효과를 위한 dropShadow() 및 innerShadow()를 제공합니다. borderWidth는 레이아웃에 참여하므로, 실제로 공간을 차지하여 컴포넌트의 측정 크기에 영향을 미친다는 점에 유의해야 합니다.
트랜스폼 속성으로는 불투명도를 위한 alpha(), 스타일이 적용된 영역 전체에 ColorFilter를 적용하는 colorFilter(), 크기 변환을 위한 scaleX(), scaleY(), scale(), 위치 오프셋을 위한 translationX(), translationY(), translation(), 3D 회전을 위한 rotationX(), rotationY(), rotationZ(), 그리고 클리핑과 레이어링을 위한 clip() 및 zIndex()가 있습니다.
텍스트 속성으로는 전체 텍스트 스타일링을 위한 textStyle(), 폰트 속성을 위한 fontSize(), fontWeight(), fontFamily(), 텍스트 색상을 위한 contentColor() 및 contentBrush(), 텍스트 레이아웃을 위한 letterSpacing(), lineHeight(), textAlign(), 그리고 애니메이션 중 텍스트 크기 조절 방식을 제어하는 textMotion()이 있습니다. textMotion은 크기 변경을 위한 TextMotion.Static 또는 부드러운 폰트 크기 전환을 위한 TextMotion.Animated 중 하나를 선택할 수 있습니다.
StyleState: 인터랙션 상태 인식
StyleState는 스타일이 적용된 요소의 현재 인터랙션 상태를 노출하는 sealed 클래스입니다.
sealed class StyleState {
abstract val isEnabled: Boolean
abstract val isFocused: Boolean
abstract val isHovered: Boolean
abstract val isPressed: Boolean
abstract val isSelected: Boolean
abstract val isChecked: Boolean
abstract val triStateToggle: ToggleableState
abstract operator fun <T> get(key: StyleStateKey<T>): T
}
이 속성들은 스타일 해석(resolution) 과정에서 읽힙니다. state.isPressed에 기반한 조건부 로직을 작성하면, 스타일 시스템이 이 의존성을 추적하고 pressed 상태가 변경될 때 스타일을 다시 해석합니다.
구체적인 구현체는 MutableStyleState이며, InteractionSource를 관찰하면서 press, hover, focus 인터랙션에 따라 속성을 업데이트합니다. rememberUpdatedStyleState 컴포저블을 사용하여 생성할 수 있습니다.
@Composable
fun rememberUpdatedStyleState(
interactionSource: InteractionSource?,
block: @Composable (MutableStyleState) -> Unit = {}
): StyleState
이 컴포저블은 제공된 InteractionSource의 인터랙션을 자동으로 추적하는 StyleState를 생성하고 기억(remember)합니다. 선택적 block 매개변수를 통해 반환되기 전에 MutableStyleState에 추가 커스텀 상태 값을 설정할 수도 있습니다.
StyleStateKey를 활용한 커스텀 상태
사전 정의된 상태 외에도, StyleStateKey<T>를 사용하여 커스텀 상태 키를 정의할 수 있습니다. 각 키는 기본값을 보유하며, 필요에 따라 processInteraction을 오버라이드하여 인터랙션 이벤트에 반응할 수 있습니다.
val PlayingKey = StyleStateKey(defaultValue = false)
val StyleState.isPlaying: Boolean
get() = this[PlayingKey]
fun StyleScope.playing(block: StyleScope.() -> Unit) {
if (state.isPlaying) block()
}
사전 정의된 키(StyleStateKey.Pressed, StyleStateKey.Hovered, StyleStateKey.Focused, StyleStateKey.Selected, StyleStateKey.Enabled, StyleStateKey.Toggle)는 MutableStyleState 내부에서 최적화된 비트 패킹(bit packing) 표현을 사용합니다. 개별 boolean 필드 대신, 단일 정수 필드에 비트마스크 연산을 통해 boolean 상태를 저장하므로 메모리 효율성이 뛰어납니다.
StyleScope는 일반적인 상태 패턴을 위한 편의 함수(convenience function)도 제공합니다.
style = {
background(Color.Green)
hovered {
background(Color.Yellow)
}
pressed {
background(Color.Red)
}
focused {
borderWidth(2.dp)
borderColor(Color.Blue)
}
}
이 함수들은 if (state.isHovered) { ... } 패턴을 위한 편의 문법(syntactic sugar)이지만, 동시에 애니메이션 시스템이 상태 전환을 이해할 수 있도록 하는 역할도 합니다.
자동 애니메이션
Styles API의 가장 강력한 기능 중 하나는 선언적 애니메이션입니다. 수동으로 Animatable 인스턴스를 생성하고 코루틴을 실행하는 대신, 스타일 변경을 animate로 감싸기만 하면 됩니다.
style = {
background(Color.Blue)
size(150.dp)
hovered {
animate {
background(Color.Yellow)
scale(1.1f)
}
}
pressed {
animate(tween(100)) {
background(Color.Red)
scale(0.95f)
}
}
}
animate 함수는 전환을 커스터마이즈하기 위한 AnimationSpec 매개변수를 선택적으로 받을 수 있습니다. 요소가 hover 상태에 진입하면, 시스템이 자동으로 현재 배경색에서 노란색으로, 현재 스케일에서 1.1f로 애니메이션합니다. hover 상태에서 벗어나면 다시 원래 값으로 돌아가는 애니메이션이 실행됩니다.
내부적으로 애니메이션 시스템은 활성 애니메이션을 추적하는 StyleAnimations 클래스가 관리합니다.
internal class StyleAnimations {
private val entries = mutableObjectListOf<Entry>()
private class Entry(
val key: Any,
var style: ResolvedStyle,
val toSpec: AnimationSpec<Float>,
val fromSpec: AnimationSpec<Float>,
val animatable: Animatable<Float, AnimationVector1D>,
var state: State,
)
}
각 애니메이션 스타일 블록마다 현재 애니메이션 진행 상태를 추적하는 Entry가 생성됩니다. withAnimations 함수는 각 애니메이션의 현재 값을 기반으로 선형 보간(linear interpolation)을 수행하여 보간된 값을 해석된 스타일에 적용합니다.
시스템은 여러 복잡한 상황을 자동으로 처리합니다. 여러 상태 변경이 동시에 발생할 때의 동시 애니메이션, 애니메이션 도중 새로운 상태 변경이 발생할 때의 인터럽션 처리, 그리고 컴포지션에 스타일이 추가되거나 제거될 때의 진입/퇴장 애니메이션이 모두 자동으로 관리됩니다.
ResolvedStyle: 런타임 표현
스타일이 적용되면 모든 속성의 구체적인 값을 보유하는 ResolvedStyle 인스턴스로 해석됩니다.
internal class ResolvedStyle : StyleScope, InspectableValue {
// 레이아웃 속성
var contentPaddingStart: Dp = Dp.Unspecified
var contentPaddingEnd: Dp = Dp.Unspecified
var width: Dp = Dp.Unspecified
var height: Dp = Dp.Unspecified
// ... 약 50개의 속성
// 최적화 플래그
private var layoutFlags: Int = 0
private var drawFlags: Int = 0
private var textFlags: Int = 0
}
이 클래스는 어떤 속성이 설정되었는지 추적하기 위해 비트셋(bitset) 기반의 플래그 시스템을 사용합니다. 이 최적화는 두 가지 목적을 수행합니다. 첫째, "설정되지 않음"과 "기본값으로 설정됨"을 구분할 수 있습니다. 둘째, 개별 속성을 비교하는 대신 정수 플래그를 비교하여 효율적으로 변경 사항을 감지할 수 있습니다.
텍스트 관련 enum 값은 비트 시프팅(bit-shifting)을 사용하여 단일 정수 필드에 패킹됩니다.
private var textEnums: Int = 0
// Packed: fontWeight | fontStyle | fontSynthesis | textDecoration |
// textAlign | textDirection | hyphens | lineBreak
이를 통해 각 ResolvedStyle 인스턴스의 메모리 사용량을 줄이면서도, 비트 마스킹 연산으로 개별 값에 빠르게 접근할 수 있습니다.
투 노드 Modifier 아키텍처
스타일은 styleable Modifier 확장 함수를 통해 요소에 적용됩니다.
public fun Modifier.styleable(
styleState: StyleState?,
style: Style,
): Modifier
내부 구현은 외부 노드(outer node)와 내부 노드(inner node), 두 개의 Modifier 노드를 사용합니다. 구현부의 설명에 따르면, "두 개의 LayoutModifierNode가 필요합니다. 외부 Modifier가 대부분의 기능을 구현하지만, 패딩은 예외입니다. 패딩, 드로잉 등이 올바르게 동작하려면 이 내부 Modifier에서 패딩을 추가해야 합니다."
StyleOuterNode는 레이아웃 제약, 측정, 배경 드로잉, 트랜스폼, 그림자 등 대부분의 스타일 적용을 처리합니다. StyleInnerNode는 콘텐츠 패딩만 전담하며, 올바른 레이아웃 동작을 위해 외부 수정 이후에 적용되어야 합니다.
외부 노드는 DelegatingNode를 확장하고 여러 노드 인터페이스를 구현합니다.
internal class StyleOuterNode :
DelegatingNode(),
DrawModifierNode,
LayoutModifierNode,
ObserverModifierNode
DelegatingNode를 확장함으로써 외부 노드는 레이아웃, 드로잉, composition local 관찰에 참여하면서 필요한 경우 특정 책임을 자식 노드에 위임할 수 있습니다. 이 설계는 Modifier 체인 내 노드 수를 최소화하여 성능을 높여 줍니다.
선택적 무효화
스타일 시스템은 속성이 변경될 때 어떤 하위 시스템에 무효화가 필요한지를 세밀하게 추적합니다. ResolvedStyle은 레이아웃, 드로잉, 텍스트 변경에 대해 각각 별도의 플래그를 유지합니다.
internal fun invalidate(previous: ResolvedStyle): Int {
var result = 0
if (layoutChanged(previous)) result = result or LAYOUT_INVALIDATION
if (drawChanged(previous)) result = result or DRAW_INVALIDATION
if (textChanged(previous)) result = result or TEXT_INVALIDATION
return result
}
스타일이 background나 alpha 같은 드로잉 속성만 변경하면, 드로잉 페이즈만 무효화됩니다. 레이아웃과 Composition은 그대로 유지됩니다. 이 세분화된 무효화는 Compose의 페이즈 시스템과 동일한 원리로 동작합니다. graphicsLayer 속성의 변경이 리컴포지션이나 레이아웃 재계산 없이 드로잉만 트리거하는 것과 같은 메커니즘입니다.
애니메이션 시스템 역시 애니메이션 값을 적용한 후 무효화 플래그를 반환하므로, 각 애니메이션 프레임마다 필요한 페이즈만 실행되도록 보장합니다.
Composition local 접근
StyleScope가 CompositionLocalAccessorScope를 확장하기 때문에, 스타일 내에서 composition local을 직접 읽을 수 있습니다.
style = {
val colors = LocalColors.current
background(colors.surface)
contentColor(colors.onSurface)
pressed {
background(colors.surfaceVariant)
}
}
이 통합 덕분에 별도의 색상 매개변수 없이도 스타일이 테마를 인식할 수 있습니다. 테마가 변경되면 테마 값을 읽는 스타일은 자동으로 다시 해석됩니다.
이 관찰은 ObserverModifierNode가 처리하며, 스타일 해석 중 composition local 읽기를 추적하고 해당 값이 변경되면 노드를 무효화합니다.
종합 예제
Styles API는 Compose가 인터랙티브 스타일링을 처리하는 방식을 크게 혁신하는 새로운 API입니다. 상태 관찰, 자동 애니메이션, 선택적 무효화를 하나의 선언적 API로 통합하여 보일러플레이트 코드를 줄이면서도 성능을 유지합니다.
다음은 이 API의 표현력을 보여주는 종합 예제입니다.
@Composable
fun StyledCard(
title: String,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val cardStyle = Style {
background(MaterialTheme.colorScheme.surface)
shape(RoundedCornerShape(12.dp))
contentPadding(16.dp)
dropShadow(4.dp, Color.Black.copy(alpha = 0.1f))
hovered {
animate(tween(200)) {
dropShadow(8.dp, Color.Black.copy(alpha = 0.15f))
translationY((-2).dp)
}
}
pressed {
animate(tween(100)) {
dropShadow(2.dp, Color.Black.copy(alpha = 0.05f))
scale(0.98f)
}
}
focused {
borderWidth(2.dp)
borderColor(MaterialTheme.colorScheme.primary)
}
}
ClickableStyleableBox(
onClick = onClick,
modifier = modifier,
style = cardStyle
) {
Text(title)
}
}
이 단일 스타일 정의만으로 기본 외관, 그림자와 위치 변경을 포함하는 hover 효과, 스케일 애니메이션이 적용되는 press 효과, 그리고 테두리로 표시되는 focus 상태를 모두 처리할 수 있습니다. 모든 전환은 자동으로 처리되고, 상태 추적은 암시적이며, 시스템이 업데이트 중 최소한의 무효화만 수행하도록 보장합니다.
결론
실험적 Styles API는 Jetpack Compose에서 인터랙티브하고 상태에 따라 달라지는 UI 외관을 정의하는 새로운 패러다임을 제시합니다. InteractionSource, 상태 파생, 애니메이션 값을 여러 선언에 걸쳐 수동으로 관리하는 대신, 스타일링 로직을 응집력 있는 선언적 블록으로 통합할 수 있습니다.
Style 함수형 인터페이스는 시각적 속성을 StyleScope 위에서 동작하는 컴포저블 람다로 캡슐화합니다. 이 스코프는 치수와 패딩 같은 레이아웃 속성, 배경과 테두리 같은 드로잉 속성, 스케일과 회전 같은 트랜스폼 속성, 그리고 타이포그래피를 제어하는 텍스트 속성에 대한 포괄적인 접근을 제공합니다. StyleState 인터페이스는 pressed, hovered, focused, selected, checked를 포함한 인터랙션 상태를 노출하여, 명시적 상태 관리 없이도 조건부 스타일링을 적용할 수 있습니다.
애니메이션 시스템은 전환을 자동으로 처리합니다. 스타일 변경을 animate 블록으로 감싸면 시스템이 상태 간 부드러운 보간을 수행하며, 선택적 AnimationSpec 매개변수를 통해 커스터마이즈할 수 있습니다. StyleAnimations 클래스가 내부적으로 엔트리 추적, 보간, 동시 애니메이션 처리를 모두 관리합니다.
내부적으로, 투 노드 Modifier 아키텍처는 외부 수정(레이아웃, 드로잉, 트랜스폼)과 내부 수정(콘텐츠 패딩)을 분리하여 올바른 동작을 보장합니다. ResolvedStyle 클래스는 약 50개의 속성을 비트셋 기반 최적화를 통해 저장하여, 메모리 효율성과 변경 감지 성능을 동시에 달성합니다. 선택적 무효화는 드로잉 속성의 변경이 드로잉 페이즈만 트리거하고, 레이아웃 변경은 레이아웃과 드로잉을 트리거하되 Composition은 건너뛰도록 보장합니다.
Styles API는 현재 실험적(experimental) 상태이며, Compose Foundation의 알파 채널에서 @ExperimentalFoundationStyleApi 어노테이션과 함께 제공됩니다. 최초 도입 이후 colorFilter 지원, 애니메이션에 적합한 텍스트 스케일링을 위한 textMotion, foreground 레이어, 콘텐츠 및 외부 패딩을 위한 PaddingValues 오버로드, 간소화된 상태 생성을 위한 rememberUpdatedStyleState 컴포저블 등 다양한 기능이 추가되었습니다. StyleStateKey 시스템은 사전 정의된 pressed, hovered, focused 상태를 넘어 커스텀 상태 키를 정의할 수 있도록 지원합니다. 알파 채널을 통해 계속 성숙해 나가고 있는 이 API는, 인터랙티브 스타일링을 선언적 정의로 통합하고 프레임워크가 자동으로 최적화할 수 있게 하는 Compose의 미래 방향을 보여주는 매우 의미 있는 시도입니다.

