องค์ประกอบแบบใช้ร่วมกันในการเปลี่ยนภาพเป็นวิธีที่ราบรื่นในการเปลี่ยนระหว่างคอมโพสิเบิลที่มีเนื้อหาสอดคล้องกัน มักใช้เพื่อการนําทาง ซึ่งช่วยให้คุณเชื่อมต่อหน้าจอต่างๆ เข้าด้วยกันได้เมื่อผู้ใช้ไปยังหน้าจอต่างๆ
ตัวอย่างเช่น ในวิดีโอต่อไปนี้ คุณจะเห็นรูปภาพและชื่อของอาหารว่างที่แชร์จากหน้าข้อมูลไปยังหน้ารายละเอียด
ในเครื่องมือเขียน มี API ระดับสูง 2-3 รายการที่จะช่วยคุณสร้างองค์ประกอบที่แชร์
SharedTransitionLayout
: เลย์เอาต์ด้านนอกสุดจำเป็นสำหรับการใช้การเปลี่ยนองค์ประกอบที่แชร์ มีSharedTransitionScope
Composable ต้องอยู่ในSharedTransitionScope
จึงจะใช้ตัวแก้ไของค์ประกอบที่แชร์ได้Modifier.sharedElement()
: ตัวแก้ไขที่แจ้งให้SharedTransitionScope
คอมโพสิเบิลที่ควรจับคู่กับคอมโพสิเบิลอื่นModifier.sharedBounds()
: ตัวแก้ไขที่แจ้งให้SharedTransitionScope
ทราบว่าควรใช้ขอบเขตของคอมโพสิเบิลนี้เป็นขอบเขตของคอนเทนเนอร์สำหรับตำแหน่งที่ควรทำการเปลี่ยนsharedBounds()
ได้รับการออกแบบมาสำหรับเนื้อหาที่ดูแตกต่างไปจากเดิม แตกต่างจากsharedElement()
แนวคิดสําคัญในการสร้างองค์ประกอบที่แชร์ในเครื่องมือเขียนคือวิธีทํางานขององค์ประกอบเหล่านั้นกับการวางซ้อนและการครอบตัด ลองดูส่วนการตัดและการวางซ้อนเพื่อดูข้อมูลเพิ่มเติมเกี่ยวกับหัวข้อที่สำคัญนี้
การใช้งานพื้นฐาน
ระบบจะสร้างทรานซิชันต่อไปนี้ในส่วนนี้ โดยเปลี่ยนจากรายการ "รายการ" ขนาดเล็กไปเป็นรายการแบบละเอียดขนาดใหญ่
วิธีที่ดีที่สุดในการใช้ Modifier.sharedElement()
คือใช้ร่วมกับ AnimatedContent
, AnimatedVisibility
หรือ NavHost
เนื่องจากจะจัดการการเปลี่ยนระหว่างคอมโพสิเบิลให้คุณโดยอัตโนมัติ
จุดเริ่มต้นคือ AnimatedContent
พื้นฐานที่มีอยู่ซึ่งมี MainContent
และ DetailsContent
ที่คอมโพสิเบิลได้ก่อนที่จะเพิ่มองค์ประกอบที่แชร์
หากต้องการให้องค์ประกอบที่แชร์เคลื่อนไหวระหว่างเลย์เอาต์ 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()
ลงในเชนตัวแก้ไขคอมโพสิเบิลในคอมโพสิเบิล 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 สิ่งนี้และแสดงภาพเคลื่อนไหวการเปลี่ยนภาพ ทำให้เมตริกเติบโตระหว่างกันได้
@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
ให้
SharedTransitionScope
ตรวจสอบว่าได้วางที่ระดับบนสุดเดียวกันในลำดับชั้น UI ที่มีองค์ประกอบที่คุณต้องการแชร์
โดยทั่วไปแล้ว คุณควรวางคอมโพสิเบิลภายใน 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 } ) }
การจัดเรียงตัวปรับแต่ง
เมื่อใช้ 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()
ในคอมโพสิเบิล ในกรณีนี้ Compose จะวางองค์ประกอบย่อยโดยใช้ข้อจำกัดเป้าหมายและใช้ตัวคูณมาตราส่วนเพื่อแสดงภาพเคลื่อนไหวแทนการเปลี่ยนขนาดเลย์เอาต์เอง
คีย์ที่ไม่ซ้ำกัน
เมื่อทำงานกับองค์ประกอบที่แชร์ซึ่งมีความซับซ้อน แนวทางปฏิบัติที่ดีคือการสร้างคีย์ที่ไม่ใช่สตริง เนื่องจากสตริงมีแนวโน้มที่จะเกิดข้อผิดพลาดที่จะจับคู่ได้ แต่ละคีย์ต้องไม่ซ้ำกันเพื่อให้เกิดการจับคู่ ตัวอย่างเช่น ใน Jetsnack เรามีองค์ประกอบต่อไปนี้ที่แชร์กัน
คุณอาจสร้าง 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 เหล่านี้มีข้อจํากัดบางอย่าง โดยการเปลี่ยนแปลงที่สำคัญที่สุดมีดังนี้
- โดยระบบไม่รองรับความสามารถในการทำงานร่วมกันระหว่างข้อมูลพร็อพเพอร์ตี้และการเขียน ซึ่งรวมถึงคอมโพสิเบิลใดก็ตามที่รวม
AndroidView
เช่นDialog
- ไม่รองรับภาพเคลื่อนไหวอัตโนมัติสำหรับรายการต่อไปนี้
- คอมโพสรูปภาพที่แชร์
ContentScale
จะไม่มีภาพเคลื่อนไหวโดยค่าเริ่มต้น เส้นจะยึดตามจุดสิ้นสุดที่กำหนดไว้ContentScale
- การปักหมุดรูปร่าง - ระบบไม่รองรับภาพเคลื่อนไหวอัตโนมัติระหว่างรูปร่างต่างๆ เช่น ภาพเคลื่อนไหวจากสี่เหลี่ยมจัตุรัสเป็นวงกลมเมื่อรายการเปลี่ยน
- สำหรับกรณีที่ระบบไม่รองรับ ให้ใช้
Modifier.sharedBounds()
แทนsharedElement()
และเพิ่มModifier.animateEnterExit()
ลงในรายการ
- คอมโพสรูปภาพที่แชร์