Jetpack Compose cung cấp các API mạnh mẽ và có thể mở rộng giúp việc triển khai nhiều nội dung ảnh động trở nên dễ dàng hơn trong giao diện người dùng của ứng dụng. Tài liệu này mô tả cách sử dụng các API này và cho biết cần dùng API nào theo từng trường hợp ảnh động của bạn.
Tổng quan
Ảnh động là yếu tố cần thiết trong ứng dụng dành cho thiết bị di động hiện đại để mang lại trải nghiệm người dùng suôn sẻ và dễ hiểu. Nhiều API Ảnh động có sẵn trong Jetpack Compose dưới dạng hàm có khả năng kết hợp giống như bố cục và các thành phần khác trên giao diện người dùng. Các API này được hỗ trợ bởi các API cấp thấp hơn được xây dựng bằng hàm tạm ngưng coroutine trong Kotlin. Hướng dẫn này bắt đầu với các API cấp cao hữu ích trong nhiều trường hợp thực tế, rồi chuyển sang giải thích về các API cấp thấp nhằm giúp bạn tăng khả năng kiểm soát và tuỳ chỉnh.
Sơ đồ dưới đây giúp bạn quyết định nên sử dụng API nào để triển khai ảnh động.
- Nếu bạn đang tạo ảnh động thay đổi nội dung trong bố cục:
- Nếu bạn đang tạo ảnh động xuất hiện và biến mất:
- Sử dụng
AnimatedVisibility
.
- Sử dụng
- Hoán đổi nội dung theo trạng thái:
- Nếu bạn đang chuyển đổi nội dung:
- Sử dụng
Crossfade
.
- Sử dụng
- Nếu không, hãy sử dụng
AnimatedContent
.
- Nếu bạn đang chuyển đổi nội dung:
- Nếu không, hãy sử dụng
Modifier.animateContentSize
.
- Nếu bạn đang tạo ảnh động xuất hiện và biến mất:
- Nếu ảnh động dựa trên trạng thái:
- Nếu ảnh động diễn ra trong suốt quá trình hợp thành:
- Nếu ảnh động là vô hạn:
- Sử dụng
rememberInfiniteTransition
.
- Sử dụng
- Nếu bạn đang tạo ảnh động mang nhiều giá trị cùng một lúc:
- Sử dụng
updateTransition
.
- Sử dụng
- Nếu không, hãy sử dụng
animate*AsState
.
- Nếu ảnh động là vô hạn:
- Nếu ảnh động diễn ra trong suốt quá trình hợp thành:
- Nếu bạn muốn kiểm soát tốt thời gian chạy ảnh động:
- Hãy sử dụng
Animation
, chẳng hạn nhưTargetBasedAnimation
hoặcDecayAnimation
.
- Hãy sử dụng
- Nếu ảnh động là nguồn đáng tin cậy duy nhất
- Sử dụng
Animatable
.
- Sử dụng
- Nếu không, hãy sử dụng
AnimationState
hoặcanimate
.
API ảnh động cấp cao
Compose cung cấp các API ảnh động cấp cao cho một số mẫu ảnh động phổ biến được sử dụng trong nhiều ứng dụng. Các API này được điều chỉnh để phù hợp với các phương pháp hay nhất về Chuyển động Material Design.
AnimatedVisibility
Thành phần kết hợp AnimatedVisibility
tạo ảnh động làm xuất hiện nội dung của ảnh động và làm nội dung đó biến mất.
var editable by remember { mutableStateOf(true) }
AnimatedVisibility(visible = editable) {
Text(text = "Edit")
}
Theo mặc định, nội dung xuất hiện theo kiểu rõ dần và mở rộng, và biến mất theo kiểu mờ dần và thu nhỏ. Quá trình chuyển đổi có thể được tuỳ chỉnh bởi hàm cụ thể
EnterTransition
và
ExitTransition
.
var visible by remember { mutableStateOf(true) }
val density = LocalDensity.current
AnimatedVisibility(
visible = visible,
enter = slideInVertically {
// Slide in from 40 dp from the top.
with(density) { -40.dp.roundToPx() }
} + expandVertically(
// Expand from the top.
expandFrom = Alignment.Top
) + fadeIn(
// Fade in with the initial alpha of 0.3f.
initialAlpha = 0.3f
),
exit = slideOutVertically() + shrinkVertically() + fadeOut()
) {
Text("Hello", Modifier.fillMaxWidth().height(200.dp))
}
Như có thể thấy trong ví dụ trên, bạn có thể kết hợp nhiều đối tượng EnterTransition
hoặc ExitTransition
với toán tử +
và mỗi đối tượng sẽ chấp nhận các tham số tuỳ chọn để tuỳ chỉnh hoạt động của đối tượng đó. Hãy xem phần tham khảo để biết thêm thông tin.
Ví dụ về EnterTransition
và ExitTransition
AnimatedVisibility
cũng cung cấp một biến thể nhận MutableTransitionState
. Biến thể này cho phép bạn kích hoạt một ảnh động ngay khi
bạn thêm AnimatedVisibility
vào cây sáng tác. Việc này cũng hữu ích khi
quan sát trạng thái ảnh động.
// Create a MutableTransitionState<Boolean> for the AnimatedVisibility.
val state = remember {
MutableTransitionState(false).apply {
// Start the animation immediately.
targetState = true
}
}
Column {
AnimatedVisibility(visibleState = state) {
Text(text = "Hello, world!")
}
// Use the MutableTransitionState to know the current animation state
// of the AnimatedVisibility.
Text(
text = when {
state.isIdle && state.currentState -> "Visible"
!state.isIdle && state.currentState -> "Disappearing"
state.isIdle && !state.currentState -> "Invisible"
else -> "Appearing"
}
)
}
Nhập và thoát hình ảnh động cho bố cục con
Nội dung trong AnimatedVisibility
(bố cục con trực tiếp hoặc gián tiếp) có thể sử dụng công cụ sửa đổi
animateEnterExit
để xác định chế độ ảnh động khác nhau cho từng thành phần. Hiệu ứng
hình ảnh cho mỗi bố cục con là sự kết hợp của các ảnh động được chỉ định
trong thành phần kết hợp AnimatedVisibility
và nhập và thoát ảnh động của riêng bố cục con.
AnimatedVisibility(
visible = visible,
enter = fadeIn(),
exit = fadeOut()
) {
// Fade in/out the background and the foreground.
Box(Modifier.fillMaxSize().background(Color.DarkGray)) {
Box(
Modifier
.align(Alignment.Center)
.animateEnterExit(
// Slide in/out the inner box.
enter = slideInVertically(),
exit = slideOutVertically()
)
.sizeIn(minWidth = 256.dp, minHeight = 64.dp)
.background(Color.Red)
) {
// Content of the notification…
}
}
}
Trong một số trường hợp, bạn có thể không áp dụng AnimatedVisibility
cho ảnh động nào
để bố cục con có thể có ảnh động của riêng mình bằng
animateEnterExit
. Để đạt được mục đích này, hãy chỉ định EnterTransition.None
và
ExitTransition.None
tại thành phần kết hợp AnimatedVisibility
.
Thêm ảnh động tuỳ chỉnh
Nếu bạn muốn thêm các hiệu ứng ảnh động tuỳ chỉnh ngoài các ảnh động nhập và thoát được tích hợp sẵn, hãy truy cập phiên bản Transition
dưới đây thông qua thuộc tính transition
bên trong hàm lambda nội dung dành cho AnimatedVisibility
. Mọi trạng thái ảnh động
được thêm vào phiên bản Chuyển đổi sẽ chạy đồng thời với
các ảnh động nhập và thoát của AnimatedVisibility
. AnimatedVisibility
đợi cho đến khi
tất cả các ảnh động trong Transition
hoàn tất trước khi xoá nội dung.
Đối với các ảnh động thoát được tạo độc lập với Transition
(chẳng hạn như sử dụng
animate*AsState
), AnimatedVisibility
sẽ không thể tính đến các ảnh động đó và vì thế, có thể xoá nội dung thành phần kết hợp trước khi hoàn tất.
AnimatedVisibility(
visible = visible,
enter = fadeIn(),
exit = fadeOut()
) { // this: AnimatedVisibilityScope
// Use AnimatedVisibilityScope#transition to add a custom animation
// to the AnimatedVisibility.
val background by transition.animateColor { state ->
if (state == EnterExitState.Visible) Color.Blue else Color.Gray
}
Box(modifier = Modifier.size(128.dp).background(background))
}
Hãy xem updateTransition để biết thông tin chi tiết về Transition
.
animate*AsState
Các hàm animate*AsState
là API ảnh động đơn giản nhất trong Compose để tạo ảnh động cho một giá trị duy nhất. Bạn chỉ cung cấp giá trị cuối cùng (hoặc giá trị mục tiêu) và API sẽ bắt đầu tạo ảnh động từ giá trị hiện tại đến giá trị đã chỉ định.
Dưới đây là ví dụ về cách tạo ảnh động alpha bằng API này. Chỉ cần gói giá trị mục tiêu trong animateFloatAsState
, giá trị alpha giờ là giá trị ảnh động giữa các giá trị đã cung cấp (1f
hoặc 0.5f
trong trường hợp này).
val alpha: Float by animateFloatAsState(if (enabled) 1f else 0.5f)
Box(
Modifier.fillMaxSize()
.graphicsLayer(alpha = alpha)
.background(Color.Red)
)
Lưu ý rằng bạn không cần tạo một phiên bản của bất kỳ lớp ảnh động nào, hoặc xử lý
gián đoạn. Trong trường hợp này, một đối tượng ảnh động (cụ thể là một thực thể Animatable
) sẽ được tạo và ghi nhớ tại nơi hàm được gọi, với giá trị mục tiêu đầu tiên chính là giá trị ban đầu. Kể từ đó, bất cứ khi nào bạn cung cấp cho thành phần kết hợp này một giá trị mục tiêu khác, hệ thống sẽ tự động bắt đầu một ảnh động theo giá trị đó. Nếu đã có ảnh động trong giai đoạn hiển thị, thì ảnh động sẽ bắt đầu từ giá trị hiện tại (và vận tốc) và tạo ảnh động hướng tới giá trị mục tiêu. Trong quá trình
ảnh động, thành phần kết hợp này sẽ được ghép lại và trả về một giá trị ảnh động
được cập nhật cho mọi khung hình.
Ngay lập tức, Compose cung cấp các hàm animate*AsState
cho Float
,
Color
, Dp
, Size
, Offset
, Rect
, Int
, IntOffset
và
IntSize
. Bạn có thể dễ dàng thêm tính năng hỗ trợ cho các loại dữ liệu khác bằng cách cung cấp TwoWayConverter
cho animateValueAsState
nhận một loại chung.
Bạn có thể sử dụng AnimationSpec
để tuỳ chỉnh thông số kỹ thuật của ảnh động.
Hãy xem AnimationSpec để biết thêm thông tin.
AnimatedContent (thử nghiệm)
Thành phần kết hợp AnimatedContent
tạo hoạt ảnh cho nội dung của nó khi thay đổi theo trạng thái mục tiêu.
Row {
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) {
Text("Add")
}
AnimatedContent(targetState = count) { targetCount ->
// Make sure to use `targetCount`, not `count`.
Text(text = "Count: $targetCount")
}
}
Lưu ý phải luôn sử dụng tham số hàm lambda và phản ánh tham số đó vào nội dung. API sử dụng giá trị này làm chìa khoá để xác định nội dung hiện đang hiển thị.
Theo mặc định, nội dung ban đầu mờ dần và sau đó nội dung mục tiêu rõ dần
(hoạt động này được gọi là mờ dần qua). Bạn có thể tuỳ chỉnh hành vi ảnh động này bằng cách chỉ định đối tượng ContentTransform
cho tham số transitionSpec
. Bạn có thể tạo ContentTransform
bằng cách kết hợp EnterTransition
với ExitTransition
bằng cách sử dụng hàm infix with
. Bạn có thể áp dụng SizeTransform
cho ContentTransform
bằng cách gắn nó với hàm infix using
AnimatedContent(
targetState = count,
transitionSpec = {
// Compare the incoming number with the previous number.
if (targetState > initialState) {
// If the target number is larger, it slides up and fades in
// while the initial (smaller) number slides up and fades out.
slideInVertically { height -> height } + fadeIn() with
slideOutVertically { height -> -height } + fadeOut()
} else {
// If the target number is smaller, it slides down and fades in
// while the initial number slides down and fades out.
slideInVertically { height -> -height } + fadeIn() with
slideOutVertically { height -> height } + fadeOut()
}.using(
// Disable clipping since the faded slide-in/out should
// be displayed out of bounds.
SizeTransform(clip = false)
)
}
) { targetCount ->
Text(text = "$targetCount")
}
EnterTransition
xác định cách hiển thị của nội dung mục tiêu, còn ExitTransition
xác định cách làm biến mất nội dung ban đầu. Ngoài
tất cả các hàm EnterTransition
và ExitTransition
có sẵn cho
AnimatedVisibility
, AnimatedContent
cung cấp slideIntoContainer
và slideOutOfContainer
.
Đây là các lựa chọn thay thế thuận tiện cho slideInHorizontally/Vertically
và
slideOutHorizontally/Vertically
để tính khoảng cách trên trang trình bày dựa trên
kích thước của nội dung ban đầu và nội dung mục tiêu
của AnimatedContent
.
SizeTransform
xác định cách
kích thước sẽ tạo ảnh động giữa nội dung ban đầu và nội dung mục tiêu. Bạn có quyền
truy cập vào cả kích thước ban đầu và kích thước mục tiêu khi tạo
ảnh động. SizeTransform
cũng kiểm soát việc có nên cắt nội dung
thành kích thước thành phần trong các hình ảnh động hay không.
var expanded by remember { mutableStateOf(false) }
Surface(
color = MaterialTheme.colors.primary,
onClick = { expanded = !expanded }
) {
AnimatedContent(
targetState = expanded,
transitionSpec = {
fadeIn(animationSpec = tween(150, 150)) with
fadeOut(animationSpec = tween(150)) using
SizeTransform { initialSize, targetSize ->
if (targetState) {
keyframes {
// Expand horizontally first.
IntSize(targetSize.width, initialSize.height) at 150
durationMillis = 300
}
} else {
keyframes {
// Shrink vertically first.
IntSize(initialSize.width, targetSize.height) at 150
durationMillis = 300
}
}
}
}
) { targetExpanded ->
if (targetExpanded) {
Expanded()
} else {
ContentIcon()
}
}
}
Nhập/thoát ảnh động cho trẻ em
Cũng giống như AnimatedVisibility
, công cụ sửa đổi animateEnterExit
có sẵn bên trong nội dung hàm lambda của AnimatedContent
. Sử dụng thuộc tính này để áp dụng EnterAnimation
và ExitAnimation
cho từng bố cục con trực tiếp hoặc gián tiếp.
Thêm ảnh động tuỳ chỉnh
Giống như AnimatedVisibility
, trường transition
có sẵn bên trong
hàm lambda nội dung của AnimatedContent
. Sử dụng trường này để tạo hiệu ứng ảnh động tuỳ chỉnh
chạy đồng thời với quá trình chuyển đổi AnimatedContent
. Hãy xem
updateTransition để biết thông tin chi tiết.
animateContentSize
Công cụ sửa đổi animateContentSize
sẽ tạo ảnh động cho việc thay đổi về kích thước.
var message by remember { mutableStateOf("Hello") }
Box(
modifier = Modifier.background(Color.Blue).animateContentSize()
) {
Text(text = message)
}
Chuyển đổi
Crossfade
tạo ảnh động giữa hai bố cục bằng một ảnh động chuyển đổi. Bằng cách chuyển đổi giá trị được truyền vào tham số current
, nội dung được chuyển bằng một ảnh động chuyển đổi.
var currentPage by remember { mutableStateOf("A") }
Crossfade(targetState = currentPage) { screen ->
when (screen) {
"A" -> Text("Page A")
"B" -> Text("Page B")
}
}
updateTransition
Transition
quản lý một hoặc nhiều ảnh động dưới dạng thành phần con và chạy
đồng thời giữa nhiều trạng thái.
Các trạng thái có thể thuộc bất kỳ loại dữ liệu nào. Trong nhiều trường hợp, bạn có thể sử dụng loại enum
tuỳ chỉnh để đảm bảo loại an toàn, như trong ví dụ này:
enum class BoxState {
Collapsed,
Expanded
}
updateTransition
tạo và ghi nhớ một phiên bản của Transition
và cập nhật
trạng thái của phiên bản đó.
var currentState by remember { mutableStateOf(BoxState.Collapsed) }
val transition = updateTransition(currentState)
Bạn có thể sử dụng một trong các hàm mở rộng animate*
để xác định ảnh động
bố cục con trong hoạt động chuyển đổi này. Chỉ định các giá trị mục tiêu cho mỗi trạng thái.
Các hàm animate*
này trả về một giá trị ảnh động. Mọi khung hình đều cập nhật
trong suốt chế độ ảnh động khi trạng thái chuyển đổi được cập nhật với
updateTransition
.
val rect by transition.animateRect { state ->
when (state) {
BoxState.Collapsed -> Rect(0f, 0f, 100f, 100f)
BoxState.Expanded -> Rect(100f, 100f, 300f, 300f)
}
}
val borderWidth by transition.animateDp { state ->
when (state) {
BoxState.Collapsed -> 1.dp
BoxState.Expanded -> 0.dp
}
}
Bạn có thể chuyển tham số transitionSpec
để chỉ định một AnimationSpec
khác cho mỗi kiểu kết hợp của các thay đổi trạng thái chuyển đổi. Hãy xem
AnimationSpec để biết thêm thông tin.
val color by transition.animateColor(
transitionSpec = {
when {
BoxState.Expanded isTransitioningTo BoxState.Collapsed ->
spring(stiffness = 50f)
else ->
tween(durationMillis = 500)
}
}
) { state ->
when (state) {
BoxState.Collapsed -> MaterialTheme.colors.primary
BoxState.Expanded -> MaterialTheme.colors.background
}
}
Khi hoạt động chuyển đổi đạt đến trạng thái mục tiêu, Transition.currentState
sẽ giống với Transition.targetState
. Bạn có thể sử dụng hoạt động này như một tín hiệu cho biết
hoạt động chuyển đổi đã hoàn tất hay chưa.
Đôi khi, chúng ta muốn có một trạng thái ban đầu khác với trạng thái mục tiêu
đầu tiên. Chúng ta có thể sử dụng updateTransition
cùng với MutableTransitionState
để đạt được
điều này. Ví dụ: chúng ta được quyền khởi động ảnh động ngay khi mã nhập
vào phương thức hợp thành.
// Start in collapsed state and immediately animate to expanded
var currentState = remember { MutableTransitionState(BoxState.Collapsed) }
currentState.targetState = BoxState.Expanded
val transition = updateTransition(currentState)
// ...
Đối với quá trình chuyển đổi phức tạp hơn liên quan đến nhiều hàm có khả năng kết hợp, bạn có thể sử dụng createChildTransition
để tạo nội dung chuyển đổi con. Kỹ thuật này dùng để phân biệt các mối lo ngại giữa nhiều thành phần phụ trong một thành phần kết hợp phức tạp. Quá trình chuyển đổi thành phần mẹ sẽ
nhận ra được tất cả các giá trị ảnh động trong các chuyển đổi bố cục con.
enum class DialerState { DialerMinimized, NumberPad }
@Composable
fun DialerButton(isVisibleTransition: Transition<Boolean>) {
// `isVisibleTransition` spares the need for the content to know
// about other DialerStates. Instead, the content can focus on
// animating the state change between visible and not visible.
}
@Composable
fun NumberPad(isVisibleTransition: Transition<Boolean>) {
// `isVisibleTransition` spares the need for the content to know
// about other DialerStates. Instead, the content can focus on
// animating the state change between visible and not visible.
}
@Composable
fun Dialer(dialerState: DialerState) {
val transition = updateTransition(dialerState)
Box {
// Creates separate child transitions of Boolean type for NumberPad
// and DialerButton for any content animation between visible and
// not visible
NumberPad(
transition.createChildTransition {
it == DialerState.NumberPad
}
)
DialerButton(
transition.createChildTransition {
it == DialerState.DialerMinimized
}
)
}
}
Sử dụng hiệu ứng chuyển đổi với AnimatedVisibility và AnimatedContent
AnimatedVisibility
và AnimatedContent
có sẵn dưới dạng các hàm mở rộng của Transition
. targetState
cho Transition.AnimatedVisibility
và Transition.AnimatedContent
có nguồn gốc từ Transition
và kích hoạt các nội dung chuyển đổi nhập/thoát cần thiết khi targetState
của Transition
thay đổi. Các hàm mở rộng này cho phép tất cả
các ảnh động enter/exit/sizeTransform mà nếu không nội bộ bên trong
AnimatedVisibility
/AnimatedContent
sẽ được nâng lên thành Transition
.
Bạn có thể ghi nhận từ bên ngoài sự thay đổi trạng thái
của AnimatedVisibility
/AnimatedContent
ở các hàm mở rộng này, Thay vì thông số visible
boolean,
phiên bản này của AnimatedVisibility
sẽ lấy một hàm lambda chuyển đổi trạng thái mục tiêu của
lệnh chuyển đổi thành phần mẹ thành một boolean.
Hãy xem AnimatedVisibility và AnimatedContent để biết thêm chi tiết.
var selected by remember { mutableStateOf(false) }
// Animates changes when `selected` is changed.
val transition = updateTransition(selected)
val borderColor by transition.animateColor { isSelected ->
if (isSelected) Color.Magenta else Color.White
}
val elevation by transition.animateDp { isSelected ->
if (isSelected) 10.dp else 2.dp
}
Surface(
onClick = { selected = !selected },
shape = RoundedCornerShape(8.dp),
border = BorderStroke(2.dp, borderColor),
elevation = elevation
) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Text(text = "Hello, world!")
// AnimatedVisibility as a part of the transition.
transition.AnimatedVisibility(
visible = { targetSelected -> targetSelected },
enter = expandVertically(),
exit = shrinkVertically()
) {
Text(text = "It is fine today.")
}
// AnimatedContent as a part of the transition.
transition.AnimatedContent { targetState ->
if (targetState) {
Text(text = "Selected")
} else {
Icon(imageVector = Icons.Default.Phone, contentDescription = "Phone")
}
}
}
}
Đóng gói lượt chuyển đổi và đặt lượt chuyển đổi đó ở chế độ tái sử dụng được
Đối với các trường hợp sử dụng đơn giản, việc xác định ảnh động chuyển đổi trong cùng một thành phần kết hợp như giao diện người dùng là tuỳ chọn hoàn toàn hợp lệ. Tuy nhiên, khi thao tác trên một thành phần phức tạp có một số giá trị được tạo ảnh động, bạn có thể muốn tách riêng việc triển khai ảnh động với giao diện người dùng có thể kết hợp.
Bạn có thể thực hiện việc này bằng cách tạo một lớp chứa tất cả các giá trị ảnh động và hàm "update" để trả về một thực thể của lớp đó. Việc triển khai chuyển đổi có thể được trích xuất vào hàm riêng mới. Mẫu này rất hữu ích khi cần phải tập trung vào logic ảnh động hoặc làm cho các ảnh động phức tạp có thể sử dụng lại.
enum class BoxState { Collapsed, Expanded }
@Composable
fun AnimatingBox(boxState: BoxState) {
val transitionData = updateTransitionData(boxState)
// UI tree
Box(
modifier = Modifier
.background(transitionData.color)
.size(transitionData.size)
)
}
// Holds the animation values.
private class TransitionData(
color: State<Color>,
size: State<Dp>
) {
val color by color
val size by size
}
// Create a Transition and return its animation values.
@Composable
private fun updateTransitionData(boxState: BoxState): TransitionData {
val transition = updateTransition(boxState)
val color = transition.animateColor { state ->
when (state) {
BoxState.Collapsed -> Color.Gray
BoxState.Expanded -> Color.Red
}
}
val size = transition.animateDp { state ->
when (state) {
BoxState.Collapsed -> 64.dp
BoxState.Expanded -> 128.dp
}
}
return remember(transition) { TransitionData(color, size) }
}
rememberInfiniteTransition
InfiniteTransition
lưu giữ một hoặc nhiều ảnh động bố cục con như Transition
, nhưng các ảnh động bắt đầu chạy ngay khi chúng ta nhập phương thức hợp thành và không dừng lại trừ phi bạn xoá chúng. Bạn có thể tạo một phiên bản của InfiniteTransition
với rememberInfiniteTransition
. Ảnh động bố cục con có thể được thêm vào với
animateColor
, animatedFloat
hoặc animatedValue
. Ngoài ra, bạn còn cần chỉ định infiniteRepeatable làm thông số kỹ thuật của ảnh động.
val infiniteTransition = rememberInfiniteTransition()
val color by infiniteTransition.animateColor(
initialValue = Color.Red,
targetValue = Color.Green,
animationSpec = infiniteRepeatable(
animation = tween(1000, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
)
)
Box(Modifier.fillMaxSize().background(color))
API ảnh động cấp thấp
Tất cả các API ảnh động cấp cao đã đề cập trong phần trước đều được xây dựng dựa trên nền tảng của các API ảnh động cấp thấp.
Các hàm animate*AsState
là các API đơn giản nhất để hiển thị thay đổi giá trị tức thì dưới dạng giá trị ảnh động. Hàm này được Animatable
hỗ trợ vì đây là một API dựa vào coroutine để tạo ảnh động cho một giá trị duy nhất. updateTransition
tạo một
đối tượng chuyển đổi có thể quản lý nhiều giá trị ảnh động và chạy các giá trị đó dựa trên
sự thay đổi về trạng thái. rememberInfiniteTransition
tương tự như vậy, nhưng hàm này tạo
một sự chuyển đổi vô hạn có thể quản lý nhiều ảnh động tiếp tục chạy
vô thời hạn. Tất cả các API này đều là thành phần kết hợp ngoại trừ Animatable
, nghĩa là
các ảnh động này có thể được tạo bên ngoài phương thức hợp thành.
Tất cả các API này đều dựa trên API Animation
nền tảng hơn. Mặc dù hầu hết
các ứng dụng sẽ không tương tác trực tiếp với Animation
, nhưng một số khả năng tuỳ chỉnh
dành cho Animation
lại có sẵn thông qua các API cấp cao hơn. Hãy xem
Tuỳ chỉnh ảnh động để biết thêm thông tin về
AnimationVector
và AnimationSpec
.
Animatable
Animatable
là trình lưu giữ giá trị có thể tạo ảnh động cho giá trị khi được thay đổi thông qua
animateTo
. Đây là API đang sao lưu nội dung triển khai animate*AsState
.
Giao diện này đảm bảo tiếp tục duy trì tính nhất quán và loại trừ nhau, nghĩa là việc thay đổi giá trị luôn diễn ra liên tục và mọi ảnh động đang diễn ra sẽ bị huỷ.
Nhiều tính năng của Animatable
, bao gồm cả animateTo
, được cung cấp dưới dạng hàm tạm ngưng. Nghĩa là các tính năng này cần được chứa trong một phạm vi
coroutine thích hợp. Ví dụ: bạn có thể sử dụng thành phần kết hợp LaunchedEffect
để tạo một phạm vi chỉ trong khoảng thời gian của khoá-giá trị được chỉ định.
// Start out gray and animate to green/red based on `ok`
val color = remember { Animatable(Color.Gray) }
LaunchedEffect(ok) {
color.animateTo(if (ok) Color.Green else Color.Red)
}
Box(Modifier.fillMaxSize().background(color.value))
Trong ví dụ trên, chúng ta tạo và ghi nhớ một thực thể của Animatable
với giá trị ban đầu là Color.Gray
. Tuỳ thuộc vào giá trị của cờ boolean
ok
, màu sắc kết hợp ảnh động cho một trong hai hàm Color.Green
hoặc Color.Red
. Bất kỳ thay đổi nào tiếp theo đối với giá trị boolean sẽ bắt đầu chuyển ảnh động thành màu khác. Nếu có một ảnh động đang hoạt động khi giá trị bị thay đổi, thì ảnh động sẽ bị huỷ và ảnh động mới sẽ bắt đầu từ giá trị hiện tại của ảnh chụp nhanh với vận tốc hiện tại.
Đây là triển khai ảnh động sao lưu API animate*AsState
đã đề cập trong phần trước. So với animate*AsState
, việc sử dụng
Animatable
trực tiếp giúp kiểm soát chặt chẽ hơn trên một vài khía cạnh. Trước tiên,
Animatable
có thể có giá trị ban đầu khác với giá trị mục tiêu đầu tiên.
Ví dụ: ví dụ về mã ở trên cho thấy một hộp màu xám ở đầu tiên, ngay lập tức
bắt đầu tạo ảnh động sang màu xanh lục hoặc đỏ. Thứ hai, Animatable
cung cấp nhiều thao tác trên giá trị nội dung, cụ thể là snapTo
và animateDecay
. snapTo
đặt giá trị hiện tại thành giá trị mục tiêu ngay. Điều này hữu ích khi ảnh động không phải là nguồn đáng tin cậy duy nhất và phải được đồng bộ hoá với các trạng thái khác, chẳng hạn như các sự kiện chạm. animateDecay
bắt đầu một ảnh động chậm lại từ tốc độ đã cho. Điều này rất hữu ích trong việc triển khai hành vi vuốt nhanh. Hãy xem
Cử chỉ và ảnh động để biết thêm thông tin.
Ngay từ đầu, Animatable
hỗ trợ Float
và Color
, nhưng bạn có thể dùng bất kỳ loại dữ liệu nào
bằng cách cung cấp một TwoWayConverter
. Hãy xem
AnimationVector để biết thêm thông tin.
Bạn có thể sử dụng AnimationSpec
để tuỳ chỉnh thông số kỹ thuật của ảnh động.
Hãy xem AnimationSpec để biết thêm thông tin.
Ảnh động
Animation
là API Ảnh động cấp thấp nhất hiện có. Nhiều ảnh động mà chúng ta thấy cho đến nay đã xây dựng trên trang Ảnh động. Có hai loại phụ Animation
: TargetBasedAnimation
và DecayAnimation
.
Bạn chỉ nên dùng Animation
để kiểm soát thời lượng của ảnh động theo cách thủ công.
Animation
không có trạng thái và không có bất kỳ khái niệm nào về vòng đời. Nó đóng vai trò như một công cụ tính toán ảnh động mà các API cấp cao hơn sử dụng.
TargetBasedAnimation
Các API khác dùng được trong hầu hết các trường hợp, nhưng việc sử dụng trực tiếp TargetBasedAnimation
cho phép bạn tự kiểm soát thời lượng phát ảnh động. Trong ví dụ bên dưới, thời gian phát của TargetAnimation
được kiểm soát theo cách thủ công dựa trên khung thời gian do withFrameNanos
cung cấp.
val anim = remember {
TargetBasedAnimation(
animationSpec = tween(200),
typeConverter = Float.VectorConverter,
initialValue = 200f,
targetValue = 1000f
)
}
var playTime by remember { mutableStateOf(0L) }
LaunchedEffect(anim) {
val startTime = withFrameNanos { it }
do {
playTime = withFrameNanos { it } - startTime
val animationValue = anim.getValueFromNanos(playTime)
} while (someCustomCondition())
}
DecayAnimation
Không giống như TargetBasedAnimation
, DecayAnimation
không yêu cầu cung cấp targetValue
. Thay vào đó, phương thức này tính toán targetValue
dựa trên các điều kiện bắt đầu do initialVelocity
và initialValue
cung cấp, cũng như DecayAnimationSpec
được cung cấp.
Ảnh động phân rã thường được sử dụng sau cử chỉ hất để làm chậm các phần tử xuống điểm dừng. Tốc độ hoạt ảnh bắt đầu ở giá trị do initialVelocityVector
đặt và sẽ chậm lại theo thời gian.
Tuỳ chỉnh ảnh động
Nhiều API ảnh động thường chấp nhận các tham số để tuỳ chỉnh hoạt động của chúng.
AnimationSpec
Hầu hết các API ảnh động đều cho phép nhà phát triển tuỳ chỉnh thông số kỹ thuật của ảnh động bằng tham số AnimationSpec
tuỳ chọn.
val alpha: Float by animateFloatAsState(
targetValue = if (enabled) 1f else 0.5f,
// Configure the animation duration and easing.
animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)
)
Có nhiều loại AnimationSpec
khác nhau để tạo các loại ảnh động khác nhau.
spring
spring
tạo một ảnh động dựa trên kiến thức vật lý giữa giá trị bắt đầu và giá trị kết thúc. Nội dung này
có 2 thông số: dampingRatio
và stiffness
.
dampingRatio
xác định khả năng bật của spring. Giá trị mặc định là Spring.DampingRatioNoBouncy
.
stiffness
xác định tốc độ chuyển động của spring hướng về giá trị cuối cùng. Giá trị mặc định là Spring.StiffnessMedium
.
val value by animateFloatAsState(
targetValue = 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioHighBouncy,
stiffness = Spring.StiffnessMedium
)
)
spring
có thể xử lý sự cố gián đoạn một cách mượt mà hơn các mã AnimationSpec
dựa trên thời lượng
vì các mã này đảm bảo tính liên tục của tốc độ khi
giá trị mục tiêu thay đổi giữa các nội dung ảnh động. spring
được sử dụng như thông số ảnh động mặc định
của nhiều API ảnh động, chẳng hạn như animate*AsState
và
updateTransition
.
tiền thiếu niên
tween
tạo ảnh động giữa giá trị bắt đầu và giá trị kết thúc qua việc sử dụng một easing curve durationMillis
được chỉ định. Hãy xem Easing để biết thêm
thông tin. Bạn cũng có thể chỉ định delayMillis
để trì hoãn việc bắt đầu ảnh động.
val value by animateFloatAsState(
targetValue = 1f,
animationSpec = tween(
durationMillis = 300,
delayMillis = 50,
easing = LinearOutSlowInEasing
)
)
keyframes
keyframes
tạo ảnh động dựa trên các giá trị ảnh chụp nhanh được chỉ định tại các dấu thời gian trong suốt thời gian tạo ảnh động. Tại một thời điểm bất kỳ, giá trị ảnh động sẽ được tự ý thêm vào giữa 2 giá trị khung hình chính. Đối với mỗi
khung hình chính, bạn có thể chỉ định Easing để xác định đường cong nội suy.
Có thể tuỳ chọn chỉ định các giá trị ở 0 giây và tại khoảng thời gian. Nếu bạn không chỉ định các giá trị này, thì giá trị mặc định lần lượt là giá trị bắt đầu và kết thúc của ảnh động.
val value by animateFloatAsState(
targetValue = 1f,
animationSpec = keyframes {
durationMillis = 375
0.0f at 0 with LinearOutSlowInEasing // for 0-15 ms
0.2f at 15 with FastOutLinearInEasing // for 15-75 ms
0.4f at 75 // ms
0.4f at 225 // ms
}
)
repeatable
repeatable
chạy một ảnh động dựa trên thời lượng (chẳng hạn như tween
hoặc keyframes
)
lặp lại cho đến khi ảnh động đạt đến số vòng lặp được chỉ định. Bạn có thể chuyển thông số
repeatMode
để chỉ định xem ảnh động nên lặp lại bằng cách
bắt đầu từ đầu (RepeatMode.Restart
) hay từ cuối
(RepeatMode.Reverse
).
val value by animateFloatAsState(
targetValue = 1f,
animationSpec = repeatable(
iterations = 3,
animation = tween(durationMillis = 300),
repeatMode = RepeatMode.Reverse
)
)
infiniteRepeatable
infiniteRepeatable
giống như repeatable
nhưng số lượng vòng lặp lại là vô hạn.
val value by animateFloatAsState(
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 300),
repeatMode = RepeatMode.Reverse
)
)
Trong các thử nghiệm sử dụng
ComposeTestRule
,
các ảnh động sử dụng infiniteRepeatable
sẽ không chạy. Thành phần sẽ được
hiển thị bằng việc sử dụng giá trị ban đầu của từng giá trị ảnh động.
snap
snap
là một AnimationSpec
đặc biệt sẽ ngay lập tức chuyển giá trị đó
sang giá trị kết thúc. Bạn có thể chỉ định delayMillis
để hoãn việc khởi động
ảnh động.
val value by animateFloatAsState(
targetValue = 1f,
animationSpec = snap(delayMillis = 50)
)
Hàm Easing
Các thao tác AnimationSpec
dựa trên thời lượng (chẳng hạn như tween
hoặc keyframes
) sử dụng
Easing
để điều chỉnh một phân đoạn của ảnh động. Các hoạt động này cho phép giá trị ảnh động
tăng tốc và chậm lại, thay vì di chuyển với tốc độ không đổi. Một phân đoạn là một
giá trị nằm trong khoảng từ 0 (bắt đầu) đến 1.0 (kết thúc) cho biết điểm hiện tại trong
ảnh động.
Easing thực chất là một hàm nhận giá trị phân số từ 0 đến 1.0 và trả về một số thực có độ chính xác đơn. Giá trị được trả về có thể nằm ngoài ranh giới đại diện cho tình trạng quá mức hoặc không đạt mức. Hàm Easing tuỳ chỉnh có thể được tạo giống như mã bên dưới.
val CustomEasing = Easing { fraction -> fraction * fraction }
@Composable
fun EasingUsage() {
val value by animateFloatAsState(
targetValue = 1f,
animationSpec = tween(
durationMillis = 300,
easing = CustomEasing
)
)
// ...
}
Compose cung cấp nhiều hàm Easing
được tích hợp sẵn cho hầu hết các trường hợp sử dụng.
Vui lòng xem bài viết Speed (Tốc độ) – Material Design để biết thêm thông tin về mục đích sử dụng hàm Easing theo tình huống của bạn.
FastOutSlowInEasing
LinearOutSlowInEasing
FastOutLinearEasing
LinearEasing
CubicBezierEasing
- Xem thêm
AnimationVector
Hầu hết các API ảnh động trong Compose đều hỗ trợ Float
, Color
, Dp
và các loại dữ liệu cơ bản khác dưới dạng giá trị ảnh động ngay từ đầu, nhưng đôi khi bạn cần tạo ảnh động cho các loại dữ liệu khác bao gồm cả các dữ liệu tuỳ chỉnh. Trong suốt thời gian tạo ảnh động, mọi giá trị ảnh động đều được biểu thị dưới dạng AnimationVector
. Giá trị được chuyển đổi thành AnimationVector
và ngược lại bằng TwoWayConverter
tương ứng để hệ thống ảnh động chính có thể xử lý thống nhất. Ví dụ: Int
được biểu thị dưới dạng AnimationVector1D
và có một giá trị số thực duy nhất.
TwoWayConverter
cho Int
có dạng như:
val IntToVector: TwoWayConverter<Int, AnimationVector1D> =
TwoWayConverter({ AnimationVector1D(it.toFloat()) }, { it.value.toInt() })
Color
cơ bản là một tập hợp gồm 4 giá trị: đỏ, xanh lá cây, xanh lam và alpha, vì vậy, Color
được chuyển đổi thành AnimationVector4D
chứa 4 giá trị số thực chính xác. Bằng cách này mọi loại dữ liệu sử dụng trong ảnh động sẽ được chuyển đổi thành AnimationVector1D
, AnimationVector2D
, AnimationVector3D
hoặc AnimationVector4D
tuỳ thuộc vào kích thước. Điều này cho phép các thành phần khác nhau của đối tượng được tạo ảnh động độc lập, mỗi thành phần có theo dõi vận tốc riêng. Có thể truy cập các bộ chuyển đổi tích hợp cho kiểu dữ liệu cơ bản bằng cách sử dụng Color.VectorConverter
, Dp.VectorConverter
, v.v..
Khi muốn hỗ trợ thêm cho một loại dữ liệu mới dưới dạng giá trị ảnh động, bạn có thể tạo TwoWayConverter
riêng của mình và cung cấp dữ liệu đó cho API. Ví dụ: bạn có thể sử dụng animateValueAsState
để tạo ảnh động cho loại dữ liệu tuỳ chỉnh của mình như sau:
data class MySize(val width: Dp, val height: Dp)
@Composable
fun MyAnimation(targetSize: MySize) {
val animSize: MySize by animateValueAsState<MySize, AnimationVector2D>(
targetSize,
TwoWayConverter(
convertToVector = { size: MySize ->
// Extract a float value from each of the `Dp` fields.
AnimationVector2D(size.width.value, size.height.value)
},
convertFromVector = { vector: AnimationVector2D ->
MySize(vector.v1.dp, vector.v2.dp)
}
)
)
}
Tài nguyên vectơ động (thử nghiệm)
Để dùng tài nguyên AnimatedVectorDrawable
, hãy tải tệp có thể vẽ lên bằng animatedVectorResource
và truyền vào boolean
để chuyển đổi giữa trạng thái bắt đầu và kết thúc của đối tượng có thể vẽ.
@Composable
fun AnimatedVectorDrawable() {
val image = AnimatedImageVector.animatedVectorResource(R.drawable.ic_hourglass_animated)
var atEnd by remember { mutableStateOf(false) }
Image(
painter = rememberAnimatedVectorPainter(image, atEnd),
contentDescription = "Timer",
modifier = Modifier.clickable {
atEnd = !atEnd
},
contentScale = ContentScale.Crop
)
}
Để biết thêm thông tin chi tiết về định dạng của tệp có thể vẽ được, vui lòng xem nội dung Tạo ảnh động cho đồ hoạ có thể vẽ được.
Ảnh động cho mục danh sách
Nếu bạn đang tìm cách tạo ảnh động cho việc sắp xếp lại thứ tự mục bên trong lưới hoặc danh sách Lazy, hãy xem tài liệu ảnh động của mục bố cục lazy.
Cử chỉ và ảnh động (nâng cao)
Có một số điều mà chúng ta phải cân nhắc khi thao tác với cả với các sự kiện chạm và ảnh động, so với khi chúng ta chỉ thao tác với các ảnh động. Trước hết, chúng ta có thể cần làm gián đoạn ảnh động đang diễn ra khi các sự kiện chạm bắt đầu do tương tác của người dùng sẽ có mức độ ưu tiên cao nhất.
Trong ví dụ bên dưới, chúng tôi sử dụng Animatable
để thể hiện cho vị trí chênh lệch của
thành phần vòng kết nối. Các sự kiện nhấn được xử lý bằng
công cụ sửa đổi
pointerInput
. Khi phát hiện một sự kiện nhấn mới, chúng ta gọi lệnh animateTo
để tạo ảnh động
cho giá trị chênh lệch vào vị trí nhấn. Một sự kiện chạm cũng có thể xảy ra trong quá trình tạo ảnh động và trong trường hợp đó, animateTo
làm gián đoạn ảnh động đang diễn ra và chạy ảnh động đó đến vị trí mục tiêu mới trong khi vẫn duy trì tốc độ của ảnh động bị gián đoạn.
@Composable
fun Gesture() {
val offset = remember { Animatable(Offset(0f, 0f), Offset.VectorConverter) }
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
coroutineScope {
while (true) {
// Detect a tap event and obtain its position.
val position = awaitPointerEventScope {
awaitFirstDown().position
}
launch {
// Animate to the tap position.
offset.animateTo(position)
}
}
}
}
) {
Circle(modifier = Modifier.offset { offset.value.toIntOffset() })
}
}
private fun Offset.toIntOffset() = IntOffset(x.roundToInt(), y.roundToInt())
Một mẫu thường gặp khác là chúng ta cần đồng bộ hoá các giá trị ảnh động với các giá trị hình thành từ các sự kiện chạm, chẳng hạn như kéo. Trong ví dụ bên dưới, chúng ta thấy tính năng "vuốt để loại bỏ" được triển khai dưới dạng Modifier
(thay vì sử dụng thành phần kết hợp SwipeToDismiss
). Độ chênh lệch chiều ngang của thành phần được biểu thị dưới dạng Animatable
. API này có một đặc điểm hữu ích trong ảnh động cử chỉ. Sự kiện nhấn
cũng như nội dung ảnh động có thể thay đổi giá trị của API. Khi nhận được một
sự kiện nhấn, chúng ta sẽ dừng Animatable
bằng phương thức stop
để
có thể chặn mọi ảnh động đang phát.
Trong một sự kiện kéo, chúng ta sử dụng snapTo
để cập nhật giá trị Animatable
bằng
giá trị được tính từ các sự kiện nhấn. Để vuốt nhanh, công cụ Compose cung cấp
VelocityTracker
để ghi lại các sự kiện kéo và tính tốc độ. Vận tốc có thể được
cung cấp trực tiếp cho animateDecay
để tạo ảnh động vuốt nhanh. Khi muốn trượt
giá trị độ dời trở về vị trí ban đầu, chúng ta chỉ định giá trị chênh lệch
mục tiêu của 0f
bằng phương thức animateTo
.
fun Modifier.swipeToDismiss(
onDismissed: () -> Unit
): Modifier = composed {
val offsetX = remember { Animatable(0f) }
pointerInput(Unit) {
// Used to calculate fling decay.
val decay = splineBasedDecay<Float>(this)
// Use suspend functions for touch events and the Animatable.
coroutineScope {
while (true) {
// Detect a touch down event.
val pointerId = awaitPointerEventScope { awaitFirstDown().id }
val velocityTracker = VelocityTracker()
// Stop any ongoing animation.
offsetX.stop()
awaitPointerEventScope {
horizontalDrag(pointerId) { change ->
// Update the animation value with touch events.
launch {
offsetX.snapTo(
offsetX.value + change.positionChange().x
)
}
velocityTracker.addPosition(
change.uptimeMillis,
change.position
)
}
}
// No longer receiving touch events. Prepare the animation.
val velocity = velocityTracker.calculateVelocity().x
val targetOffsetX = decay.calculateTargetValue(
offsetX.value,
velocity
)
// The animation stops when it reaches the bounds.
offsetX.updateBounds(
lowerBound = -size.width.toFloat(),
upperBound = size.width.toFloat()
)
launch {
if (targetOffsetX.absoluteValue <= size.width) {
// Not enough velocity; Slide back.
offsetX.animateTo(
targetValue = 0f,
initialVelocity = velocity
)
} else {
// The element was swiped away.
offsetX.animateDecay(velocity, decay)
onDismissed()
}
}
}
}
}
.offset { IntOffset(offsetX.value.roundToInt(), 0) }
}
Thử nghiệm
Compose cung cấp ComposeTestRule
cho phép bạn viết các phép kiểm thử cho ảnh động theo cách thức xác định với toàn quyền kiểm soát trên đồng hồ kiểm thử. Tính năng này cho phép bạn
xác minh các giá trị ảnh động trung gian. Ngoài ra, một thử nghiệm ảnh động có thể chạy nhanh hơn
thời lượng thực tế của ảnh động.
ComposeTestRule
cho thấy đồng hồ thử nghiệm là mainClock
. Bạn có thể thiết lập thuộc tính
autoAdvance
thành false để kiểm soát đồng hồ trong mã thử nghiệm. Sau khi
bắt đầu ảnh động mà bạn muốn thử nghiệm, đồng hồ có thể được di chuyển về phía trước bằng
advanceTimeBy
.
Cần lưu ý là advanceTimeBy
không di chuyển đồng hồ một cách chính xác theo
thời lượng đã chỉ định. Thay vào đó, hệ thống sẽ làm tròn lên thời lượng gần nhất bằng hệ số của thời lượng khung hình.
@get:Rule
val rule = createComposeRule()
@Test
fun testAnimationWithClock() {
// Pause animations
rule.mainClock.autoAdvance = false
var enabled by mutableStateOf(false)
rule.setContent {
val color by animateColorAsState(
targetValue = if (enabled) Color.Red else Color.Green,
animationSpec = tween(durationMillis = 250)
)
Box(Modifier.size(64.dp).background(color))
}
// Initiate the animation.
enabled = true
// Let the animation proceed.
rule.mainClock.advanceTimeBy(50L)
// Compare the result with the image showing the expected result.
// `assertAgainGolden` needs to be implemented in your code.
rule.onRoot().captureToImage().assertAgainstGolden()
}
Hỗ trợ công cụ
Android Studio hỗ trợ việc kiểm tra updateTransition
và animatedVisibility
trong phần Xem trước ảnh động. Bạn có thể làm như sau:
- Xem trước quá trình chuyển đổi theo khung
- Kiểm tra các giá trị cho tất cả ảnh động trong quá trình chuyển đổi
- Xem trước quá trình chuyển đổi giữa trạng thái ban đầu và trạng thái mục tiêu
- Kiểm tra và phối hợp nhiều ảnh động cùng lúc
Khi khởi động tính năng Xem trước ảnh động, bạn sẽ thấy ngăn "Animations". Trong ngăn này, bạn có thể chạy bất kỳ lượt chuyển đổi nào có trong bản xem trước. Quá trình chuyển đổi cũng như mỗi
giá trị ảnh động trong quá trình đó đều được gắn nhãn với tên mặc định. Bạn có thể tuỳ chỉnh nhãn bằng cách chỉ định tham số label
trong các hàm updateTransition
và AnimatedVisibility
. Để biết thêm thông tin, hãy xem phần Xem trước ảnh động.
Tìm hiểu thêm
Để tìm hiểu thêm về ảnh động trong Jetpack Compose, hãy tham khảo thêm các tài nguyên sau đây: