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

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

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

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

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

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

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

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

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

انتقال أساسي للعناصر المشتركة بين دالتَين مركّبتَين في Jetsnack
الشكل 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 أيضًا.

على سبيل المثال، في مثال LazyColumn هذا، يتم تضمين كل عنصر في 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 = SharedTransitionScope.ResizeMode.scaleToBounds() للحركة، أو Modifier.skipToLookaheadSize() في دالة مركّبة. في هذه الحالة، يضع Compose العنصر الفرعي باستخدام قيود الاستهداف، ويستخدم بدلاً من ذلك عامل قياس لتنفيذ الحركة بدلاً من تغيير حجم التنسيق نفسه.

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

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

واجهة 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() إلى العناصر.