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

Paylaşılan öğe geçişleri, aralarında tutarlı içeriğe sahip olan composable'lar arasında sorunsuz bir geçiş yöntemidir. Genellikle gezinme için kullanılırlar. Böylece, kullanıcı ekranlar arasında gezinirken farklı ekranları görsel olarak birbirine bağlayabilirsiniz.

Örneğin, aşağıdaki videoda resmin 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ı olan birkaç üst düzey API vardır:

  • SharedTransitionLayout: Paylaşılan öğe geçişlerini uygulamak için gereken en dış düzen. SharedTransitionScope sağlar. Paylaşılan öğe değiştiricileri kullanmak için composable'ların bir SharedTransitionScope içinde olması gerekir.
  • Modifier.sharedElement(): Başka bir composable ile eşleştirilmesi gereken composable'ı SharedTransitionScope işaretleyen düzenleyici.
  • Modifier.sharedBounds(): Bu composable'ın sınırlarının, geçişin gerçekleşmesi gereken yerlerdeki kapsayıcı sınırları olarak kullanılması gerektiğini belirten SharedTransitionScope işaretleyici düzenleyici. sharedElement() uygulamasının aksine sharedBounds(), görsel olarak farklı içerikler için tasarlanmıştır.

Oluştur'da paylaşılan öğeler oluştururken üzerinde düşünülen önemli bir nokta, bu öğelerin yer paylaşımları ve kırpmayla 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 aşağıdaki geçiş oluşturulur ve küçük "liste" öğesinden daha büyük ve ayrıntılı öğeye geçiş yapılır:

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

composable'lar arasındaki geçişi sizin için otomatik olarak yönettiğinden Modifier.sharedElement() kullanmanın en iyi yolu AnimatedContent, AnimatedVisibility veya NavHost ile birlikte kullanılmasıdır.

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

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

  1. Paylaşılan öğelerin iki düzen arasında animasyonlu olmasını sağlamak için AnimatedContent composable için SharedTransitionLayout kullanın. SharedTransitionLayout ve AnimatedContent kapsamındaki kapsamlar MainContent ve DetailsContent kanallarına 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. Bir SharedContentState nesnesi oluşturun ve bunu 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 hatırlanacak öğe için rememberSharedContentState() öğesini kullanın. AnimatedContentScope, animasyonu koordine etmek için kullanılan değiştiriciye aktarılır.

    @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şmesinin oluşup oluşmadığı hakkında bilgi edinmek için rememberSharedContentState() kodunu bir değişkene çıkarın ve isMatchFound sorgusunu sorgulayın.

Bunun sonucunda aşağıdaki otomatik animasyon elde edilir:

Şekil 4. İki composable arasında paylaşılan öğe geçişinin temel ö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 karşılaştırması

Modifier.sharedBounds(), Modifier.sharedElement() ile benzer. Bununla birlikte, değiştiriciler şu şekillerde farklılık gösterir:

  • 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 iki durum arasındaki geçiş sırasında ekrana giren ve çıkan içerik görünürken sharedElement() ile dönüşüm sınırlarında yalnızca hedef içerik oluşturulur. Modifier.sharedBounds(), içeriğin nasıl geçiş yapacağını belirlemek için AnimatedContent'in çalışma şekline benzer şekilde enter ve exit parametrelerine sahiptir.
  • sharedBounds() için en yaygın kullanım alanı kapsayıcı dönüşüm kalıbı, sharedElement() için örnek kullanım alanı ise bir hero geçiştir.
  • Text composable'ları kullanılırken, italik ile 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 örnekten iki farklı senaryoda Row ve Column bölümlerine Modifier.sharedBounds() eklenmesi, ikisinin sınırlarını paylaşmamıza ve geçiş animasyonunu gerçekleştirmemize olanak tanıyarak bunların birbirleri arasında büyümelerini 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ında 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. 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 aynı zamanda AnimatedVisibilityScope içine de yerleştirilmelidir. Görünürlüğü manuel olarak yönetmediğiniz sürece, genellikle composable'lar arasında geçiş yapmak için AnimatedContent kullanılarak veya doğrudan AnimatedVisibility kullanılırken ya da NavHost composable işleviyle sağlanır. Birden fazla kapsam kullanmak için zorunlu kapsamlarınızı bir CompositionLocal öğesine kaydedin, Kotlin'de bağlam alıcıları kullanın veya kapsamları işlevlerinize parametre olarak aktarın.

CompositionLocals'yi, takip etmeniz gereken birden fazla kapsamınızın veya derinlemesine iç içe yerleştirilmiş bir hiyerarşinin olduğu senaryoda 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 öğ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 derinlemesine iç içe yerleştirilmemişse kapsamları parametre olarak geçirebilirsiniz:

@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 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 kutusuna benzer bir bileşene çekilirmiş gibi 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() ürünlerinde, Compose'un geri kalanında olduğu gibi düzenleyici zincirinizin sırası önemlidir. Boyut etkileyen değiştiricilerin yanlış yerleştirilmesi, paylaşılan öğe eşleştirmesi sırasında beklenmedik görsel atlamalara neden olabilir.

Örneğin, paylaşılan iki öğede bir dolgu değiştiriciyi farklı bir konuma 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 yeniden boyutlandırılması 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ştiricilere kısıtlamalar sağlar. Bu da ilk ve hedef sınırları ve 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ştiricileri, 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 composable'da Modifier.skipToLookaheadSize() kullanılmasıdır. Bu durumda Compose, hedef kısıtlamaları kullanarak alt öğeyi düzenler ve düzenin boyutunu değiştirmek yerine animasyonu gerçekleştirmek için ölçek faktörü kullanır.

Benzersiz anahtarlar

Paylaşılan karmaşık öğelerle çalışırken, dizeler eşleşmeye açık olabileceğinden dize olmayan bir anahtar oluşturmak iyi bir uygulamadır. Eşleşmelerin gerçekleşmesi için her anahtar benzersiz olmalıdır. Örneğin, Jetsnack'te aşağıdaki paylaşılan öğelere sahibiz:

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

Paylaşılan öğe türünü temsil eden bir enum oluşturabilirsiniz. Bu örnekte, atıştırmalık kartının tamamı ana ekranda birden fazla farklı yerden de (örneğin, "Popüler" ve "Önerilen" bölümlerinde) görünebilir. Paylaşılacak paylaşılan öğenin snackId, origin ("Popüler" / "Önerilen") ve type öğelerine sahip 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() uygulayan anahtarlar için önerilir.

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

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 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. En önemlisi:

  • Görünümler ve Oluşturma arasında birlikte çalışabilirlik özelliği desteklenmez. Bu, Dialog gibi AndroidView öğesini sarmalayan herhangi bir composable'ı içerir.
  • Aşağıdakiler için otomatik animasyon desteği yoktur:
    • Paylaşılan Resim composable'ları:
      • ContentScale varsayılan olarak animasyonlu değildir. ContentScale olarak ayarlanan uca tutturur.
    • Ş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 oluşturma).
    • Desteklenmeyen durumlar için sharedElement() yerine Modifier.sharedBounds() kullanın ve öğelere Modifier.animateEnterExit() ekleyin.