انتقالات العناصر المشتركة في ميزة "الإنشاء"

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

على سبيل المثال، في الفيديو التالي، يمكنك رؤية صورة وعنوان الوجبة الخفيفة تتم مشاركتهما من صفحة القائمة إلى صفحة التفاصيل.

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

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

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

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

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

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

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

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

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

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

  1. لإضافة تأثيرات متحركة إلى العناصر المشترَكة بين التنسيقَين، أحِط الدالة البرمجية المركّبة AnimatedContent بـ SharedTransitionLayout. يتم تمرير النطاقات من 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() هي نمط تحويل الحاوية، بينما حالة الاستخدام لـ sharedElement() هي انتقال البطل.
  • عند استخدام دوال 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(
                            sharedContentState = 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() وequals().

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

في الحالات التي قد لا تستخدم فيها 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)
    }
}

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

هناك بعض القيود على واجهات برمجة التطبيقات هذه. وأبرزها:

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