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

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

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

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

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

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