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

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

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

الشكل 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، ما لم تدير مستوى الرؤية يدويًا. ولاستخدام نطاقات متعددة، يمكنك حفظ النطاقات المطلوبة في GenreLocal، أو استخدام أدوات استقبال السياق في 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() إلى العناصر.