การเปลี่ยนองค์ประกอบแบบใช้ร่วมกันเป็นวิธีที่ราบรื่นในการเปลี่ยนระหว่าง Composable ที่มีเนื้อหาที่สอดคล้องกัน โดยมักใช้สำหรับการนำทาง ซึ่งช่วยให้คุณเชื่อมต่อหน้าจอต่างๆ ด้วยภาพได้เมื่อผู้ใช้ ไปยังหน้าจอเหล่านั้น
ตัวอย่างเช่น ในวิดีโอต่อไปนี้ คุณจะเห็นว่ามีการแชร์รูปภาพและชื่อของ ของว่างจากหน้าข้อมูลไปยังหน้ารายละเอียด
ใน Compose มี API ระดับสูง 2-3 รายการที่จะช่วยคุณสร้างองค์ประกอบที่แชร์
SharedTransitionLayout
: เลย์เอาต์ชั้นนอกสุดที่จำเป็นต่อการใช้การเปลี่ยนภาพองค์ประกอบที่ใช้ร่วมกัน โดยจะแสดงSharedTransitionScope
Composable ต้องอยู่ในSharedTransitionScope
เพื่อใช้ตัวแก้ไของค์ประกอบที่แชร์Modifier.sharedElement()
: ตัวแก้ไขที่ติดค่าสถานะไปยังSharedTransitionScope
Composable ที่ควรจับคู่กับ Composable อื่นModifier.sharedBounds()
: ตัวแก้ไขที่แจ้งไปยังSharedTransitionScope
ว่าควรใช้ขอบเขตของ Composable นี้เป็น ขอบเขตของคอนเทนเนอร์สำหรับตำแหน่งที่ควรเกิดการเปลี่ยนsharedBounds()
ได้รับการออกแบบมาสำหรับเนื้อหาที่แตกต่างกันในเชิงภาพ ซึ่งแตกต่างจากsharedElement()
แนวคิดที่สำคัญเมื่อสร้างองค์ประกอบที่แชร์ใน Compose คือวิธีที่องค์ประกอบทำงาน ร่วมกับภาพซ้อนทับและการครอบตัด ดูข้อมูลเพิ่มเติมเกี่ยวกับหัวข้อสำคัญนี้ได้ที่ส่วนการครอบตัดและ การซ้อนทับ
การใช้งานพื้นฐาน
การเปลี่ยนภาพต่อไปนี้จะสร้างขึ้นในส่วนนี้ โดยเปลี่ยนจากรายการ "รายการ" ที่มีขนาดเล็กกว่าไปเป็นรายการแบบละเอียดที่มีขนาดใหญ่กว่า

วิธีที่ดีที่สุดในการใช้ Modifier.sharedElement()
คือการใช้ร่วมกับ
AnimatedContent
, AnimatedVisibility
หรือ NavHost
เนื่องจากวิธีนี้จะจัดการ
การเปลี่ยนผ่านระหว่าง Composable ให้คุณโดยอัตโนมัติ
จุดเริ่มต้นคือ AnimatedContent
พื้นฐานที่มีอยู่ซึ่งมี MainContent
และ DetailsContent
ที่ประกอบได้ก่อนที่จะเพิ่มองค์ประกอบที่แชร์

AnimatedContent
โดยไม่มีการเปลี่ยนภาพองค์ประกอบแบบใช้ร่วมกันหากต้องการทำให้องค์ประกอบที่แชร์เคลื่อนไหวระหว่างเลย์เอาต์ทั้ง 2 ให้ ล้อมรอบ
AnimatedContent
Composable ด้วย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 ) } } }
เพิ่ม
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
ซึ่งจะส่งผลให้เกิดภาพเคลื่อนไหวอัตโนมัติดังนี้

คุณอาจสังเกตเห็นว่าสีพื้นหลังและขนาดของคอนเทนเนอร์ทั้งหมดยังคงใช้AnimatedContent
การตั้งค่าเริ่มต้น
ขอบเขตที่ใช้ร่วมกันเทียบกับองค์ประกอบที่ใช้ร่วมกัน
Modifier.sharedBounds()
คล้ายกับ Modifier.sharedElement()
อย่างไรก็ตาม ตัวแก้ไขจะแตกต่างกันในลักษณะต่อไปนี้
sharedBounds()
ใช้สำหรับเนื้อหาที่แตกต่างกันในเชิงภาพ แต่ควรแชร์พื้นที่เดียวกันระหว่างรัฐ ในขณะที่sharedElement()
คาดหวังให้เนื้อหาเหมือนกัน- เมื่อใช้
sharedBounds()
เนื้อหาที่เข้าและออกจากหน้าจอจะ มองเห็นได้ในระหว่างการเปลี่ยนสถานะทั้ง 2 สถานะ ในขณะที่เมื่อใช้sharedElement()
ระบบจะแสดงเฉพาะเนื้อหาเป้าหมายในขอบเขตที่ เปลี่ยนรูปModifier.sharedBounds()
มีพารามิเตอร์enter
และexit
สำหรับ การระบุวิธีเปลี่ยนเนื้อหา คล้ายกับวิธีที่AnimatedContent
ทำงาน - กรณีการใช้งานที่พบบ่อยที่สุดสำหรับ
sharedBounds()
คือรูปแบบการเปลี่ยนคอนเทนเนอร์ ส่วนกรณีการใช้งานตัวอย่างสำหรับsharedElement()
คือการเปลี่ยนฮีโร่ - เมื่อใช้ Composable ของ
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() ) // ... ) { // ... } } }
ทำความเข้าใจขอบเขต
หากต้องการใช้ Modifier.sharedElement()
คอมโพสเซเบิลต้องอยู่ใน SharedTransitionScope
SharedTransitionLayout
Composable จะมี
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 Grid นี้ องค์ประกอบแต่ละรายการจะอยู่ในแท็ก
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( 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 ที่เหลือ การวางตัวแก้ไขที่ส่งผลต่อขนาดอย่างไม่ถูกต้อง
อาจทำให้เกิดการกระโดดของภาพที่ไม่คาดคิดในระหว่างการจับคู่องค์ประกอบที่ใช้ร่วมกัน
เช่น หากคุณวางตัวแก้ไขระยะห่างจากขอบในตำแหน่งที่ต่างกันในองค์ประกอบที่แชร์ 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 จะวางเลย์เอาต์ขององค์ประกอบย่อยโดยใช้ข้อจํากัดเป้าหมาย และใช้
ปัจจัยการปรับขนาดเพื่อทําภาพเคลื่อนไหวแทนการเปลี่ยนขนาดเลย์เอาต์
เอง
requiredSize()
คีย์ที่ไม่ซ้ำกัน
เมื่อทำงานกับองค์ประกอบที่แชร์ที่ซับซ้อน แนวทางปฏิบัติที่ดีคือการสร้างคีย์ ที่ไม่ใช่สตริง เนื่องจากสตริงอาจเกิดข้อผิดพลาดในการจับคู่ได้ แต่ละคีย์ต้องไม่ซ้ำกันเพื่อให้เกิดการจับคู่ ตัวอย่างเช่น ใน 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()
และ
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
หรือModalBottomSheet
- ระบบไม่รองรับภาพเคลื่อนไหวอัตโนมัติสำหรับรายการต่อไปนี้
- Composable ของรูปภาพที่แชร์
ContentScale
จะไม่เคลื่อนไหวโดยค่าเริ่มต้น โดยจะสแนปไปที่จุดสิ้นสุดที่ตั้งไว้ContentScale
- การครอบตัดรูปร่าง - ไม่มีการรองรับการเปลี่ยนภาพอัตโนมัติ ระหว่างรูปร่างในตัว เช่น การเปลี่ยนภาพจากสี่เหลี่ยมจัตุรัสเป็น วงกลมเมื่อรายการเปลี่ยน
- สำหรับกรณีที่ไม่รองรับ ให้ใช้
Modifier.sharedBounds()
แทนsharedElement()
และเพิ่มModifier.animateEnterExit()
ลงในรายการ
- Composable ของรูปภาพที่แชร์