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