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

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

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

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

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

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

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

استفاده پایه

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

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

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

نقطه شروع 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 را پُرسمان کنید.

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

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

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

شکل ۶. عناصر با AnimatedVisibility هم‌رسانی شد.

ترتیب تغییردهنده

با Modifier.sharedElement() و Modifier.sharedBounds()، ترتیب زنجیره اصلاح‌گر اهمیت دارد، همانند بقیه «نوشتن». جای‌گذاری نادرست اصلاح‌کننده‌های تأثیرگذار بر اندازه می‌تواند باعث پرش‌های دیداری غیرمنتظره درطول تطبیق عنصر هم‌رسانی‌شده شود.

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

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 را با توضیحات هر بخش از میانای کاربر نشان می‌دهد.

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

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

این «میاناهای برنامه‌سازی کاربردی» چند محدودیت دارند. به‌طور قابل‌توجه:

  • از هم‌کنش‌پذیری بین «نماها» و «نوشتن» پشتیبانی نمی‌شود. این شامل هر عنصر ترکیبی‌ای می‌شود که AndroidView را می‌پیچد، مثل Dialog یا ModalBottomSheet.
  • برای موارد زیر از پویانمایی خودکار پشتیبانی نمی‌شود:
    • Shared Image composables:
      • ‫ContentScale به‌طور پیش‌فرض پویانمایی نمی‌شود. به انتهای تنظیم‌شده می‌چسبد ContentScale.
    • برش شکل - پشتیبانی داخلی برای پویانمایی خودکار بین شکل‌ها وجود ندارد - برای مثال، پویانمایی از مربع به دایره هنگام انتقال عنصر.
    • برای موارد پشتیبانی‌نشده، به‌جای sharedElement() از Modifier.sharedBounds() استفاده کنید و Modifier.animateEnterExit() را به موارد اضافه کنید.