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

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

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

الشكل 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() و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)
    }
}

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

لهذه الواجهات بعض القيود. أبرزها:

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