การเปลี่ยนองค์ประกอบที่แชร์ใน Compose

การเปลี่ยนองค์ประกอบที่แชร์เป็นวิธีที่ราบรื่นในการเปลี่ยนระหว่าง Composable ที่มีเนื้อหาสอดคล้องกัน มักใช้เพื่อการนําทาง ซึ่งช่วยให้คุณเชื่อมต่อหน้าจอต่างๆ เข้าด้วยกันได้เมื่อผู้ใช้ไปยังหน้าจอต่างๆ

ตัวอย่างเช่น ในวิดีโอต่อไปนี้ คุณจะเห็นรูปภาพและชื่อของอาหารว่างที่แชร์จากหน้าข้อมูลไปยังหน้ารายละเอียด

รูปที่ 1 การสาธิตองค์ประกอบที่แชร์ของ Jetsnack

ใน Compose มี API ระดับสูง 2-3 รายการที่จะช่วยคุณสร้างองค์ประกอบที่แชร์ ได้แก่

  • SharedTransitionLayout: เลย์เอาต์ด้านนอกสุดจำเป็นสำหรับการใช้การเปลี่ยนองค์ประกอบที่แชร์ และมี SharedTransitionScope Composable ต้องอยู่ใน SharedTransitionScope จึงจะใช้ตัวแก้ไของค์ประกอบที่แชร์ได้
  • Modifier.sharedElement(): ตัวแก้ไขที่แจ้งเตือนไปยัง SharedTransitionScope Composable ที่ควรจับคู่กับ Composable อื่น
  • Modifier.sharedBounds(): ตัวแก้ไขที่แจ้งไปยัง SharedTransitionScope ว่าควรใช้ขอบเขตของ Composable นี้เป็นขอบเขตของคอนเทนเนอร์สำหรับสถานที่ที่จะเปลี่ยน sharedBounds() ออกแบบมาสำหรับเนื้อหาที่แตกต่างออกไปเมื่อเทียบกับ sharedElement()

แนวคิดสําคัญในการสร้างองค์ประกอบที่แชร์ในเครื่องมือเขียนคือวิธีทํางานขององค์ประกอบเหล่านั้นกับการวางซ้อนและการครอบตัด โปรดดูข้อมูลเพิ่มเติมเกี่ยวกับหัวข้อสำคัญนี้ในส่วนการครอบตัดและการวางซ้อน

การใช้งานพื้นฐาน

การเปลี่ยนต่อไปนี้จะสร้างขึ้นในส่วนนี้โดยเปลี่ยนจากรายการ "list" เล็กๆ ไปยังรายการที่มีรายละเอียดที่ใหญ่ขึ้น

รูปที่ 2 ตัวอย่างพื้นฐานของการเปลี่ยนองค์ประกอบที่แชร์ระหว่าง Composable 2 รายการ

วิธีที่ดีที่สุดในการใช้ Modifier.sharedElement() คือการใช้ร่วมกับ AnimatedContent, AnimatedVisibility หรือ NavHost เนื่องจากเครื่องมือนี้จะช่วยจัดการการเปลี่ยนระหว่าง Composable โดยอัตโนมัติให้คุณ

จุดเริ่มต้นคือ AnimatedContent พื้นฐานที่มีอยู่ซึ่งมี MainContent และ DetailsContent ที่เขียนได้ด้วย Compose ก่อนที่จะเพิ่มองค์ประกอบที่แชร์

รูปที่ 3 เริ่มต้น AnimatedContent โดยไม่มีการเปลี่ยนองค์ประกอบที่แชร์

  1. เพื่อให้องค์ประกอบที่แชร์มีการเคลื่อนไหวระหว่าง 2 เลย์เอาต์ ให้ล้อมรอบ 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 ทั้ง 2 รายการที่ตรงกัน สร้างออบเจ็กต์ 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 ตัวอย่างพื้นฐานของการเปลี่ยนองค์ประกอบที่แชร์ระหว่าง Composable 2 รายการ

คุณอาจเห็นว่าสีพื้นหลังและขนาดของคอนเทนเนอร์ทั้งใบยังคงใช้การตั้งค่า AnimatedContent เริ่มต้น

ขอบเขตที่แชร์เทียบกับองค์ประกอบที่แชร์

Modifier.sharedBounds() คล้ายกับ Modifier.sharedElement() อย่างไรก็ตาม ตัวแก้ไขจะแตกต่างกันดังนี้

  • sharedBounds() มีไว้สำหรับเนื้อหาที่มีลักษณะแตกต่างกัน แต่ควรมีพื้นที่เหมือนกันระหว่างรัฐ ในขณะที่ sharedElement() คาดว่าเนื้อหาจะเหมือนกัน
  • เมื่อใช้ sharedBounds() เนื้อหาการเข้าและออกจากหน้าจอจะปรากฏให้เห็นในระหว่างการเปลี่ยนระหว่าง 2 รัฐ แต่เมื่อใช้ sharedElement() เฉพาะเนื้อหาเป้าหมายจะแสดงผลในขอบเขตการเปลี่ยนรูปแบบ Modifier.sharedBounds()มีพารามิเตอร์ enter และ exit สำหรับระบุวิธีเปลี่ยนเนื้อหา ซึ่งคล้ายกับวิธีการทำงานของ AnimatedContent
  • กรณีการใช้งานที่พบบ่อยที่สุดสำหรับ sharedBounds() คือรูปแบบการเปลี่ยนรูปแบบคอนเทนเนอร์ ในขณะที่กรณีการใช้งานตัวอย่างสำหรับ sharedElement() คือการเปลี่ยนผ่านหลัก
  • เมื่อใช้คอมโพสิเบิล Text คุณควรใช้ sharedBounds() เพื่อรองรับการเปลี่ยนแปลงแบบอักษร เช่น การเปลี่ยนระหว่างตัวเอียงกับตัวหนาหรือการเปลี่ยนสี

จากตัวอย่างก่อนหน้า การเพิ่ม Modifier.sharedBounds() ลงใน Row และ Column ใน 2 สถานการณ์ที่ต่างกันจะช่วยให้เราแชร์ขอบเขตของ 2 สิ่งนี้และแสดงภาพเคลื่อนไหวการเปลี่ยนภาพ ทำให้เมตริกเติบโตระหว่างกันได้

@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 ขอบที่แชร์ระหว่างคอมโพสิเบิล 2 รายการ

ทำความเข้าใจขอบเขต

หากต้องการใช้ Modifier.sharedElement() คอมโพสิเบิลต้องอยู่ใน SharedTransitionScope คอมโพสิเบิล SharedTransitionLayout ให้ SharedTransitionScope ตรวจสอบว่าได้วางไว้ที่จุดระดับบนสุดเดียวกันในลําดับชั้น UI ที่มีองค์ประกอบที่คุณต้องการแชร์

โดยทั่วไป คุณควรวาง Composable ไว้ใน AnimatedVisibilityScope ด้วย ซึ่งโดยทั่วไปจะใช้ AnimatedContent เพื่อสลับระหว่าง Composable หรือเมื่อใช้ AnimatedVisibility โดยตรง หรือโดยใช้ฟังก์ชัน Composable ของ 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 ด้วย

ตัวอย่างเช่น ในตัวอย่างตารางกริดแบบ Lazy นี้องค์ประกอบแต่ละรายการจะรวมอยู่ใน AnimatedVisibility เมื่อคลิกรายการ เนื้อหาจะมีเอฟเฟกต์เป็นภาพที่ดึงออกจาก UI ไปยังคอมโพเนนต์ที่คล้ายกับกล่องโต้ตอบ

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 การวางตัวแก้ไขที่ส่งผลต่อขนาดไม่ถูกต้องอาจทําให้ภาพกระโดดขึ้นโดยไม่คาดคิดระหว่างการจับคู่องค์ประกอบที่แชร์

เช่น หากวางตัวแก้ไขระยะห่างจากขอบในตำแหน่งที่แตกต่างกันบนองค์ประกอบที่แชร์ 2 รายการ ภาพเคลื่อนไหวจะมีความแตกต่างกัน

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 ซึ่งรวมถึง Composable ที่รวม AndroidView เช่น Dialog
  • ไม่รองรับภาพเคลื่อนไหวอัตโนมัติสำหรับรายการต่อไปนี้
    • รูปภาพที่แชร์ได้:
      • ContentScale ไม่เคลื่อนไหวโดยค่าเริ่มต้น สแนปไปยังจุดสิ้นสุดที่ตั้งไว้ ContentScale
    • การปักหมุดรูปร่าง - ระบบไม่รองรับภาพเคลื่อนไหวอัตโนมัติระหว่างรูปร่างต่างๆ เช่น ภาพเคลื่อนไหวจากสี่เหลี่ยมจัตุรัสเป็นวงกลมเมื่อรายการเปลี่ยน
    • สำหรับกรณีที่ระบบไม่รองรับ ให้ใช้ Modifier.sharedBounds() แทน sharedElement() และเพิ่ม Modifier.animateEnterExit() ลงในรายการ