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

Paylaşılan öğe geçişleri, içerikleri tutarlı olan composable'lar arasında sorunsuz bir şekilde geçiş yapmanın bir yoludur. Genellikle gezinme için kullanılır. Kullanıcılar ekranlar arasında gezinirken farklı ekranları görsel olarak bağlamanıza olanak tanır.

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

1. Şekil. 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üzen. SharedTransitionScope sağlar. Paylaşılan öğe değiştiricilerini kullanmak için composable'ların SharedTransitionScope içinde olması gerekir.
  • Modifier.sharedElement(): Başka bir composable ile eşleştirilmesi gereken composable'ı SharedTransitionScope işaretleyen değiştirici.
  • Modifier.sharedBounds(): 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 SharedTransitionScope'e bildiren değiştirici. sharedElement()'nın aksine sharedBounds(), görsel olarak farklı içerikler için tasarlanmıştır.

Compose'da paylaşılan öğeler oluştururken önemli bir kavram, bu öğelerin 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ş yapan aşağıdaki geçiş oluşturulacaktır:

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

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

Başlangıç noktası, MainContent özelliğine sahip mevcut bir temel AnimatedContent'dır ve paylaşılan öğeler eklenmeden önce DetailsContent birleştirilebilir:

3.şekil 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ını sağlamak için AnimatedContent composable'ı SharedTransitionLayout ile sarın. SharedTransitionLayout ve AnimatedContent kapsamları, MainContent ve DetailsContent'a 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 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ı saklar. İç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 çıkarın ve isMatchFound öğesini sorgulayın.

Bu işlem sonucunda aşağıdaki otomatik animasyon oluşturulur:

4. şekil. İki composable arasında paylaşılan öğe geçişinin temel örneği.

Kapsayıcının tamamının arka plan rengi ve boyutunun hâlâ varsayılan AnimatedContent ayarlarını kullandığını fark edebilirsiniz.

Paylaşılan sınırlara karşı paylaşılan öğe

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

  • sharedBounds(), görsel olarak farklı olan ancak eyaletler 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 ekrandan çıkan içerik, iki durum arasındaki geçiş sırasında görünür. sharedElement() ile ise yalnızca hedef içerik, dönüşen sınırlarda oluşturulur. Modifier.sharedBounds(), enter ve exit parametrelerine sahiptir. Bu parametreler, AnimatedContent'ün işleyiş şekline benzer şekilde, içeriğin nasıl geçiş yapacağını belirtmek için kullanılır.
  • sharedBounds() için en yaygın kullanım alanı container transform pattern iken sharedElement() için örnek kullanım alanı bir hero geçişidir.
  • Text composable'ları kullanı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 Modifier.sharedBounds() öğesini Row ve Column öğelerine eklediğimizde, ikisinin sınırlarını paylaşabilir ve geçiş animasyonunu gerçekleştirebiliriz. Böylece öğeler birbirleri arasında büyüyebilir:

@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.Şekil İki composable arasındaki paylaşılan sınırlar.

Kapsamları anlama

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

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

İzlemeniz gereken birden fazla kapsamın veya derin iç içe yerleştirilmiş bir hiyerarşinin olduğu senaryolarda CompositionLocals kullanın. CompositionLocal, kaydedilecek ve kullanılacak kapsamları tam olarak 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, iç içe yerleştirilmiş birden fazla 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 derin şekilde 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şti. 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 efektle gösterilir.

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

6.Şekil 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 beklenmeyen görsel atlamalara neden olabilir.

Örneğin, iki paylaşılan öğede dolgu değiştiriciyi farklı konumlara yerleştirirseniz animasyonda görsel bir fark oluşur.

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 farklı göründüğünü fark 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, başlangıç ve hedef sınırları, ardından da sınır animasyonunu türetmek için kullanılır.

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

Animasyon için resizeMode = ScaleToBounds() veya composable'da Modifier.skipToLookaheadSize() kullanıyorsanız bu durum geçerli değildir. Bu durumda Compose, hedef kısıtlamaları kullanarak alt öğeyi yerleştirir 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, dizeler eşleşme konusunda hataya açık olabileceğinden dize olmayan bir anahtar oluşturmak iyi bir uygulamadır. Eşleşme olması için her anahtar benzersiz olmalıdır. Örneğin, Jetsnack'te aşağıdaki paylaşılan öğeler vardır:

7.şekil Kullanıcı arayüzünün her bölümü için ek açıklamalar içeren Jetsnack görüntüsü.

Paylaşılan öğe türünü temsil eden bir enum oluşturabilirsiniz. Bu örnekte, tüm kısa bilgi kartı ana ekranda farklı yerlerde de görünebilir. Örneğin, "Popüler" ve "Önerilen" bölümlerinde. Paylaşılan öğenin snackId, origin ("Popüler" / "Önerilen") ve type özelliklerini 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
                    )
            )
            // ...
}

Anahtarlar için veri sınıfları önerilir. Çünkü bunlar hashCode() ve isEquals()'yi uygular.

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 veya görünmez 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 bazı sınırlamaları vardır. En önemlisi:

  • Views ve Compose arasında birlikte çalışabilirlik desteklenmez. Bu kapsamda, AndroidView öğesini sarmalayan tüm composable'lar (ör. Dialog veya ModalBottomSheet) yer alır.
  • Aşağıdakiler için otomatik animasyon desteği yoktur:
    • Shared Image composables:
      • ContentScale varsayılan olarak animasyonlu değildir. Ayarlanan bitiş zamanına ContentScale sabitlenir.
    • Şekil kırpma: Öğeler arasında geçiş yapılırken şekiller arasında otomatik animasyon (ör. öğe geçişi sırasında kareyi daireye dönüştürme) için yerleşik destek yoktur.
    • Desteklenmeyen durumlarda sharedElement() yerine Modifier.sharedBounds() özelliğini kullanın ve öğelere Modifier.animateEnterExit() özelliğini ekleyin.