انتقال عناصر مشترک در «نوشتن»

انتقال‌های عنصر مشترک روشی یکپارچه برای انتقال بین ترکیب‌شدنی‌هایی است که محتوای سازگار بین آن‌ها وجود دارد. این انتقال‌ها اغلب برای پیمایش استفاده می‌شوند و به شما امکان می‌دهند صفحه‌های مختلف را به‌صورت بصری به هم متصل کنید تا کاربر بتواند بین آن‌ها پیمایش کند.

برای مثال، در ویدیو زیر، می‌توانید ببینید که تصویر و عنوان تنقلات از صفحه فهرستگان به صفحه جزئیات هم‌رسانی شده است.

شکل ۱. نسخه نمایشی عنصر هم‌رسانی‌شده Jetsnack.

در Compose، چند میانای برنامه‌سازی کاربردی سطح بالا وجود دارد که به شما کمک می‌کند عناصر مشترک ایجاد کنید:

  • SharedTransitionLayout: بیرونی‌ترین چیدمان موردنیاز برای پیاده‌سازی انتقال‌های عنصر هم‌رسانی‌شده. این مؤلفه SharedTransitionScope ارائه می‌دهد. برای استفاده از اصلاح‌کننده‌های عنصر مشترک، ترکیب‌پذیرها باید در SharedTransitionScope باشند.
  • Modifier.sharedElement(): اصلاح‌کننده‌ای که به SharedTransitionScope پرچم می‌زند که composable باید با composable دیگری مطابقت داده شود.
  • Modifier.sharedBounds(): اصلاح‌کننده‌ای که به SharedTransitionScope نشان می‌دهد که مرزهای این composable باید به‌عنوان مرزهای محفظه برای جایی که انتقال باید انجام شود استفاده شود. برخلاف sharedElement()، sharedBounds() برای محتوای متفاوت ازنظر بصری طراحی شده است.

یک مفهوم مهم هنگام ایجاد عناصر مشترک در Compose نحوه کار آن‌ها با هم‌پوشانی‌ها و برش است. برای کسب اطلاعات بیشتر درباره این موضوع مهم، به بخش برش و هم‌پوشانی مراجعه کنید.

استفاده پایه

گذار زیر در این بخش ساخته خواهد شد، گذار از مورد «فهرست» کوچک‌تر به مورد مفصل بزرگ‌تر:

انتقال عنصر مشترک پایه بین دو composable در Jetsnack
شکل ۲. نمونه‌ای ساده از انتقال عنصر مشترک بین دو ترکیب‌شدنی.

بهترین راه برای استفاده از Modifier.sharedElement()، استفاده از آن در ترکیب با AnimatedContent، AnimatedVisibility، یا NavHost است، زیرا این کار انتقال بین composableها را به‌طور خودکار برای شما مدیریت می‌کند.

نقطه شروع یک AnimatedContent پایه موجود است که دارای MainContent و DetailsContent ترکیب‌پذیر قبل‌از افزودن عناصر مشترک است:

پویانمایی که انتقال پیش‌فرض محو تدریجی و لغزشی را بدون عناصر مشترک نشان می‌دهد
شکل ۳. درحال شروع AnimatedContent بدون هیچ‌گونه انتقال عنصر هم‌رسانی‌شده.

  1. برای اینکه عناصر مشترک بین دو چیدمان پویانمایی شوند، AnimatedContent composable را با 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 را پُرسمان کنید.

این کار منجر به پویانمایی خودکار زیر می‌شود:

پویانمایی که تصویر و نوشتار را درحال گسترش یافتن به‌طور روان در صفحه‌نمایش‌ها نشان می‌دهد
شکل ۴. نمونه‌ای ساده از انتقال عنصر مشترک بین دو ترکیب‌شدنی.

ممکن است متوجه شوید که رنگ پس‌زمینه و اندازه کل محفظه همچنان از تنظیمات پیش‌فرض AnimatedContent استفاده می‌کند.

مرزهای مشترک درمقابل عنصر مشترک

Modifier.sharedBounds() شبیه به Modifier.sharedElement() است. بااین‌حال، اصلاح‌کننده‌ها از جنبه‌های زیر متفاوت هستند:

  • sharedBounds() برای محتوایی است که ازنظر بصری متفاوت است اما باید ناحیه یکسانی را بین حالت‌ها هم‌رسانی کند، درحالی‌که sharedElement() انتظار دارد محتوا یکسان باشد.
  • با sharedBounds()، محتوایی که وارد صفحه‌نمایش می‌شود و از آن خارج می‌شود درطول انتقال بین دو حالت قابل‌مشاهده است، درحالی‌که با sharedElement() فقط محتوای هدف در مرزهای تبدیل ارائه می‌شود. Modifier.sharedBounds() دارای پارامترهای enter و exit برای مشخص کردن نحوه انتقال محتوا است، مشابه نحوه عملکرد AnimatedContent.
  • رایج‌ترین مورد استفاده برای sharedBounds()، الگوی تبدیل محفظه است، درحالی‌که برای sharedElement()، مورد استفاده نمونه انتقال قهرمان است.
  • هنگام استفاده از Text composables، 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()
                )
                // ...

        ) {
            // ...
        }
    }
}

شکل ۵. مرزهای مشترک بین دو ترکیب‌پذیر.

آشنایی با دامنه‌ها

برای استفاده از Modifier.sharedElement()، ترکیب‌پذیر باید در SharedTransitionScope باشد. SharedTransitionLayout composable provides the 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
        }
    )
}

شکل ۶. عناصر هم‌رسانی‌شده با 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() در یک composable استفاده کنید. در این مورد، Compose فرزند را بااستفاده از محدودیت‌های هدف چیدمان می‌کند و به‌جای تغییر اندازه خود چیدمان، از یک ضریب مقیاس برای اجرای پویانمایی استفاده می‌کند.

کلیدهای یکتا

هنگام کار با عناصر مشترک پیچیده، بهتر است کلیدی ایجاد کنید که رشته نباشد، زیرا رشته‌ها ممکن است برای مطابقت دادن مستعد خطا باشند. هر کلید باید یکتا باشد تا مطابقت‌ها انجام شود. برای مثال، در Jetsnack عناصر مشترک زیر را داریم:

میانای حاشیه‌نویسی‌شده Jetsnack که عناصر میانای کاربر هم‌رسانی‌شده را شناسایی می‌کند
شکل ۷. تصویری که Jetsnack را با حاشیه‌نویسی برای هر بخش از میانای کاربر نشان می‌دهد.

می‌توانید یک enum برای نمایش نوع عنصر مشترک ایجاد کنید. در این مثال کل کارت میان‌وعده می‌تواند از چندین مکان مختلف در صفحه اصلی نیز ظاهر شود، برای مثال در بخش «محبوب» و «توصیه‌شده». می‌توانید کلیدی ایجاد کنید که دارای 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.
    • برش شکل - پشتیبانی داخلی برای پویانمایی خودکار بین شکل‌ها وجود ندارد - برای مثال، پویانمایی از مربع به دایره هنگام انتقال مورد.
    • برای موارد پشتیبانی‌نشده، به‌جای sharedElement() از Modifier.sharedBounds() استفاده کنید و Modifier.animateEnterExit() را به موارد اضافه کنید.