Oluşturma bölümünde paylaşılan öğe geçişleri

Paylaşılan öğe geçişleri, aralarında tutarlı içerik bulunan composable'lar arasında sorunsuz bir geçiş yöntemidir. Genellikle gezinme için kullanılırlar ve kullanıcı farklı ekranlar arasında gezinirken bu ekranları görsel olarak birbirine bağlamanıza olanak tanırlar.

Ö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.

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

  • SharedTransitionLayout: Paylaşılan öğe geçişlerini uygulamak için gereken en dıştaki düzendir. SharedTransitionScope sağlar. Birleştirilebilir öğelerin, paylaşılan öğe değiştiricileri kullanabilmesi için SharedTransitionScope içinde olması gerekir.
  • Modifier.sharedElement(): SharedTransitionScope'e başka bir composable ile eşleştirilmesi gereken composable'ı işaretleyen değiştirici.
  • Modifier.sharedBounds(): SharedTransitionScope öğesine, bu composable'ın sınırlarının, geçişin gerçekleşeceği yer için kapsayıcı sınırları olarak kullanılması gerektiğini bildiren değiştirici. sharedElement()'nin aksine sharedBounds(), görsel olarak farklı içerikler için tasarlanmıştır.

Oluşturma'da ortak öğeler oluştururken önemli bir kavram, bunların yer paylaşımları ve kırpma ile nasıl çalıştığıdır. Bu önemli konu hakkında daha fazla bilgi edinmek için Kırpma ve yer paylaşımları bölümüne bakın.

Temel kullanım

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

Jetsnack'te iki composable arasında temel paylaşılan öğe geçişi
Şekil 2. İki composable arasında paylaşılan öğe geçişinin temel bir örneği.

Modifier.sharedElement(), en iyi şekilde AnimatedContent, AnimatedVisibility veya NavHost ile birlikte kullanılır. Bu sayede, composable'lar arasındaki geçiş sizin için otomatik olarak yönetilir.

Başlangıç noktası, paylaşılan öğeler eklenmeden önce MainContent ve DetailsContent composable'ına sahip mevcut bir temel AnimatedContent'dir:

Paylaşılan öğeler olmadan varsayılan çapraz geçiş ve kaydırma geçişini gösteren animasyon
Şekil 3. AnimatedContent, paylaşılan öğe geçişleri olmadan başlatılıyor.

  1. Paylaşılan öğelerin iki düzen arasında animasyonlu geçiş yapması için AnimatedContent composable'ı SharedTransitionLayout ile sarmalayın. SharedTransitionLayout ve AnimatedContent kapsamları, MainContent ve DetailsContent'e aktarılır:

    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 composable'da composable değiştirici zincirinize Modifier.sharedElement() ekleyin. 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() değerini bir değişkene ayıklayın ve isMatchFound değerini sorgulayın.

Bu durumda aşağıdaki otomatik animasyon oluşturulur:

Resim ve metnin ekranlar arasında sorunsuz bir şekilde genişlediğini gösteren animasyon
Şekil 4. İki composable arasında paylaşılan öğe geçişinin temel bir örneği.

Tüm kapsayıcının arka plan rengi 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 şekillerde farklılık gösterir:

  • sharedBounds(), görsel olarak farklı olan ancak durumlar arasında aynı alanı paylaşması gereken içerikler için kullanılırken sharedElement(), içeriğin aynı olmasını bekler.
  • sharedBounds() ile ekrana giren ve çıkan içerik, iki durum arasındaki geçiş sırasında görünürken sharedElement() ile yalnızca hedef içerik, dönüşüm sınırları içinde oluşturulur. Modifier.sharedBounds(), AnimatedContent işleyiş şekline benzer şekilde içeriğin nasıl geçiş yapması gerektiğini belirten enter ve exit parametrelerine sahiptir.
  • sharedBounds() için en yaygın kullanım alanı kapsayıcı dönüşüm kalıbıdır. sharedElement() için örnek kullanım alanı ise hero geçişidir.
  • Text composable'ları kullanılırken italik ve kalın arasında geçiş 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() eklediğimizde, ikisinin sınırlarını paylaşabilir ve geçiş animasyonunu gerçekleştirerek birbirleri arasında büyümelerini sağlayabiliriz:

@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 composable arasında paylaşılan sınırlar.

Kapsamları anlama

Modifier.sharedElement() öğesini kullanmak için composable'ın SharedTransitionScope içinde olması gerekir. SharedTransitionLayout composable'ı, SharedTransitionScope sağlar. Bu öğeyi, kullanıcı arayüzü hiyerarşinizde paylaşmak istediğiniz öğeleri içeren aynı üst düzey noktaya yerleştirdiğinizden emin olun.

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

Takip etmeniz gereken birden fazla kapsam veya derinlemesine iç içe yerleştirilmiş bir hiyerarşi varsa CompositionLocals kullanın. CompositionLocal, kaydedilecek ve kullanılacak tam kapsamları seçmenize olanak tanır. Öte yandan, bağlam parametrelerini 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 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 çok derin değilse 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şti ancak paylaşılan öğeler AnimatedVisibility ile de kullanılabilir.

Örneğin, bu LazyColumn örneğinde her öğe AnimatedVisibility içine alınmıştır. Öğe tıklandığında içerik, kullanıcı arayüzünden çıkarılarak iletişim kutusu benzeri bir bileşene yerleştirilmiş gibi görünür.

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(
                            sharedContentState = 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 değiştirici zincirinizin sırası, Compose'un geri kalanında olduğu gibi önemlidir. Boyutu etkileyen değiştiricilerin yanlış yerleştirilmesi, paylaşılan öğe eşleştirme sırasında beklenmedik görsel sıçramalara neden olabilir.

Örneğin, iki paylaşılan öğede farklı bir konuma 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ğinden biraz bozuk göründüğüne dikkat edin.

Paylaşılan öğe değiştiricilerden önce kullanılan değiştiriciler, paylaşılan öğe değiştiricilere kısıtlamalar sağlar. Bu kısıtlamalar daha sonra ilk ve hedef sınırları, ardından da sınır animasyonunu türetmek için kullanılır.

Paylaşılan öğe değiştiricilerden 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 ilk boyuttan hedef boyuta kademeli olarak dönüştürmek için bir dizi animasyonlu kısıtlama oluşturur.

Animasyon için resizeMode = SharedTransitionScope.ResizeMode.scaleToBounds() veya bir composable'da Modifier.skipToLookaheadSize() kullanıyorsanız bu durum geçerli değildir. 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çeklendirme faktörü kullanır.

Benzersiz anahtarlar

Karmaşık paylaşılan öğelerle çalışırken dize olmayan bir anahtar oluşturmak iyi bir uygulamadır. Çünkü dizeler, eşleştirme sırasında hataya açık olabilir. Eşleşmelerin gerçekleşmesi için her anahtarın benzersiz olması gerekir. Örneğin, Jetsnack'te aşağıdaki ortak öğeler bulunur:

Paylaşılan kullanıcı arayüzü öğelerini tanımlayan açıklamalı Jetsnack arayüzü
Şekil 7. Kullanıcı arayüzünün her bir bölümü için ek açıklamalar içeren Jetsnack'i gösteren resim.

Paylaşılan öğe türünü temsil eden bir enum oluşturabilirsiniz. Bu örnekte, tüm atıştırmalık kartı ana ekrandaki birden fazla farklı yerden de (ör. "Popüler" ve "Önerilen" bölümü) görünebilir. Paylaşılacak ortak öğenin snackId, origin ("Popüler" / "Önerilen") ve type değerlerini 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
                    )
            )
            // ...
}

hashCode() ve equals() uyguladıkları için anahtarlar için veri sınıfları ö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() kullanın ve bir öğenin ne zaman görünür olması gerektiğini belirleyen kendi koşulunuzu sağlayın:

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 Compose arasında birlikte çalışabilirlik desteklenmez. AndroidView öğesini sarmalayan tüm composable'lar (ör. Dialog veya ModalBottomSheet) buna dahildir.
  • Aşağıdakiler için otomatik animasyon desteği yoktur:
    • Paylaşılan resim composable'ları:
      • ContentScale varsayılan olarak animasyonlu değildir. Ayarlanan bitişe ContentScale sabitlenir.
    • Şekil kırpma: Şekiller arasında otomatik animasyon için yerleşik destek yoktur. Örneğin, öğe geçiş yaparken kareden daireye animasyon oluşturulamaz.
    • Desteklenmeyen durumlarda sharedElement() yerine Modifier.sharedBounds() kullanın ve öğelere Modifier.animateEnterExit() ekleyin.