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

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

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

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

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

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

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

استفاده پایه

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

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

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

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

شکل ۳. درحال شروع 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 را پُرسمان کنید.

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

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

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

به‌طورکلی، composableها باید در داخل 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
        }
    )
}

شکل ۶. عناصر هم‌رسانی‌شده با 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 عناصر مشترک زیر را داریم:

شکل ۷. تصویری که 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() را به موارد اضافه کنید.