Tạo ảnh động cho các phần tử trong Jetpack Compose

1. Giới thiệu

Biểu trưng Jetpack Compose

Lần cập nhật gần đây nhất: 21/11/2023

Trong lớp học lập trình này, bạn sẽ tìm hiểu cách sử dụng một số API Ảnh động trong Jetpack Compose.

Jetpack Compose là một bộ công cụ giao diện người dùng hiện đại được thiết kế để đơn giản hoá quá trình phát triển giao diện người dùng. Nếu mới sử dụng Jetpack Compose, bạn có thể muốn thử một số lớp học lập trình trước đây.

Kiến thức bạn sẽ học được

  • Cách sử dụng một số API ảnh động cơ bản

Điều kiện tiên quyết

Bạn cần có

2. Thiết lập

Tải mã lớp học lập trình xuống. Sau đây là cách sao chép kho lưu trữ:

$ git clone https://github.com/android/codelab-android-compose.git

Ngoài ra, bạn có thể tải kho lưu trữ xuống dưới dạng tệp ZIP:

Nhập dự án AnimationCodelab trong Android Studio.

Nhập lớp học lập trình ảnh động vào Android Studio

Dự án có nhiều mô-đun trong đó:

  • start là trạng thái bắt đầu của lớp học lập trình.
  • finished là trạng thái cuối cùng của ứng dụng sau khi bạn hoàn thành lớp học lập trình này.

Hãy đảm bảo bạn đã chọn start trong trình đơn thả xuống cho cấu hình chạy.

Hiển thị điểm bắt đầu đã chọn trong Android Studio

Chúng ta sẽ bắt đầu xử lý một số tình huống ảnh động trong chương tiếp theo. Mỗi đoạn mã chúng ta xử lý trong lớp học lập trình này đều được đánh dấu bằng một chú thích // TODO. Một mẹo nhỏ là mở cửa sổ công cụ TODO trong Android Studio rồi chuyển đến từng nhận xét về TODO cho chương đó.

Danh sách VIỆC CẦN LÀM trong Android Studio

3. Tạo ảnh động cho thay đổi đơn giản về giá trị

Hãy bắt đầu bằng một trong những API Ảnh động đơn giản nhất trong Compose: API animate*AsState. Bạn nên sử dụng API này khi tạo ảnh động cho các thay đổi của State.

Chạy cấu hình start rồi thử chuyển đổi các thẻ bằng cách nhấp vào nút "Home" (Trang chủ) và "Work" (Công việc) ở trên cùng. Tính năng này không chuyển đổi nội dung thẻ, nhưng bạn có thể thấy màu nền của nội dung thay đổi.

Đã chọn thẻ Trang chủ

Đã chọn thẻ Công việc

Nhấp vào TODO 1 trong cửa sổ công cụ TODO và xem cách này được triển khai. Nó nằm trong thành phần kết hợp Home.

val backgroundColor = if (tabPage == TabPage.Home) Seashell else GreenLight

Ở đây, tabPage là một TabPage được đối tượng State hỗ trợ. Màu nền sẽ chuyển đổi giữa màu hồng đào và màu xanh lục tuỳ thuộc vào giá trị. Chúng ta muốn tạo ảnh động cho sự thay đổi về giá trị này.

Để tạo ảnh động cho một thay đổi đơn giản về giá trị như thế này, chúng ta có thể sử dụng các API animate*AsState. Bạn có thể tạo giá trị ảnh động bằng cách gói giá trị thay đổi với biến thể tương ứng của thành phần kết hợp animate*AsState, trong trường hợp này là animateColorAsState. Giá trị trả về là đối tượng State<T> nên chúng ta có thể sử dụng thuộc tính uỷ quyền cục bộ cùng phần khai báo by để xử lý giá trị này như một biến thông thường.

val backgroundColor by animateColorAsState(
        targetValue = if (tabPage == TabPage.Home) Seashell else GreenLight,
        label = "background color")

Chạy lại ứng dụng rồi thử chuyển sang các thẻ khác. Ảnh thay đổi màu giờ sẽ là ảnh động.

Ảnh động thay đổi màu giữa các thẻ

4. Ảnh động hiển thị

Nếu cuộn nội dung của ứng dụng thì bạn sẽ thấy nút hành động nổi mở rộng và thu nhỏ tuỳ theo hướng cuộn.

Đã mở rộng nút Chỉnh sửa hành động nổi

Chỉnh sửa nút hành động nổi nhỏ

Tìm TODO 2-1 và kiểm tra cách hoạt động của nút này Tệp này nằm trong thành phần kết hợp HomeFloatingActionButton. Dòng chữ "EDIT" (Chỉnh sửa) sẽ xuất hiện hoặc bị ẩn bằng câu lệnh if.

if (extended) {
    Text(
        text = stringResource(R.string.edit),
        modifier = Modifier
            .padding(start = 8.dp, top = 3.dp)
    )
}

Để tạo ảnh động cho thay đổi trong chế độ hiển thị này, bạn chỉ cần thay thế if bằng thành phần kết hợp AnimatedVisibility.

AnimatedVisibility(extended) {
    Text(
        text = stringResource(R.string.edit),
        modifier = Modifier
            .padding(start = 8.dp, top = 3.dp)
    )
}

Chạy ứng dụng ngay và đồng thời xem FAB sẽ mở rộng và thu nhỏ như thế nào.

Ảnh động nút chỉnh sửa thao tác nổi

AnimatedVisibility chạy ảnh động mỗi khi giá trị Boolean được chỉ định thay đổi. Theo mặc định, AnimatedVisibility cho thấy phần tử bằng cách làm mờ và mở rộng, ngoài ra còn ẩn phần tử đó bằng cách làm mờ và thu nhỏ. Hành vi trên phù hợp với ví dụ về FAB này. Tuy vậy, chúng ta cũng có thể tuỳ chỉnh hành vi đó.

Thử nhấp vào nút hành động nổi và bạn sẽ thấy thông báo có nội dung "Tính năng chỉnh sửa không được hỗ trợ". Mã này cũng sử dụng AnimatedVisibility để tạo hiệu ứng ảnh động cho sự xuất hiện và biến mất của nó. Tiếp theo, bạn sẽ tuỳ chỉnh hành vi này để nội dung thông báo trượt vào từ trên cùng và trượt ra lên trên cùng.

Thông báo chi tiết về việc tính năng chỉnh sửa không được hỗ trợ

Tìm TODO 2-2 và xem mã trong thành phần kết hợp EditMessage.

AnimatedVisibility(
    visible = shown
) {
    Surface(
        modifier = Modifier.fillMaxWidth(),
        color = MaterialTheme.colorScheme.secondary,
        elevation = 4.dp
    ) {
        Text(
            text = stringResource(R.string.edit_message),
            modifier = Modifier.padding(16.dp)
        )
    }
}

Để tuỳ chỉnh ảnh động, hãy thêm tham số enterexit vào thành phần kết hợp AnimatedVisibility.

Tham số enter phải là một thực thể của EnterTransition. Trong ví dụ này, chúng ta có thể sử dụng hàm slideInVertically để tạo một EnterTransitionslideOutVertically cho quá trình chuyển đổi thoát. Thay đổi mã như bên dưới:

AnimatedVisibility(
    visible = shown,
    enter = slideInVertically(),
    exit = slideOutVertically()
)

Chạy lại ứng dụng, nhấp vào nút chỉnh sửa, bạn có thể thấy ảnh động trông đẹp hơn nhưng không chính xác. Đó là do hành vi mặc định của slideInVerticallyslideOutVertically sử dụng một nửa chiều cao của mục.

Trượt dọc theo chiều cắt ngắn

Đối với chuyển đổi enter (vào): chúng ta có thể điều chỉnh hành vi mặc định để sử dụng toàn bộ chiều cao của mục nhằm tạo ảnh động đúng cách bằng cách thiết lập tham số initialOffsetY. initialOffsetY phải là hàm lambda trả về vị trí ban đầu.

Hàm lambda nhận một đối số, chiều cao của phần tử. Để đảm bảo mục đó trượt vào từ đầu màn hình, chúng ta trả về giá trị âm vì điểm đầu màn hình có giá trị bằng 0. Chúng ta muốn ảnh động bắt đầu từ -height đến 0 (vị trí nghỉ cuối cùng) để ảnh động trượt vào từ trên xuống và tạo hiệu ứng động.

Khi sử dụng slideInVertically, độ lệch mục tiêu sau khi trượt vào luôn là 0 (pixel). Bạn có thể chỉ định initialOffsetY dưới dạng một giá trị tuyệt đối, hoặc tỷ lệ phần trăm chiều cao đầy đủ của phần tử thông qua hàm lambda.

Tương tự, slideOutVertically giả định độ lệch ban đầu là 0 nên bạn chỉ cần chỉ định targetOffsetY.

AnimatedVisibility(
    visible = shown,
    enter = slideInVertically(
        // Enters by sliding down from offset -fullHeight to 0.
        initialOffsetY = { fullHeight -> -fullHeight }
    ),
    exit = slideOutVertically(
        // Exits by sliding up from offset 0 to -fullHeight.
        targetOffsetY = { fullHeight -> -fullHeight }
    )
) {
    Surface(
        modifier = Modifier.fillMaxWidth(),
        color = MaterialTheme.colorScheme.secondary,
        elevation = 4.dp
    ) {
        Text(
            text = stringResource(R.string.edit_message),
            modifier = Modifier.padding(16.dp)
        )
    }
}

Khi chạy lại ứng dụng, chúng ta có thể thấy ảnh động phù hợp hơn những gì mà chúng ta mong đợi:

Ảnh động trượt vào với hiệu ứng độ lệch hoạt động

Chúng ta có thể tuỳ chỉnh ảnh động nhiều hơn bằng tham số animationSpec. animationSpec là một tham số phổ biến cho nhiều API Ảnh động, bao gồm EnterTransitionExitTransition. Chúng ta có thể truyền một trong các loại AnimationSpec để chỉ định cách giá trị ảnh động sẽ thay đổi theo thời gian. Trong ví dụ này, hãy sử dụng AnimationSpec dựa trên thời lượng đơn giản. Bạn có thể tạo hàm này bằng hàm tween. Thời lượng là 150 mili giây và tốc độ là LinearOutSlowInEasing. Đối với ảnh động thoát, hãy sử dụng cùng một hàm tween cho tham số animationSpec, nhưng có thời lượng là 250 mili giây và tốc độ là FastOutLinearInEasing.

Mã kết quả sẽ có dạng như bên dưới:

AnimatedVisibility(
    visible = shown,
    enter = slideInVertically(
        // Enters by sliding down from offset -fullHeight to 0.
        initialOffsetY = { fullHeight -> -fullHeight },
        animationSpec = tween(durationMillis = 150, easing = LinearOutSlowInEasing)
    ),
    exit = slideOutVertically(
        // Exits by sliding up from offset 0 to -fullHeight.
        targetOffsetY = { fullHeight -> -fullHeight },
        animationSpec = tween(durationMillis = 250, easing = FastOutLinearInEasing)
    )
) {
    Surface(
        modifier = Modifier.fillMaxWidth(),
        color = MaterialTheme.colorScheme.secondary,
        elevation = 4.dp
    ) {
        Text(
            text = stringResource(R.string.edit_message),
            modifier = Modifier.padding(16.dp)
        )
    }
}

Chạy ứng dụng rồi nhấp lại vào nút hành động nổi. Bạn có thể thấy thông báo đang trượt vào và ra khỏi đầu màn hình theo nhiều hàm easing và thời lượng:

Ảnh động minh họa thông báo chỉnh sửa trượt từ trên xuống

5. Ảnh động thay đổi kích thước nội dung

Ứng dụng này hiển thị một số chủ đề trong nội dung. Thử nhấp vào một trong các thẻ, thẻ này sẽ mở ra và hiển thị văn bản nội dung cho chủ đề. Thẻ chứa văn bản sẽ mở rộng và thu nhỏ khi nội dung được ẩn hoặc hiển thị.

Danh sách chủ đề được thu gọn

Đã mở rộng danh sách chủ đề

Xem mã cho TODO 3 trong thành phần kết hợp TopicRow.

Column(
    modifier = Modifier
        .fillMaxWidth()
        .padding(16.dp)
) {
    // ... the title and the body
}

Thành phần kết hợp Column này sẽ thay đổi kích thước khi nội dung của nó thay đổi. Chúng ta có thể tạo ảnh động cho thay đổi về kích thước bằng cách thêm đối tượng sửa đổi animateContentSize.

Column(
    modifier = Modifier
        .fillMaxWidth()
        .padding(16.dp)
        .animateContentSize()
) {
    // ... the title and the body
}

Chạy ứng dụng rồi nhấp vào một trong các chủ đề. Bạn có thể thấy chủ đề mở rộng và thu gọn cùng với ảnh động.

Ảnh động mở rộng và thu gọn danh sách chủ đề

Bạn cũng có thể tuỳ chỉnh animateContentSize bằng một animationSpec tuỳ chỉnh. Chúng ta có thể cung cấp các lựa chọn để thay đổi loại ảnh động từ spring (ảnh động dựa trên lực lò xo) sang tween (quá trình tạo hình ảnh đi giữa các khung hình chính), v.v. Vui lòng xem tài liệu về Ảnh động tuỳ chỉnh để biết thêm thông tin chi tiết.

6. Tạo ảnh động nhiều giá trị

Giờ thì chúng ta đã quen với một số API ảnh động cơ bản, hãy tìm hiểu cách API Transition hỗ trợ chúng ta tạo ảnh động phức tạp hơn. API Transition giúp chúng ta theo dõi cho đến khi tất cả ảnh động trên Transition hoàn tất. Như đã thấy ở phần trước, chúng ta chưa làm được việc này khi sử dụng từng API animate*AsState. API Transition cũng giúp chúng ta xác định từng transitionSpec khi chuyển đổi giữa các trạng thái. Hãy xem chúng ta có thể sử dụng bằng cách nào:

Đối với ví dụ này, chúng ta tuỳ chỉnh chỉ báo thẻ. Đây là hình chữ nhật hiển thị trên thẻ hiện được chọn.

Đã chọn thẻ Trang chủ

Đã chọn thẻ Công việc

Tìm TODO 4 trong thành phần kết hợp HomeTabIndicator rồi xem cách triển khai chỉ báo thẻ.

val indicatorLeft = tabPositions[tabPage.ordinal].left
val indicatorRight = tabPositions[tabPage.ordinal].right
val color = if (tabPage == TabPage.Home) PaleDogwood else Green

Ở đây, indicatorLeft là vị trí theo chiều ngang cạnh bên trái của chỉ báo trong hàng thẻ. indicatorRight là vị trí theo chiều ngang cạnh bên phải của chỉ báo. Màu sắc cũng thay đổi giữa màu hồng đào và màu xanh lục.

Để tạo ảnh động cho nhiều giá trị này cùng một lúc, chúng ta có thể sử dụng Transition. Bạn có thể tạo Transition bằng hàm updateTransition. Truyền chỉ mục của thẻ hiện được chọn làm tham số targetState.

Bạn có thể khai báo cho từng giá trị ảnh động bằng các hàm mở rộng animate* của Transition. Trong ví dụ này, chúng ta sẽ sử dụng animateDpanimateColor. Các khối này lấy một khối lambda, và chúng ta có thể chỉ định giá trị mục tiêu cho mỗi trạng thái. Chúng ta đã biết giá trị mục tiêu của trạng thái trên là gì, do đó có thể gói những giá trị này như bên dưới. Đừng quên rằng chúng ta có thể sử dụng nội dung khai báo by và tạo lại thuộc tính uỷ quyền cục bộ tại đây do các hàm animate* trả về đối tượng State.

val transition = updateTransition(tabPage, label = "Tab indicator")
val indicatorLeft by transition.animateDp(label = "Indicator left") { page ->
   tabPositions[page.ordinal].left
}
val indicatorRight by transition.animateDp(label = "Indicator right") { page ->
   tabPositions[page.ordinal].right
}
val color by transition.animateColor(label = "Border color") { page ->
   if (page == TabPage.Home) PaleDogwood else Green
}

Hãy chạy ứng dụng ngay để thấy rằng việc chuyển đổi thẻ giờ đã thú vị hơn rất nhiều. Khi nhấp vào thẻ, giá trị trạng thái tabPage sẽ thay đổi và tất cả giá trị ảnh động liên kết với transition cũng bắt đầu tạo ảnh động thành giá trị được chỉ định cho trạng thái mục tiêu.

Ảnh động giữa thẻ trang chủ và thẻ công việc

Ngoài ra, chúng ta có thể chỉ định tham số transitionSpec để tuỳ chỉnh hoạt động của ảnh động. Ví dụ: chúng ta có thể đạt được hiệu ứng đàn hồi cho chỉ báo bằng cách làm cho cạnh gần đích đến di chuyển nhanh hơn các cạnh khác. Chúng ta có thể sử dụng hàm infix isTransitioningTo trong lambda transitionSpec để xác định hướng thay đổi trạng thái.

val transition = updateTransition(
    tabPage,
    label = "Tab indicator"
)
val indicatorLeft by transition.animateDp(
    transitionSpec = {
        if (TabPage.Home isTransitioningTo TabPage.Work) {
            // Indicator moves to the right.
            // The left edge moves slower than the right edge.
            spring(stiffness = Spring.StiffnessVeryLow)
        } else {
            // Indicator moves to the left.
            // The left edge moves faster than the right edge.
            spring(stiffness = Spring.StiffnessMedium)
        }
    },
    label = "Indicator left"
) { page ->
    tabPositions[page.ordinal].left
}
val indicatorRight by transition.animateDp(
    transitionSpec = {
        if (TabPage.Home isTransitioningTo TabPage.Work) {
            // Indicator moves to the right
            // The right edge moves faster than the left edge.
            spring(stiffness = Spring.StiffnessMedium)
        } else {
            // Indicator moves to the left.
            // The right edge moves slower than the left edge.
            spring(stiffness = Spring.StiffnessVeryLow)
        }
    },
    label = "Indicator right"
) { page ->
    tabPositions[page.ordinal].right
}
val color by transition.animateColor(
    label = "Border color"
) { page ->
    if (page == TabPage.Home) PaleDogwood else Green
}

Chạy lại ứng dụng và thử chuyển đổi các thẻ.

Hiệu ứng co giãn tuỳ chỉnh khi chuyển đổi thẻ

Android Studio hỗ trợ hoạt động kiểm tra hiệu ứng Chuyển tiếp trong tính năng Xem trước của Compose. Để sử dụng tính năng Xem trước ảnh động, hãy bắt đầu chế độ tương tác bằng cách nhấp vào biểu tượng "Bắt đầu xem trước ảnh động" ở góc trên cùng bên phải của một thành phần kết hợp trong bản xem trước (biểu tượng Biểu tượng xem trước ảnh động). Hãy thử nhấp vào biểu tượng của thành phần kết hợp PreviewHomeTabBar. Thao tác này sẽ mở một ngăn "Animations" mới.

Bạn có thể chạy ảnh động bằng cách nhấp vào nút biểu tượng "Phát". Bạn cũng có thể kéo thanh tua để xem từng khung ảnh động. Để xem thông tin mô tả rõ hơn về các giá trị ảnh động, bạn có thể chỉ định tham số label trong updateTransition cũng như các phương thức animate*.

Tìm ảnh động trong Android Studio

7. Hoạt ảnh lặp lại

Hãy thử nhấp vào nút làm mới bên cạnh nhiệt độ hiện tại. Ứng dụng bắt đầu tải thông tin thời tiết mới nhất (giả vờ). Trước khi quá trình tải hoàn tất, bạn sẽ thấy chỉ báo đang tải là một vòng tròn màu xám và một thanh. Hãy tạo ảnh động cho giá trị alpha của chỉ báo này để làm rõ hơn quy trình đang diễn ra.

Ảnh tĩnh về thẻ thông tin phần giữ chỗ chưa được tạo ảnh động.

Tìm TODO 5 trong thành phần kết hợp LoadingRow.

val alpha = 1f

Chúng tôi muốn làm cho giá trị này tạo hiệu ứng động từ 0f đến 1f nhiều lần. Chúng ta có thể sử dụng InfiniteTransition cho mục đích này. API này tương tự như API Transition trong phần trước. Cả hai đều tạo ảnh động cho nhiều giá trị, nhưng trong khi Transition tạo ảnh động cho giá trị dựa trên thay đổi về trạng thái, InfiniteTransition sẽ tạo ảnh động cho giá trị vô thời hạn.

Để tạo InfiniteTransition, hãy sử dụng hàm rememberInfiniteTransition. Sau đó, bạn có thể khai báo từng thay đổi giá trị ảnh động bằng một trong các hàm mở rộng animate* của InfiniteTransition. Trong trường hợp này, chúng ta sẽ tạo ảnh động giá trị alpha, vì vậy hãy sử dụng animatedFloat. Tham số initialValue phải là 0ftargetValue 1f. Chúng ta cũng có thể chỉ định AnimationSpec cho ảnh động này, nhưng API này chỉ sử dụng InfiniteRepeatableSpec. Hãy dùng hàm infiniteRepeatable để tạo một hàm. AnimationSpec này gói mọi AnimationSpec dựa trên thời lượng và khiến nó lặp lại được. Ví dụ như mã kết quả sẽ có dạng như dưới đây.

val infiniteTransition = rememberInfiniteTransition()
val alpha by infiniteTransition.animateFloat(
    initialValue = 0f,
    targetValue = 1f,
    animationSpec = infiniteRepeatable(
        animation = keyframes {
            durationMillis = 1000
            0.7f at 500
        },
        repeatMode = RepeatMode.Reverse
    ),
    label = "alpha"
)

Mặc định repeatModeRepeatMode.Restart . Quá trình chuyển đổi này từ initialValue sang targetValue và bắt đầu lại tại initialValue. Bằng cách đặt repeatMode thành RepeatMode.Reverse, ảnh động sẽ chuyển từ initialValue sang targetValue, sau đó từ targetValue sang initialValue. Ảnh động tiến từ 0 đến 1 rồi 1 đến 0.

Ảnh động keyFrames là một loại animationSpec khác (một số ảnh động khác là tweenspring) cho phép thay đổi giá trị đang diễn ra ở từng mili giây. Ban đầu, chúng tôi đặt durationMillis thành 1000 mili giây. Sau đó, chúng ta có thể xác định các khung hình chính trong ảnh động, ví dụ: ở 500 mili giây của ảnh động, chúng ta muốn giá trị alpha là 0,7f. Quá trình trên thay đổi tiến trình ảnh động, giúp tiến trình này tiến triển nhanh từ 0 đến 0,7 trong vòng 500 mili giây của ảnh động, và từ 0,7 đến 1,0 từ 500 mili giây đến 1000 mili giây của ảnh động, sau đó chậm lại ở cuối.

Nếu muốn có nhiều khung hình chính, chúng ta có thể xác định nhiều keyFrames như bên dưới:

animation = keyframes {
   durationMillis = 1000
   0.7f at 500
   0.9f at 800
}

Chạy ứng dụng rồi thử nhấp vào nút làm mới. Bây giờ, bạn có thể xem ảnh động chỉ báo tải.

Lặp lại nội dung giữ chỗ động

8. Ảnh động cử chỉ

Trong phần cuối cùng này, chúng ta sẽ tìm hiểu về cách chạy ảnh động dựa trên phương thức nhập bằng cách chạm. Chúng ta sẽ xây dựng một công cụ sửa đổi swipeToDismiss từ đầu.

Tìm TODO 6-1 trong đối tượng sửa đổi swipeToDismiss. Ở đây, chúng ta đang cố gắng tạo một công cụ sửa đổi có thể vuốt thành phần khi chạm. Khi phần tử xoay sang cạnh màn hình, chúng ta gọi lệnh gọi lại onDismissed để có thể xoá phần tử.

Để xây dựng một công cụ sửa đổi swipeToDismiss, có một số khái niệm chính mà chúng ta cần hiểu rõ. Trước tiên, người dùng đặt ngón tay trên màn hình tạo ra một sự kiện chạm với toạ độ x và y. Sau đó, họ sẽ di chuyển ngón tay sang phải – di chuyển x và y dựa trên chuyển động. Mục mà họ đang chạm cần di chuyển bằng ngón tay, vậy nên chúng ta sẽ cập nhật vị trí của mục này dựa trên vị trí và tốc độ của sự kiện chạm.

Chúng ta có thể sử dụng một số khái niệm nêu trong tài liệu về Cử chỉ trong Compose. Bằng cách sử dụng đối tượng sửa đổi pointerInput, chúng ta có thể truy cập ở cấp thấp vào các sự kiện chạm con trỏ sắp đến cũng như theo dõi tốc độ mà người dùng kéo bằng cách sử dụng cùng một con trỏ. Nếu người dùng thả ra trước khi vượt quá ranh giới bị loại bỏ, thì mục sẽ khôi phục trở lại vị trí.

Trong trường hợp này, bạn cần xem xét một vài điều khác biệt. Trước tiên, bất kỳ ảnh động nào đang diễn ra cũng đều có thể bị chặn bằng sự kiện chạm. Tiếp đến, giá trị ảnh động có thể không phải là nguồn đáng tin cậy duy nhất. Nói cách khác, chúng ta có thể cần đồng bộ hoá giá trị ảnh động với các giá trị đến từ sự kiện chạm.

Animatable là API ảnh động cấp thấp nhất mà chúng tôi thấy từ trước đến nay. API này có nhiều tính năng hữu ích cho nhiều trường hợp cử chỉ (chẳng hạn như khả năng điều chỉnh giá trị mới ngay lập tức sau một cử chỉ) và dừng mọi ảnh động đang chạy khi một sự kiện chạm mới được kích hoạt. Hãy tạo một thực thể của Animatable rồi dùng thực thể này để thể hiện độ dời theo chiều ngang của phần tử có thể vuốt. Lưu ý nhập Animatable từ androidx.compose.animation.core.Animatable, không phải từ androidx.compose.animation.Animatable.

val offsetX = remember { Animatable(0f) } // Add this line
// used to receive user touch events
pointerInput {
    // Used to calculate a settling position of a fling animation.
    val decay = splineBasedDecay<Float>(this)
    // Wrap in a coroutine scope to use suspend functions for touch events and animation.
    coroutineScope {
        while (true) {
            // ...

TODO 6-2 là nơi chúng ta vừa nhận được một sự kiện chạm. Chúng ta nên chặn ảnh động nếu nó hiện đang chạy. Bạn có thể thực hiện việc này bằng cách gọi stop trên Animatable. Lưu ý cuộc gọi sẽ bị bỏ qua nếu ảnh động không chạy. VelocityTracker sẽ được dùng để tính tốc độ di chuyển của người dùng từ trái sang phải. awaitPointerEventScope là một hàm tạm ngưng có thể chờ các sự kiện do người dùng nhập, đồng thời phản hồi các sự kiện đó.

// Wait for a touch down event. Track the pointerId based on the touch
val pointerId = awaitPointerEventScope { awaitFirstDown().id }
offsetX.stop() // Add this line to cancel any on-going animations
// Prepare for drag events and record velocity of a fling gesture
val velocityTracker = VelocityTracker()
// Wait for drag events.
awaitPointerEventScope {

Tại TODO 6-3, chúng tôi liên tục nhận được các sự kiện kéo. Chúng ta phải đồng bộ hoá vị trí của sự kiện nhấn vào giá trị ảnh động. Chúng ta có thể sử dụng snapTo trên Animatable. snapTo phải được gọi bên trong một khối launch khác, vì awaitPointerEventScopehorizontalDrag là các phạm vi coroutine bị hạn chế. Tức là chúng chỉ có thể suspend cho awaitPointerEvents, snapTo không phải là một sự kiện con trỏ.

horizontalDrag(pointerId) { change ->
    // Add these 4 lines
    // Get the drag amount change to offset the item with
    val horizontalDragOffset = offsetX.value + change.positionChange().x
    // Need to call this in a launch block in order to run it separately outside of the awaitPointerEventScope
    launch {
        // Instantly set the Animable to the dragOffset to ensure its moving
        // as the user's finger moves
        offsetX.snapTo(horizontalDragOffset)
    }
    // Record the velocity of the drag.
    velocityTracker.addPosition(change.uptimeMillis, change.position)

    // Consume the gesture event, not passed to external
    if (change.positionChange() != Offset.Zero) change.consume()

}

TODO 6-4 là nơi phần tử vừa được giải phóng và tung ra. Chúng ta cần tính toán vị trí cuối cùng mà cử chỉ hất được bố trí, để có thể quyết định xem chúng ta nên trượt phần tử này về vị trí ban đầu hay trượt phần tử đó ra xa và gọi lệnh gọi lại. Chúng ta sẽ sử dụng đối tượng decay đã tạo trước đó để tính toán targetOffsetX:

// Dragging finished. Calculate the velocity of the fling.
val velocity = velocityTracker.calculateVelocity().x
// Add this line to calculate where it would end up with
// the current velocity and position
val targetOffsetX = decay.calculateTargetValue(offsetX.value, velocity)

Trong TODO 6-5, chúng tôi sắp bắt đầu tạo ảnh động. Nhưng trước đó, chúng ta sẽ thiết lập giới hạn giá trị trên và dưới thành Animatable để ảnh động dừng ngay khi đạt giới hạn (-size.widthsize.width vì chúng ta không muốn offsetX có thể mở rộng và vượt quá hai giá trị này). Đối tượng sửa đổi pointerInput cho phép truy cập vào kích thước của phần tử theo thuộc tính size. Vì vậy, hãy sử dụng giá trị đó để nhận các giới hạn mà chúng ta vừa thiết lập.

offsetX.updateBounds(
    lowerBound = -size.width.toFloat(),
    upperBound = size.width.toFloat()
)

TODO 6-6 là nơi cuối cùng chúng ta có thể bắt đầu ảnh động. Trước tiên, chúng ta so sánh vị trí sắp đặt của cử chỉ hất đã được tính toán trước đó cũng như kích thước của phần tử. Nếu vị trí sắp đặt dưới kích thước, điều đó có nghĩa tốc độ của cử chỉ hất chưa đủ. Chúng ta có thể sử dụng animateTo để tạo ảnh động trở lại giá trị thành 0f. Nếu không, chúng ta sẽ sử dụng animateDecay để bắt đầu ảnh động vuốt nhanh. Khi ảnh động hoàn tất (nhiều khả năng là trong các giới hạn đã đặt trước đó), chúng ta có thể gọi lệnh gọi lại.

launch {
    if (targetOffsetX.absoluteValue <= size.width) {
        // Not enough velocity; Slide back.
        offsetX.animateTo(targetValue = 0f, initialVelocity = velocity)
    } else {
        // Enough velocity to slide away the element to the edge.
        offsetX.animateDecay(velocity, decay)
        // The element was swiped away.
        onDismissed()
    }
}

Cuối cùng, hãy xem mục TODO 6-7. Chúng ta đã thiết lập tất cả ảnh động và cử chỉ, vậy nên đừng quên áp dụng độ bù trừ cho phần tử. Thao tác này sẽ giúp di chuyển phần tử trên màn hình về giá trị do cử chỉ hoặc ảnh động tạo ra:

.offset { IntOffset(offsetX.value.roundToInt(), 0) }

Kết quả của phần này sẽ là mã dành cho bạn như bên dưới:

private fun Modifier.swipeToDismiss(
    onDismissed: () -> Unit
): Modifier = composed {
    // This Animatable stores the horizontal offset for the element.
    val offsetX = remember { Animatable(0f) }
    pointerInput(Unit) {
        // Used to calculate a settling position of a fling animation.
        val decay = splineBasedDecay<Float>(this)
        // Wrap in a coroutine scope to use suspend functions for touch events and animation.
        coroutineScope {
            while (true) {
                // Wait for a touch down event.
                val pointerId = awaitPointerEventScope { awaitFirstDown().id }
                // Interrupt any ongoing animation.
                offsetX.stop()
                // Prepare for drag events and record velocity of a fling.
                val velocityTracker = VelocityTracker()
                // Wait for drag events.
                awaitPointerEventScope {
                    horizontalDrag(pointerId) { change ->
                        // Record the position after offset
                        val horizontalDragOffset = offsetX.value + change.positionChange().x
                        launch {
                            // Overwrite the Animatable value while the element is dragged.
                            offsetX.snapTo(horizontalDragOffset)
                        }
                        // Record the velocity of the drag.
                        velocityTracker.addPosition(change.uptimeMillis, change.position)
                        // Consume the gesture event, not passed to external
                        change.consumePositionChange()
                    }
                }
                // Dragging finished. Calculate the velocity of the fling.
                val velocity = velocityTracker.calculateVelocity().x
                // Calculate where the element eventually settles after the fling animation.
                val targetOffsetX = decay.calculateTargetValue(offsetX.value, velocity)
                // The animation should end as soon as it reaches these bounds.
                offsetX.updateBounds(
                    lowerBound = -size.width.toFloat(),
                    upperBound = size.width.toFloat()
                )
                launch {
                    if (targetOffsetX.absoluteValue <= size.width) {
                        // Not enough velocity; Slide back to the default position.
                        offsetX.animateTo(targetValue = 0f, initialVelocity = velocity)
                    } else {
                        // Enough velocity to slide away the element to the edge.
                        offsetX.animateDecay(velocity, decay)
                        // The element was swiped away.
                        onDismissed()
                    }
                }
            }
        }
    }
        // Apply the horizontal offset to the element.
        .offset { IntOffset(offsetX.value.roundToInt(), 0) }
}

Chạy ứng dụng rồi thử vuốt một trong các mục tác vụ. Bạn có thể thấy phần tử trượt trở lại vị trí mặc định, hoặc trượt đi và bị xoá tuỳ thuộc vào tốc độ hất. Bạn cũng có thể tìm thấy phần tử đó trong khi tạo ảnh động.

Vuốt ảnh động bằng cử chỉ để loại bỏ các mục

9. Xin chúc mừng!

Xin chúc mừng! Bạn đã tìm hiểu về các API Ảnh động cơ bản.

Trong lớp học lập trình này, chúng ta đã tìm hiểu cách sử dụng:

API ảnh động cấp cao

  • animatedContentSize
  • AnimatedVisibility

API ảnh động cấp thấp hơn:

  • animate*AsState để tạo ảnh động cho một giá trị duy nhất
  • updateTransition để tạo ảnh động mang nhiều giá trị
  • infiniteTransition để tạo ảnh động cho các giá trị vô thời hạn
  • Animatable để tạo ảnh động tuỳ chỉnh bằng các cử chỉ chạm

Tiếp theo là gì?

Hãy tham khảo các lớp học lập trình khác trên Lộ trình học tập về Compose.

Để tìm hiểu thêm, vui lòng tham khảo nội dung Ảnh động của Compose và các tài liệu tham khảo sau: