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