Chuyển đổi phần tử dùng chung trong Compose

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 giữa các thành phần đó. Các thành phần này thường được dùng để điều hướng, cho phép bạn kết nối trực quan các màn hình khác nhau 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 sang trang chi tiết.

Hình 1. Bản minh hoạ thành phần dùng chung của Jetsnack

Trong Compose, có một số 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. Hàm này cung cấp một SharedTransitionScope. Thành phần kết hợp cần nằm trong SharedTransitionScope để sử dụng đối tượng sửa đổi phần tử được chia sẻ.
  • Modifier.sharedElement(): Đối tượng sửa đổi gắn cờ cho thành phần kết hợp cần so khớp với một thành phần kết hợp khác trong SharedTransitionScope.
  • Modifier.sharedBounds(): Đối tượng sửa đổi gắn cờ cho SharedTransitionScope rằng các ranh giới của thành phần kết hợp này sẽ được dùng làm ranh giới vùng chứa cho vị trí diễn ra quá trình chuyển đổi. Ngược lại với sharedElement(), sharedBounds() được thiết kế cho nội dung khác nhau về mặt hình ảnh.

Một khái niệm quan trọng khi tạo các phần tử dùng chung trong Compose là cách các phần tử đó 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

Hiệu ứng chuyển đổi sau đây sẽ được tạo 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:

Hình 2. Ví dụ cơ bản về hiệu ứng chuyển đổi phần tử được chia sẻ giữa hai thành phần kết hợp.

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ó thành phần kết hợp MainContentDetailsContent trước khi thêm các phần tử dùng chung:

Hình 3. Khởi động AnimatedContent mà không có bất kỳ hiệu ứng chuyển đổi phần tử dùng chung nào.

  1. Để tạo ảnh động cho các thành phần được chia sẻ giữa hai bố cục, hãy bao quanh thành phần kết hợp AnimatedContent bằng SharedTransitionLayout. Phạm vi từ SharedTransitionLayoutAnimatedContent được truyền đến MainContentDetailsContent:

    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
                )
            }
        }
    }

  2. Thêm Modifier.sharedElement() vào chuỗi đối tượng sửa đổi có khả năng kết hợp trên hai thành phần kết hợp khớp nhau. Tạo đối tượng SharedContentState và ghi nhớ đối tượng đó bằng rememberSharedContentState(). Đối tượng SharedContentState đang lưu trữ khoá duy nhấ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ụng rememberSharedContentState() để ghi nhớ mục. 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 có xảy ra trường hợp khớp phần tử được chia sẻ 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:

Hình 4. Ví dụ cơ bản về hiệu ứng chuyển đổi phần tử được chia sẻ giữa hai thành phần kết hợp.

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.

Giới hạn 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 này khác nhau ở những điểm sau:

  • sharedBounds() dành cho nội dung khác nhau về mặt hình ảnh nhưng phải có cùng một khu vực giữa các trạng thái, trong khi sharedElement() 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ẽ xuất hiện trong quá trình chuyển đổi giữa hai trạng thái, trong khi với sharedElement(), chỉ nội dung mục tiêu mới được kết xuất trong giới hạn biến đổi. Modifier.sharedBounds() có các tham số enterexit để chỉ định cách chuyển đổi nội dung, tương tự như cách hoạt động của AnimatedContent.
  • Trường hợp sử dụng phổ biến nhất cho sharedBounds()mẫu chuyển đổi vùng chứa, còn đối với sharedElement(), trường hợp sử dụng mẫu là chuyển đổi nhân vật chính.
  • Khi sử dụng thành phần kết hợp Text, bạn nên dùng sharedBounds() để hỗ trợ các thay đổi về phông chữ, chẳng hạn như chuyển đổi giữa in nghiêng và in đậm hoặc thay đổi màu sắc.

Từ ví dụ trước, việc thêm Modifier.sharedBounds() vào RowColumn trong hai trường hợp khác nhau sẽ cho phép chúng ta chia sẻ các giới hạn của hai lớp này và thực hiện ảnh động chuyển đổi, cho phép các lớp này phát triển với 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()
                )
                // ...

        ) {
            // ...
        }
    }
}

Hình 5. Các giới hạn dùng chung giữa hai thành phần kết hợp.

Tìm hiểu về phạm vi

Để sử dụng Modifier.sharedElement(), thành phần kết hợp cần nằm trong SharedTransitionScope. Thành phần kết hợp SharedTransitionLayout cung cấp SharedTransitionScope. Hãy nhớ đặt tại 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 phải được đặt bên trong AnimatedVisibilityScope. Thông tin này thường được cung cấp bằng cách sử dụng AnimatedContent để chuyển đổi giữa các thành phần kết hợp hoặc khi sử dụng trực tiếp AnimatedVisibility hoặc bằng hàm có khả năng kết hợp 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 bắt buộc trong 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ố đến các hàm của bạn.

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 sâu. CompositionLocal cho phép bạn chọn phạm vi chính xác để 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, 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 các 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
) {
}

Các 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 lười 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(
                            state = rememberSharedContentState(key = snack.name),
                            animatedVisibilityScope = this@AnimatedVisibility
                        ),
                        onClick = {
                            selectedSnack = snack
                        }
                    )
                }
            }
        }
    }
    // Contains matching AnimatedContent with sharedBounds modifiers.
    SnackEditDetails(
        snack = selectedSnack,
        onConfirmClick = {
            selectedSnack = null
        }
    )
}

Hình 6.Các thành phần dùng chung với AnimatedVisibility.

Thứ tự đối tượng sửa đổi

Với Modifier.sharedElement()Modifier.sharedBounds(), thứ tự của chuỗi đối tượng sửa đổi là quan trọng, giống như phần còn lại của Compose. Việc đặt sai công cụ sửa đổi ảnh hưởng đến kích thước có thể gây ra hiện tượng chuyển đổi hình ảnh không mong muốn trong quá trình so khớp phần tử dùng chung.

Ví dụ: nếu bạn đặ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ì ảnh động sẽ có sự khác biệt về hình ảnh.

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
                )
            }
        }
    }
}

Giới hạn trùng khớp

Giới hạn không khớp: Hãy lưu ý cách ảnh động phần tử được chia sẻ xuất hiện hơi lệch vì ảnh động này cần đổi kích thước theo các giới hạn không chính xác

Các đối tượng sửa đổi được sử dụng trước khi đối tượng sửa đổi phần tử dùng chung cung cấp các quy tắc ràng buộc cho đối tượng sửa đổi phần tử dùng chung, sau đó được dùng để lấy giới hạn ban đầu và giới hạn mục tiêu, và sau đó là ảnh động giới hạn.

Các đối tượng sửa đổi được sử dụng sau khi các đối tượng sửa đổi phần tử dùng chung 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. Các đối tượng sửa đổi thành phần dùng chung tạo ra một loạt các quy tắc ràng buộc ảnh động để dần biến đổi thành phần con từ kích thước ban đầu thành 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 thành phần kết hợp. Trong trường hợp này, Compose 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ì thay đổi kích thước bố cục.

Khoá duy nhấ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 khoá không phải là chuỗi vì chuỗi có thể dễ gặp 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:

Hình 7. Hình ảnh hiển thị Jetsnack với chú thích cho từng phần của giao diện người dùng.

Bạn có thể tạo một enum để biểu thị loại phần tử được chia sẻ. Trong ví dụ này, toàn bộ thẻ món ăn nhẹ cũng có thể xuất hiện ở nhiều vị trí trên màn hình chính, chẳng hạn như trong phần "Phổ biến" và "Đề xuất". Bạn có thể tạo một khoá có snackId, origin ("Phổ biến" / "Đề 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 sử dụng các lớp dữ liệu cho khoá vì các lớp này triển khai hashCode()isEquals().

Quản lý chế độ hiển thị của các phần tử được chia sẻ 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ị phần tử được chia sẻ. Sử dụng Modifier.sharedElementWithCallerManagedVisibility() và cung cấp điều kiện riêng để xác định thời điểm hiển thị một mục:

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 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ư Dialog.
  • Không có tính năng hỗ trợ ảnh động tự động cho các trường hợp sau:
    • Thành phần kết hợp Hình ảnh dùng chung:
      • ContentScale không có ảnh động theo mặc định. Nó sẽ tự động căn chỉnh với điểm cuối đã đặt ContentScale.
    • 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 chuyển đổi mục.
    • Đố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êm Modifier.animateEnterExit() vào các mục.