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

Hiệu ứng chuyển đổi thành phần 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 màn hình này thường được dùng để điều hướng, cho phép bạn kết nối trực quan nhiều màn hình 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 danh sách đến trang chi tiết.

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

Trong Compose, có một số API cấp cao giúp bạn tạo 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 thành phần dùng chung. Lớp này cung cấp một SharedTransitionScope. Các thành phần kết hợp cần phải nằm trong SharedTransitionScope để sử dụng đối tượng sửa đổi phần tử dùng chung.
  • Modifier.sharedElement(): Đối tượng sửa đổi gắn cờ đến SharedTransitionScope thà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ờ đến SharedTransitionScope mà các giới hạn của thành phần kết hợp này sẽ được dùng làm giới hạn của vùng chứa ở nơi quá trình chuyển đổi sẽ diễn ra. Trái ngược với sharedElement(), sharedBounds() được thiết kế cho nội dung trực quan khác.

Một khái niệm quan trọng khi tạo các thành phần dùng chung trong Compose là cách các thành phần này hoạt động với lớp phủ và tính năng cắt. Hãy xem phần đoạ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 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:

Hình 2. Ví dụ cơ bản về hiệu ứng chuyển đổi phần tử dùng chung giữa 2 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 bắt đầu 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 thành phần dùng chung:

Hình 3. Kể từ AnimatedContent mà không có 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 dùng chung giữa 2 bố cục, hãy bao quanh thành phần kết hợp AnimatedContent bằng SharedTransitionLayout. Các phạm vi từ SharedTransitionLayoutAnimatedContent được chuyể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 thành phần kết hợp trên 2 thành phần kết hợp phù hợp. Tạo một đố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 giúp 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() để mục được 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 có xảy ra trùng khớp với phần tử dùng chung hay không, hãy trích xuất rememberSharedContentState() thành một biến và truy vấn isMatchFound.

Điều gì dẫn đến ảnh động tự động sau đây:

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

Bạn có thể nhận thấy 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 chung so với phần tử dùng chung

Modifier.sharedBounds() tương tự như Modifier.sharedElement(). Tuy nhiên, đối tượng sửa đổi sẽ khác nhau ở những điểm sau:

  • sharedBounds() dành cho nội dung có hình ảnh khác nhau nhưng cần chia sẻ 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 giống nhau.
  • Với sharedBounds(), nội dung vào và thoát 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ới sharedElement(), chỉ nội dung mục tiêu được hiển thị trong giới hạn biến đổi. Modifier.sharedBounds() có các tham số enterexit để chỉ định cách nội dung cần chuyển đổi, tương tự như cách AnimatedContent hoạt động.
  • Trường hợp sử dụng phổ biến nhất cho sharedBounds()mẫu biến đổi vùng chứa, trong khi đối với sharedElement(), trường hợp sử dụng mẫu là hiệu ứng chuyển đổi chính.
  • Khi sử dụng thành phần kết hợp Text, sharedBounds() được ưu tiên 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.

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

Tìm hiểu phạm vi

Để sử dụng Modifier.sharedElement(), thành phần kết hợp cần phải nằm trong SharedTransitionScope. Thành phần kết hợp SharedTransitionLayout cung cấp SharedTransitionScope. Hãy nhớ đặt ở cùng một điểm cấp cao nhất trong hệ phân cấp giao diện người dùng có chứa các phần tử bạn muốn chia sẻ.

Nhìn chung, bạn cũng nên đặt các thành phần kết hợp bên trong AnimatedVisibilityScope. Điều 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 AnimatedVisibility trực tiếp hay 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 của bạn trong CompositionLocal, sử dụng trình tiếp nhận ngữ cảnh trong Kotlin hoặc truyền các phạm vi dưới dạng tham số vào 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 có một hệ phân cấp được 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 tiếp nhận ngữ cảnh, các bố cục khác trong hệ phân cấp của bạn có thể vô tình ghi đè các phạm vi đã 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ể chuyể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
) {
}

Các phần tử được chia sẻ với AnimatedVisibility

Các ví dụ trước cho thấy cách sử dụng các thành phần dùng chung với AnimatedContent, nhưng các thành phần 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 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 phần tử dùng chung với AnimatedVisibility.

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

Với Modifier.sharedElement()Modifier.sharedBounds(), chuỗi thứ tự của đối tượng sửa đổi rất quan trọng, giống như phần còn lại của Compose. Vị trí không chính xác của các đối tượng sửa đổi ảnh hưởng đến kích thước có thể gây ra bước nhảy hình ảnh không mong muốn trong quá trình so khớp phần tử được chia sẻ.

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 2 thành phần 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
                )
            }
        }
    }
}

Giới hạn phù hợp

Giới hạn không khớp: Lưu ý cách ảnh động phần tử dùng chung có vẻ hơi lệch vì cần đổi kích thước thành giới hạn không chính xác

Đối tượng sửa đổi phần tử dùng chung đã sử dụng trước đố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 các đối tượng sửa đổi phần tử dùng chung. Sau đó, đối tượng sửa đổi này được dùng để lấy giới hạn ban đầu và giới hạn mục tiêu, sau đó là ảnh động giới hạn.

Đối tượng sửa đổi được sử dụng sau đối tượng sửa đổi phần tử dùng chung sử dụng các điều kiện ràng buộc từ 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 phần tử dùng chung tạo ra một loạt các điều kiện ràng buộc dạng ảnh động để dần dần chuyển đổi phần tử 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 thành phần kết hợp. Trong trường hợp này, Compose sẽ bố trí thành phần con bằng cách sử dụng các điều kiện ràng buộc mục tiêu và thay vào đó 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á 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ì các chuỗi có thể dễ gặp lỗi khi so khớp. Mỗi khoá phải là duy nhất để kết quả trùng khớp xảy ra. Ví dụ: trong Jetsnack, chúng ta có các thành phần chung sau đây:

Hình 7. Hình ảnh minh hoạ Jetsnack với chú giải cho từng phần trên giao diện người dùng.

Bạn có thể tạo một enum để đại diện cho loại phần tử dùng chung. Trong ví dụ này, toàn bộ thẻ món ăn vặt cũng có thể xuất hiện từ 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" / "Đượ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 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 thành phần dùng chung theo cách thủ công

Trong trường hợp không dùng AnimatedVisibility hoặc AnimatedContent, bạn có thể tự quản lý chế độ hiển thị của phần tử dùng chung. Sử dụng Modifier.sharedElementWithCallerManagedVisibility() và cung cấp điều kiện của riêng bạn để xác định thời điểm một mục có thể xuất hiện hoặc không xuất hiện:

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

Hạn chế hiện tại

Những 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 hỗ trợ ảnh động tự động cho những mục sau:
    • Thành phần kết hợp Hình ảnh được chia sẻ:
      • ContentScale không được tạo ảnh động theo mặc định. Nó điều chỉnh theo điểm kết thúc đã đặ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 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êm Modifier.animateEnterExit() vào các mục.