عمليات نقل العناصر المشتركة في Compose

إنّ انتقالات العناصر المشتركة هي طريقة سلسة للانتقال بين العناصر القابلة للتجميع التي تتضمّن محتوى متسقًا بينها. ويتم استخدامها غالبًا للتنقّل، ما يتيح لك ربط الشاشات المختلفة بشكل مرئي أثناء تنقّل المستخدمين بينها.

على سبيل المثال، في الفيديو التالي، يمكنك رؤية صورة ومقدّمة عن الطعم المشترَكَين من صفحة البيانات إلى صفحة التفاصيل.

الشكل 1. عرض توضيحي للعنصر المشترَك في Jetsnack

في Compose، تتوفّر بعض واجهات برمجة التطبيقات ذات المستوى العالي لمساعدتك في إنشاء عناصر مشترَكة:

  • SharedTransitionLayout: التنسيق الخارجي المطلوب لتنفيذ عمليات انتقال العناصر المشترَكة يوفّر SharedTransitionScope. يجب أن تكون العناصر القابلة للتجميع في SharedTransitionScope لاستخدام عوامل تعديل العناصر المشترَكة.
  • Modifier.sharedElement(): المُعدِّل الذي يُعلِم SharedTransitionScope بالعنصر القابل للتجميع الذي يجب مطابقته بعنصر قابل للتجميع آخر.
  • Modifier.sharedBounds(): المُعدِّل الذي يُعلم SharedTransitionScope بأنّه يجب استخدام حدود هذا المكوّن القابل للتجميع كحدود الحاوية التي يجب أن يحدث فيها الانتقال على عكس sharedElement()، تم تصميم sharedBounds() للمحتوى الذي يختلف من حيث الشكل.

من المفاهيم المهمة عند إنشاء عناصر مشترَكة في ميزة "الإنشاء" هي كيفية عملها مع العناصر التي تظهر على سطح الشاشة والعناصر التي تم اقتصاصها. اطّلِع على قسم الاقتصاص والتراكبات للتعرّف على مزيد من المعلومات حول هذا الموضوع المهم.

الاستخدام الأساسي

سيتم إنشاء عملية النقل التالية في هذا القسم، من العنصر الأصغر حجمًا "قائمة" إلى العنصر الأكبر حجمًا والمفصّل:

الشكل 2. مثال أساسي على انتقال عنصر مشترَك بين عنصرَين قابلَين للتجميع

إنّ أفضل طريقة لاستخدام Modifier.sharedElement() هي مع AnimatedContent أو AnimatedVisibility أو NavHost لأنّ ذلك يدير الانتقال بين العناصر القابلة للتجميع تلقائيًا نيابةً عنك.

نقطة البداية هي AnimatedContent أساسي حالي يحتوي على MainContent وDetailsContent قابلَين للتركيب قبل إضافة عناصر مشترَكة:

الشكل 3. بدء AnimatedContent بدون أي انتقالات لعناصر مشترَكة

  1. لإضافة تأثيرات متحركة إلى العناصر المشتركة بين التنسيقَين، احط SharedTransitionLayout حول العنصر القابل للتجميع AnimatedContent. يتم تمرير النطاقات من SharedTransitionLayout وAnimatedContent إلى MainContent وDetailsContent:

    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. أضِف Modifier.sharedElement() إلى سلسلة المُعدِّلات القابلة للتجميع في العنصرَين القابلَين للتجميع المطابقَين. أنشئ عنصرًا SharedContentState و احفظه باستخدام rememberSharedContentState(). يخزِّن عنصر SharedContentState المفتاح الفريد الذي يحدِّد العناصر التي تتم مشاركتها. أدخِل مفتاحًا فريدًا لتحديد المحتوى، واستخدِم rememberSharedContentState() لتذكر العنصر. يتم تمرير الرمز AnimatedContentScope إلى المُعدِّل الذي يُستخدَم لتنسيق الرسوم المتحركة.

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

للحصول على معلومات حول ما إذا حدثت مطابقة لعنصر مشترَك، استخرِج rememberSharedContentState() في متغيّر وابحث عن isMatchFound.

ما يؤدي إلى ظهور الرسم المتحرك التلقائي التالي:

الشكل 4. مثال أساسي على انتقال عنصر مشترَك بين عنصرَين قابلَين للتجميع

قد تلاحظ أنّ لون الخلفية وحجم الحاوية بالكامل ما زالا يستخدِمان الإعدادات التلقائية AnimatedContent.

الحدود المشتركة في مقابل العنصر المشترَك

Modifier.sharedBounds() مشابه لـ Modifier.sharedElement(). ومع ذلك، تختلف المُعدِّلات بالطرق التالية:

  • يُستخدَم sharedBounds() للمحتوى الذي يختلف من الناحية المرئية ولكن يجب أن يتضمّن المنطقة نفسها بين الولايات، في حين يتوقع sharedElement() أن يكون المحتوى متطابقًا.
  • باستخدام sharedBounds()، يكون المحتوى الذي يدخل الشاشة ويخرج منها مرئيًا أثناء الانتقال بين الحالتَين، في حين أنّه باستخدام sharedElement()، يتم عرض المحتوى المستهدَف فقط في حدود التحوّل. يحتوي Modifier.sharedBounds() على مَعلمتَي enter وexit لتحديد كيفية انتقال المحتوى، تمامًا مثل طريقة عمل AnimatedContent.
  • تتمثّل حالة الاستخدام الأكثر شيوعًا لعنصر sharedBounds() في نمط تحويل الحاوية، whereas for sharedElement() the example use case is a hero transition.
  • عند استخدام العناصر القابلة للتجميع Text، يُفضَّل استخدام sharedBounds() لتفعيل ميزة تغييرات الخط، مثل الانتقال بين الخط المائل والخط الغامق أو تغييرات اللون.

من المثال السابق، ستسمح لنا إضافة Modifier.sharedBounds() إلى Row و Column في السيناريوهَين المختلفَين بمشاركة حدود العنصرَين وتنفيذ التأثير المتحرّك للانتقال، ما يسمح لهما بالنمو بين بعضهما:

@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: الحدود المشتركة بين عنصرَي تركيب

فهم النطاقات

لاستخدام Modifier.sharedElement()، يجب أن يكون العنصر القابل للتجميع في SharedTransitionScope. توفّر الوحدات القابلة للتجميع SharedTransitionLayout العناصر التالية: SharedTransitionScope. احرص على وضعها في نقطة المستوى الأعلى نفسها في التسلسل الهرمي لملف واجهة المستخدِم الذي يحتوي على العناصر التي تريد مشاركتها.

بشكل عام، يجب أيضًا وضع العناصر القابلة للتجميع داخل AnimatedVisibilityScope. يتم عادةً توفير ذلك باستخدام AnimatedContent للتبديل بين العناصر القابلة للتجميع أو عند استخدام AnimatedVisibility مباشرةً، أو باستخدام الدالة القابلة للتجميع NavHost، ما لم تكن تدير مستوى العرض يدويًا. لاستخدام نطاقات متعددة، احفظ النطاقات المطلوبة في CompositionLocal، أو استخدِم أجهزة استقبال السياق في Kotlin، أو مرِّر النطاقات كمَعلمات إلى دوالّك.

استخدِم CompositionLocals في السيناريو الذي يكون فيه لديك نطاقات متعددة لتتبُّعها، أو تسلسل هرمي مُدمَج بعمق. تتيح لك CompositionLocal اختيار النطاقات الدقيقة التي تريد حفظها واستخدامها. من ناحية أخرى، عند استخدام مستلِمي السياق، قد تلغي التنسيقات الأخرى في التسلسل الهرمي النطاقات المقدَّمة عن طريق الخطأ. على سبيل المثال، إذا كان لديك عدة AnimatedContent مُدمَجة، يمكن تجاوز النطاقات.

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

بدلاً من ذلك، إذا لم يكن التسلسل الهرمي متداخلًا بشكل عميق، يمكنك تمرير النطاقات للأسفل كمَعلمات:

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

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

العناصر التي تمت مشاركتها مع "AnimatedVisibility"

توضّح الأمثلة السابقة كيفية استخدام العناصر المشترَكة مع AnimatedContent، ولكن تعمل العناصر المشترَكة مع AnimatedVisibility أيضًا.

على سبيل المثال، في مثال الشبكة البطيئة هذا، يتم لف كل عنصر في AnimatedVisibility. عند النقر على العنصر، يُظهر المحتوى أثرًا مرئيًا يتمثل في سحبه من واجهة المستخدم إلى مكوّن يشبه مربّع الحوار.

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

الشكل 6: العناصر المشتركة مع AnimatedVisibility

ترتيب المُعدِّلات

مع Modifier.sharedElement() وModifier.sharedBounds()، يُعدّ ترتيب سلسلة المُعدِّل مهمًا، كما هو الحال مع بقية أدوات Compose. يمكن أن يؤدي وضع المُعدِّلات التي تؤثّر في الحجم بشكل غير صحيح إلى حدوث قفزات مرئية غير متوقّعة أثناء مطابقة العناصر المشترَكة.

على سبيل المثال، إذا وضعت مُعدِّل للتباعد في موضع مختلف على عنصرَين مشترَكين، سيظهر فرق مرئي في الصورة المتحركة.

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

الحدود المطابقة

الحدود غير المطابقة: لاحظ أنّ الصورة المتحركة للعنصر المشترَك تظهر بشكل غير صحيح بعض الشيء لأنّه يجب تغيير حجمها لتتلاءم مع الحدود غير الصحيحة.

تفرض المُعدِّلات المستخدَمة قبل مُعدِّلات العناصر المشترَكة قيودًا على مُعدِّلات العناصر المشترَكة، والتي يتم استخدامها بعد ذلك لاستخراج الحدود الأولية والحدود المستهدَفة، ثمّ الرسوم المتحركة للحدود.

إنّ المُعدِّلات المستخدَمة بعد مُعدِّلات العناصر المشترَكة تستخدِم القيود من قبل لقياس واحتساب الحجم المستهدَف للعنصر الفرعي. تُنشئ عناصر تعديل العنصر المشترَك سلسلة من القيود المتحركة لتحويل العنصر التابع تدريجيًا من الحجم الأولي إلى الحجم المستهدَف.

ويُستثنى من ذلك استخدام resizeMode = ScaleToBounds() ل الرسوم المتحركة أو Modifier.skipToLookaheadSize() في عنصر قابل للتجميع. في هذا الحالة، يُنشئ Compose العنصر الثانوي باستخدام قيود الاستهداف، ويستخدم بدلاً من ذلك مقياسًا للحجم لتنفيذ الحركة بدلاً من تغيير حجم التنسيق نفسه.

المفاتيح الفريدة

عند التعامل مع العناصر المشترَكة المعقدة، من الممارسات الجيدة إنشاء مفتاح ليس سلسلة، لأنّ السلاسل قد تكون عرضة للخطأ في المطابقة. يجب أن يكون كل مفتاح فريدًا لكي تحدث المطابقات. على سبيل المثال، في Jetsnack، لدينا يلي: العناصر المشتركة:

الشكل 7. صورة تعرض تطبيق Jetsnack مع تعليقات توضيحية لكل جزء من واجهة المستخدم

يمكنك إنشاء مصنّف للتمثيل نوع العنصر المشترَك. في هذا المثال، يمكن أن تظهر بطاقة الوجبة الخفيفة بالكامل أيضًا من أماكن متعددة مختلفة على الشاشة الرئيسية، على سبيل المثال في قسمَي "المحتوى الرائج" و "المحتوى المقترَح". يمكنك إنشاء مفتاح يحتوي على snackId وorigin ("رائج" / "مُقترَح") و type للعنصر المشترَك الذي سيتمّت مشاركته:

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() و isEquals().

إدارة مستوى ظهور العناصر المشتركة يدويًا

في الحالات التي لا تستخدم فيها AnimatedVisibility أو AnimatedContent، يمكنك إدارة مستوى رؤية العنصر المشترَك بنفسك. استخدِم Modifier.sharedElementWithCallerManagedVisibility() وقدِّم شرطًا خاصًا بك يحدّد الحالات التي يجب فيها ظهور عنصر معيّن أو عدم ظهوره:

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

القيود الحالية

تفرض واجهات برمجة التطبيقات هذه بعض القيود. في ما يلي أهم التغييرات:

  • لا تتوفّر إمكانية التشغيل التفاعلي بين "الاطِّلاع" و"الإنشاء". ويشمل ذلك أي عنصر قابل للتجميع يلف AndroidView، مثل Dialog.
  • لا تتوفّر ميزة الصور المتحركة التلقائية في ما يلي:
    • العناصر القابلة للتجميع من الصور المشترَكة:
      • لا يكون ContentScale متحركًا تلقائيًا. يتم تثبيته في النهاية المحدّدة ContentScale.
    • اقتصاص الأشكال: لا تتوفّر ميزة مدمجة لإضافة تأثيرات متحركة تلقائية بين الأشكال، مثل إضافة تأثير متحركة من مربّع إلى دائرة أثناء انتقال العنصر.
    • في الحالات غير المتوافقة، استخدِم Modifier.sharedBounds() بدلاً من sharedElement() وأضِف Modifier.animateEnterExit() إلى العناصر.