انتقال عناصر مشترک در Compose

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

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

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

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

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

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

کاربرد اولیه

گذار زیر در این بخش ساخته خواهد شد که از آیتم «لیست» کوچک‌تر به آیتم جزئیات بزرگ‌تر منتقل می‌شود:

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

بهترین روش برای استفاده از 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 ، 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() ، عنصر composable باید در یک SharedTransitionScope باشد. عنصر composable SharedTransitionLayout عنصر SharedTransitionScope را فراهم می‌کند. مطمئن شوید که عنصر composable را در همان نقطه سطح بالایی از سلسله مراتب رابط کاربری خود قرار می‌دهید که شامل عناصری است که می‌خواهید به اشتراک بگذارید.

به طور کلی، composableها باید درون یک AnimatedVisibilityScope نیز قرار گیرند. این امر معمولاً با استفاده از AnimatedContent برای جابجایی بین composableها یا هنگام استفاده مستقیم از AnimatedVisibility یا توسط تابع composable NavHost فراهم می‌شود، مگر اینکه شما قابلیت مشاهده را به صورت دستی مدیریت کنید . برای استفاده از چندین scope، scopeهای مورد نیاز خود را در یک CompositionLocal ذخیره کنید، از context receivers در Kotlin استفاده کنید، یا scopeها را به عنوان پارامتر به توابع خود ارسال کنید.

در سناریویی که چندین scope برای پیگیری دارید یا یک سلسله مراتب تو در تو دارید، از CompositionLocals استفاده کنید. CompositionLocal به شما امکان می‌دهد scopeهای دقیقی را برای ذخیره و استفاده انتخاب کنید. از طرف دیگر، وقتی از گیرنده‌های context استفاده می‌کنید، سایر layoutها در سلسله مراتب شما ممکن است به طور تصادفی scopeهای ارائه شده را override کنند. به عنوان مثال، اگر چندین AnimatedContent تو در تو دارید، scopeها می‌توانند override شوند.

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 نیز کار می‌کنند.

برای مثال، در این مثال lazy grid، هر عنصر در 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، اهمیت دارد. قرار دادن نادرست اصلاح‌کننده‌های مؤثر بر اندازه می‌تواند باعث پرش‌های بصری غیرمنتظره در طول تطبیق عنصر مشترک شود.

برای مثال، اگر یک اصلاح‌کننده‌ی فاصله‌گذاری (padding modifier) ​​را در موقعیت‌های مختلف روی دو عنصر مشترک قرار دهید، یک تفاوت بصری در انیمیشن ایجاد می‌شود.

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

کلیدهای منحصر به فرد

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

محدودیت‌های فعلی

این APIها چند محدودیت دارند. مهم‌ترین آنها:

  • هیچ قابلیت همکاری بین Views و Compose پشتیبانی نمی‌شود. این شامل هر composable که AndroidView را در بر می‌گیرد، مانند Dialog یا ModalBottomSheet نیز می‌شود.
  • پشتیبانی خودکار از انیمیشن برای موارد زیر وجود ندارد:
    • ترکیبات تصویر مشترک :
      • ContentScale به طور پیش‌فرض متحرک نیست. به انتهای تنظیم‌شده‌ی ContentScale می‌چسبد.
    • برش شکل - هیچ پشتیبانی داخلی برای انیمیشن خودکار بین شکل‌ها وجود ندارد - برای مثال، انیمیشن از یک مربع به یک دایره هنگام انتقال آیتم.
    • برای موارد پشتیبانی نشده، به جای sharedElement() از Modifier.sharedBounds() استفاده کنید و Modifier.animateEnterExit() به موارد اضافه کنید.