Oluşturma işleminde Paylaşılan Öğe Geçişleri

Paylaşılan öğe geçişleri, aralarında tutarlı içerik bulunan bileşenler arasında sorunsuz bir şekilde geçiş yapmanızı sağlar. Bağlantı noktaları genellikle gezinme için kullanılır. Kullanıcılar arasında gezinirken farklı ekranları görsel olarak bağlamanıza olanak tanır.

Örneğin, aşağıdaki videoda atıştırmalığın resminin ve başlığının listeleme sayfasından ayrıntılar sayfasına paylaşıldığını görebilirsiniz.

Şekil 1. Jetsnack paylaşılan öğe demosu

Oluşturma'da, paylaşılan öğeler oluşturmanıza yardımcı olacak birkaç üst düzey API vardır:

  • SharedTransitionLayout: Ortak öğe geçişlerini uygulamak için gereken en dış düzen. SharedTransitionScope sağlar. Ortak öğe değiştiricileri kullanmak için bileşenlerin SharedTransitionScope içinde olması gerekir.
  • Modifier.sharedElement(): Başka bir derlenebilir öğeyle eşleştirilmesi gereken derlenebilir öğeyi SharedTransitionScopeişaretleyen değiştirici.
  • Modifier.sharedBounds(): SharedTransitionScopeBu derlenebilir öğenin sınırlarının, geçişin gerçekleşeceği kapsayıcı sınırları olarak kullanılması gerektiğini SharedTransitionScopeişaretleyen değiştirici. sharedElement()'ün aksine sharedBounds(), görsel açıdan farklı içerikler için tasarlanmıştır.

Oluşturma bölümünde paylaşılan öğeler oluştururken bunların yer paylaşımları ve kırpma ile nasıl çalıştığı önemli bir kavramdır. Bu önemli konu hakkında daha fazla bilgi edinmek için kırpma ve yer paylaşımları bölümüne göz atın.

Temel Kullanım

Bu bölümde, küçük "liste" öğesinden daha büyük ayrıntılı öğeye geçiş yapan aşağıdaki geçiş oluşturulur:

Şekil 2. İki bileşen arasında ortak öğe geçişinin temel örneği.

Modifier.sharedElement()'ü kullanmanın en iyi yolu, AnimatedContent, AnimatedVisibility veya NavHost ile birlikte kullanmaktır. Bu yöntem, bileşenler arasındaki geçişi sizin için otomatik olarak yönetir.

Başlangıç noktası, ortak öğeler eklenmeden önce MainContent ve DetailsContent bileşeni içeren mevcut bir temel AnimatedContent'tür:

Şekil 3. Ortak öğe geçişi olmadan AnimatedContent'ten başlıyor.

  1. Paylaşılan öğelerin iki düzen arasında animasyonlu olarak hareket etmesini sağlamak için AnimatedContent bileşenini SharedTransitionLayout ile çevreleyin. SharedTransitionLayout ve AnimatedContent kapsamındaki kapsamlar MainContent ve DetailsContent'e iletilir:

    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. Eşleşen iki bileşende, bileşen değiştirici zincirinize Modifier.sharedElement() ekleyin. Bir SharedContentState nesnesi oluşturun ve rememberSharedContentState() ile hatırlayın. SharedContentState nesnesi, paylaşılan öğeleri belirleyen benzersiz anahtarı depolar. İçeriği tanımlamak için benzersiz bir anahtar sağlayın ve öğenin hatırlanması için rememberSharedContentState() kullanın. AnimatedContentScope, animasyonu koordine etmek için kullanılan değiştiriciye iletilir.

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

Paylaşılan öğe eşleşmesi olup olmadığı hakkında bilgi edinmek için rememberSharedContentState() öğesini bir değişkene ayıklayın ve isMatchFound öğesini sorgulayın.

Bu, aşağıdaki otomatik animasyonla sonuçlanır:

Şekil 4. İki bileşen arasında ortak öğe geçişinin temel örneği.

Kapsülün tamamının arka plan renginin ve boyutunun hâlâ varsayılan AnimatedContent ayarlarını kullandığını fark edebilirsiniz.

Paylaşılan sınırlar ve paylaşılan öğe

Modifier.sharedBounds(), Modifier.sharedElement() ile benzer. Ancak değiştiriciler aşağıdaki yönlerden farklıdır:

  • sharedBounds(), görsel olarak farklı ancak eyaletler arasında aynı alanı paylaşması gereken içerikler içindir. sharedElement() ise içeriğin aynı olmasını bekler.
  • sharedBounds() ile iki durum arasındaki geçiş sırasında ekrana giren ve ekrandan çıkan içerik görünür. sharedElement() ile ise yalnızca hedef içerik, dönüştürme sınırlarında oluşturulur. Modifier.sharedBounds(), AnimatedContent'ın işleyiş şekline benzer şekilde içeriğin nasıl geçiş yapacağını belirtmek için enter ve exit parametrelerine sahiptir.
  • sharedBounds() için en yaygın kullanım alanı kapsayıcı dönüştürme kalıbıdır. sharedElement() için ise örnek kullanım alanı kahraman geçişidir.
  • Text bileşenleri kullanılırken italik ve kalın arasında geçiş yapma veya renk değişiklikleri gibi yazı tipi değişikliklerini desteklemek için sharedBounds() tercih edilir.

Önceki örnekte, iki farklı senaryoda Row ve Column öğelerine Modifier.sharedBounds() eklendiğinde, bu iki öğenin sınırlarını paylaşabilir ve geçiş animasyonunu gerçekleştirerek öğelerin birbirlerinin içinde büyümesine izin verebiliriz:

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

        ) {
            // ...
        }
    }
}

Şekil 5. İki bileşen arasında paylaşılan sınırlar.

Kapsamları anlama

Modifier.sharedElement() kullanmak için derlenebilir öğenin bir SharedTransitionScope içinde olması gerekir. SharedTransitionLayout bileşeni, SharedTransitionScope sağlar. Paylaşmak istediğiniz öğeleri içeren kullanıcı arayüzü hiyerarşinizdeki aynı üst düzey noktaya yerleştirdiğinizden emin olun.

Genel olarak, derlenebilirler de bir AnimatedVisibilityScope içine yerleştirilmelidir. Bu, genellikle görünürlüğü manuel olarak yönetmediğiniz sürece, bileşenler arasında geçiş yapmak için AnimatedContent veya doğrudan AnimatedVisibility kullanırken ya da NavHost bileşen işlevi kullanılarak sağlanır. Birden fazla kapsam kullanmak için gerekli kapsamlarınızı bir CompositionLocal içine kaydedin, Kotlin'de bağlam alıcıları kullanın veya kapsamları işlevlerinize parametre olarak iletin.

İzlemeniz gereken birden fazla kapsamınız veya derinlemesine iç içe yerleştirilmiş bir hiyerarşiniz varsa CompositionLocals kullanın. CompositionLocal, kaydedilecek ve kullanılacak tam kapsamları seçmenize olanak tanır. Öte yandan, bağlam alıcıları kullandığınızda hiyerarşinizdeki diğer düzenler, sağlanan kapsamları yanlışlıkla geçersiz kılabilir. Örneğin, birden fazla iç içe yerleştirilmiş AnimatedContent öğeniz varsa kapsamlar geçersiz kılınabilir.

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

Alternatif olarak, hiyerarşiniz derin iç içe yerleştirilmemişse kapsamları parametre olarak iletebilirsiniz:

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

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

AnimatedVisibility ile paylaşılan öğeler

Önceki örneklerde, paylaşılan öğelerin AnimatedContent ile nasıl kullanılacağı gösterilmiştir ancak paylaşılan öğeler AnimatedVisibility ile de çalışır.

Örneğin, bu tembel ızgara örneğinde her öğe AnimatedVisibility içine yerleştirilmiştir. Öğe tıklandığında içerik, kullanıcı arayüzünden iletişim kutusu benzeri bir bileşene çekilmiş gibi görsel bir efekte sahiptir.

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

Şekil 6. AnimatedVisibility ile paylaşılan öğeler.

Değiştirici sıralaması

Modifier.sharedElement() ve Modifier.sharedBounds() ile, Oluştur'un geri kalanında olduğu gibi değişkeninizin zincirinin sırası önemlidir. Boyutu etkileyen değiştiricilerin yanlış yerleştirilmesi, ortak öğe eşleştirme sırasında beklenmedik görsel sıçramalara neden olabilir.

Örneğin, iki paylaşılan öğeye farklı bir konumda dolgu değiştirici yerleştirirseniz animasyonda görsel bir fark olur.

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

Eşleşen sınırlar

Eşleşmeyen sınırlar: Paylaşılan öğe animasyonunun, yanlış sınırlara göre yeniden boyutlandırılması gerektiği için biraz farklı göründüğünü fark edin.

Paylaşılan öğe değiştiricileri önce kullanılan değiştiriciler, paylaşılan öğe değiştiricilerine kısıtlamalar sağlar. Bu kısıtlamalar, ilk ve hedef sınırları ve ardından sınır animasyonu oluşturmak için kullanılır.

Paylaşılan öğe değiştiricilerinden sonra kullanılan değiştiriciler, alt öğenin hedef boyutunu ölçmek ve hesaplamak için önceki kısıtlamaları kullanır. Paylaşılan öğe değiştiriciler, alt öğeyi başlangıç boyutundan hedef boyuta kademeli olarak dönüştürmek için bir dizi animasyonlu kısıtlama oluşturur.

Bunun istisnası, animasyon için resizeMode = ScaleToBounds() veya bir kompozisyonda Modifier.skipToLookaheadSize() kullanmanızdır. Bu durumda, Compose, hedef kısıtlamaları kullanarak alt öğeyi düzenler ve düzen boyutunu değiştirmek yerine animasyonu gerçekleştirmek için bir ölçek faktörü kullanır.

Benzersiz anahtarlar

Karmaşık paylaşılan öğelerle çalışırken, dizelerin eşleşmesi hataya açık olabileceğinden dize olmayan bir anahtar oluşturmak iyi bir uygulamadır. Eşleşmelerin gerçekleşmesi için her anahtarın benzersiz olması gerekir. Örneğin, Jetsnack'ta aşağıdaki paylaşılan öğeler bulunur:

Şekil 7. Kullanıcı arayüzünün her bölümüne ilişkin ek açıklamalar içeren Jetsnack'ı gösteren resim.

Ortak öğe türünü temsil etmek için bir enum oluşturabilirsiniz. Bu örnekte, atıştırmalık kartının tamamı ana ekrandaki birden fazla farklı yerde (ör. "Popüler" ve "Önerilenler" bölümünde) gösterilebilir. Paylaşılacak ortak öğenin snackId, origin ("Popüler" / "Önerilen") ve type değerini içeren bir anahtar oluşturabilirsiniz:

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

Veri sınıfları, hashCode() ve isEquals()'ü uyguladığından anahtarlar için önerilir.

Paylaşılan öğelerin görünürlüğünü manuel olarak yönetme

AnimatedVisibility veya AnimatedContent kullanmadığınız durumlarda paylaşılan öğe görünürlüğünü kendiniz yönetebilirsiniz. Modifier.sharedElementWithCallerManagedVisibility() öğesini kullanın ve bir öğenin ne zaman görünür ne zaman görünmeyeceğini belirleyen kendi koşullu ifadenizi girin:

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

Mevcut sınırlamalar

Bu API'lerin birkaç sınırlaması vardır. Özellikle:

  • Görünümler ile Oluştur arasında birlikte çalışabilirlik desteklenmez. Buna AndroidView'ü saran tüm derlenebilirler (ör. Dialog) dahildir.
  • Aşağıdakiler için otomatik animasyon desteği yoktur:
    • Paylaşılan resim bileşimleri:
      • ContentScale varsayılan olarak animasyonlu değildir. Ayarlanan sonuca ContentScale sabitlenir.
    • Şekil kırpma: Şekiller arasında otomatik animasyon için yerleşik destek yoktur (ör. öğe geçişi sırasında kareden daireye animasyon).
    • Desteklenmeyen durumlarda sharedElement() yerine Modifier.sharedBounds() kullanın ve öğelere Modifier.animateEnterExit() ekleyin.