1. Giới thiệu
Ảnh động là một cách tuyệt vời để cải thiện trải nghiệm người dùng của ứng dụng, truyền đạt thông tin quan trọng cho người dùng và giúp ứng dụng của bạn trở nên tinh tế và thú vị hơn khi sử dụng.
Tổng quan về khung ảnh động của Flutter
Flutter hiển thị hiệu ứng ảnh động bằng cách tạo lại một phần của cây tiện ích trên mỗi khung hình. Thư viện này cung cấp các hiệu ứng ảnh động tạo sẵn và các API khác để giúp việc tạo và soạn ảnh động trở nên dễ dàng hơn.
- Ảnh động ngầm ẩn là các hiệu ứng ảnh động được tạo sẵn tự động chạy toàn bộ ảnh động. Khi giá trị mục tiêu của ảnh động thay đổi, ảnh động sẽ chạy từ giá trị hiện tại đến giá trị mục tiêu và hiển thị từng giá trị ở giữa để tiện ích tạo ảnh động một cách mượt mà. Ví dụ về ảnh động ngầm bao gồm
AnimatedSize
,AnimatedScale
vàAnimatedPositioned
. - Ảnh động phản cảm cũng là hiệu ứng ảnh động tạo sẵn, nhưng cần có đối tượng
Animation
để hoạt động. Ví dụ:SizeTransition
,ScaleTransition
hoặcPositionedTransition
. - Ảnh động là một lớp đại diện cho ảnh động đang chạy hoặc đã dừng và bao gồm một giá trị đại diện cho giá trị mục tiêu mà ảnh động đang chạy đến và trạng thái đại diện cho giá trị hiện tại mà ảnh động đang hiển thị trên màn hình tại bất kỳ thời điểm nào. Đây là lớp con của
Listenable
và thông báo cho trình nghe khi trạng thái thay đổi trong khi ảnh động đang chạy. - AnimationController là một cách để tạo Ảnh động và kiểm soát trạng thái của ảnh động đó. Bạn có thể sử dụng các phương thức của lớp này như
forward()
,reset()
,stop()
vàrepeat()
để kiểm soát ảnh động mà không cần xác định hiệu ứng ảnh động đang hiển thị, chẳng hạn như tỷ lệ, kích thước hoặc vị trí. - Tween được dùng để nội suy các giá trị giữa giá trị bắt đầu và giá trị kết thúc, đồng thời có thể biểu thị bất kỳ loại nào, chẳng hạn như một số thực,
Offset
hoặcColor
. - Đường cong được dùng để điều chỉnh tốc độ thay đổi của một tham số theo thời gian. Khi một ảnh động chạy, thường thì bạn sẽ áp dụng đường cong giảm tốc để tăng hoặc giảm tốc độ thay đổi ở đầu hoặc cuối ảnh động. Đường cong lấy một giá trị đầu vào từ 0 đến 1 và trả về một giá trị đầu ra từ 0 đến 1.
Sản phẩm bạn sẽ tạo ra
Trong lớp học lập trình này, bạn sẽ xây dựng một trò chơi đố vui có nhiều hiệu ứng và kỹ thuật ảnh động.
Bạn sẽ tìm hiểu cách...
- Tạo một tiện ích tạo ảnh động cho kích thước và màu sắc
- Tạo hiệu ứng lật thẻ 3D
- Sử dụng các hiệu ứng ảnh động tạo sẵn bắt mắt trong gói ảnh động
- Thêm tính năng hỗ trợ cử chỉ xem trước thao tác quay lại trên phiên bản Android mới nhất
Kiến thức bạn sẽ học được
Trong lớp học lập trình này, bạn sẽ tìm hiểu:
- Cách sử dụng các hiệu ứng ảnh động ngầm ẩn để tạo ra ảnh động trông tuyệt vời mà không cần nhiều mã.
- Cách sử dụng các hiệu ứng ảnh động rõ ràng để định cấu hình hiệu ứng của riêng bạn bằng các tiện ích ảnh động tạo sẵn như
AnimatedSwitcher
hoặcAnimationController
. - Cách sử dụng
AnimationController
để xác định tiện ích của riêng bạn hiển thị hiệu ứng 3D. - Cách sử dụng gói
animations
để hiển thị các hiệu ứng ảnh động bắt mắt với mức thiết lập tối thiểu.
Bạn cần có
- SDK Flutter
- Một IDE, chẳng hạn như VSCode hoặc Android Studio / IntelliJ
2. Thiết lập môi trường phát triển Flutter
Bạn cần có hai phần mềm để hoàn thành lớp học này — SDK Flutter và một trình chỉnh sửa.
Bạn có thể chạy lớp học lập trình này bằng bất kỳ thiết bị nào sau đây:
- Thiết bị Android thực (nên triển khai tính năng xem trước thao tác quay lại ở bước 7) hoặc thiết bị iOS được kết nối với máy tính và đặt thành Chế độ nhà phát triển.
- Trình mô phỏng iOS (yêu cầu cài đặt các công cụ Xcode).
- Trình mô phỏng Android (yêu cầu thiết lập trong Android Studio).
- Trình duyệt (bạn cần có Chrome để gỡ lỗi).
- Máy tính để bàn chạy Windows, Linux hoặc macOS. Bạn phải phát triển trên nền tảng mà bạn dự định triển khai. Vì vậy, nếu muốn phát triển ứng dụng dành cho máy tính Windows, bạn phải phát triển trên Windows để truy cập vào chuỗi xây dựng thích hợp. Có các yêu cầu dành riêng cho hệ điều hành được đề cập chi tiết trên docs.flutter.dev/desktop.
Xác minh quá trình cài đặt
Để xác minh rằng SDK Flutter của bạn được định cấu hình chính xác và bạn đã cài đặt ít nhất một trong các nền tảng mục tiêu ở trên, hãy sử dụng công cụ Flutter Doctor:
$ flutter doctor Doctor summary (to see all details, run flutter doctor -v): [✓] Flutter (Channel stable, 3.24.2, on macOS 14.6.1 23G93 darwin-arm64, locale en) [✓] Android toolchain - develop for Android devices [✓] Xcode - develop for iOS and macOS [✓] Chrome - develop for the web [✓] Android Studio [✓] IntelliJ IDEA Ultimate Edition [✓] VS Code [✓] Connected device (4 available) [✓] Network resources • No issues found!
3. Chạy ứng dụng khởi động
Tải ứng dụng ban đầu xuống
Sử dụng git
để sao chép ứng dụng khởi động từ kho lưu trữ flutter/samples trên GitHub.
$ git clone https://github.com/flutter/codelabs.git $ cd codelabs/animations/step_01/
Ngoài ra, bạn có thể tải mã nguồn xuống dưới dạng tệp .zip.
Chạy ứng dụng
Để chạy ứng dụng, hãy sử dụng lệnh flutter run
và chỉ định một thiết bị mục tiêu, chẳng hạn như android
, ios
hoặc chrome
. Để xem danh sách đầy đủ các nền tảng được hỗ trợ, hãy xem trang Nền tảng được hỗ trợ.
$ flutter run -d android
Bạn cũng có thể chạy và gỡ lỗi ứng dụng bằng IDE mà bạn chọn. Hãy xem tài liệu chính thức về Flutter để biết thêm thông tin.
Tham quan mã
Ứng dụng khởi động là một trò chơi đố vui có nhiều lựa chọn, bao gồm hai màn hình tuân theo mẫu thiết kế mô hình-thành phần hiển thị-thành phần hiển thị-mô hình (model-view-view-model) hoặc MVVM. QuestionScreen
(Thành phần hiển thị) sử dụng lớp QuizViewModel
(Mô hình thành phần hiển thị) để đặt câu hỏi trắc nghiệm cho người dùng từ lớp QuestionBank
(Mô hình).
- home_screen.dart – Hiển thị màn hình có nút New Game (Trò chơi mới)
- main.dart – Định cấu hình
MaterialApp
để sử dụng Material 3 và hiển thị màn hình chính - model.dart – Xác định các lớp cốt lõi được sử dụng trong toàn bộ ứng dụng
- question_screen.dart – Hiển thị giao diện người dùng cho trò chơi đố vui
- view_model.dart – Lưu trữ trạng thái và logic cho trò chơi đố vui, do
QuestionScreen
hiển thị
Ứng dụng chưa hỗ trợ hiệu ứng ảnh động nào, ngoại trừ hiệu ứng chuyển đổi chế độ xem mặc định do lớp Navigator
của Flutter hiển thị khi người dùng nhấn nút New Game (Trò chơi mới).
4. Sử dụng hiệu ứng ảnh động ngầm ẩn
Ảnh động ngầm ẩn là một lựa chọn tuyệt vời trong nhiều trường hợp vì không yêu cầu bất kỳ cấu hình đặc biệt nào. Trong phần này, bạn sẽ cập nhật tiện ích StatusBar
để hiển thị một bảng điểm động. Để tìm các hiệu ứng ảnh động ngầm ẩn phổ biến, hãy duyệt xem tài liệu về API ImplicitlyAnimatedWidget.
Tạo tiện ích bảng điểm không có ảnh động
Tạo một tệp mới, lib/scoreboard.dart
bằng mã sau:
lib/scoreboard.dart
import 'package:flutter/material.dart';
class Scoreboard extends StatelessWidget {
final int score;
final int totalQuestions;
const Scoreboard({
super.key,
required this.score,
required this.totalQuestions,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var i = 0; i < totalQuestions; i++)
Icon(
Icons.star,
size: 50,
color:
score < i + 1 ? Colors.grey.shade400 : Colors.yellow.shade700,
)
],
),
);
}
}
Sau đó, hãy thêm tiện ích Scoreboard
trong các tiện ích con của tiện ích StatusBar
, thay thế các tiện ích Text
trước đó hiển thị điểm số và tổng số câu hỏi. Trình chỉnh sửa sẽ tự động thêm import "scoreboard.dart"
bắt buộc ở đầu tệp.
lib/question_screen.dart
class StatusBar extends StatelessWidget {
final QuizViewModel viewModel;
const StatusBar({required this.viewModel, super.key});
@override
Widget build(BuildContext context) {
return Card(
elevation: 4,
child: Padding(
padding: EdgeInsets.all(8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Scoreboard( // NEW
score: viewModel.score, // NEW
totalQuestions: viewModel.totalQuestions, // NEW
),
],
),
),
);
}
}
Tiện ích này hiển thị biểu tượng ngôi sao cho từng câu hỏi. Khi bạn trả lời đúng một câu hỏi, một ngôi sao khác sẽ sáng lên ngay lập tức mà không có bất kỳ ảnh động nào. Trong các bước sau, bạn sẽ thông báo cho người dùng về việc điểm số của họ đã thay đổi bằng cách tạo ảnh động cho kích thước và màu sắc của điểm số.
Sử dụng hiệu ứng ảnh động ngầm ẩn
Tạo một tiện ích mới có tên là AnimatedStar
. Tiện ích này sử dụng tiện ích AnimatedScale
để thay đổi số lượng scale
từ 0.5
thành 1.0
khi ngôi sao trở nên hoạt động:
lib/scoreboard.dart
import 'package:flutter/material.dart';
class Scoreboard extends StatelessWidget {
final int score;
final int totalQuestions;
const Scoreboard({
super.key,
required this.score,
required this.totalQuestions,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var i = 0; i < totalQuestions; i++)
AnimatedStar( // NEW
isActive: score > i, // NEW
) // NEW
],
),
);
}
}
class AnimatedStar extends StatelessWidget { // Add from here...
final bool isActive;
final Duration _duration = const Duration(milliseconds: 1000);
final Color _deactivatedColor = Colors.grey.shade400;
final Color _activatedColor = Colors.yellow.shade700;
AnimatedStar({super.key, required this.isActive});
@override
Widget build(BuildContext context) {
return AnimatedScale(
scale: isActive ? 1.0 : 0.5,
duration: _duration,
child: Icon(
Icons.star,
size: 50,
color: isActive ? _activatedColor : _deactivatedColor,
),
);
}
} // To here.
Giờ đây, khi người dùng trả lời đúng câu hỏi, tiện ích AnimatedStar
sẽ cập nhật kích thước bằng ảnh động ngầm ẩn. color
của Icon
không được tạo ảnh động ở đây, chỉ có scale
được thực hiện bằng tiện ích AnimatedScale
.
Sử dụng Tween để nội suy giữa hai giá trị
Lưu ý rằng màu của tiện ích AnimatedStar
sẽ thay đổi ngay sau khi trường isActive
thay đổi thành true.
Để đạt được hiệu ứng màu động, bạn có thể thử sử dụng tiện ích AnimatedContainer
(là một lớp con khác của ImplicitlyAnimatedWidget
), vì tiện ích này có thể tự động tạo ảnh động cho tất cả các thuộc tính, bao gồm cả màu sắc. Rất tiếc, tiện ích của chúng ta cần hiển thị một biểu tượng, chứ không phải một vùng chứa.
Bạn cũng có thể thử AnimatedIcon
để triển khai hiệu ứng chuyển đổi giữa các hình dạng của biểu tượng. Tuy nhiên, không có cách triển khai mặc định cho biểu tượng dấu sao trong lớp AnimatedIcons
.
Thay vào đó, chúng ta sẽ sử dụng một lớp con khác của ImplicitlyAnimatedWidget
có tên là TweenAnimationBuilder
. Lớp con này lấy Tween
làm tham số. Tween là một lớp lấy hai giá trị (begin
và end
) và tính toán các giá trị ở giữa để ảnh động có thể hiển thị các giá trị đó. Trong ví dụ này, chúng ta sẽ sử dụng ColorTween
, đáp ứng giao diện Tween<Color>
cần thiết để tạo hiệu ứng ảnh động.
Chọn tiện ích Icon
và sử dụng thao tác nhanh "Wrap with Builder" (Gói bằng Trình tạo) trong IDE, thay đổi tên thành TweenAnimationBuilder
. Sau đó, hãy cung cấp thời lượng và ColorTween
.
lib/scoreboard.dart
class AnimatedStar extends StatelessWidget {
final bool isActive;
final Duration _duration = const Duration(milliseconds: 1000);
final Color _deactivatedColor = Colors.grey.shade400;
final Color _activatedColor = Colors.yellow.shade700;
AnimatedStar({super.key, required this.isActive});
@override
Widget build(BuildContext context) {
return AnimatedScale(
scale: isActive ? 1.0 : 0.5,
duration: _duration,
child: TweenAnimationBuilder( // Add from here...
duration: _duration,
tween: ColorTween(
begin: _deactivatedColor,
end: isActive ? _activatedColor : _deactivatedColor,
),
builder: (context, value, child) { // To here.
return Icon(
Icons.star,
size: 50,
color: value, // Modify from here...
);
}, // To here.
),
);
}
}
Bây giờ, hãy tải lại nóng ứng dụng để xem ảnh động mới.
Lưu ý rằng giá trị end
của ColorTween
thay đổi dựa trên giá trị của tham số isActive
. Nguyên nhân là do TweenAnimationBuilder
chạy lại ảnh động bất cứ khi nào giá trị Tween.end
thay đổi. Khi điều này xảy ra, ảnh động mới sẽ chạy từ giá trị ảnh động hiện tại đến giá trị kết thúc mới, cho phép bạn thay đổi màu sắc bất cứ lúc nào (ngay cả khi ảnh động đang chạy) và hiển thị hiệu ứng ảnh động mượt mà với các giá trị trung gian chính xác.
Áp dụng đường cong
Cả hai hiệu ứng ảnh động này đều chạy ở tốc độ không đổi, nhưng ảnh động thường thú vị và cung cấp nhiều thông tin hơn về mặt hình ảnh khi tăng tốc hoặc giảm tốc.
Curve
áp dụng hàm làm dịu, xác định tốc độ thay đổi của một tham số theo thời gian. Flutter đi kèm với một bộ sưu tập các đường cong chuyển động được tạo sẵn trong lớp Curves
, chẳng hạn như easeIn
hoặc easeOut
.
Các sơ đồ này (có trên trang tài liệu về API Curves
) cho biết cách hoạt động của các đường cong. Đường cong chuyển đổi giá trị đầu vào từ 0 đến 1 (hiển thị trên trục x) thành giá trị đầu ra từ 0 đến 1 (hiển thị trên trục y). Các sơ đồ này cũng cho thấy bản xem trước của nhiều hiệu ứng ảnh động khi sử dụng một đường cong làm dịu.
Tạo một trường mới trong AnimatedStar có tên là _curve
và truyền trường đó dưới dạng tham số đến các tiện ích AnimatedScale
và TweenAnimationBuilder
.
lib/scoreboard.dart
class AnimatedStar extends StatelessWidget {
final bool isActive;
final Duration _duration = const Duration(milliseconds: 1000);
final Color _deactivatedColor = Colors.grey.shade400;
final Color _activatedColor = Colors.yellow.shade700;
final Curve _curve = Curves.elasticOut; // NEW
AnimatedStar({super.key, required this.isActive});
@override
Widget build(BuildContext context) {
return AnimatedScale(
scale: isActive ? 1.0 : 0.5,
curve: _curve, // NEW
duration: _duration,
child: TweenAnimationBuilder(
curve: _curve, // NEW
duration: _duration,
tween: ColorTween(
begin: _deactivatedColor,
end: isActive ? _activatedColor : _deactivatedColor,
),
builder: (context, value, child) {
return Icon(
Icons.star,
size: 50,
color: value,
);
},
),
);
}
}
Trong ví dụ này, đường cong elasticOut
tạo ra hiệu ứng mùa xuân phóng đại bắt đầu bằng chuyển động mùa xuân và cân bằng về cuối.
Tải lại nóng ứng dụng để xem đường cong này được áp dụng cho AnimatedSize
và TweenAnimationBuilder
.
Sử dụng Công cụ cho nhà phát triển để bật ảnh động chậm
Để gỡ lỗi bất kỳ hiệu ứng ảnh động nào, Flutter DevTools cung cấp một cách để làm chậm tất cả ảnh động trong ứng dụng, nhờ đó bạn có thể xem ảnh động rõ ràng hơn.
Để mở DevTools, hãy đảm bảo ứng dụng đang chạy ở chế độ gỡ lỗi và mở Widget Inspector (Trình kiểm tra tiện ích) bằng cách chọn tiện ích này trong Debug toolbar (Thanh công cụ gỡ lỗi) trong VSCode hoặc bằng cách chọn nút Open Flutter DevTools (Mở DevTools Flutter) trong Debug tool window (Cửa sổ công cụ gỡ lỗi) trong IntelliJ / Android Studio.
Sau khi trình kiểm tra tiện ích mở ra, hãy nhấp vào nút Slow animations (Ảnh động chậm) trong thanh công cụ.
5. Sử dụng hiệu ứng ảnh động phản cảm
Giống như ảnh động ngầm ẩn, ảnh động rõ ràng là các hiệu ứng ảnh động được tạo sẵn, nhưng thay vì lấy giá trị mục tiêu, ảnh động rõ ràng sẽ lấy đối tượng Animation
làm tham số. Điều này giúp các lớp này hữu ích trong những trường hợp ảnh động đã được xác định bằng một hiệu ứng chuyển đổi điều hướng, AnimatedSwitcher
hoặc AnimationController
, chẳng hạn.
Sử dụng hiệu ứng ảnh động phản cảm
Để bắt đầu với hiệu ứng ảnh động rõ ràng, hãy gói tiện ích Card
bằng AnimatedSwitcher
.
lib/question_screen.dart
class QuestionCard extends StatelessWidget {
final String? question;
const QuestionCard({
required this.question,
super.key,
});
@override
Widget build(BuildContext context) {
return AnimatedSwitcher( // NEW
duration: const Duration(milliseconds: 300), // NEW
child: Card(
key: ValueKey(question),
elevation: 4,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
question ?? '',
style: Theme.of(context).textTheme.displaySmall,
),
),
), // NEW
);
}
}
AnimatedSwitcher
sử dụng hiệu ứng chuyển màu theo mặc định, nhưng bạn có thể ghi đè hiệu ứng này bằng tham số transitionBuilder
. Trình tạo chuyển đổi cung cấp tiện ích con đã được truyền đến AnimatedSwitcher
và một đối tượng Animation
. Đây là cơ hội tuyệt vời để sử dụng ảnh động rõ ràng.
Đối với lớp học lập trình này, ảnh động rõ ràng đầu tiên mà chúng ta sẽ sử dụng là SlideTransition
. Ảnh động này sẽ lấy Animation<Offset>
xác định độ dời bắt đầu và kết thúc mà các tiện ích đến và đi sẽ di chuyển giữa.
Tween có một hàm trợ giúp, animate()
, chuyển đổi mọi Animation
thành một Animation
khác khi áp dụng tween. Điều này có nghĩa là bạn có thể sử dụng Tween<Offset>
để chuyển đổi Animation<double>
do AnimatedSwitcher
cung cấp thành Animation<Offset>
, để cung cấp cho tiện ích SlideTransition
.
lib/question_screen.dart
class QuestionCard extends StatelessWidget {
final String? question;
const QuestionCard({
required this.question,
super.key,
});
@override
Widget build(BuildContext context) {
return AnimatedSwitcher(
transitionBuilder: (child, animation) { // Add from here...
final curveAnimation =
CurveTween(curve: Curves.easeInCubic).animate(animation);
final offsetAnimation =
Tween<Offset>(begin: Offset(-0.1, 0.0), end: Offset.zero)
.animate(curveAnimation);
return SlideTransition(position: offsetAnimation, child: child);
}, // To here.
duration: const Duration(milliseconds: 300),
child: Card(
key: ValueKey(question),
elevation: 4,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
question ?? '',
style: Theme.of(context).textTheme.displaySmall,
),
),
),
);
}
}
Xin lưu ý rằng mã này sử dụng Tween.animate
để áp dụng Curve
cho Animation
, sau đó chuyển đổi Tween<double>
từ 0 đến 1 thành Tween<Offset>
chuyển đổi từ -0, 1 đến 0 trên trục x.
Ngoài ra, lớp Ảnh động có một hàm drive()
nhận bất kỳ Tween
(hoặc Animatable
) nào và chuyển đổi thành một Animation
mới. Điều này cho phép "chuỗi" các tween, giúp mã thu được trở nên súc tích hơn:
lib/question_screen.dart
transitionBuilder: (child, animation) {
var offsetAnimation = animation
.drive(CurveTween(curve: Curves.easeInCubic))
.drive(Tween<Offset>(begin: Offset(-0.1, 0.0), end: Offset.zero));
return SlideTransition(position: offsetAnimation, child: child);
},
Một lợi thế khác của việc sử dụng ảnh động rõ ràng là bạn có thể dễ dàng kết hợp các ảnh động đó với nhau. Thêm một ảnh động rõ ràng khác, FadeTransition sử dụng cùng một ảnh động cong bằng cách gói tiện ích SlideTransition.
lib/question_screen.dart
return AnimatedSwitcher(
transitionBuilder: (child, animation) {
final curveAnimation =
CurveTween(curve: Curves.easeInCubic).animate(animation);
final offsetAnimation =
Tween<Offset>(begin: Offset(-0.1, 0.0), end: Offset.zero)
.animate(curveAnimation);
final fadeInAnimation = curveAnimation; // NEW
return FadeTransition( // NEW
opacity: fadeInAnimation, // NEW
child: SlideTransition(position: offsetAnimation, child: child), // NEW
); // NEW
},
Tuỳ chỉnh layoutBuilder
Bạn có thể nhận thấy một vấn đề nhỏ với AnimationSwitcher. Khi chuyển sang một câu hỏi mới, QuestionCard sẽ hiển thị câu hỏi đó ở giữa không gian có sẵn trong khi ảnh động đang chạy, nhưng khi ảnh động dừng, tiện ích sẽ chuyển sang đầu màn hình. Điều này gây ra ảnh động giật vì vị trí cuối cùng của thẻ câu hỏi không khớp với vị trí trong khi ảnh động đang chạy.
Để khắc phục vấn đề này, AnimatedSwitcher cũng có một tham số layoutBuilder có thể dùng để xác định bố cục. Sử dụng hàm này để định cấu hình trình tạo bố cục nhằm căn chỉnh thẻ vào đầu màn hình:
lib/question_screen.dart
@override
Widget build(BuildContext context) {
return AnimatedSwitcher(
layoutBuilder: (currentChild, previousChildren) {
return Stack(
alignment: Alignment.topCenter,
children: <Widget>[
...previousChildren,
if (currentChild != null) currentChild,
],
);
},
Mã này là phiên bản sửa đổi của defaultLayoutBuilder từ lớp AnimatedSwitcher, nhưng sử dụng Alignment.topCenter
thay vì Alignment.center
.
Tóm tắt
- Ảnh động rõ ràng là các hiệu ứng ảnh động lấy đối tượng Ảnh động (tương phản với ImplicitlyAnimatedWidgets, lấy giá trị mục tiêu và thời lượng)
- Lớp Ảnh động đại diện cho ảnh động đang chạy, nhưng không xác định hiệu ứng cụ thể.
- Sử dụng Tween().animate hoặc Animation.drive() để áp dụng Tween và Curve (sử dụng CurveTween) cho ảnh động.
- Sử dụng thông số layoutBuilder của AnimatedSwitcher để điều chỉnh cách bố trí các thành phần con.
6. Kiểm soát trạng thái của ảnh động
Cho đến nay, mọi ảnh động đều được khung chạy tự động. Ảnh động ngầm ẩn chạy tự động và hiệu ứng ảnh động rõ ràng yêu cầu Ảnh động hoạt động đúng cách. Trong phần này, bạn sẽ tìm hiểu cách tạo đối tượng Ảnh động của riêng mình bằng AnimationController và sử dụng TweenSequence để kết hợp các Tween với nhau.
Chạy ảnh động bằng AnimationController
Để tạo ảnh động bằng AnimationController, bạn cần làm theo các bước sau:
- Tạo StatefulWidget
- Sử dụng mixin SingleTickerProviderStateMixin trong lớp Trạng thái để cung cấp một Ticker cho AnimationController
- Khởi động AnimationController trong phương thức vòng đời initState, cung cấp đối tượng Trạng thái hiện tại cho tham số
vsync
(TickerProvider). - Đảm bảo rằng tiện ích của bạn sẽ tạo lại bất cứ khi nào AnimationController thông báo cho trình nghe, bằng cách sử dụng AnimatedBuilder hoặc bằng cách gọi listen() và setState theo cách thủ công.
Tạo một tệp mới, flip_effect.dart và sao chép-dán mã sau:
lib/flip_effect.dart
import 'dart:math' as math;
import 'package:flutter/widgets.dart';
class CardFlipEffect extends StatefulWidget {
final Widget child;
final Duration duration;
const CardFlipEffect({
super.key,
required this.child,
required this.duration,
});
@override
State<CardFlipEffect> createState() => _CardFlipEffectState();
}
class _CardFlipEffectState extends State<CardFlipEffect>
with SingleTickerProviderStateMixin {
late final AnimationController _animationController;
Widget? _previousChild;
@override
void initState() {
super.initState();
_animationController =
AnimationController(vsync: this, duration: widget.duration);
_animationController.addListener(() {
if (_animationController.value == 1) {
_animationController.reset();
}
});
}
@override
void didUpdateWidget(covariant CardFlipEffect oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.child.key != oldWidget.child.key) {
_handleChildChanged(widget.child, oldWidget.child);
}
}
void _handleChildChanged(Widget newChild, Widget previousChild) {
_previousChild = previousChild;
_animationController.forward();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
..rotateX(_animationController.value * math.pi),
child: _animationController.isAnimating
? _animationController.value < 0.5
? _previousChild
: Transform.flip(flipY: true, child: child)
: child,
);
},
child: widget.child,
);
}
}
Lớp này thiết lập một AnimationController và chạy lại ảnh động bất cứ khi nào khung gọi didUpdateWidget để thông báo rằng cấu hình tiện ích đã thay đổi và có thể có một tiện ích con mới.
AnimatedBuilder đảm bảo rằng cây tiện ích được tạo lại bất cứ khi nào AnimationController thông báo cho trình nghe và tiện ích Transform được dùng để áp dụng hiệu ứng xoay 3D nhằm mô phỏng một thẻ đang được lật.
Để sử dụng tiện ích này, hãy gói từng thẻ câu trả lời bằng tiện ích CardFlipEffect. Hãy nhớ cung cấp key
cho tiện ích Thẻ:
lib/question_screen.dart
@override
Widget build(BuildContext context) {
return GridView.count(
shrinkWrap: true,
crossAxisCount: 2,
childAspectRatio: 5 / 2,
children: List.generate(answers.length, (index) {
var color = Theme.of(context).colorScheme.primaryContainer;
if (correctAnswer == index) {
color = Theme.of(context).colorScheme.tertiaryContainer;
}
return CardFlipEffect( // NEW
duration: const Duration(milliseconds: 300), // NEW
child: Card.filled( // NEW
key: ValueKey(answers[index]), // NEW
color: color,
elevation: 2,
margin: EdgeInsets.all(8),
clipBehavior: Clip.hardEdge,
child: InkWell(
onTap: () => onTapped(index),
child: Padding(
padding: EdgeInsets.all(16.0),
child: Center(
child: Text(
answers.length > index ? answers[index] : '',
style: Theme.of(context).textTheme.titleMedium,
overflow: TextOverflow.clip,
),
),
),
),
), // NEW
);
}),
);
}
Bây giờ, hãy tải lại nóng ứng dụng để xem các thẻ câu trả lời lật qua lại bằng tiện ích CardFlipEffect.
Bạn có thể nhận thấy lớp này trông giống như một hiệu ứng ảnh động rõ ràng. Trên thực tế, bạn nên mở rộng trực tiếp lớp AnimatedWidget để triển khai phiên bản của riêng mình. Rất tiếc, vì lớp này cần lưu trữ tiện ích trước đó trong Trạng thái, nên lớp này cần sử dụng StatefulWidget. Để tìm hiểu thêm về cách tạo hiệu ứng ảnh động rõ ràng của riêng bạn, hãy xem tài liệu về API cho AnimatedWidget.
Thêm độ trễ bằng TweenSequence
Trong phần này, bạn sẽ thêm độ trễ vào tiện ích CardFlipEffect để mỗi thẻ lật lần lượt một thẻ. Để bắt đầu, hãy thêm một trường mới có tên là delayAmount
.
lib/flip_effect.dart
class CardFlipEffect extends StatefulWidget {
final Widget child;
final Duration duration;
final double delayAmount; // NEW
const CardFlipEffect({
super.key,
required this.child,
required this.duration,
required this.delayAmount, // NEW
});
@override
State<CardFlipEffect> createState() => _CardFlipEffectState();
}
Sau đó, thêm delayAmount
vào phương thức tạo AnswerCards
.
lib/question_screen.dart
@override
Widget build(BuildContext context) {
return GridView.count(
shrinkWrap: true,
crossAxisCount: 2,
childAspectRatio: 5 / 2,
children: List.generate(answers.length, (index) {
var color = Theme.of(context).colorScheme.primaryContainer;
if (correctAnswer == index) {
color = Theme.of(context).colorScheme.tertiaryContainer;
}
return CardFlipEffect(
delayAmount: index.toDouble() / 2, // NEW
duration: const Duration(milliseconds: 300),
child: Card.filled(
key: ValueKey(answers[index]),
Sau đó, trong _CardFlipEffectState
, hãy tạo một Ảnh động mới áp dụng độ trễ bằng TweenSequence
. Xin lưu ý rằng phương thức này không sử dụng bất kỳ tiện ích nào trong thư viện dart:async
, chẳng hạn như Future.delayed
. Điều này là do độ trễ là một phần của ảnh động và không phải là điều mà tiện ích kiểm soát rõ ràng khi sử dụng AnimationController. Điều này giúp bạn dễ dàng gỡ lỗi hiệu ứng ảnh động khi bật ảnh động chậm trong DevTools, vì hiệu ứng này sử dụng cùng một TickerProvider.
Để sử dụng TweenSequence
, hãy tạo hai TweenSequenceItem
, một chứa ConstantTween
giữ ảnh động ở 0 trong một khoảng thời gian tương đối và một Tween
thông thường đi từ 0.0
đến 1.0
.
lib/flip_effect.dart
class _CardFlipEffectState extends State<CardFlipEffect>
with SingleTickerProviderStateMixin {
late final AnimationController _animationController;
Widget? _previousChild;
late final Animation<double> _animationWithDelay; // NEW
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this, duration: widget.duration * (widget.delayAmount + 1));
_animationController.addListener(() {
if (_animationController.value == 1) {
_animationController.reset();
}
});
_animationWithDelay = TweenSequence<double>([ // NEW
if (widget.delayAmount > 0) // NEW
TweenSequenceItem( // NEW
tween: ConstantTween<double>(0.0), // NEW
weight: widget.delayAmount, // NEW
), // NEW
TweenSequenceItem( // NEW
tween: Tween(begin: 0.0, end: 1.0), // NEW
weight: 1.0, // NEW
), // NEW
]).animate(_animationController); // NEW
}
Cuối cùng, hãy thay thế ảnh động của AnimationController bằng ảnh động bị trì hoãn mới trong phương thức bản dựng.
lib/flip_effect.dart
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animationWithDelay, // Modify this line
builder: (context, child) {
return Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
..rotateX(_animationWithDelay.value * math.pi), // And this line
child: _animationController.isAnimating
? _animationWithDelay.value < 0.5 // And this one.
? _previousChild
: Transform.flip(flipY: true, child: child)
: child,
);
},
child: widget.child,
);
}
Bây giờ, hãy tải lại nóng ứng dụng và xem các thẻ lật lần lượt. Để thử thách bản thân, hãy thử thay đổi phối cảnh của hiệu ứng 3D do tiện ích Transform
cung cấp.
7. Sử dụng hiệu ứng chuyển đổi điều hướng tuỳ chỉnh
Cho đến nay, chúng ta đã xem cách tuỳ chỉnh hiệu ứng trên một màn hình, nhưng có một cách khác để sử dụng ảnh động là sử dụng ảnh động để chuyển đổi giữa các màn hình. Trong phần này, bạn sẽ tìm hiểu cách áp dụng hiệu ứng ảnh động cho các hiệu ứng chuyển đổi màn hình bằng cách sử dụng hiệu ứng ảnh động tích hợp sẵn và hiệu ứng ảnh động tạo sẵn bắt mắt do gói ảnh động chính thức cung cấp trên pub.dev
Tạo hiệu ứng động cho quá trình chuyển đổi điều hướng
Lớp PageRouteBuilder
là một Route
cho phép bạn tuỳ chỉnh ảnh động chuyển đổi. Phương thức này cho phép bạn ghi đè lệnh gọi lại transitionBuilder
, cung cấp hai đối tượng Ảnh động, đại diện cho ảnh động đến và đi do Trình điều hướng chạy.
Để tuỳ chỉnh ảnh động chuyển đổi, hãy thay thế MaterialPageRoute
bằng PageRouteBuilder
và để tuỳ chỉnh ảnh động chuyển đổi khi người dùng di chuyển từ HomeScreen
đến QuestionScreen
. Sử dụng FadeTransition (một tiện ích động rõ ràng) để làm cho màn hình mới mờ dần trên màn hình trước.
lib/home_screen.dart
ElevatedButton(
onPressed: () {
// Show the question screen to start the game
Navigator.push(
context,
PageRouteBuilder( // NEW
pageBuilder: (context, animation, secondaryAnimation) { // NEW
return QuestionScreen(); // NEW
}, // NEW
transitionsBuilder: // NEW
(context, animation, secondaryAnimation, child) { // NEW
return FadeTransition( // NEW
opacity: animation, // NEW
child: child, // NEW
); // NEW
}, // NEW
), // NEW
);
},
child: Text('New Game'),
),
Gói ảnh động cung cấp các hiệu ứng ảnh động tạo sẵn bắt mắt, chẳng hạn như FadeThroughTransition. Nhập gói ảnh động và thay thế FadeTransition bằng tiện ích FadeThroughTransition:
lib/home_screen.dart
import 'package;animations/animations.dart';
ElevatedButton(
onPressed: () {
// Show the question screen to start the game
Navigator.push(
context,
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) {
return const QuestionScreen();
},
transitionsBuilder:
(context, animation, secondaryAnimation, child) {
return FadeThroughTransition( // NEW
animation: animation, // NEW
secondaryAnimation: secondaryAnimation, // NEW
child: child, // NEW
); // NEW
},
),
);
},
child: Text('New Game'),
),
Tuỳ chỉnh ảnh động xem trước thao tác quay lại
Xem trước thao tác quay lại là một tính năng mới của Android cho phép người dùng xem trước tuyến hoặc ứng dụng hiện tại để biết nội dung đằng sau trước khi điều hướng. Ảnh động xem trước được điều khiển bởi vị trí ngón tay của người dùng khi họ kéo ngược trên màn hình.
Flutter hỗ trợ tính năng xem trước thao tác quay lại của hệ thống bằng cách bật tính năng này ở cấp hệ thống khi Flutter không có tuyến đường nào để bật lên trên ngăn xếp điều hướng, hay nói cách khác, khi thao tác quay lại sẽ thoát khỏi ứng dụng. Ảnh động này do hệ thống xử lý chứ không phải do chính Flutter xử lý.
Flutter cũng hỗ trợ tính năng xem trước thao tác quay lại khi di chuyển giữa các tuyến trong ứng dụng Flutter. Một PageTransitionsBuilder đặc biệt có tên là PredictiveBackPageTransitionsBuilder
sẽ theo dõi các cử chỉ xem trước thao tác quay lại của hệ thống và điều khiển quá trình chuyển đổi trang bằng tiến trình của cử chỉ.
Tính năng xem trước thao tác quay lại chỉ được hỗ trợ trong Android U trở lên, nhưng Flutter sẽ quay lại hành vi cử chỉ quay lại ban đầu và ZoomPageTransitionBuilder một cách linh hoạt. Hãy xem bài đăng trên blog của chúng tôi để biết thêm thông tin, bao gồm cả phần về cách thiết lập tính năng này trong ứng dụng của riêng bạn.
Trong cấu hình ThemeData cho ứng dụng, hãy định cấu hình PageTransitionsTheme để sử dụng tính năng PredictiveBack trên Android và hiệu ứng chuyển đổi mờ từ gói ảnh động trên các nền tảng khác:
lib/main.dart
import 'package:animations/animations.dart'; // NEW
import 'package:flutter/material.dart';
import 'home_screen.dart';
void main() {
runApp(MainApp());
}
class MainApp extends StatelessWidget {
const MainApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
pageTransitionsTheme: PageTransitionsTheme(
builders: {
TargetPlatform.android: PredictiveBackPageTransitionsBuilder(), // NEW
TargetPlatform.iOS: FadeThroughPageTransitionsBuilder(), // NEW
TargetPlatform.macOS: FadeThroughPageTransitionsBuilder(), // NEW
TargetPlatform.windows: FadeThroughPageTransitionsBuilder(), // NEW
TargetPlatform.linux: FadeThroughPageTransitionsBuilder(), // NEW
},
),
),
home: HomeScreen(),
);
}
}
Bây giờ, bạn có thể thay đổi lệnh gọi Navigator.push() trở lại MaterialPageRoute.
lib/home_screen.dart
ElevatedButton(
onPressed: () {
// Show the question screen to start the game
Navigator.push(
context,
MaterialPageRoute(builder: (context) { // NEW
return const QuestionScreen(); // NEW
}), // NEW
);
},
child: Text('New Game'),
),
Sử dụng FadeThroughTransition để thay đổi câu hỏi hiện tại
Tiện ích AnimatedSwitcher chỉ cung cấp một Ảnh động trong lệnh gọi lại trình tạo. Để giải quyết vấn đề này, gói animations
cung cấp PageTransitionSwitcher.
lib/question_screen.dart
class QuestionCard extends StatelessWidget {
final String? question;
const QuestionCard({
required this.question,
super.key,
});
@override
Widget build(BuildContext context) {
return PageTransitionSwitcher( // NEW
layoutBuilder: (entries) { // NEW
return Stack( // NEW
alignment: Alignment.topCenter, // NEW
children: entries, // NEW
); // NEW
}, // NEW
transitionBuilder: (child, animation, secondaryAnimation) { // NEW
return FadeThroughTransition( // NEW
animation: animation, // NEW
secondaryAnimation: secondaryAnimation, // NEW
child: child, // NEW
); // NEW
}, // NEW
duration: const Duration(milliseconds: 300),
child: Card(
key: ValueKey(question),
elevation: 4,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
question ?? '',
style: Theme.of(context).textTheme.displaySmall,
),
),
),
);
}
}
Sử dụng OpenContainer
Tiện ích OpenContainer trong gói animations
cung cấp hiệu ứng ảnh động biến đổi vùng chứa mở rộng để tạo kết nối trực quan giữa hai tiện ích.
Tiện ích do closedBuilder
trả về sẽ hiển thị ban đầu và mở rộng thành tiện ích do openBuilder
trả về khi nhấn vào vùng chứa hoặc khi lệnh gọi lại openContainer
được gọi.
Để kết nối lệnh gọi lại openContainer
với mô hình khung hiển thị, hãy thêm một lệnh truyền mới vào viewModel vào tiện ích QuestionCard và lưu trữ lệnh gọi lại sẽ được dùng để hiển thị màn hình "Game Over" (Kết thúc trò chơi):
lib/question_screen.dart
class QuestionScreen extends StatefulWidget {
const QuestionScreen({super.key});
@override
State<QuestionScreen> createState() => _QuestionScreenState();
}
class _QuestionScreenState extends State<QuestionScreen> {
late final QuizViewModel viewModel =
QuizViewModel(onGameOver: _handleGameOver);
VoidCallback? _showGameOverScreen; // NEW
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: viewModel,
builder: (context, child) {
return Scaffold(
appBar: AppBar(
actions: [
TextButton(
onPressed:
viewModel.hasNextQuestion && viewModel.didAnswerQuestion
? () {
viewModel.getNextQuestion();
}
: null,
child: const Text('Next'),
)
],
),
body: Center(
child: Column(
children: [
QuestionCard( // NEW
onChangeOpenContainer: _handleChangeOpenContainer, // NEW
question: viewModel.currentQuestion?.question, // NEW
viewModel: viewModel, // NEW
), // NEW
Spacer(),
AnswerCards(
onTapped: (index) {
viewModel.checkAnswer(index);
},
answers: viewModel.currentQuestion?.possibleAnswers ?? [],
correctAnswer: viewModel.didAnswerQuestion
? viewModel.currentQuestion?.correctAnswer
: null,
),
StatusBar(viewModel: viewModel),
],
),
),
);
},
);
}
void _handleChangeOpenContainer(VoidCallback openContainer) { // NEW
_showGameOverScreen = openContainer; // NEW
} // NEW
void _handleGameOver() { // NEW
if (_showGameOverScreen != null) { // NEW
_showGameOverScreen!(); // NEW
} // NEW
} // NEW
}
Thêm một tiện ích mới, GameOverScreen:
lib/question_screen.dart
class GameOverScreen extends StatelessWidget {
final QuizViewModel viewModel;
const GameOverScreen({required this.viewModel, super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Scoreboard(
score: viewModel.score,
totalQuestions: viewModel.totalQuestions,
),
Text(
'You Win!',
style: Theme.of(context).textTheme.displayLarge,
),
Text(
'Score: ${viewModel.score} / ${viewModel.totalQuestions}',
style: Theme.of(context).textTheme.displaySmall,
),
ElevatedButton(
child: Text('OK'),
onPressed: () {
Navigator.popUntil(context, (route) => route.isFirst);
},
),
],
),
),
);
}
}
Trong tiện ích QuestionCard, hãy thay thế Thẻ bằng tiện ích OpenContainer từ gói ảnh động, thêm hai trường mới cho viewModel và lệnh gọi lại vùng chứa mở:
lib/question_screen.dart
class QuestionCard extends StatelessWidget {
final String? question;
const QuestionCard({
required this.onChangeOpenContainer,
required this.question,
required this.viewModel,
super.key,
});
final ValueChanged<VoidCallback> onChangeOpenContainer;
final QuizViewModel viewModel;
static const _backgroundColor = Color(0xfff2f3fa);
@override
Widget build(BuildContext context) {
return PageTransitionSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (child, animation, secondaryAnimation) {
return FadeThroughTransition(
animation: animation,
secondaryAnimation: secondaryAnimation,
child: child,
);
},
child: OpenContainer( // NEW
key: ValueKey(question), // NEW
tappable: false, // NEW
closedColor: _backgroundColor, // NEW
closedShape: const RoundedRectangleBorder( // NEW
borderRadius: BorderRadius.all(Radius.circular(12.0)), // NEW
), // NEW
closedElevation: 4, // NEW
closedBuilder: (context, openContainer) { // NEW
onChangeOpenContainer(openContainer); // NEW
return ColoredBox( // NEW
color: _backgroundColor, // NEW
child: Padding( // NEW
padding: const EdgeInsets.all(16.0), // NEW
child: Text(
question ?? '',
style: Theme.of(context).textTheme.displaySmall,
),
),
);
},
openBuilder: (context, closeContainer) { // NEW
return GameOverScreen(viewModel: viewModel); // NEW
}, // NEW
),
);
}
}
8. Xin chúc mừng
Xin chúc mừng! Bạn đã thêm thành công hiệu ứng ảnh động vào ứng dụng Flutter và tìm hiểu về các thành phần cốt lõi của hệ thống ảnh động Flutter. Cụ thể, bạn đã tìm hiểu:
- Cách sử dụng ImplicitlyAnimatedWidget
- Cách sử dụng ExplicitlyAnimatedWidget
- Cách áp dụng Đường cong và Tween cho ảnh động
- Cách sử dụng các tiện ích chuyển đổi tạo sẵn như AnimatedSwitcher hoặc PageRouteBuilder
- Cách sử dụng các hiệu ứng ảnh động tạo sẵn bắt mắt từ gói
animations
, chẳng hạn như FadeThroughTransition và OpenContainer - Cách tuỳ chỉnh ảnh động chuyển đổi mặc định, bao gồm cả việc thêm tính năng hỗ trợ cho tính năng Xem trước thao tác quay lại trên Android.
Tiếp theo là gì?
Hãy tham khảo một số lớp học lập trình sau:
- Tạo bố cục ứng dụng thích ứng có ảnh động bằng Material 3
- Tạo hiệu ứng chuyển đổi đẹp mắt bằng Material Motion cho Flutter
- Thay đổi ứng dụng Flutter từ nhàm chán thành đẹp mắt
Hoặc tải ứng dụng mẫu ảnh động xuống để xem các kỹ thuật ảnh động
Tài liệu đọc thêm
Bạn có thể tìm thấy thêm tài nguyên ảnh động trên flutter.dev:
- Giới thiệu về ảnh động
- Hướng dẫn về ảnh động (hướng dẫn)
- Ảnh động ngầm ẩn (hướng dẫn)
- Tạo ảnh động cho các thuộc tính của vùng chứa (cuốn sách dạy nấu ăn)
- Hiệu ứng mờ khi một tiện ích xuất hiện và biến mất (sổ tay công thức)
- Ảnh động chính
- Tạo hiệu ứng động cho quá trình chuyển đổi tuyến trang (cẩm nang)
- Tạo ảnh động cho tiện ích bằng cách mô phỏng vật lý (cẩm nang)
- Ảnh động được xếp kề
- Tiện ích ảnh động và chuyển động (Danh mục tiện ích)
Hoặc xem các bài viết sau trên Medium:
- Tìm hiểu chuyên sâu về ảnh động
- Ảnh động ngầm tuỳ chỉnh trong Flutter
- Quản lý ảnh động bằng Flutter và Flux / Redux
- Làm thế nào để chọn tiện ích ảnh động Flutter phù hợp với bạn?
- Ảnh động theo hướng có ảnh động tục tĩu tích hợp sẵn
- Kiến thức cơ bản về ảnh động Flutter với ảnh động ngầm ẩn
- Khi nào tôi nên sử dụng AnimatedBuilder hoặc AnimatedWidget?