การเปลี่ยนองค์ประกอบที่แชร์เป็นวิธีการเปลี่ยนผ่าน Composable ที่ราบรื่น ที่มีเนื้อหาที่สอดคล้องกัน มักใช้เพื่อ การนำทางที่ช่วยให้คุณสามารถเชื่อมต่อด้วยภาพบนหน้าจอต่างๆ ในฐานะผู้ใช้ ไปยังส่วนต่างๆ ได้
ตัวอย่างเช่น ในวิดีโอต่อไปนี้ คุณจะเห็นรูปภาพและชื่อของ จะมีการแชร์ของว่างจากหน้ารายชื่อไปยังหน้ารายละเอียด
ใน Compose มี API ระดับสูง 2-3 รายการที่จะช่วยคุณสร้างการแชร์ องค์ประกอบ:
SharedTransitionLayout
: เลย์เอาต์ด้านนอกสุดจำเป็นสำหรับการใช้พื้นที่ที่แชร์ การเปลี่ยนองค์ประกอบ และมีSharedTransitionScope
ความต้องการของ Composable อยู่ในSharedTransitionScope
เพื่อใช้ตัวแก้ไของค์ประกอบที่แชร์Modifier.sharedElement()
: ตัวแก้ไขที่จะแจ้งSharedTransitionScope
Composable ที่ควรจับคู่กับ ComposableModifier.sharedBounds()
: ตัวแก้ไขที่จะแจ้งSharedTransitionScope
ว่าขอบเขตของ Composable นี้ควรใช้เป็น ขอบเขตของคอนเทนเนอร์ที่ควรใช้ ตรงข้ามกับsharedElement()
,sharedBounds()
ออกแบบมาให้ดูแตกต่าง เนื้อหา
แนวคิดสำคัญเมื่อสร้างองค์ประกอบที่ใช้ร่วมกันใน Compose คือวิธีการทำงาน ด้วยการวางซ้อนและการคลิป ลองดูที่การตัดคลิปและ การวางซ้อน เพื่อดูข้อมูลเพิ่มเติมเกี่ยวกับหัวข้อที่สำคัญนี้
การใช้งานพื้นฐาน
การเปลี่ยนแปลงต่อไปนี้จะสร้างขึ้นในส่วนนี้ โดยเปลี่ยนจาก "รายการ" เล็กลง ไปยังรายการที่มีรายละเอียดขนาดใหญ่กว่านี้
![](https://developer.android.com/static/develop/ui/compose/images/animations/shared-element/basic_shared_element_jetsnack.gif?hl=th)
วิธีที่ดีที่สุดในการใช้ Modifier.sharedElement()
คือเมื่อใช้ร่วมกับ
AnimatedContent
, AnimatedVisibility
หรือ NavHost
เพื่อจัดการ
การเปลี่ยนระหว่าง Composable โดยอัตโนมัติให้คุณ
จุดเริ่มต้นคือ AnimatedContent
พื้นฐานที่มีอยู่ซึ่งมี
MainContent
และ DetailsContent
ที่เขียนได้ด้วย Compose ได้ก่อนเพิ่มองค์ประกอบที่แชร์:
![](https://developer.android.com/static/develop/ui/compose/images/animations/shared-element/basic_no_animation_jetsnack.gif?hl=th)
AnimatedContent
โดยไม่มีการเปลี่ยนองค์ประกอบที่แชร์วิธีทำให้องค์ประกอบที่แชร์มีการเคลื่อนไหวระหว่าง 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 ) } } }
เพิ่ม
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
ซึ่งส่งผลให้เกิดภาพเคลื่อนไหวอัตโนมัติต่อไปนี้
![](https://developer.android.com/static/develop/ui/compose/images/animations/shared-element/basic_shared_element_jetsnack.gif?hl=th)
คุณอาจสังเกตเห็นว่าสีพื้นหลังและขนาดของทั้งคอนเทนเนอร์ยังคง
ใช้การตั้งค่า 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 และสร้างภาพเคลื่อนไหวการเปลี่ยน
ระหว่างกัน
@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()
Composable จะต้องอยู่ในรูปแบบ
SharedTransitionScope
Composable 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 } ) }
AnimatedVisibility
การจัดเรียงตัวปรับแต่ง
เมื่อใช้ Modifier.sharedElement()
และ Modifier.sharedBounds()
คำสั่งซื้อของ
ตัวปรับแต่งเชนในกรณี
เช่นเดียวกับส่วนอื่นๆ ใน Compose ตำแหน่งที่ไม่ถูกต้องของตัวปรับที่มีผลต่อขนาด
อาจทำให้เกิดการข้ามภาพที่ไม่คาดคิดระหว่างการจับคู่องค์ประกอบที่แชร์
เช่น ถ้าวางตัวแก้ไขระยะห่างจากขอบไว้ในอีกตำแหน่งหนึ่งบน องค์ประกอบร่วมกัน จะมีความแตกต่างด้านภาพในภาพเคลื่อนไหว
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 เรามีสิ่งต่อไปนี้ องค์ประกอบที่แชร์:
![](https://developer.android.com/static/develop/ui/compose/images/animations/shared-element/unique_keys_shared_elements.jpeg?hl=th)
คุณสร้าง 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 เหล่านี้มีข้อจำกัดบางประการ สิ่งที่เห็นได้ชัดที่สุดคือ
- ระบบไม่รองรับการทำงานร่วมกันระหว่างข้อมูลพร็อพเพอร์ตี้และการเขียน ซึ่งรวมถึง
Composable ที่รวม
AndroidView
เช่นDialog
- ไม่มีการรองรับภาพเคลื่อนไหวอัตโนมัติสำหรับรายการต่อไปนี้
- รูปภาพที่แชร์ได้ด้วย Composable:
ContentScale
ไม่เคลื่อนไหวโดยค่าเริ่มต้น ภาพสแนปจนถึงจุดสิ้นสุดที่ตั้งไว้ContentScale
- การตัดรูปร่าง - ไม่มีการสนับสนุนในตัวสำหรับโหมดอัตโนมัติ ภาพเคลื่อนไหวระหว่างรูปทรงต่างๆ เช่น การสร้างภาพเคลื่อนไหวจากสี่เหลี่ยมจัตุรัสเป็น วงกลมขณะที่รายการเปลี่ยน
- สำหรับเคสที่ไม่รองรับ ให้ใช้
Modifier.sharedBounds()
แทนsharedElement()
และเพิ่มModifier.animateEnterExit()
ลงในรายการ
- รูปภาพที่แชร์ได้ด้วย Composable: