오늘은 MotionLayout을 사용하여 애니메이션을 구현해보려구 합니다.
Jetpack compose를 사용하면서 하나의 animation을 구현하려고 할 때, 변경하는 요소마다 animate value를 적용해야하는게 너무 힘들었던 경험이 있습니다. ( 쌓여가는 animateState들...)
애니메이션을 한번에 처리하기 좋은 MotionLayout을 사용하여 애니메이션을 처리하는 방법을 알아보려구 합니다.
자꾸자꾸 MotionLayout이라고 하는데 무엇이냐
MotionLayout은 ConstraintLayout의 서브 클래스이며 ConstraintLayout의 다양한 레이아웃 기능을 기초로 가지고 있다. MotionLayout은 레이아웃 전환과 복잡한 모션 처리 사이를 연결한다.
라고 Android Developer에 적혀있습니다
한마디로 ConstraintLayout이고 애니메이션을 위해 만들어졌구나~라고 이해하면 됩니다.
근데 Compose에서는 ConstraintLayout의 이점이 없지 않나?
Compose는 깊은 레이아웃 계층 구조는 효율적으로 처리하기 때문에, ConstraintLayout이 계층 구조를 평평하게 만드는 이점이 Compose를 사용하면 굳이 사용하지 않아도 되다고 하더군요.
공식 문서에 따르면 Constraintlayout은 다음 시나리오에서 사용을 고려하라고 합니다.
- 코드의 가독성을 향상시키기 위해(여러 Row와 Column을 중첩하지 않도록 한다)
- 다른 컴포저블을 기준으로 컴포저블을 배치하거나, 가이드라인 , 배리어 등등을 사용하는 경우
SubClass의 Motion Layout을 사용하면 다른 컴포저블을 기준으로 컴포저블을 배치하는 시나리오에 더해서 애니메이션을 쉽게 구현할 수 있다는 점이 ConstraintLayout을 사용하는 하나의 시나리오가 되지 않을까 싶습니다.
MotionLayout사용하기
MotionLayout을 사용하기 위해서는 ConstraintLayout 종속성을 추가해줘야합니다. 저는 Compose에서 사용할것이기 때문에 다음과 같이 추가했습니다.
implementation "androidx.constraintlayout:constraintlayout-compose:1.0.1" // stable
implementation "androidx.constraintlayout:constraintlayout-compose:1.1.0-alpha09" // onSwipe가능
1.1.0부터 json에서 onSwipe를 JSON에서 지정할 수 있어서 저는1.1.0버전을 사용해보도록하겠습니다.
MotionLayout으로 어떤 것을 만들어볼까?
위의 동작을 MotionLayout으로 만들어보겠습니다.
컴포저블로 필요한 요소는 다음으로 지정했습니다.
각각 후다닥 만들어보자.
Composable들~
@Composable
fun CircleImage(
modifier: Modifier = Modifier,
) {
Image(
modifier = modifier.clip(CircleShape),
painter = painterResource(R.drawable.image),
contentDescription = null,
)
}
@Composable
fun ColorBackground(
modifier: Modifier = Modifier,
) {
val brush = listOf(
Color.Blue.copy(alpha = 0.3f),
Color.Blue,
)
Canvas(modifier = modifier, onDraw = {
drawRect(
brush = Brush.horizontalGradient(brush),
)
})
}
@Composable
fun SnackText(
lazyListState: LazyListState,
scrollEnable: Boolean,
modifier: Modifier = Modifier,
) {
LazyColumn(
state = lazyListState,
modifier = modifier,
userScrollEnabled = scrollEnable,
) {
item {
Text(
text = text,
fontSize = 20.sp,
)
}
}
}
val text = "Lorem ipsum ~~~ 실제로는 길어요"
@Composable
fun Title(
modifier: Modifier = Modifier,
text: String,
) {
Text(
modifier = modifier,
text = text,
fontSize = 50.sp,
fontWeight = FontWeight.Bold,
color = Color.Black,
)
}
@Composable
fun Price(
modifier: Modifier = Modifier,
price: String,
) {
Text(
modifier = modifier,
text = price,
fontSize = 25.sp,
fontWeight = FontWeight.SemiBold,
color = Color.Blue,
)
}
각각의 Modifier를 열어둔 이유는 MotionLayout 안에서 id를 지정해야하기 때문입니다. 각각 컴포저블들을 만들고서, 이제 MotionLayout을 사용하려고합니다.
MotionLayout Composable
@OptIn(ExperimentalMotionApi::class)
@Composable
fun SnackDetail(
modifier: Modifier = Modifier,
) {
MotionLayout(
motionScene = , progress = ) { // MotionLayoutScope
}
}
매개변수로 motionScene와 progress를 받는군요.
각각 무엇을 의미하는 걸까요?
motionScene
@Immutable
interface MotionScene {
fun setConstraintSetContent(name: String, content: String)
fun setTransitionContent(name: String, content: String)
fun getConstraintSet(name: String): String?
fun getConstraintSet(index: Int): String?
fun getTransition(name: String): String?
fun setUpdateFlag(needsUpdate: MutableState<Long>)
fun setDebugName(name: String?)
fun getForcedProgress(): Float
fun resetForcedProgress()
fun getForcedDrawDebug(): MotionLayoutDebugFlags
}
@SuppressLint("ComposableNaming")
@Composable
fun MotionScene(@Language("json5") content: String): MotionScene {
return remember(content) {
JSONMotionScene(content)
}
}
internal class JSONMotionScene(@Language("json5") content: String)
: EditableJSONLayout(content), MotionScene {
/.../
}
MotionScene는 여러개의 ConstraintSets 사이에서 애니메이션을 적용하기위한 MotionLayout들의 정보입니다.
이 MotionScene는 JsonMotionScene가 구현하고 있고, 우리는 Json파일을 String으로 변환하여 MotionScene를 생성할 수 있습니다.
progress?
MotionLayout은 처음 상태와 애니메이션 이후의 상태를 ConstraintSet으로 정의해서 start -> end의 과정을 애니메이션으로 표현해줍니다.
이 progress는 end상태까지의 진행도라고 생각하시면 됩니다.
0일 경우 초기상태 , 1f 일 경우 end상태입니다.
이 progress는
var animateSwitch by remember { mutableStateOf(false) }
val progress by animateFloatAsState(
targetValue = if (animateSwitch) 1f else 0f,
animationSpec = tween(),
)
위의 코드처럼 애니메이션을 진행할 수 있도록 해주는 코드로도 사용할 수 있지만~
이번에 저희는 사용하지 않을 것입니다.
위의 두 정보만 알아도 애니메이션을 구현할 수 있으니, 이제 MotionScene의 정보를 담은 Json을 생성해보겟습니다.
Constraint을 담아두는 json은 res 폴더안에 raw파일안에 생성해줍시다.
{
"ConstraintSets": {
"start":{
},
"end":{
}
}
}
기본적으로 ConstraintSets는 위와 같이 구성됩니다.
start에서는 초기의 Composable들의 위치를 잡아주면 됩니다.
처음 배경화면 부터 위치를 잡아보겠습니다.
constraint의 사용법처럼 top, start, bottom, end의 범위를 잡아주시면 되고, width나 height 도 지정할 수 있습니다.
{
ConstraintSets: {
start: {
colorBackground : {
width : 'spread',
height : 200,
start: ['parent', 'start', 0],
top: ['parent', 'top', 0],
end: ['parent', 'end', 0],
},
/.../
MotionLayout의 가장 큰 장점은 초기 제약사항과 애니메이션이 끝났을 때에 우리가 기대하는 제약사항을 지정하면, 그사이는 Motion Layout이 애니메이션으로 처리해준다는 점입니다.
초기 Colorbackground
Colorbackground는 초기 start, top, end 가 각각 parent.start, parent.top, parent.end에 제약을 걸었습니다.
또한 width가 최대이므로 "spread"라고 적어줍시다. 용어에 대한 설명이 나와있는 페이지는 하단에 남겨두겠습니다.
height은 대략 200에서 300으로 잡겠습니다.
애니메이션이 끝난 후 Colorbackground
{
ConstraintSets: {
start: {
colorBackground : {
width : 'spread',
height : 200,
start: ['parent', 'start', 0],
top: ['parent', 'top', 0],
end: ['parent', 'end', 0],
},
end: {
colorBackground: {
width: 'spread',
height: 50,
start : ['parent', 'start', 0],
top: ['parent', 'top', 0],
end : ['parent', 'end', 0],
},
}
애니메이션이 끝났을 경우는 end의 colorBackground과 같은 제약이 있을 것이라고 예상합니다.
나머지 컴포저블들의 대한 제약은 생략할테니 모두 작성해주세용.
MotionSence를 생성하자
val context = LocalContext.current
val motionString = remember {
context.resources.openRawResource(R.raw.snack)
.readBytes()
.decodeToString()
}
val motionSence = MotionScene(content = motionScene)
이렇게 MotionScene에 대한 정보를 가진 json을 string으로 변환하여 motionScence로 만들 수 있습니다.
이어서 모든 컴포저블에 modifier.layoutId("제약을 지정한 Id")를 설정해주면서, 제약정보가 적용될 수 있도록 합니다.
@OptIn(ExperimentalMotionApi::class)
@Composable
fun SnackDetail(
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val motionScene = remember {
context.resources.openRawResource(R.raw.snack)
.readBytes()
.decodeToString()
}
MotionLayout(
modifier = modifier,
motionScene = MotionScene(content = motionScene),
) {
ColorBackground(modifier = Modifier.layoutId("colorBackground"))
CircleImage(modifier = Modifier.layoutId("circleImage"))
Title(modifier = Modifier.layoutId("title"), text = "Chips")
Price(modifier = Modifier.layoutId("price"), price = "$12.55")
SnackText(modifier = Modifier.layoutId("text"))
}
}
하지만 이대로 했을때 애니메이션이 적용이 될까요??
뭐야 애니메이션이 안되지나
네 맞습니다. 우리는 이제 애니메이션을 트리거할 Transition에 대해서 정의를 해줘야합니다.
{
ConstraintSets: {
start: {
/.../
},
end: {
/.../
}
},
Transitions : {
default: {
from : 'start',
to : 'end',
onSwipe : {
anchor : 'background',
direction : 'up',
side : 'bottom'
},
}
}
}
Transitions은 모든 transition 동작들에 대한 컨테이너입니다. 각각의 transition들은 이름이 지정될 수 있고, 'default'라는 이름은 초기 트렌지션을 정의하는 특별한 이름인가봅니다.
우리는 transition이 초기 (start ) -> 애니메이션이 끝나고 기대하는 모습( end )로 지워지길 바라기 때문에 from : 'start', top :'end'로 지정합니다. onSwipe는 transition을 드래그를 통해서 컨트롤할 수 있도록 해줍니다.
OnSwipe
다시 말씀드리자면, constraintlayout-compose버전의 1.1.0-alpha01버전부터 사용이 가능합니다. anchor는 onSwipe의 동작을 감지할 composable을 의미합니다.
side는 컴포즈블의 드래그를 어디서 감지할 지의 방향입니다. 우리는 하단부터 드래그하는 것을 감지하고 싶어하기 때문에, bottom으로 설정합니다. direction은 그 하단으로부터 어느 방향으로 드래그되는지에 대한 방향입니다.
우리는 아래에서 위로 드래그하는 것을 감지하고 싶어하기 때문에, bottom side에서 direction up으로 설정합니다.
뭔가 이상하다. background라고 id는 없는데...
우리가 작성한 json을 살펴보면 background id를 가지지않습니다. 이 background는 드래그 재스쳐를 전달해줄 화면에 꽉찬 빈 레이아웃컴포저블로 지정할 것입니다.
우리는 화면 전체에서 드래그 이벤트를 발생할 것이기 때문입니다. 그러면 우리는 이 컴포저블을 드래그하게 되면, Swipe이벤트가 발생하여 start -> end로 모든 제약들이 적용되게 되는 것입니다.
MotionLayout(
modifier = modifier,
motionScene = MotionScene(content = motionScene),
) {
ColorBackground(modifier = Modifier.layoutId("colorBackground"))
CircleImage(modifier = Modifier.layoutId("circleImage"))
Box(modifier = Modifier.fillMaxSize().layoutId("background")) //여기에~
Title(modifier = Modifier.layoutId("title"), text = "Chips")
Price(modifier = Modifier.layoutId("price"), price = "$12.55")
SnackText(modifier = Modifier.layoutId("text"))
}
이로써 우리는 애니메이션 적용을 끝마췄습니다. Constraint를 잘 사용하기만 한다면, 쉽게 애니메이션을 작성할 수 있는 장점이 있었습니다!!
ConstraintSets보기 ~
```json
{
ConstraintSets: {
start: {
background: {
width: "spread",
height: "spread",
top: ['parent', 'top', 0],
start : ['parent', 'start', 0],
end : ['parent', 'end', 0],
bottom : ['parent','bottom',0]
},
},
end: {
background: {
width: 'spread',
height: 'spread',
start : ['parent', 'start', 0],
top: ['parent', 'top', 0],
end : ['parent', 'end', 0],{
ConstraintSets: {
start: {
background: {
width: "spread",
height: "spread",
top: ['parent', 'top', 0],
start : ['parent', 'start', 0],
end : ['parent', 'end', 0],
bottom : ['parent','bottom',0]
},
colorBackground : {
width : 'spread',
height : 200,
start: ['parent', 'start', 0],
top: ['parent', 'top', 0],
end: ['parent', 'end', 0],
},
circleImage : {
width: 'spread',
height : 'wrap',
start: ['parent', 'start', 20],
end : ['parent', 'end',20],
top : ['colorBackground', 'top', 20],
},
title : {
width: 'wrap',
height: 'wrap',
start: ['parent', 'start', 20],
top: ['circleImage', 'bottom',10],
},
price : {
width : 'wrap',
height : 'wrap',
start: ['parent', 'start', 20],
top: ['title', 'bottom', 20],
},
text : {
width : 'spread',
height : 'spread',
top: ['price', 'bottom', 20],
start: ['parent', 'start', 20],
end: ['parent', 'end', 20],
bottom: ['parent', 'bottom', 0],
custom : {
scrollable : 0
}
},
},
end: {
background: {
width: 'spread',
height: 'spread',
start : ['parent', 'start', 0],
top: ['parent', 'top', 0],
end : ['parent', 'end', 0],
},
colorBackground : {
width : 'spread',
height : 50,
start: ['parent', 'start', 0],
top: ['parent', 'top', 0],
end: ['parent', 'end', 0],
},
circleImage : {
width: 100,
height : 100,
top : ['colorBackground','top', 10],
end : ['colorBackground', 'end',20]
},
title : {
width: 'wrap',
height: 'wrap',
start: ['parent', 'start', 20],
top: ['colorBackground', 'bottom',10],
},
price : {
width : 'wrap',
height : 'wrap',
start: ['parent', 'start', 20],
top: ['title', 'bottom', 20],
},
text : {
width : 'spread',
height : 'spread',
top: ['price', 'bottom', 20],
start: ['parent', 'start', 20],
end: ['parent', 'end', 20],
bottom: ['parent', 'bottom', 0],
custom : {
scrollable : 200
}
},
}
},
Transitions : {
default: {
from : 'start',
to : 'end',
onSwipe : {
anchor : 'background',
direction : 'up',
side : 'bottom'
},
mode : 'spring',
}
}
}
},
},
Transitions : {
default: {
from : 'start',
to : 'end',
onSwipe : {
anchor : 'background',
direction : 'up',
side : 'bottom'
}
}
}
}
완성품~
나중에 알았던 점
글의 맨 초기 gif처럼 구현을 하기 위해서 SnackText를 LazyColumn으로 생성했으나, LazyColumn의 위치에서 드래그를 진행했을 경우, 드래그 이벤트를 LazyColumn이 가져가게 됩니다. ( onSwipe를 호출할 background Box Composable이 아니라 )
그렇게 때문에 MotionLayout과 Scroll를 같이 쓰기 위해서는 이러한 이벤트를 별도로 처리해줄 필요가 있습니다.
저는.. 아직초보기에 다음에 도전해보려고합니다.~
MotionLayout에 대해 더 알 수 있는 출처~
explore-compose-motionlayout
Compose-MotionLayout-JSON-Syntax
Compose-MotionLayout-JSON5-Syntax
Androidx-Constraintlayout
'Compose' 카테고리의 다른 글
Compose UI Test 맛보기 (0) | 2023.06.14 |
---|---|
Jetpack Compose Theme 적용하기 (0) | 2023.06.07 |
rememberUpdatedState (0) | 2023.02.15 |
SwipeToDismiss Jetpack Compose로 구현하기 (0) | 2023.02.02 |
Jetpack Compose GapBuffer (1) | 2022.10.27 |