Hiệu ứng chuyển đổi phần tử dùng chung là một cách liền mạch để chuyển đổi giữa các thành phần kết hợp có nội dung nhất quán. Các hiệu ứng chuyển đổi này thường được dùng để điều hướng, cho phép bạn kết nối các màn hình khác nhau một cách trực quan khi người dùng di chuyển giữa các màn hình đó.
Ví dụ: trong video sau, bạn có thể thấy hình ảnh và tiêu đề của món ăn nhẹ được chia sẻ từ trang thông tin đến trang thông tin chi tiết.
Trong Compose, có một vài API cấp cao giúp bạn tạo các phần tử dùng chung:
SharedTransitionLayout: Bố cục ngoài cùng cần thiết để triển khai các hiệu ứng chuyển đổi phần tử dùng chung. Thư viện này cung cấpSharedTransitionScope. Thành phần kết hợp cần phải nằm trongSharedTransitionScopeđể sử dụng đối tượng sửa đổi thành phần dùng chung.Modifier.sharedElement(): Đối tượng sửa đổi gắn cờ choSharedTransitionScopethành phần kết hợp cần được so khớp với một thành phần kết hợp khác.Modifier.sharedBounds(): Đối tượng sửa đổi gắn cờ choSharedTransitionScoperằng ranh giới của thành phần kết hợp này phải được dùng làm ranh giới vùng chứa cho nơi diễn ra quá trình chuyển đổi. Trái ngược vớisharedElement(),sharedBounds()được thiết kế cho nội dung khác biệt về mặt hình ảnh.
Một khái niệm quan trọng khi tạo phần tử dùng chung trong Compose là cách các phần tử này hoạt động với lớp phủ và tính năng cắt. Hãy xem phần cắt và lớp phủ để tìm hiểu thêm về chủ đề quan trọng này.
Cách sử dụng cơ bản
Quá trình chuyển đổi sau đây sẽ được xây dựng trong phần này, chuyển đổi từ mục "danh sách" nhỏ hơn sang mục chi tiết lớn hơn:
Cách tốt nhất để sử dụng Modifier.sharedElement() là kết hợp với AnimatedContent, AnimatedVisibility hoặc NavHost, vì cách này sẽ tự động quản lý quá trình chuyển đổi giữa các thành phần kết hợp cho bạn.
Điểm xuất phát là một AnimatedContent cơ bản hiện có, có một thành phần kết hợp MainContent và DetailsContent trước khi thêm các phần tử dùng chung:
AnimatedContent mà không có bất kỳ chuyển đổi phần tử dùng chung nào.Để tạo ảnh động cho các thành phần dùng chung giữa hai bố cục, hãy bao quanh thành phần kết hợp
AnimatedContentbằngSharedTransitionLayout. Phạm vi từSharedTransitionLayoutvàAnimatedContentđược truyền đếnMainContentvà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 ) } } }
Thêm
Modifier.sharedElement()vào chuỗi modifier composable trên hai composable phù hợp. Tạo đối tượngSharedContentStatevà ghi nhớ đối tượng đó bằngrememberSharedContentState(). Đối tượngSharedContentStateđang lưu trữ khoá riêng biệt xác định các phần tử được chia sẻ. Cung cấp một khoá duy nhất để xác định nội dung và sử dụngrememberSharedContentState()cho mục cần ghi nhớ.AnimatedContentScopeđược truyền vào đối tượng sửa đổi, dùng để điều phối ảnh động.@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 ) // ... } } }
Để biết thông tin về việc liệu có xảy ra kết quả trùng khớp phần tử dùng chung hay không, hãy trích xuất rememberSharedContentState() vào một biến và truy vấn isMatchFound.
Điều này dẫn đến ảnh động tự động sau:
Bạn có thể nhận thấy rằng màu nền và kích thước của toàn bộ vùng chứa vẫn sử dụng chế độ cài đặt AnimatedContent mặc định.
Ranh giới dùng chung so với phần tử dùng chung
Modifier.sharedBounds() tương tự như Modifier.sharedElement().
Tuy nhiên, các đối tượng sửa đổi có những điểm khác biệt sau:
sharedBounds()dành cho nội dung khác biệt về mặt hình ảnh nhưng phải có cùng một vùng giữa các trạng thái, trong khisharedElement()yêu cầu nội dung phải giống nhau.- Với
sharedBounds(), nội dung vào và ra khỏi màn hình sẽ hiển thị trong quá trình chuyển đổi giữa hai trạng thái, trong khi vớisharedElement(), chỉ nội dung mục tiêu mới được hiển thị trong giới hạn chuyển đổi.Modifier.sharedBounds()có các tham sốentervàexitđể chỉ định cách chuyển đổi nội dung, tương tự như cách hoạt động củaAnimatedContent. - Trường hợp sử dụng phổ biến nhất cho
sharedBounds()là mẫu biến đổi vùng chứa, trong khi trường hợp sử dụng mẫu chosharedElement()là chuyển đổi chính. - Khi sử dụng thành phần kết hợp
Text, bạn nên dùngsharedBounds()để hỗ trợ các thay đổi về phông chữ, chẳng hạn như chuyển đổi giữa kiểu in nghiêng và in đậm hoặc thay đổi màu sắc.
Trong ví dụ trước, việc thêm Modifier.sharedBounds() vào Row và Column trong hai trường hợp khác nhau sẽ cho phép chúng ta chia sẻ ranh giới của hai thành phần kết hợp và thực hiện ảnh động chuyển đổi, cho phép chúng phát triển giữa nhau:
@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() ) // ... ) { // ... } } }
Tìm hiểu về phạm vi
Để sử dụng Modifier.sharedElement(), composable cần phải nằm trong một
SharedTransitionScope. Thành phần kết hợp SharedTransitionLayout cung cấp
SharedTransitionScope. Hãy nhớ đặt thành phần này ở cùng một điểm cấp cao nhất trong hệ phân cấp giao diện người dùng chứa các phần tử bạn muốn chia sẻ.
Nhìn chung, các thành phần kết hợp cũng nên được đặt bên trong một
AnimatedVisibilityScope. Thông thường, bạn có thể cung cấp tính năng này bằng cách sử dụng AnimatedContent để chuyển đổi giữa các composable hoặc khi sử dụng trực tiếp AnimatedVisibility, hoặc bằng hàm composable NavHost, trừ phi bạn quản lý chế độ hiển thị theo cách thủ công. Để sử dụng nhiều phạm vi, hãy lưu các phạm vi cần thiết vào một CompositionLocal, sử dụng trình nhận ngữ cảnh trong Kotlin hoặc truyền các phạm vi dưới dạng tham số cho hàm.
Sử dụng CompositionLocals trong trường hợp bạn có nhiều phạm vi cần theo dõi hoặc một hệ phân cấp lồng nhau sâu. CompositionLocal cho phép bạn chọn chính xác phạm vi cần lưu và sử dụng. Mặt khác, khi bạn sử dụng trình nhận ngữ cảnh, các bố cục khác trong hệ phân cấp có thể vô tình ghi đè các phạm vi được cung cấp.
Ví dụ: nếu bạn có nhiều AnimatedContent lồng nhau, thì các phạm vi có thể bị ghi đè.
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") } // ... } } } }
Ngoài ra, nếu hệ phân cấp của bạn không được lồng sâu, bạn có thể truyền phạm vi xuống dưới dạng tham số:
@Composable fun MainContent( animatedVisibilityScope: AnimatedVisibilityScope, sharedTransitionScope: SharedTransitionScope ) { } @Composable fun Details( animatedVisibilityScope: AnimatedVisibilityScope, sharedTransitionScope: SharedTransitionScope ) { }
Phần tử dùng chung với AnimatedVisibility
Các ví dụ trước cho thấy cách sử dụng các phần tử dùng chung với AnimatedContent, nhưng
các phần tử dùng chung cũng hoạt động với AnimatedVisibility.
Ví dụ: trong ví dụ về lưới tải từng phần này, mỗi phần tử được gói trong AnimatedVisibility. Khi người dùng nhấp vào mục, nội dung sẽ có hiệu ứng hình ảnh được kéo ra khỏi giao diện người dùng vào một thành phần giống như hộp thoại.
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.Thứ tự của đối tượng sửa đổi
Với Modifier.sharedElement() và Modifier.sharedBounds(), thứ tự của chuỗi đối tượng sửa đổi rất quan trọng, cũng như với phần còn lại của Compose. Việc đặt sai vị trí của các đối tượng sửa đổi ảnh hưởng đến kích thước có thể gây ra hiện tượng nhảy hình ảnh ngoài dự kiến trong quá trình so khớp phần tử dùng chung.
Ví dụ: nếu bạn đặt một đối tượng sửa đổi khoảng đệm ở một vị trí khác trên hai phần tử dùng chung, thì sẽ có sự khác biệt về hình ảnh trong ảnh động.
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 ) } } } }
Ranh giới trùng khớp |
Ranh giới không khớp: Hãy lưu ý rằng ảnh động của thành phần dùng chung có vẻ hơi lệch vì cần đổi kích thước theo ranh giới không chính xác |
|---|---|
Các đối tượng sửa đổi được dùng trước đối tượng sửa đổi thành phần dùng chung sẽ cung cấp các quy tắc ràng buộc cho đối tượng sửa đổi thành phần dùng chung. Sau đó, các quy tắc ràng buộc này được dùng để lấy ranh giới ban đầu và ranh giới mục tiêu, rồi đến ảnh động ranh giới.
Đối tượng sửa đổi được dùng sau đối tượng sửa đổi phần tử dùng chung sẽ sử dụng các quy tắc ràng buộc trước đó để đo lường và tính toán kích thước mục tiêu của phần tử con. Đối tượng sửa đổi thành phần dùng chung tạo ra một loạt các giá trị ràng buộc ảnh động để chuyển đổi dần thành phần con từ kích thước ban đầu sang kích thước mục tiêu.
Trường hợp ngoại lệ là nếu bạn sử dụng resizeMode = ScaleToBounds() cho ảnh động hoặc Modifier.skipToLookaheadSize() trên một composable. Trong trường hợp này, Compose sẽ bố trí thành phần con bằng các quy tắc ràng buộc mục tiêu và sử dụng hệ số tỷ lệ để thực hiện ảnh động thay vì tự thay đổi kích thước bố cục.
Khoá riêng biệt
Khi làm việc với các phần tử dùng chung phức tạp, bạn nên tạo một khoá không phải là chuỗi, vì chuỗi có thể dễ bị lỗi khi so khớp. Mỗi khoá phải là duy nhất để có thể so khớp. Ví dụ: trong Jetsnack, chúng ta có các phần tử dùng chung sau đây:
Bạn có thể tạo một enum để biểu thị loại phần tử dùng chung. Trong ví dụ này, toàn bộ thẻ đồ ăn nhẹ cũng có thể xuất hiện ở nhiều vị trí khác nhau trên màn hình chính, chẳng hạn như trong mục "Phổ biến" và "Được đề xuất". Bạn có thể tạo một khoá có snackId, origin ("Phổ biến" / "Được đề xuất") và type của phần tử dùng chung sẽ được chia sẻ:
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 ) ) // ... }
Bạn nên dùng các lớp dữ liệu cho khoá vì các lớp này triển khai hashCode() và equals().
Quản lý chế độ hiển thị của các phần tử dùng chung theo cách thủ công
Trong trường hợp không sử dụng AnimatedVisibility hoặc AnimatedContent,
bạn có thể tự quản lý chế độ hiển thị của thành phần dùng chung. Sử dụng Modifier.sharedElementWithCallerManagedVisibility() và cung cấp điều kiện riêng để xác định thời điểm một mục sẽ hiển thị hoặc không:
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) } }
Các điểm hạn chế hiện tại
Các API này có một số hạn chế. Đáng chú ý nhất:
- Không hỗ trợ khả năng tương tác giữa Khung hiển thị và Compose. Điều này bao gồm mọi thành phần kết hợp gói
AndroidView, chẳng hạn nhưDialoghoặcModalBottomSheet. - Không có tính năng hỗ trợ ảnh động tự động cho những trường hợp sau:
- Thành phần kết hợp hình ảnh dùng chung:
ContentScalekhông được tạo ảnh động theo mặc định. Nó sẽ bám vào điểm cuối của tập hợpContentScale.
- Cắt hình dạng – Không có tính năng hỗ trợ tích hợp sẵn cho ảnh động tự động giữa các hình dạng, ví dụ: tạo ảnh động từ hình vuông sang hình tròn khi mục chuyển đổi.
- Đối với các trường hợp không được hỗ trợ, hãy sử dụng
Modifier.sharedBounds()thay vìsharedElement()và thêmModifier.animateEnterExit()vào các mục.
- Thành phần kết hợp hình ảnh dùng chung: