องค์ประกอบแบบใช้ร่วมกันในการเปลี่ยนภาพเป็นวิธีที่ราบรื่นในการเปลี่ยนระหว่างคอมโพสิเบิลที่มีเนื้อหาสอดคล้องกัน มักใช้เพื่อการนําทาง ซึ่งช่วยให้คุณเชื่อมต่อหน้าจอต่างๆ เข้าด้วยกันได้เมื่อผู้ใช้ไปยังหน้าจอต่างๆ
ตัวอย่างเช่น ในวิดีโอต่อไปนี้ คุณจะเห็นรูปภาพและชื่อของอาหารว่างที่แชร์จากหน้าข้อมูลไปยังหน้ารายละเอียด
ในเครื่องมือเขียน มี API ระดับสูง 2-3 รายการที่จะช่วยคุณสร้างองค์ประกอบที่แชร์
SharedTransitionLayout
: เลย์เอาต์ด้านนอกสุดที่จําเป็นต่อการใช้การเปลี่ยนองค์ประกอบที่แชร์ มีSharedTransitionScope
คอมโพสิเบิลต้องอยู่ใน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
- 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() ) // ... ) { // ... } } }
ทําความเข้าใจขอบเขต
หากต้องการใช้ 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 } ) }
การจัดเรียงตัวปรับแต่ง
สำหรับ 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 เรามีองค์ประกอบต่อไปนี้ที่แชร์กัน
คุณอาจสร้าง 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()
ลงในรายการ
- คอมโพสรูปภาพที่แชร์