관리 메뉴

나만을 위한 블로그

[Android Compose] Shared Element Transition(공유 요소 전환) 구현하는 법 본문

Android/Compose

[Android Compose] Shared Element Transition(공유 요소 전환) 구현하는 법

참깨빵위에참깨빵_ 2024. 12. 3. 21:20
728x90
반응형

※ 공유 요소 전환은 컴포즈 1.7.0부터 사용할 수 있으며 아직 실험 단계라 추후 변경될 수 있음에 주의한다

 

이 포스팅에서 확인할 것은 안드로이드 디벨로퍼를 바탕으로 공유 요소 전환을 컴포즈로 어떻게 구현하는지다. 공유 요소 전환이란 이름은 생소하지만 앱을 쓰다 보면 한 번쯤은 어디서 봤을 법한 것이다.

 

https://developer.android.com/develop/ui/compose/animation/shared-elements?hl=ko

 

연락처 리사이클러뷰에서 아이템을 클릭하면 연락처의 사진이 위로 이동하면서 다음 화면으로 이동한다. 플러터를 해 봤다면 Hero 애니메이션과 유사하게 작동하는 걸 알 것이다.

이것을 컴포즈로 어떻게 구현할까? 이와 관련한 공식문서가 존재한다.

 

https://developer.android.com/develop/ui/compose/animation/shared-elements?hl=ko

 

Compose의 공유된 요소 전환  |  Jetpack Compose  |  Android Developers

이 페이지는 Cloud Translation API를 통해 번역되었습니다. Compose의 공유된 요소 전환 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 공유 요소 전환은 일관된 콘

developer.android.com

공유 요소 전환은 일관된 컨텐츠가 있는 컴포저블 간에 원활하게 전환하는 방법이다. 네비게이션에 자주 쓰이며 유저가 여러 화면 간에 이동할 때 시각적으로 연결할 수 있다. 컴포즈에는 공유 요소를 만드는 데 도움이 되는 몇 가지 상위 수준 API가 있다.

- SharedTransitionLayout : 공유 요소 전환 구현에 필요한 가장 바깥쪽 레이아웃. SharedTransitionScope를 제공한다. 공유 요소 Modifier를 쓰려면 컴포저블이 SharedTransitionScope에 있어야 한다
- Modifier.sharedElement() : 다른 컴포저블과 일치해야 하는 컴포저블을 SharedTransitionScope에 플래그함
- Modifier.sharedBounds() : 이 컴포저블의 경계를 전환이 발생해야 하는 위치의 컨테이너 경계로 사용해야 한다고 SharedTransitionScope에 플래그를 지정하는 Modifier. sharedElement()와 다르게 시각적으로 다른 컨텐츠용으로 설계됐다

컴포즈에서 공유 요소를 만들 때 중요한 개념은 오버레이, 클리핑과 함께 작동하는 방식이다...(중략)...이 섹션에선 작은 리스트 아이템에서 더 큰 세부 아이템으로 전환되는 예시를 빌드한다

컴포저블 간 공유 요소 전환 기본 예시

Modifier.sharedElement()를 사용하는 가장 좋은 방법은 AnimatedContent, AnimatedVisibility 또는 NavHost와 같이 쓰는 것이다. 이렇게 하면 컴포저블 간 전환이 자동 관리된다. 시작점은 공유 요소를 추가하기 전에 MainContent, DetailsContent 컴포저블이 있는 기존 기본 AnimatedContent다

공유 요소 전환 없이 AnimatedContent 시작

1. 공유 요소가 두 레이아웃 간에 애니메이션 처리되게 하려면 AnimatedContent 컴포저블을 SharedTransitionLayout으로 묶는다. SharedTransitionLayout과 AnimatedContent의 범위가 MainContent, DetailContent에 전달된다

 

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에 Modifier.sharedElement()를 추가한다. SharedContentState 객체를 만들고 rememberSharedContentState()로 저장한다. SharedContentState 객체는 공유되는 요소를 결정하는 고유 키를 저장한다. 컨텐츠를 식별할 고유 키를 제공하고 기억할 항목에는 rememberSharedContentState()를 쓴다. AnimatedContentScope는 애니메이션 조정에 쓰이는 Modifier에 전달된다. 공유 요소 일치가 발생했는지 알려면 rememberSharedContentState()를 변수로 추출하고 isMatchFound를 쿼리한다

 

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

 

디벨로퍼에서 깃허브 링크를 달아둬서 확인할 수 있지만 이것도 귀찮으면 아래 코드를 사용하면 된다.

cupcake란 이름의 jpg 파일도 하나 필요한데 이건 적당히 다른 사진을 찾아서 써도 될 듯하다.

 

import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.composepractice.R

val LavenderLight = Color(0xFFDDBEFC)
val RoseLight = Color(0xFFFFAFC9)

@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun MainContent(
    sharedTransitionScope: SharedTransitionScope,
    animatedVisibilityScope: AnimatedVisibilityScope,
    onShowDetails: () -> Unit,
) {
    Row(
        modifier = Modifier
            .padding(8.dp)
            .border(
                width = 1.dp,
                color = Color.Gray.copy(
                    alpha = 0.5f
                ),
                RoundedCornerShape(8.dp)
            )
            .background(LavenderLight, RoundedCornerShape(8.dp))
            .clickable { onShowDetails() }
            .padding(8.dp)
    ) {
        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
            )
            Text(
                "CupCake",
                fontSize = 21.sp,
            )
        }
    }
}

@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun DetailsContent(
    sharedTransitionScope: SharedTransitionScope,
    animatedVisibilityScope: AnimatedVisibilityScope,
    onBack: () -> Unit,
) {
    Column(
        modifier = Modifier
            .padding(
                top = 200.dp,
                start = 16.dp,
                end = 16.dp,
            )
            .border(
                width = 1.dp,
                color = Color.Gray.copy(
                    alpha = 0.5f
                ),
                RoundedCornerShape(8.dp)
            )
            .background(RoseLight, RoundedCornerShape(8.dp))
            .clickable { onBack() }
            .padding(8.dp)
    ) {
        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
            )
            Text("CupCake", fontSize = 28.sp)
            Text(
                "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur sit amet lobortis velit. " +
                        "Lorem ipsum dolor sit amet, consectetur adipiscing elit." +
                        " Curabitur sagittis, lectus posuere imperdiet facilisis, nibh massa " +
                        "molestie est, quis dapibus orci ligula non magna. Pellentesque rhoncus " +
                        "hendrerit massa quis ultricies. Curabitur congue ullamcorper leo, at maximus"
            )
        }
    }
}
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import com.example.composepractice.shared_element_transition_developer.ui.theme.ComposePracticeTheme

class SharedElementBasicUsageActivity : ComponentActivity() {
    @OptIn(ExperimentalSharedTransitionApi::class)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            ComposePracticeTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    Column(
                        modifier = Modifier
                            .fillMaxSize()
                            .padding(innerPadding.calculateTopPadding())
                    ) {
                        var showDetails by remember { mutableStateOf(false) }
                        SharedTransitionLayout {
                            AnimatedContent(
                                showDetails,
                                label = "basic_transition"
                            ) { targetState ->
                                if (!targetState) {
                                    MainContent(
                                        onShowDetails = {
                                            showDetails = true
                                        },
                                        animatedVisibilityScope = this,
                                        sharedTransitionScope = this@SharedTransitionLayout
                                    )
                                } else {
                                    DetailsContent(
                                        onBack = {
                                            showDetails = false
                                        },
                                        animatedVisibilityScope = this,
                                        sharedTransitionScope = this@SharedTransitionLayout
                                    )
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

 

실행하면 아래와 같이 작동한다.

 

 

이 다음 예제는 작은 리스트 아이템에서 큰 리스트 아이템으로 전환되는 예시를 보여주는 내용인데 별다를 건 없어서 생략한다. 다음 포스팅에선 이 코드에서 사용된 요소들이 무엇인지 확인한다.

반응형
Comments