https://developer.android.com/develop/ui/compose/animation/introduction
AnimatedVectorDrawable
Lottie
rememberInfiniteTransition
composable()
(enterTransition
및 exitTransition
)이 설정AnimatedContent
, Crossfade
또는Pager
AnimatedVisibility
또는animateFloatAsState
(Modifier.alpha()
)Modifier.animateContentSize
animateItemPlacement()
(재주문 및 삭제 예정)animate*AsState
, 텍스트의 경우 TextMotion.Animated
사용updateTransition
(AnimatedVisibility
, animateFloat
, animateInt
등 사용)animateTo
가 포함된 Animatable가 다른 타이밍으로 호출되었습니다 (suspend 함수 사용).animate*AsState
, 텍스트의 경우 TextMotion.Animated
사용Animatable
animateTo
/snapTo
AnimationState
또는animate
AnimatedVisibility를 사용하여 숨기거나 표시.
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))
}
AnimatedContent
AnimatedContent 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.VectorConverter
Dp.VectorConverter
Offset.VectorConverter
Int.VectorConverter
Float.VectorConverter
IntSize.VectorConverter
data 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
comments powered by Disqus
Subscribe to this blog via RSS.
LazyColumn/Row에서 동일한 Key를 사용하면 크래시가 발생하는 이유
Posted on 30 Nov 2024