[Android Compose] Shared Element Transition 구현 요소 알아보기
이전 포스팅에서 공유 요소 전환 구현에 사용한 코드에 쓰인 개념들을 확인한다.
먼저 SharedTransitionLayout부터 확인한다.
@ExperimentalSharedTransitionApi
@Composable
fun SharedTransitionLayout(
modifier: Modifier = Modifier,
content: @Composable SharedTransitionScope.() -> Unit
): Unit
컨텐츠의 하위 레이아웃을 위한 레이아웃, SharedTransitionScope를 만든다. SharedTransitionLayout의 모든 자식(직접 또는 간점)은 공유 요소 또는 공유 바운드 트랜지션을 만들기 위해 리시버 스코프인 SharedTransitionScope를 쓸 수 있다
이 레이아웃은 새 레이아웃을 만든다. 컨텐츠, 부모 레이아웃 사이에 새 레이아웃을 도입하지 않는 게 바람직한 사용 사례라면 SharedTransitionScope를 대신 쓰는 걸 고려하라
공유 요소 전환 구현 시 가장 바깥에 위치하는 레이아웃이며 SharedTransitionScope를 제공한다. 그냥 레이아웃만 구현하면 공유 요소 전환 애니메이션이 작동하지 않아서 공유 요소 관련 Modifier를 사용하려면 컴포저블 함수가 이 레이아웃이 제공하는 스코프(SharedTransitionScope) 안에 있어야 한다.
Modifier로는 sharedBounds()와 sharedElement()가 있다. 각각의 특징은 아래와 같다.
- sharedBounds
- 전환이 발생하는 동안 컴포저블의 경계를 공유하게 지정
- 시각적으로 다른 컨텐츠 간 전환에 상요됨
- 컨테이너 변환 패턴 구현 시 유용
- sharedElement
- SharedTransitionScope 안에서 다른 컴포저블과 일치시켜야 하는 컴포저블 지정
- 이를 통해 두 컴포저블 간 전환 애니메이션이 가능해짐
컨테이너 변환 패턴이 뭔지는 아래 링크를 참고한다.
sharedElement()를 사용하려면 컴포저블은 SharedTransitionScope 안에 있어야 하고, AnimatedVisibilityScope 안에 있어야 한다.
컴포저블은 일반적으로 AnimatedVisibilityScope 안에 배치돼야 하는데 AnimatedContent를 써서 컴포저블 간 전환하거나 AnimatedVisibility를 직접 사용할 때 or 공유 요소의 공개 상태를 수동 관리하지 않는 한 NavHost 컴포저블 함수를 써서 제공된다. 여러 스코프를 사용하려면 필요한 스코프를 CompositionLocal에 저장하거나 코틀린의 컨텍스트 리시버, 범위를 함수에 매개변수로 전달하는 3가지 방법 중 하나를 사용한다.
추적해야 하는 스코프가 여럿이거나 계층 구조가 깊게 중첩된 경우 CompositionLocals를 사용한다. 이걸 쓰면 저장하고 사용할 정확한 스코프를 선택할 수 있다. 컨텍스트 리시버를 사용하면 계층 구조 안의 다른 레이아웃이 제공된 스코프를 실수로 재정의할 수 있다.
공유 요소는 AnimatedVisibility에서도 작동한다. 아래 예시에서 각 요소는 AnimatedVisibility로 래핑되며 아이템을 클릭하면 컨텐츠가 UI에서 다이얼로그 같이 끌어당겨지는 시각적 효과를 낸다.
data class Snack(
val name: String,
val description: String,
@DrawableRes val image: Int
)
private val listSnacks = listOf(
Snack("Cupcake", "", R.drawable.cupcake),
Snack("Donut", "", R.drawable.donut),
Snack("Eclair", "", R.drawable.eclair),
Snack("Froyo", "", R.drawable.froyo),
Snack("Gingerbread", "", R.drawable.gingerbread),
Snack("Honeycomb", "", R.drawable.honeycomb),
)
private val shapeForSharedElement = RoundedCornerShape(16.dp)
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun AnimatedVisibilitySharedElementShortenedExample() {
var selectedSnack by remember { mutableStateOf<Snack?>(null) }
SharedTransitionLayout(modifier = Modifier.fillMaxSize()) {
LazyColumn(
// [START_EXCLUDE]
modifier = Modifier
.fillMaxSize()
.background(Color.LightGray.copy(alpha = 0.5f))
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
// [END_EXCLUDE]
) {
items(listSnacks) { snack ->
AnimatedVisibility(
visible = snack != selectedSnack,
enter = fadeIn() + scaleIn(),
exit = fadeOut() + scaleOut(),
modifier = Modifier.animateItem()
) {
Box(
modifier = Modifier
.sharedBounds(
sharedContentState = rememberSharedContentState(key = "${snack.name}-bounds"),
// Using the scope provided by AnimatedVisibility
animatedVisibilityScope = this,
clipInOverlayDuringTransition = OverlayClip(shapeForSharedElement)
)
.background(Color.White, shapeForSharedElement)
.clip(shapeForSharedElement)
) {
SnackContents(
snack = snack,
modifier = Modifier.sharedElement(
state = rememberSharedContentState(key = snack.name),
animatedVisibilityScope = this@AnimatedVisibility
),
onClick = {
selectedSnack = snack
}
)
}
}
}
}
// Contains matching AnimatedContent with sharedBounds modifiers.
SnackEditDetails(
snack = selectedSnack,
onConfirmClick = {
selectedSnack = null
}
)
}
}
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun SharedTransitionScope.SnackEditDetails(
snack: Snack?,
modifier: Modifier = Modifier,
onConfirmClick: () -> Unit
) {
AnimatedContent(
modifier = modifier,
targetState = snack,
transitionSpec = {
fadeIn() togetherWith fadeOut()
},
label = "SnackEditDetails"
) { targetSnack ->
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
if (targetSnack != null) {
Box(
modifier = Modifier
.fillMaxSize()
.clickable {
onConfirmClick()
}
.background(Color.Black.copy(alpha = 0.5f))
)
Column(
modifier = Modifier
.padding(horizontal = 16.dp)
.sharedBounds(
sharedContentState = rememberSharedContentState(key = "${targetSnack.name}-bounds"),
animatedVisibilityScope = this@AnimatedContent,
clipInOverlayDuringTransition = OverlayClip(shapeForSharedElement)
)
.background(Color.White, shapeForSharedElement)
.clip(shapeForSharedElement)
) {
SnackContents(
snack = targetSnack,
modifier = Modifier.sharedElement(
state = rememberSharedContentState(key = targetSnack.name),
animatedVisibilityScope = this@AnimatedContent,
),
onClick = {
onConfirmClick()
}
)
Row(
Modifier
.fillMaxWidth()
.padding(bottom = 8.dp, end = 8.dp),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = { onConfirmClick() }) {
Text(text = "Save changes")
}
}
}
}
}
}
}
@Composable
fun SnackContents(
snack: Snack,
modifier: Modifier = Modifier,
onClick: () -> Unit
) {
Column(
modifier = modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
onClick()
}
) {
Image(
painter = painterResource(id = snack.image),
modifier = Modifier
.fillMaxWidth()
.aspectRatio(20f / 9f),
contentScale = ContentScale.Crop,
contentDescription = null
)
Text(
text = snack.name,
modifier = Modifier
.wrapContentWidth()
.padding(8.dp),
style = MaterialTheme.typography.titleSmall
)
}
}
class SharedElementBasicUsageActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ComposePracticeTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding.calculateTopPadding())
) {
AnimatedVisibilitySharedElementShortenedExample()
}
}
}
}
}
}
sharedElement()와 sharedBounds()는 다른 Modifier처럼 체이닝 순서가 중요하다. 크기에 영향을 주는 Modifier를 잘못 배치하면 공유 요소 일치 중 예상 못한 시각적 점프가 발생할 수 있다.
- 공유 요소 Modifier 앞에 쓰인 Modifier는 공유 요소 Modifier에 제약 조건을 제공하고, 이 조건은 초기 및 타겟 경계와 이후 경계 애니메이션을 파생하는 데 쓰인다
- 공유 요소 Modifier를 사용한 후 쓰이는 Modifier는 이전 제약 조건을 써서 하위 요소의 타겟 크기를 측정하고 계산한다. 공유 요소 Modifier는 일련의 애니메이션 제약 조건을 만들어 하위 요소를 초기 크기에서 대상 크기로 점진적 변환한다
단, 애니메이션에 resizeMode = ScaleToBounds()를 쓰거나 컴포저블에 Modifier.skipToLookaheadSize()를 쓰는 경우는 예외다. 이 때 컴포즈는 대상 제약 조건을 써서 하위 요소를 배치하고 레이아웃 크기 자체를 바꾸는 대신 배율을 써서 애니메이션을 실행한다.
일치하는 아이템의 Modifier 순서와 일관된 순서를 유지하자. requiredSize()를 쓰는 경우를 제외하고 크기 Modifier를 공유 요소 Modifier 뒤에 배치한다. 공유 요소 Modifier 뒤에 requiredSize()를 쓰면 ScaleToBounds()를 쓰더라도 변환 중 하위 요소가 다시 레이아웃되지 않는다. 그러나 requiredSize()가 공유 요소 Modifier 앞에 있으면 requiredSize()의 상위 요소는 공유 요소 animatedSize를 관찰할 수 없다.
공유 요소가 복잡할 때는 문자열 일치에 오류가 발생할 수 있어서 문자열이 아닌 key를 만드는 게 좋다. 일치가 발생하려면 각 key는 고유해야 한다. Jetsnack엔 아래 같은 공유 요소가 있다.
공유 요소의 타입을 나타내는 enum을 만들 수도 있다.
data class SnackSharedElementKey(
val snackId: Long,
val origin: String,
val type: SnackSharedElementType
)
enum class SnackSharedElementType {
Bounds,
Image,
Title,
Tagline,
Background
}
@Composable
fun SharedElementUniqueKey() {
// ...
Box(
modifier = Modifier
.sharedElement(
rememberSharedContentState(
key = SnackSharedElementKey(
snackId = 1,
origin = "latest",
type = SnackSharedElementType.Image
)
),
animatedVisibilityScope = this@AnimatedVisibility
)
)
// ...
}
또한 이전 포스팅에서 말했듯 공유 요소 전환은 컴포즈 1.7.0 이상에서만 가능하고 아직 실험 단계기 때문에 제한사항이 있다.
- 뷰와 컴포즈 간 상호 운용성은 지원되지 않는다. 여기엔 다이얼로그 같이 AndroidView를 래핑하는 모든 컴포저블이 포함된다
- 공유 이미지 컴포저블, 도형 클리핑 등 자동 애니메이션이 지원되지 않는 경우가 있다.
참고한 사이트)
https://developer.android.com/develop/ui/compose/animation/shared-elements?hl=ko