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

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

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

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

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

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

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

استفاده پایه

انتقال زیر در این بخش ساخته خواهد شد و از آیتم "فهرست" کوچکتر به آیتم با جزئیات بزرگتر منتقل می شود:

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

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

نقطه شروع یک 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() را به زنجیره اصلاح کننده قابل ترکیب خود در دو composable که مطابقت دارند اضافه کنید. یک شی 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 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()
                )
                // ...

        ) {
            // ...
        }
    }
}

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

Scopes را درک کنید

برای استفاده از Modifier.sharedElement() ، composable باید در SharedTransitionScope باشد. Composable 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(
                            state = 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 اهمیت دارد. قرارگیری نادرست اصلاح‌کننده‌های تأثیرگذار بر اندازه می‌تواند باعث پرش‌های بصری غیرمنتظره در طول تطبیق عناصر مشترک شود.

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

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

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

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

شکل 7. تصویر Jetsnack را با حاشیه نویسی برای هر قسمت از UI نشان می دهد.

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