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

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

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

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

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

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

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

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

ระบบจะสร้างทรานซิชันต่อไปนี้ในส่วนนี้ โดยเปลี่ยนจากรายการ "รายการ" ขนาดเล็กไปเป็นรายการแบบละเอียดขนาดใหญ่

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

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

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

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

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

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

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

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

จากตัวอย่างก่อนหน้านี้ การเพิ่ม Modifier.sharedBounds() ลงใน Row และ Column ใน 2 สถานการณ์ที่แตกต่างกันจะช่วยให้เราแชร์ขอบเขตของ 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 ที่มีองค์ประกอบที่คุณต้องการแชร์

โดยทั่วไปแล้ว คุณควรวางคอมโพสิเบิลภายใน 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 ด้วย

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

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