https://developer.android.com/develop/ui/compose/animation/introduction

AnimatedVectorDrawableLottierememberInfiniteTransitioncomposable()(enterTransition및 exitTransition)이 설정AnimatedContent, Crossfade또는PagerAnimatedVisibility또는animateFloatAsState(Modifier.alpha())Modifier.animateContentSizeanimateItemPlacement()(재주문 및 삭제 예정)animate*AsState, 텍스트의 경우 TextMotion.Animated 사용updateTransition(AnimatedVisibility, animateFloat, animateInt 등 사용)animateTo가 포함된 Animatable가 다른 타이밍으로 호출되었습니다 (suspend 함수 사용).animate*AsState, 텍스트의 경우 TextMotion.Animated 사용Animatable animateTo/snapToAnimationState또는animateAnimatedVisibility를 사용하여 숨기거나 표시.
AnimatedVisibility 내부의 자식은 Modifier.animateEnterExit()를 사용하여 자체 enter/exit transition 가능
var visible by remember {
mutableStateOf(true)
}
// Animated visibility는 애니메이션이 완료되면 Composition에서 항목을 제거
AnimatedVisibility(visible) {
// your composable here
// ...
}

drawBehind 이 옵션은 Modifier.background()를 사용하는 것보다 성능이 좋다.
Modifier.background() : 단발성 색상 설정에는 사용. 색상 애니메이션시에는 필요 이상으로 Recomposition될 수 있다.
val animatedColor by animateColorAsState(
if (animateBackgroundColor) colorGreen else colorBlue,
label = "color"
)
Column(
modifier = Modifier.drawBehind {
drawRect(animatedColor)
}
) {
// your composable here
}

animateContentSize()를 사용하여 Composable 크기 변경 사이에 애니메이션을 적용
var expanded by remember { mutableStateOf(false) }
Box(
modifier = Modifier
.background(colorBlue)
.animateContentSize()
.height(if (expanded) 400.dp else 200.dp)
.fillMaxWidth()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
expanded = !expanded
}
) {
}

Modifier.offset{}과 animateIntOffsetAsState()를 함께 사용
offset은 실제 배치되는 위치에는 영향을 안주며, offset 하위에만 영향을 준다
var moved by remember { mutableStateOf(false) }
val pxToMove = with(LocalDensity.current) {
100.dp.toPx().roundToInt()
}
val offset by animateIntOffsetAsState(
targetValue = if (moved) {
IntOffset(pxToMove, pxToMove)
} else {
IntOffset.Zero
},
label = "offset"
)
Box(
modifier = Modifier
.offset {
offset
}
.background(colorBlue)
.size(100.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
moved = !moved
}
)

Composable 애니메이션 처리 시 겹쳐지지 않게 하려면 Modifier.layout{}을 사용해야 한다.
var toggled by remember {
mutableStateOf(false)
}
val interactionSource = remember {
MutableInteractionSource()
}
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxSize()
.clickable(indication = null, interactionSource = interactionSource) {
toggled = !toggled
}
) {
val offsetTarget = if (toggled) {
IntOffset(150, 150)
} else {
IntOffset.Zero
}
val offset = animateIntOffsetAsState(
targetValue = offsetTarget, label = "offset"
)
Box(
modifier = Modifier
.size(100.dp)
.background(colorBlue)
)
Box(
modifier = Modifier
.layout { measurable, constraints ->
val offsetValue = if (isLookingAhead) offsetTarget else offset.value
val placeable = measurable.measure(constraints)
layout(placeable.width + offsetValue.x, placeable.height + offsetValue.y) {
placeable.placeRelative(offsetValue)
}
}
.size(100.dp)
.background(colorGreen)
)
Box(
modifier = Modifier
.size(100.dp)
.background(colorBlue)
)
}

Modifier.padding()과 결합된 animateDpAsState를 사용
var toggled by remember {
mutableStateOf(false)
}
val animatedPadding by animateDpAsState(
if (toggled) {
0.dp
} else {
20.dp
},
label = "padding"
)
Box(
modifier = Modifier
.aspectRatio(1f)
.fillMaxSize()
.padding(animatedPadding)
.background(Color(0xff53D9A1))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
toggled = !toggled
}
)

animateDpAsState와 Modifier.graphicsLayer{}를 함께 사용.
단발성의 경우 Modifier.shadow()를 사용
val mutableInteractionSource = remember {
MutableInteractionSource()
}
val pressed = mutableInteractionSource.collectIsPressedAsState()
val elevation = animateDpAsState(
targetValue = if (pressed.value) {
32.dp
} else {
8.dp
},
label = "elevation"
)
Box(
modifier = Modifier
.size(100.dp)
.align(Alignment.Center)
.graphicsLayer {
this.shadowElevation = elevation.value.toPx()
}
.clickable(interactionSource = mutableInteractionSource, indication = null) {
}
.background(colorGreen)
) {
}
TextStyle의 textMotion 매개변수를 TextMotion.Animated로 설정. 텍스트 애니메이션 간의 전환이 더 원활해짐
Modifier.graphicsLayer{ }를 사용하여 텍스트를 변환, 회전 또는 크기 조정할 수 있습니다.
val infiniteTransition = rememberInfiniteTransition(label = "infinite transition")
val scale by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 8f,
animationSpec = infiniteRepeatable(tween(1000), RepeatMode.Reverse),
label = "scale"
)
Box(modifier = Modifier.fillMaxSize()) {
Text(
text = "Hello",
modifier = Modifier
.graphicsLayer {
scaleX = scale
scaleY = scale
transformOrigin = TransformOrigin.Center
}
.align(Alignment.Center),
// 기본 Text composable에서는 TextMotion 파라미터를 사용안해도 된다.
style = LocalTextStyle.current.copy(textMotion = TextMotion.Animated)
)
}

val infiniteTransition = rememberInfiniteTransition(label = "infinite transition")
val animatedColor by infiniteTransition.animateColor(
initialValue = Color(0xFF60DDAD),
targetValue = Color(0xFF4285F4),
animationSpec = infiniteRepeatable(tween(1000), RepeatMode.Reverse),
label = "color"
)
BasicText(
text = "Hello Compose",
color = {
animatedColor
},
// ...
)

다른 Composable 간에 애니메이션을 적용하려면 AnimatedContent를 사용.
커스텀으로 enter/exit transition 정의 가능
var state by remember {
mutableStateOf(UiState.Loading)
}
AnimatedContent(
state,
transitionSpec = {
fadeIn(
animationSpec = tween(3000)
) togetherWith fadeOut(animationSpec = tween(3000))
},
modifier = Modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
state = when (state) {
UiState.Loading -> UiState.Loaded
UiState.Loaded -> UiState.Error
UiState.Error -> UiState.Loading
}
},
label = "Animated Content"
) { targetState ->
when (targetState) {
UiState.Loading -> {
LoadingScreen()
}
UiState.Loaded -> {
LoadedScreen()
}
UiState.Error -> {
ErrorScreen()
}
}
}

navigation composable 사용 시 transition 애니메이션 사용 시 enterTransition/exitTransition을 지정
val navController = rememberNavController()
NavHost(
navController = navController, startDestination = "landing",
enterTransition = { EnterTransition.None },
exitTransition = { ExitTransition.None }
) {
composable("landing") {
ScreenLanding(
// ...
)
}
composable(
"detail/{photoUrl}",
arguments = listOf(navArgument("photoUrl") { type = NavType.StringType }),
enterTransition = {
fadeIn(
animationSpec = tween(
300, easing = LinearEasing
)
) + slideIntoContainer(
animationSpec = tween(300, easing = EaseIn),
towards = AnimatedContentTransitionScope.SlideDirection.Start
)
},
exitTransition = {
fadeOut(
animationSpec = tween(
300, easing = LinearEasing
)
) + slideOutOfContainer(
animationSpec = tween(300, easing = EaseOut),
towards = AnimatedContentTransitionScope.SlideDirection.End
)
}
) { backStackEntry ->
ScreenDetails(
// ...
)
}
}

애니메이션을 계속 반복하려면 infiniteRepeatable animationSpec과 함께 rememberInfiniteTransition을 사용
val infiniteTransition = rememberInfiniteTransition(label = "infinite")
val color by infiniteTransition.animateColor(
initialValue = Color.Green,
targetValue = Color.Blue,
animationSpec = infiniteRepeatable(
animation = tween(1000, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
),
label = "color"
)
Column(
modifier = Modifier.drawBehind {
drawRect(color)
}
) {
// your composable here
}

LaunchedEffect와 Animatable를 animateTo 메서드와 함께 사용하여 애니메이션 시작
val alphaAnimation = remember {
Animatable(0f)
}
LaunchedEffect(Unit) {
alphaAnimation.animateTo(1f)
}
Box(
modifier = Modifier.graphicsLayer {
alpha = alphaAnimation.value
}
)
Lazy Layout에서
LaunchedEffects를 사용 시, 아이템이 Composition을 다시 시작하면 재실행된다
Animatable coroutine API를 사용하여 순차/동시 애니메이션을 실행.
Animatable의 animateTo가 suspend 함수이므로 차례로 호출하면 순차로 진행된다.
val alphaAnimation = remember { Animatable(0f) }
val yAnimation = remember { Animatable(0f) }
LaunchedEffect("animationKey") {
alphaAnimation.animateTo(1f)
yAnimation.animateTo(100f)
yAnimation.animateTo(500f, animationSpec = tween(100))
}
Coroutine API(Animatable#animateTo() 또는 animate) 또는 Transition API를 사용하여 동시 애니메이션을 구현
val alphaAnimation = remember { Animatable(0f) }
val yAnimation = remember { Animatable(0f) }
LaunchedEffect("animationKey") {
launch {
alphaAnimation.animateTo(1f)
}
launch {
yAnimation.animateTo(100f)
}
}
updateTransition API를 사용하면 동일한 상태를 사용하여 여러 가지 프로퍼티 애니메이션을 동시에 실행 가능
var currentState by remember { mutableStateOf(BoxState.Collapsed) }
val transition = updateTransition(currentState, label = "transition")
val rect by transition.animateRect(label = "rect") { state ->
when (state) {
BoxState.Collapsed -> Rect(0f, 0f, 100f, 100f)
BoxState.Expanded -> Rect(100f, 100f, 300f, 300f)
}
}
val borderWidth by transition.animateDp(label = "borderWidth") { state ->
when (state) {
BoxState.Collapsed -> 1.dp
BoxState.Expanded -> 0.dp
}
}
Compose의 composition, layout, draw 단계가 있으므로, 애니메이션을 draw 단계에서 발생하면 layout 단계에서 실행할 때보다 해야 할 일이 적기 때문에 성능이 더 좋다.
Compose는 대부분의 애니메이션에 spring 애니메이션을 사용하여 타이밍을 조정 가능

애니메이션 visibility는 애니메이션이 완료되면 Composition에서 항목을 제거
// 커스텀 EnterTransition/ExitTransition를 지정 가능
var visible by remember { mutableStateOf(true) }
val density = LocalDensity.current
AnimatedVisibility(
visible = visible,
enter = slideInVertically {
// 상단에서 40dp부터 밀어 넣음
with(density) { -40.dp.roundToPx() }
} + expandVertically(
// 위에서부터 펼치기
expandFrom = Alignment.Top
) + fadeIn(
// 초기 알파 0.3f로 fadeIn
initialAlpha = 0.3f
),
exit = slideOutVertically() + shrinkVertically() + fadeOut()
) {
Text("Hello", Modifier.fillMaxWidth().height(200.dp))
}

https://developer.android.com/develop/ui/compose/animation/composables-modifiers#enter-exit-transition
AnimatedVisibility는 MutableTransitionState를 사용하는 형태도 가능
AnimatedVisibility가 Composition Tree에 추가되는 즉시 애니메이션을 트리거 가능
// AnimatedVisibility에 대한 MutableTransitionState<Boolean> 생성
val state = remember {
MutableTransitionState(false).apply {
// 애니메이션을 즉시 시작
targetState = true
}
}
Column {
AnimatedVisibility(visibleState = state) {
Text(text = "Hello, world!")
}
// MutableTransitionState를 사용하여 AnimatedVisibility의 현재 애니메이션 상태를 확인
Text(
text = when {
state.isIdle && state.currentState -> "Visible"
!state.isIdle && state.currentState -> "Disappearing"
state.isIdle && !state.currentState -> "Invisible"
else -> "Appearing"
}
)
}
AnimatedVisibility 내의 컨텐츠(직접/자식)는 animateEnterExit Modifier 사용하여 각각에 대해 서로 다른 애니메이션 지정가능
var visible by remember { mutableStateOf(true) }
AnimatedVisibility(
visible = visible,
enter = fadeIn(),
exit = fadeOut()
) {
// background/foreground를 Fade in/out 처리
Box(Modifier.fillMaxSize().background(Color.DarkGray)) {
Box(
Modifier
.align(Alignment.Center)
.animateEnterExit(
// Box에서 Slide in/out 처리
enter = slideInVertically(),
exit = slideOutVertically()
)
.sizeIn(minWidth = 256.dp, minHeight = 64.dp)
.background(Color.Red)
) {
// TODO
}
}
}
커스텀 애니메이션
var visible by remember { mutableStateOf(true) }
AnimatedVisibility(
visible = visible,
enter = fadeIn(),
exit = fadeOut()
) { // this: AnimatedVisibilityScope
// AnimatedVisibilityScope#transition을 사용하여 AnimatedVisibility에 커스텀 애니메이션을 추가
val background by transition.animateColor(label = "color") { state ->
if (state == EnterExitState.Visible) Color.Blue else Color.Gray
}
Box(modifier = Modifier.size(128.dp).background(background))
}
AnimatedContentAnimatedContent composable은 컨텐츠가 변경될 때 애니메이션이 동작
Row {
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) {
Text("Add")
}
// 기본적으로 fade in/out 애니메이션이 적용
AnimatedContent(targetState = count) { targetCount ->
// count 대신 targetCount를 사용해야 함
Text(text = "Count: $targetCount")
}
}
transitionSpec 파라미터에 ContentTransform 객체를 지정하여 커스텀 애니메이션 정의 가능
AnimatedContent(
targetState = count,
transitionSpec = {
// 수신 번호와 이전 번호를 비교
if (targetState > initialState) {
// target 숫자가 더 크면 Slide up + Fade in
// 초기(작은) 숫자는 Slide Up + Fade out
slideInVertically { height -> height } + fadeIn() with
slideOutVertically { height -> -height } + fadeOut()
} else {
// target 숫자가 더 작으면 Slide down + Fade in
// 초기 숫자는 Slide down + Fade out
slideInVertically { height -> -height } + fadeIn() with
slideOutVertically { height -> height } + fadeOut()
}.using(
// Slide in/out이 범위를 벗어나므로 클리핑을 비활성화
SizeTransform(clip = false)
)
}
) { targetCount ->
Text(text = "$targetCount")
}

SizeTransform : 초기 컨텐츠와 타겟 컨텐츠 사이에 크기 애니메이션되는 방식을 정의var expanded by remember { mutableStateOf(false) }
Surface(
color = MaterialTheme.colorScheme.primary,
onClick = { expanded = !expanded }
) {
AnimatedContent(
targetState = expanded,
transitionSpec = {
fadeIn(animationSpec = tween(150, 150)) with
fadeOut(animationSpec = tween(150)) using
SizeTransform { initialSize, targetSize ->
if (targetState) {
keyframes {
// 먼저 가로로 확장
IntSize(targetSize.width, initialSize.height) at 150
durationMillis = 300
}
} else {
keyframes {
// 먼저 세로로 축소
IntSize(initialSize.width, targetSize.height) at 150
durationMillis = 300
}
}
}
}
) { targetExpanded ->
if (targetExpanded) {
Expanded()
} else {
ContentIcon()
}
}
}

하위 요소의 enter/exit transition
animateEnterExit Modifier로 EnterAnimation/ExitAnimation 적용 가능
커스텀 애니메이션
transition 필드는 AnimatedContent 컨텐츠 람다 내에서 사용
AnimatedContent 전환과 동시에 실행되는 맞춤 애니메이션 효과를 만드는 데 사용
crossfade 애니메이션을 사용하여 두 레이아웃 사이의 전환을 애니메이션 처리
var currentPage by remember { mutableStateOf("A") }
Crossfade(targetState = currentPage) { screen ->
when (screen) {
"A" -> Text("Page A")
"B" -> Text("Page B")
}
}
animateContentSize를 사용하여 Composable 크기 변경에 애니메이션 적용
var expanded by remember { mutableStateOf(false) }
Box(
modifier = Modifier
.background(colorBlue)
.animateContentSize()
.height(if (expanded) 400.dp else 200.dp)
.fillMaxWidth()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
expanded = !expanded
}
) {
}

animate*AsState 함수 : 단일 값을 처리하는 간단한 애니메이션 API
Animatable 첫 번째 값을 초기 값으로 사용하여서 애니메이션 개체가 생성되고 기억// alpha 애니메이션
var enabled by remember { mutableStateOf(true) }
val alpha: Float by animateFloatAsState(if (enabled) 1f else 0.5f)
Box(
Modifier.fillMaxSize()
.graphicsLayer(alpha = alpha)
.background(Color.Red)
)
애니메이션 중에 이 Composable은 Recomposition되고 매 프레임마다 업데이트된 애니메이션 값을 반환
Transition는 하나 이상의 애니메이션을 하위 요소로 관리하고 여러 상태 간에 동시 실행
updateTransition는 Transition 인스턴스를 생성+기억하며 상태를 업데이트
var currentState by remember { mutableStateOf(BoxState.Collapsed) }
val transition = updateTransition(currentState, label = "box state")
val rect by transition.animateRect(label = "rectangle") { state ->
when (state) {
BoxState.Collapsed -> Rect(0f, 0f, 100f, 100f)
BoxState.Expanded -> Rect(100f, 100f, 300f, 300f)
}
}
val borderWidth by transition.animateDp(label = "border width") { state ->
when (state) {
BoxState.Collapsed -> 1.dp
BoxState.Expanded -> 0.dp
}
}
transitionSpec 파라미터를 전달하여 서로 다른 애니메이션 스펙 대응 가능
val color by transition.animateColor(
transitionSpec = {
when {
BoxState.Expanded isTransitioningTo BoxState.Collapsed ->
spring(stiffness = 50f)
else ->
tween(durationMillis = 500)
}
}, label = "color"
) { state ->
when (state) {
BoxState.Collapsed -> MaterialTheme.colorScheme.primary
BoxState.Expanded -> MaterialTheme.colorScheme.background
}
}
Collapsed 상태에서 시작하여 즉시 Expanded 상태로 애니메이션 적용
var currentState = remember { MutableTransitionState(BoxState.Collapsed) }
currentState.targetState = BoxState.Expanded
val transition = updateTransition(currentState, label = "box state")
// ……
좀 더 복잡한 transition의 경우 createChildTransition을 사용하여 하위 transition 가능
enum class DialerState { DialerMinimized, NumberPad }
@Composable
fun DialerButton(isVisibleTransition: Transition<Boolean>) {
...
}
@Composable
fun NumberPad(isVisibleTransition: Transition<Boolean>) {
...
}
@Composable
fun Dialer(dialerState: DialerState) {
val transition = updateTransition(dialerState, label = "dialer state")
Box {
// NumberPad/DialerButton에 별도의 하위 transition 생성
NumberPad(
transition.createChildTransition {
it == DialerState.NumberPad
}
)
DialerButton(
transition.createChildTransition {
it == DialerState.DialerMinimized
}
)
}
}
AnimatedVisibility와 AnimatedContent는 Transition의 확장 함수로도 사용 가능
var selected by remember { mutableStateOf(false) }
val transition = updateTransition(selected, label = "selected state")
val borderColor by transition.animateColor(label = "border color") { isSelected ->
if (isSelected) Color.Magenta else Color.White
}
val elevation by transition.animateDp(label = "elevation") { isSelected ->
if (isSelected) 10.dp else 2.dp
}
Surface(
onClick = { selected = !selected },
shape = RoundedCornerShape(8.dp),
border = BorderStroke(2.dp, borderColor),
elevation = elevation
) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Text(text = "Hello, world!")
// AnimatedVisibility as a part of the transition.
transition.AnimatedVisibility(
visible = { targetSelected -> targetSelected },
enter = expandVertically(),
exit = shrinkVertically()
) {
Text(text = "It is fine today.")
}
// AnimatedContent as a part of the transition.
transition.AnimatedContent { targetState ->
if (targetState) {
Text(text = "Selected")
} else {
Icon(imageVector = Icons.Default.Phone, contentDescription = "Phone")
}
}
}
}
여러 애니메이션 값으로 구성된 복잡한 구성요소를 사용하는 경우 애니메이션 구현을 Composable UI와 분리
enum class BoxState { Collapsed, Expanded }
@Composable
fun AnimatingBox(boxState: BoxState) {
val transitionData = updateTransitionData(boxState)
Box(
modifier = Modifier
.background(transitionData.color)
.size(transitionData.size)
)
}
// animation value 보유
private class TransitionData(
color: State<Color>,
size: State<Dp>
) {
val color by color
val size by size
}
// Transition을 생성하고 애니메이션 값을 반환
@Composable
private fun updateTransitionData(boxState: BoxState): TransitionData {
val transition = updateTransition(boxState, label = "box state")
val color = transition.animateColor(label = "color") { state ->
when (state) {
BoxState.Collapsed -> Color.Gray
BoxState.Expanded -> Color.Red
}
}
val size = transition.animateDp(label = "size") { state ->
when (state) {
BoxState.Collapsed -> 64.dp
BoxState.Expanded -> 128.dp
}
}
return remember(transition) { TransitionData(color, size) }
}
InfiniteTransition에는 Transition와 같은 하위 애니메이션이 하나 이상 포함되지만, Composition을 시작하자마자 애니메이션이 실행되기 시작하며 삭제되지 않는 한 중지되지 않음
val infiniteTransition = rememberInfiniteTransition()
val color by infiniteTransition.animateColor(
initialValue = Color.Red,
targetValue = Color.Green,
animationSpec = infiniteRepeatable(
animation = tween(1000, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
)
)
Box(Modifier.fillMaxSize().background(color))
모든 상위 수준 Animation API는 하위 수준 Animation API를 기반으로 빌드
Animatable을 제외하고 모두 Composable이며, 컴포지션 외부에서 이러한 애니메이션 생성 가능Animatable는 값이 animateTo를 통해 변경될 때 값을 애니메이션으로 표시할 수 있는 값 홀더
animate*AsState 구현을 지원하는 API// Start out gray and animate to green/red based on `ok`
val color = remember { Animatable(Color.Gray) }
LaunchedEffect(ok) {
color.animateTo(if (ok) Color.Green else Color.Red)
}
Box(Modifier.fillMaxSize().background(color.value))
Animatable
Animatable은 콘텐츠 값에 관한 추가 작업(snapTo 및 animateDecay)을 제공snapTo는 현재 값을 즉시 타겟 값으로 설정Animation은 사용 가능한 가장 낮은 수준의 애니메이션 API
Animation은 애니메이션 시간을 수동으로 제어하는 데만 사용해야 함. Stateless이고 수명 주기 개념이 없다.
벡터 이미지 적용하는 방법
AnimatedVectorDrawable 파일 형식 (실험 버전)ImageVector// AnimatedImageVector 사용 예시
@Composable
fun AnimatedVectorDrawable() {
val image = AnimatedImageVector.animatedVectorResource(R.drawable.ic_hourglass_animated)
var atEnd by remember { mutableStateOf(false) }
Image(
painter = rememberAnimatedVectorPainter(image, atEnd),
contentDescription = "Timer",
modifier = Modifier.clickable {
atEnd = !atEnd
},
contentScale = ContentScale.Crop
)
}

@Composable
fun Gesture() {
val offset = remember { Animatable(Offset(0f, 0f), Offset.VectorConverter) }
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
coroutineScope {
while (true) {
// 탭 이벤트를 감지하고 위치를 파악
awaitPointerEventScope {
val position = awaitFirstDown().position
launch {
// 탭 위치에 애니메이션을 적용
offset.animateTo(position)
}
}
}
}
}
) {
Circle(modifier = Modifier.offset { offset.value.toIntOffset() })
}
}
private fun Offset.toIntOffset() = IntOffset(x.roundToInt(), y.roundToInt())
애니메이션 값을 드래그와 같은 터치 이벤트에서 발생하는 값과 동기화하기
SwipeToDismiss Composable 대신 Modifier로 구현fun Modifier.swipeToDismiss(
onDismissed: () -> Unit
): Modifier = composed {
val offsetX = remember { Animatable(0f) }
pointerInput(Unit) {
// fling decay를 계산하는 데 사용
val decay = splineBasedDecay<Float>(this)
// 터치 이벤트 및 Animatable에 일시 중단 기능을 사용
coroutineScope {
while (true) {
val velocityTracker = VelocityTracker()
// 진행 중인 애니메이션을 중지
offsetX.stop()
awaitPointerEventScope {
// 터치다운 이벤트 감지
val pointerId = awaitFirstDown().id
horizontalDrag(pointerId) { change ->
// 터치 이벤트로 애니메이션 값을 업데이트
launch {
offsetX.snapTo(
offsetX.value + change.positionChange().x
)
}
velocityTracker.addPosition(
change.uptimeMillis,
change.position
)
}
}
// 더 이상 터치 이벤트를 수신하지 않는다. 애니메이션을 준비
val velocity = velocityTracker.calculateVelocity().x
val targetOffsetX = decay.calculateTargetValue(
offsetX.value,
velocity
)
// 애니메이션은 경계에 도달하면 중지
offsetX.updateBounds(
lowerBound = -size.width.toFloat(),
upperBound = size.width.toFloat()
)
launch {
if (targetOffsetX.absoluteValue <= size.width) {
// 속도가 충분하지 않으므로 뒤로 슬라이드
offsetX.animateTo(
targetValue = 0f,
initialVelocity = velocity
)
} else {
// element가 스와이프 됨
offsetX.animateDecay(velocity, decay)
onDismissed()
}
}
}
}
}
.offset { IntOffset(offsetX.value.roundToInt(), 0) }
}
대부분의 Animation API에서 AnimationSpec 파라미터로 애니메이션 사양을 커스텀 가능
val alpha: Float by animateFloatAsState(
targetValue = if (enabled) 1f else 0.5f,
// 애니메이션 duration/easing 정의
animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)
)
시작 값과 끝 값 사이에 물리학 기반 애니메이션 생성
val value by animateFloatAsState(
targetValue = 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioHighBouncy,
stiffness = Spring.StiffnessMedium
)
)
spring은 애니메이션 도중 타겟 값이 변경될 때 속도의 연속성을 보장하므로 AnimationSpec 유형보다 원활하게 중단을 처리 가능
비교 영상 : https://developer.android.com/static/develop/ui/compose/images/animations/tween_vs_spring.mp4
easing 곡선을 사용하여 지정된 durationMillis 동안 시작 값과 끝 값 간에 애니메이션을 처리
val value by animateFloatAsState(
targetValue = 1f,
animationSpec = tween(
durationMillis = 300,
delayMillis = 50, // 애니메이션 시작 연기
easing = LinearOutSlowInEasing
)
)
애니메이션 기간에 여러 타임스탬프에서 지정된 스냅샷 값을 기반으로 애니메이션을 처리
val value by animateFloatAsState(
targetValue = 1f,
animationSpec = keyframes {
durationMillis = 375
0.0f at 0 with LinearOutSlowInEasing // for 0-15 ms
0.2f at 15 with FastOutLinearInEasing // for 15-75 ms
0.4f at 75 // ms
0.4f at 225 // ms
}
)
지정된 반복 횟수에 도달할 때까지 애니메이션을 반복 실행
val value by animateFloatAsState(
targetValue = 1f,
animationSpec = repeatable(
iterations = 3,
animation = tween(durationMillis = 300),
repeatMode = RepeatMode.Reverse // 애니메이션 반복 방법
)
)
val value by animateFloatAsState(
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 300),
repeatMode = RepeatMode.Reverse
)
)
값을 즉시 최종 값으로 전환하는 특수 AnimationSpec
val value by animateFloatAsState(
targetValue = 1f,
animationSpec = snap(delayMillis = 50)
)
easing은 0과 1.0 사이의 분수 값을 가져와 부동 소수점 수를 반환하는 함수
기본 제공 Easing
val CustomEasing = Easing { fraction -> fraction * fraction }
@Composable
fun EasingUsage() {
val value by animateFloatAsState(
targetValue = 1f,
animationSpec = tween(
durationMillis = 300,
easing = CustomEasing
)
)
// ……
}
애니메이션 중에 모든 애니메이션 값은 AnimationVector로 표시
값은 애니메이션 시스템이 균일하게 처리할 수 있도록 TwoWayConverter에 의해 AnimationVector로 변환되며 그 반대로도 변환
val IntToVector: TwoWayConverter<Int, AnimationVector1D> =
TwoWayConverter({ AnimationVector1D(it.toFloat()) }, { it.value.toInt() })
Color는 빨간색, 녹색, 파란색, 알파 등 네 값의 집합이므로 AnimationVector4D로 변환
Color.VectorConverterDp.VectorConverterOffset.VectorConverterInt.VectorConverterFloat.VectorConverterIntSize.VectorConverterdata class MySize(val width: Dp, val height: Dp)
@Composable
fun MyAnimation(targetSize: MySize) {
val animSize: MySize by animateValueAsState(
targetSize,
TwoWayConverter(
convertToVector = { size: MySize ->
// 각 `Dp` 필드에서 float 값을 추출
AnimationVector2D(size.width.value, size.height.value)
},
convertFromVector = { vector: AnimationVector2D ->
MySize(vector.v1.dp, vector.v2.dp)
}
)
)
}
상위 수준 API
SharedTransitionLayout: shared element transitions을 구현하는 데 필요한 가장 바깥쪽 레이아웃
SharedTransitionScope를 제공SharedTransitionScope에 있어야 함Modifier.sharedElement(): 다른 Composable과 일치해야 하는 Composable을 SharedTransitionScope에 표시하는 modifierModifier.sharedBounds(): Composable의 경계가 transition이 발생해야 하는 컨테이너 경계로 사용되어야 함을 SharedTransitionScope에 알리는 modifier
sharedElement()와 달리 sharedBounds()는 시각적으로 다른 컨텐츠 용도
var showDetails by remember {
mutableStateOf(false)
}
SharedTransitionLayout {
AnimatedContent(
showDetails,
label = "basic_transition"
) { targetState ->
if (!targetState) {
MainContent(
onShowDetails = {
showDetails = true
},
animatedVisibilityScope = this@AnimatedContent,
sharedTransitionScope = this@SharedTransitionLayout
)
} else {
DetailsContent(
onBack = {
showDetails = false
},
animatedVisibilityScope = this@AnimatedContent,
sharedTransitionScope = this@SharedTransitionLayout
)
}
}
}
@Composable
private fun MainContent(
onShowDetails: () -> Unit,
modifier: Modifier = Modifier,
sharedTransitionScope: SharedTransitionScope,
animatedVisibilityScope: AnimatedVisibilityScope
) {
Row(
// ...
) {
with(sharedTransitionScope) {
Image(
painter = painterResource(id = R.drawable.cupcake),
contentDescription = "Cupcake",
modifier = Modifier
.sharedElement(
rememberSharedContentState(key = "image"),
animatedVisibilityScope = animatedVisibilityScope
)
.size(100.dp)
.clip(CircleShape),
contentScale = ContentScale.Crop
)
// ...
}
}
}
@Composable
private fun DetailsContent(
modifier: Modifier = Modifier,
onBack: () -> Unit,
sharedTransitionScope: SharedTransitionScope,
animatedVisibilityScope: AnimatedVisibilityScope
) {
Column(
// ...
) {
with(sharedTransitionScope) {
Image(
painter = painterResource(id = R.drawable.cupcake),
contentDescription = "Cupcake",
modifier = Modifier
.sharedElement(
rememberSharedContentState(key = "image"),
animatedVisibilityScope = animatedVisibilityScope
)
.size(200.dp)
.clip(CircleShape),
contentScale = ContentScale.Crop
)
// ...
}
}
}
Modifier.sharedBounds()는 Modifier.sharedElement()와 유사
sharedBounds()는 시각적으로 다르지만 상태 간에 동일한 영역을 공유하는 용도. sharedElement()는 컨텐츠가 동일할 것으로 예상sharedBounds()를 사용하면 두 상태 사이를 전환하는 동안 화면에 enter/exit 컨텐츠가 표시. sharedElement()를 사용하면 대상 컨텐츠만 변형 바운드에 렌더링
sharedBounds()를 추천Modifier.sharedBounds() 샘플
@Composable
private fun MainContent(
onShowDetails: () -> Unit,
modifier: Modifier = Modifier,
sharedTransitionScope: SharedTransitionScope,
animatedVisibilityScope: AnimatedVisibilityScope
) {
with(sharedTransitionScope) {
Row(
modifier = Modifier
.padding(8.dp)
.sharedBounds(
rememberSharedContentState(key = "bounds"),
animatedVisibilityScope = animatedVisibilityScope,
enter = fadeIn(),
exit = fadeOut(),
resizeMode = SharedTransitionScope.ResizeMode.ScaleToBounds()
)
// ...
) {
// ...
}
}
}
@Composable
private fun DetailsContent(
modifier: Modifier = Modifier,
onBack: () -> Unit,
sharedTransitionScope: SharedTransitionScope,
animatedVisibilityScope: AnimatedVisibilityScope
) {
with(sharedTransitionScope) {
Column(
modifier = Modifier
.padding(top = 200.dp, start = 16.dp, end = 16.dp)
.sharedBounds(
rememberSharedContentState(key = "bounds"),
animatedVisibilityScope = animatedVisibilityScope,
enter = fadeIn(),
exit = fadeOut(),
resizeMode = SharedTransitionScope.ResizeMode.ScaleToBounds()
)
// ...
) {
// ...
}
}
}
애니메이션 치트 시트

원본 링크 : https://developer.android.com/develop/ui/compose/animation/resources
Subscribe to this blog via RSS.
[발표자료] Google I/O Extended Incheon 2025 ~ What's new in Android development tools
Posted on 16 Aug 2025