Compose의 공유된 요소 전환

공유 요소 전환은 일관된 콘텐츠가 있는 컴포저블 간에 원활하게 전환하는 방법입니다. 탐색에 자주 사용되며 사용자가 여러 화면 간에 이동할 때 시각적으로 연결할 수 있습니다.

예를 들어 다음 동영상에서는 스낵의 이미지와 제목이 등록정보 페이지에서 세부정보 페이지로 공유되는 것을 볼 수 있습니다.

그림 1. Jetsnack 공유 요소 데모

Compose에는 공유 요소를 만드는 데 도움이 되는 몇 가지 상위 수준 API가 있습니다.

  • SharedTransitionLayout: 공유 요소 전환을 구현하는 데 필요한 가장 바깥쪽 레이아웃입니다. SharedTransitionScope를 제공합니다. 공유 요소 수정자를 사용하려면 컴포저블이 SharedTransitionScope에 있어야 합니다.
  • Modifier.sharedElement(): 다른 컴포저블과 일치해야 하는 컴포저블을 SharedTransitionScope에 플래그하는 수정자입니다.
  • Modifier.sharedBounds(): 이 컴포저블의 경계를 전환이 발생해야 하는 위치의 컨테이너 경계로 사용해야 한다고 SharedTransitionScope에 플래그를 지정하는 수정자입니다. sharedElement()와 달리 sharedBounds()는 시각적으로 다른 콘텐츠용으로 설계되었습니다.

Compose에서 공유 요소를 만들 때 중요한 개념은 오버레이 및 클리핑과 함께 작동하는 방식입니다. 이 중요한 주제에 관해 자세히 알아보려면 자르기 및 오버레이 섹션을 참고하세요.

기본 사용법

이 섹션에서는 작은 '목록' 항목에서 더 큰 세부 항목으로 전환되는 다음 전환을 빌드합니다.

그림 2. 두 컴포저블 간의 공유 요소 전환에 관한 기본 예시입니다.

Modifier.sharedElement()를 사용하는 가장 좋은 방법은 AnimatedContent, AnimatedVisibility 또는 NavHost와 함께 사용하는 것입니다. 이렇게 하면 컴포저블 간의 전환이 자동으로 관리됩니다.

시작점은 공유 요소를 추가하기 전에 MainContentDetailsContent 컴포저블이 있는 기존 기본 AnimatedContent입니다.

그림 3. 공유 요소 전환 없이 AnimatedContent를 시작합니다.

  1. 공유 요소가 두 레이아웃 간에 애니메이션 처리되도록 하려면 AnimatedContent 컴포저블을 SharedTransitionLayout로 묶습니다. SharedTransitionLayoutAnimatedContent의 범위가 MainContentDetailsContent에 전달됩니다.

    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
                )
            }
        }
    }

  2. 일치하는 두 컴포저블의 컴포저블 수정자 체인에 Modifier.sharedElement()를 추가합니다. SharedContentState 객체를 만들고 rememberSharedContentState()로 기억합니다. SharedContentState 객체는 공유되는 요소를 결정하는 고유 키를 저장합니다. 콘텐츠를 식별할 고유한 키를 제공하고 기억할 항목에는 rememberSharedContentState()를 사용합니다. AnimatedContentScope는 애니메이션을 조정하는 데 사용되는 수정자에 전달됩니다.

    @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
                )
                // ...
            }
        }
    }

공유된 요소 일치가 발생했는지에 관한 정보를 가져오려면 rememberSharedContentState()를 변수로 추출하고 isMatchFound를 쿼리합니다.

그러면 다음과 같은 자동 애니메이션이 실행됩니다.

그림 4. 두 컴포저블 간의 공유 요소 전환에 관한 기본 예시입니다.

전체 컨테이너의 배경 색상과 크기가 여전히 기본 AnimatedContent 설정을 사용하는 것을 볼 수 있습니다.

공유 경계와 공유 요소 비교

Modifier.sharedBounds()Modifier.sharedElement()와 유사합니다. 그러나 수정자는 다음과 같은 차이점이 있습니다.

  • sharedBounds()는 시각적으로 다르지만 상태 간에 동일한 영역을 공유해야 하는 콘텐츠에 사용되며, sharedElement()는 콘텐츠가 동일해야 한다고 예상합니다.
  • sharedBounds()를 사용하면 두 상태 간의 전환 중에 화면에 들어가고 나가는 콘텐츠가 표시되지만 sharedElement()를 사용하면 변환 경계에서 대상 콘텐츠만 렌더링됩니다. Modifier.sharedBounds()에는 AnimatedContent의 작동 방식과 마찬가지로 콘텐츠 전환 방식을 지정하는 enterexit 매개변수가 있습니다.
  • sharedBounds()의 가장 일반적인 사용 사례는 컨테이너 변환 패턴이며 sharedElement()의 사용 사례 예시는 히어로 전환입니다.
  • Text 컴포저블을 사용할 때는 기울임꼴과 굵은 글꼴 간에 전환하거나 색상을 변경하는 등의 글꼴 변경을 지원하려면 sharedBounds()를 사용하는 것이 좋습니다.

이전 예에서 두 가지 시나리오에서 RowColumnModifier.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()
                )
                // ...

        ) {
            // ...
        }
    }
}

그림 5. 두 컴포저블 간의 공유 경계입니다.

범위 이해하기

Modifier.sharedElement()를 사용하려면 컴포저블이 SharedTransitionScope에 있어야 합니다. SharedTransitionLayout 컴포저블은 SharedTransitionScope를 제공합니다. 공유하려는 요소가 포함된 UI 계층 구조의 동일한 최상위 지점에 배치해야 합니다.

일반적으로 컴포저블은 AnimatedVisibilityScope 내에 배치되어야 합니다. 이는 일반적으로 AnimatedContent를 사용하여 컴포저블 간에 전환하거나 AnimatedVisibility를 직접 사용할 때 또는 표시 설정을 수동으로 관리하지 않는 한 NavHost 컴포저블 함수를 사용하여 제공됩니다. 여러 범위를 사용하려면 필요한 범위를 CompositionLocal에 저장하거나 Kotlin의 컨텍스트 수신기를 사용하거나 범위를 함수에 매개변수로 전달합니다.

추적할 스코프가 여러 개 있거나 계층 구조가 깊게 중첩된 시나리오에서는 CompositionLocals를 사용하세요. CompositionLocal를 사용하면 저장하고 사용할 정확한 범위를 선택할 수 있습니다. 반면에 컨텍스트 수신기를 사용하면 계층 구조의 다른 레이아웃이 제공된 범위를 실수로 재정의할 수 있습니다. 예를 들어 중첩된 AnimatedContent가 여러 개 있는 경우 범위가 재정의될 수 있습니다.

val LocalNavAnimatedVisibilityScope = compositionLocalOf<AnimatedVisibilityScope?> { null }
val LocalSharedTransitionScope = compositionLocalOf<SharedTransitionScope?> { null }

@Composable
private fun SharedElementScope_CompositionLocal() {
    // An example of how to use composition locals to pass around the shared transition scope, far down your UI tree.
    // ...
    SharedTransitionLayout {
        CompositionLocalProvider(
            LocalSharedTransitionScope provides this
        ) {
            // This could also be your top-level NavHost as this provides an AnimatedContentScope
            AnimatedContent(state, label = "Top level AnimatedContent") { targetState ->
                CompositionLocalProvider(LocalNavAnimatedVisibilityScope provides this) {
                    // Now we can access the scopes in any nested composables as follows:
                    val sharedTransitionScope = LocalSharedTransitionScope.current
                        ?: throw IllegalStateException("No SharedElementScope found")
                    val animatedVisibilityScope = LocalNavAnimatedVisibilityScope.current
                        ?: throw IllegalStateException("No AnimatedVisibility found")
                }
                // ...
            }
        }
    }
}

또는 계층 구조가 깊게 중첩되지 않은 경우 범위를 매개변수로 아래로 전달할 수 있습니다.

@Composable
fun MainContent(
    animatedVisibilityScope: AnimatedVisibilityScope,
    sharedTransitionScope: SharedTransitionScope
) {
}

@Composable
fun Details(
    animatedVisibilityScope: AnimatedVisibilityScope,
    sharedTransitionScope: SharedTransitionScope
) {
}

AnimatedVisibility와 공유된 요소

이전 예에서는 AnimatedContent와 함께 공유 요소를 사용하는 방법을 보여줬지만 공유 요소는 AnimatedVisibility에서도 작동합니다.

예를 들어 이 지연 격자 예시에서 각 요소는 AnimatedVisibility로 래핑됩니다. 항목을 클릭하면 콘텐츠가 UI에서 대화상자와 같은 구성요소로 끌어당겨지는 시각적 효과가 있습니다.

var selectedSnack by remember { mutableStateOf<Snack?>(null) }

SharedTransitionLayout(modifier = Modifier.fillMaxSize()) {
    LazyColumn(
        // ...
    ) {
        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
        }
    )
}

그림 6.AnimatedVisibility를 사용한 공유 요소

수정자 순서

Modifier.sharedElement()Modifier.sharedBounds()의 경우 나머지 Compose와 마찬가지로 수정자 순서 체이닝이 중요합니다. 크기에 영향을 미치는 수정자를 잘못 배치하면 공유 요소 일치 중에 예기치 않은 시각적 점프가 발생할 수 있습니다.

예를 들어 공유된 두 요소에 패딩 수정자를 다른 위치에 배치하면 애니메이션에 시각적 차이가 발생합니다.

var selectFirst by remember { mutableStateOf(true) }
val key = remember { Any() }
SharedTransitionLayout(
    Modifier
        .fillMaxSize()
        .padding(10.dp)
        .clickable {
            selectFirst = !selectFirst
        }
) {
    AnimatedContent(targetState = selectFirst, label = "AnimatedContent") { targetState ->
        if (targetState) {
            Box(
                Modifier
                    .padding(12.dp)
                    .sharedBounds(
                        rememberSharedContentState(key = key),
                        animatedVisibilityScope = this@AnimatedContent
                    )
                    .border(2.dp, Color.Red)
            ) {
                Text(
                    "Hello",
                    fontSize = 20.sp
                )
            }
        } else {
            Box(
                Modifier
                    .offset(180.dp, 180.dp)
                    .sharedBounds(
                        rememberSharedContentState(
                            key = key,
                        ),
                        animatedVisibilityScope = this@AnimatedContent
                    )
                    .border(2.dp, Color.Red)
                    // This padding is placed after sharedBounds, but it doesn't match the
                    // other shared elements modifier order, resulting in visual jumps
                    .padding(12.dp)

            ) {
                Text(
                    "Hello",
                    fontSize = 36.sp
                )
            }
        }
    }
}

일치하는 경계

일치하지 않는 경계: 공유 요소 애니메이션이 잘못된 경계로 크기를 조절해야 하므로 약간 어색하게 표시되는 것을 볼 수 있습니다.

공유 요소 수정자 앞에 사용된 수정자는 공유 요소 수정자에 제약 조건을 제공하며, 이 제약 조건은 초기 및 타겟 경계와 이후 경계 애니메이션을 파생하는 데 사용됩니다.

공유 요소 수정자를 사용한 후 사용되는 수정자는 이전의 제약 조건을 사용하여 하위 요소의 타겟 크기를 측정하고 계산합니다. 공유 요소 수정자는 일련의 애니메이션 제약 조건을 만들어 하위 요소를 초기 크기에서 대상 크기로 점진적으로 변환합니다.

애니메이션에 resizeMode = ScaleToBounds()를 사용하거나 컴포저블에 Modifier.skipToLookaheadSize()를 사용하는 경우는 예외입니다. 이 경우 Compose는 대상 제약 조건을 사용하여 하위 요소를 배치하고 레이아웃 크기 자체를 변경하는 대신 배율을 사용하여 애니메이션을 실행합니다.

고유 키

복잡한 공유 요소를 사용할 때는 문자열이 일치하는 데 오류가 발생할 수 있으므로 문자열이 아닌 키를 만드는 것이 좋습니다. 일치가 발생하려면 각 키가 고유해야 합니다. 예를 들어 Jetsnack에는 다음과 같은 공유 요소가 있습니다.

그림 7. UI의 각 부분에 주석이 달린 Jetsnack을 보여주는 이미지

공유 요소 유형을 나타내는 enum을 만들 수 있습니다. 이 예시에서는 전체 스낵 카드가 홈 화면의 여러 위치(예: '인기' 및 '추천' 섹션)에 표시될 수도 있습니다. 공유할 공유 요소의 snackId, origin ('인기' / '추천'), type가 있는 키를 만들 수 있습니다.

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
                    )
            )
            // ...
}

데이터 클래스는 hashCode()isEquals()를 구현하므로 키에 사용하는 것이 좋습니다.

공유 요소의 공개 상태를 수동으로 관리

AnimatedVisibility 또는 AnimatedContent를 사용하지 않는 경우 공유된 요소 공개 상태를 직접 관리할 수 있습니다. Modifier.sharedElementWithCallerManagedVisibility()를 사용하고 항목이 표시되는 시점을 결정하는 자체 조건문을 제공합니다.

var selectFirst by remember { mutableStateOf(true) }
val key = remember { Any() }
SharedTransitionLayout(
    Modifier
        .fillMaxSize()
        .padding(10.dp)
        .clickable {
            selectFirst = !selectFirst
        }
) {
    Box(
        Modifier
            .sharedElementWithCallerManagedVisibility(
                rememberSharedContentState(key = key),
                !selectFirst
            )
            .background(Color.Red)
            .size(100.dp)
    ) {
        Text(if (!selectFirst) "false" else "true", color = Color.White)
    }
    Box(
        Modifier
            .offset(180.dp, 180.dp)
            .sharedElementWithCallerManagedVisibility(
                rememberSharedContentState(
                    key = key,
                ),
                selectFirst
            )
            .alpha(0.5f)
            .background(Color.Blue)
            .size(180.dp)
    ) {
        Text(if (selectFirst) "false" else "true", color = Color.White)
    }
}

현재 제한사항

이러한 API에는 몇 가지 제한사항이 있습니다. 특히 다음 사항에 주의해야 합니다.

  • 뷰와 Compose 간의 상호 운용성은 지원되지 않습니다. 여기에는 Dialog와 같이 AndroidView를 래핑하는 모든 컴포저블이 포함됩니다.
  • 다음에는 자동 애니메이션 지원이 없습니다.
    • 공유 이미지 컴포저블:
      • ContentScale는 기본적으로 애니메이션되지 않습니다. 설정된 끝 ContentScale에 맞춰 고정됩니다.
    • 도형 클리핑 - 도형 간의 자동 애니메이션을 위한 기본 제공 지원이 없습니다. 예를 들어 항목이 전환될 때 정사각형에서 원형으로 애니메이션을 적용하는 경우입니다.
    • 지원되지 않는 케이스의 경우 sharedElement() 대신 Modifier.sharedBounds()를 사용하고 항목에 Modifier.animateEnterExit()를 추가합니다.