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