Oluşturma işleminde 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ş imkanı sunar. Genellikle gezinme için kullanılır ve kullanıcı aralarında gezinirken farklı ekranları görsel olarak bağlamanızı sağlar.

Örneğin aşağıdaki videoda, atıştırmalık resminin ve başlığının giriş 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ı olacak 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. Ortak öğe değiştiricileri kullanmak için derlenebilir öğelerin 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 yapılması gereken yer için container sınırları olarak kullanılması gerektiğini SharedTransitionScope işaret eden değiştirici. sharedElement() uygulamasından farklı olarak sharedBounds(), görsel olarak farklı içerikler için tasarlanmıştır.

Compose'da paylaşılan öğeler oluştururken önemli bir kavram, bunların yer paylaşımları ve kliplerle 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 göz atın.

Temel Kullanım

Bu bölümde, daha küçük "liste" öğesinden daha kapsamlı olan ayrıntılı öğeye geçilerek aşağıdaki geçiş oluşturulacak:

Şekil 2. İki composable arasında paylaşılan öğe geçişine basit bir örnek.

Modifier.sharedElement() özelliğini kullanmanın en iyi yolu AnimatedContent, AnimatedVisibility veya NavHost ile birlikte kullanmaktır. Çünkü bu, composable'lar arasındaki geçişi sizin için otomatik olarak yönetir.

Başlangıç noktası, paylaşılan öğeler eklemeden önce MainContent ve DetailsContent composable'ı içeren mevcut bir temel AnimatedContent:

Şekil 3. AnimatedContent, paylaşılan öğe geçişleri olmadan başlatılıyor.

  1. Paylaşılan öğelerin iki düzen arasında animasyon yapması için AnimatedContent composable'ı SharedTransitionLayout ile doldurun. SharedTransitionLayout ve AnimatedContent'ten alınan kapsamlar MainContent ve DetailsContent özelliklerine 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() kodunu ekleyin. Bir SharedContentState nesnesi oluşturun ve bu nesneyi 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 geçirilir.

    @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 bir öğe eşleşmesi olup olmadığı hakkında bilgi almak için rememberSharedContentState() öğesini bir değişkene ayıklayın ve isMatchFound sorgusunu sorgulayın.

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

Şekil 4. İki composable arasında paylaşılan öğe geçişine basit bir örnek.

Arka plan rengi ve tüm kapsayıcının boyutunun hâlâ varsayılan AnimatedContent ayarlarını kullandığını fark edebilirsiniz.

Paylaşılan sınırlar ve paylaşılan öğe karşılaştırması

Modifier.sharedBounds(), Modifier.sharedElement() ile benzer. Bununla birlikte, 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çindir. sharedElement() ise 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ürken sharedElement() ile dönüştürme sınırlarında yalnızca hedef içerik oluşturulur. Modifier.sharedBounds(), AnimatedContent'ın işleyiş şekline benzer şekilde, içeriğin geçiş yöntemini belirlemek 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 örnek kullanım alanı hero geçiştir.
  • Text composable'ları kullanırken, italik ve kalın yazı tipi veya renk değişiklikleri gibi yazı tipi değişikliklerini desteklemek için sharedBounds() tercih edilir.

Önceki örnekten, iki farklı senaryoda Row ve Column öğesine Modifier.sharedBounds() eklemek, bu iki senaryonun sınırlarını paylaşmamıza ve geçiş animasyonunu gerçekleştirmemize olanak tanıyarak bunların birbiri arasında büyümesini sağlar:

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

Kapsamları Anlama

Modifier.sharedElement() özelliğini 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.

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

Takip edilecek birden fazla kapsamın veya derinlemesine iç içe geçmiş bir hiyerarşinin olduğu senaryoda CompositionLocals kullanın. CompositionLocal, kaydedilip 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, 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 derinlemesine iç içe yerleştirilmemişse kapsamları parametre olarak aşağı aktarabilirsiniz:

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

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

Öğeler AnimatedVisibility ile paylaşıldı

Önceki örnekler, paylaşılan öğelerin AnimatedContent ile nasıl kullanılacağını gösteriyordu, ancak paylaşılan öğeler AnimatedVisibility ile de çalışıyordu.

Örneğin, bu geç ızgara örneğinde, her öğe AnimatedVisibility içine sarmalanmıştır. Öğe tıklandığında, içerik kullanıcı arayüzünden iletişim kutusu benzeri bir bileşene alınmış görsel bir etkiye 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 birlikte, Compose'un geri kalanında olduğu gibi değiştirici zincirinizin sırası önemlidir. Boyutu etkileyen değiştiricilerin yanlış yerleşimi, paylaşılan öğe eşleştirme sırasında beklenmedik görsel atlamalara neden olabilir.

Örneğin, bir dolgu değiştiriciyi iki paylaşılan öğede farklı bir konuma 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ırması gerektiğinden nasıl göründüğüne dikkat edin.

Paylaşılan öğe değiştiricilerinden önce kullanılan değiştiriciler, paylaşılan öğe değiştiricilerine kısıtlamalar getirir. Bunlar daha sonra 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, 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.

Bunun istisnası, animasyon için resizeMode = ScaleToBounds() veya composable'da Modifier.skipToLookaheadSize() kullanmanızdır. Bu örnekte Compose, hedef kısıtlamaları kullanarak alt öğeyi yerleştirir ve animasyon oluşturmak için düzenin boyutunu değiştirmek yerine ölçek faktörünü kullanır.

Benzersiz anahtarlar

Dizeler hata yapmaya açık olabileceğinden, paylaşılan karmaşık öğelerle çalışırken 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'te aşağıdaki ortak unsurlara sahibiz:

Şekil 7. Jetsnack'i kullanıcı arayüzünün her bölümü için ek açıklamalarla gösteren resim.

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

hashCode() ve isEquals() 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 kullanmıyor olabileceğiniz durumlarda paylaşılan öğe görünürlüğünü kendiniz yönetebilirsiniz. Modifier.sharedElementWithCallerManagedVisibility() özelliğini kullanın ve bir öğenin ne zaman görünür olup olmayacağını 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:

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