Các cơ chế UI nâng cao trong Flutter

1. Gesture System và Hit Testing
1.1. Tổng quan về Gesture System
Gesture System là hệ thống xử lý cử chỉ tương tác trong Flutter, cho phép ứng dụng nhận biết và phản hồi những hành động của người dùng như chạm, nhấn giữ, kéo thả, vuốt, nhấn đúp hoặc phóng to thu nhỏ.
Trong ứng dụng Flutter, các thao tác tương tác thường xuất phát từ thiết bị đầu vào như màn hình cảm ứng, chuột, bàn phím hoặc bút cảm ứng. Flutter tiếp nhận những sự kiện đầu vào này, xác định vị trí xảy ra tương tác, chuyển sự kiện đến các đối tượng liên quan và xử lý chúng thành những cử chỉ có ý nghĩa.
Ví dụ, khi người dùng nhấn vào một nút đăng nhập, Flutter cần xác định vị trí người dùng chạm, kiểm tra Widget nào nằm tại vị trí đó, theo dõi quá trình chạm và xác định liệu thao tác đó có được xem là một lần nhấn hợp lệ hay không. Sau khi nhận diện thành công, ứng dụng mới thực hiện hành động tương ứng.
Gesture System có vai trò quan trọng trong việc xây dựng các thành phần giao diện có tính tương tác cao như:
-
Nút bấm và các vùng tương tác.
-
Danh sách có thể vuốt hoặc kéo.
-
Menu ngữ cảnh xuất hiện khi nhấn giữ.
-
Thành phần kéo thả giữa các khu vực.
-
Giao diện có thể phóng to hoặc thu nhỏ.
-
Các trang có thể chuyển đổi bằng thao tác vuốt.
-
Những thành phần điều khiển bằng chuột và bàn phím.
Để hiểu đầy đủ cách Flutter xử lý tương tác, cần phân biệt hai khái niệm chính là Hit Testing và Gesture Recognition. Hit Testing xác định những đối tượng có khả năng nhận sự kiện tại một vị trí, trong khi Gesture Recognition xác định hành động cụ thể mà người dùng đang thực hiện.
1.2. Cơ chế Hit Testing
Hit Testing là quá trình Flutter xác định những đối tượng giao diện nằm tại vị trí xảy ra sự kiện đầu vào.
Khi người dùng chạm vào màn hình, Flutter nhận được tọa độ tương ứng với vị trí chạm. Hệ thống sử dụng tọa độ này để kiểm tra các đối tượng giao diện trong cây hiển thị, từ đó xây dựng danh sách những đối tượng có khả năng tham gia xử lý sự kiện.
Quá trình này thường đi từ những thành phần nằm phía trên hoặc phía trước trong thứ tự hiển thị xuống các thành phần bên dưới theo cấu trúc và quy tắc hit testing của từng RenderObject.
Ví dụ, một giao diện có cấu trúc như sau:
Stack(
children: [
GestureDetector(
onTap: () {
print('Container phía dưới');
},
child: Container(
width: 300,
height: 300,
color: Colors.blue,
),
),
Positioned(
top: 50,
left: 50,
child: GestureDetector(
onTap: () {
print('Container phía trên');
},
child: Container(
width: 100,
height: 100,
color: Colors.red,
),
),
),
],
)
Trong ví dụ này, một Container màu đỏ nằm phía trên Container màu xanh. Nếu người dùng chạm vào vùng màu đỏ, đối tượng nằm phía trên thường được ưu tiên trong quá trình hit testing. Tuy nhiên, kết quả xử lý cuối cùng còn phụ thuộc vào cách các đối tượng tham gia hit test và cơ chế phân xử gesture.
Hit Testing không đơn giản chỉ chọn duy nhất một Widget. Trong nhiều trường hợp, danh sách hit test có thể chứa nhiều đối tượng nằm trên đường đi từ vị trí chạm đến các thành phần tổ tiên trong cây giao diện.
Các đối tượng có liên quan có thể tiếp tục nhận những sự kiện thuộc cùng chuỗi tương tác, nhưng điều đó không đồng nghĩa tất cả đều được quyền thực hiện cùng một gesture.
1.3. Vòng đời của một sự kiện chạm
Một chuỗi tương tác cảm ứng thường bao gồm ba loại sự kiện cơ bản:
-
PointerDownEvent: Ngón tay hoặc thiết bị đầu vào bắt đầu tiếp xúc với màn hình. -
PointerMoveEvent: Thiết bị đầu vào di chuyển trong khi tương tác vẫn đang diễn ra. -
PointerUpEvent: Ngón tay hoặc thiết bị đầu vào kết thúc tương tác.
Ngoài ra còn có các sự kiện như PointerCancelEvent, được sử dụng khi chuỗi tương tác bị hủy, và những sự kiện phục vụ chuột, bút cảm ứng hoặc thiết bị đầu vào khác.
Quy trình xử lý tổng quát có thể được mô tả như sau:
1. Người dùng tương tác
Chạm, di chuyển hoặc thả ngón tay
2. Pointer Event được tạo
Flutter tiếp nhận sự kiện đầu vào
3. Hit Testing
Xác định các đối tượng tại vị trí tương tác
4. Gesture Recognizers
Theo dõi sự kiện và cạnh tranh quyền nhận diện
5. Callback được thực thi
Ví dụ: onTap, onLongPress hoặc onPanUpdate
Sơ đồ trên thể hiện quy trình khái quát. Trên thực tế, việc xử lý pointer event, theo dõi gesture và phân xử có thể diễn ra xen kẽ trong suốt chuỗi tương tác.
1.4. Gesture Arena và cơ chế phân xử cử chỉ
Gesture Arena là cơ chế giúp Flutter giải quyết tình huống nhiều bộ nhận diện cử chỉ cùng muốn xử lý một chuỗi sự kiện đầu vào.
Ví dụ, một Widget có thể vừa nhận thao tác nhấn, vừa nằm bên trong một danh sách cuộn. Khi người dùng chạm rồi di chuyển ngón tay, hệ thống cần xác định đây là thao tác nhấn hay thao tác kéo để cuộn danh sách.
Các Gesture Recognizer có thể tham gia Gesture Arena để cạnh tranh quyền nhận diện. Khi một recognizer thắng, những recognizer thua sẽ không tiếp tục xử lý chuỗi tương tác đó theo cùng cơ chế nhận diện.
Quá trình này thường được gọi là gesture disambiguation, tức phân biệt và lựa chọn cử chỉ phù hợp.
Ví dụ:
GestureDetector(
onTap: () {
print('Đã nhấn');
},
onVerticalDragUpdate: (details) {
print('Đang kéo theo chiều dọc');
},
child: Container(
height: 200,
color: Colors.blue,
),
)
Nếu người dùng chạm rồi thả mà không di chuyển đáng kể, hệ thống có thể nhận diện thao tác nhấn. Nếu người dùng kéo theo chiều dọc đủ để kích hoạt recognizer tương ứng, thao tác kéo có thể giành quyền xử lý.
Không phải mọi cử chỉ đều có thể đồng thời thắng. Tuy nhiên, Flutter cho phép một số recognizer tương thích cùng nhận diện một chuỗi tương tác, tùy loại gesture và cách triển khai.
1.5. Phân biệt GestureDetector, InkWell và Listener
Ba Widget này đều có thể được sử dụng để xử lý tương tác, nhưng chúng hoạt động ở những cấp độ khác nhau.
| Widget | Chức năng chính | Đặc điểm |
|---|---|---|
GestureDetector |
Nhận diện gesture | Hỗ trợ tap, double tap, long press, drag, scale và nhiều thao tác khác |
InkWell |
Tương tác theo phong cách Material | Có hiệu ứng phản hồi như ripple khi nhấn |
Listener |
Lắng nghe pointer event | Nhận sự kiện down, move, up, cancel ở cấp thấp |
GestureDetector
GestureDetector phù hợp khi cần xử lý cử chỉ mà không nhất thiết phải sử dụng hiệu ứng Material.
GestureDetector(
onTap: () {
print('Đã nhấn vào thẻ');
},
onLongPress: () {
print('Đã nhấn giữ');
},
child: Container(
padding: const EdgeInsets.all(20),
color: Colors.blue,
child: const Text(
'Nhấn vào đây',
style: TextStyle(color: Colors.white),
),
),
)
Ưu điểm của GestureDetector là linh hoạt, hỗ trợ nhiều loại cử chỉ và không yêu cầu phải xây dựng giao diện theo phong cách Material.
InkWell
InkWell thường được sử dụng cho nút, thẻ và những vùng tương tác theo thiết kế Material.
Material(
color: Colors.white,
child: InkWell(
onTap: () {
print('Đã chọn sản phẩm');
},
borderRadius: BorderRadius.circular(12),
child: const Padding(
padding: EdgeInsets.all(20),
child: Text('Xem sản phẩm'),
),
),
)
InkWell cần một Material phù hợp để hiển thị hiệu ứng mực. Nếu sử dụng InkWell bên trong một Container có màu nền riêng, hiệu ứng ripple đôi khi có thể bị che khuất. Trong trường hợp đó, cần sắp xếp các lớp giao diện hoặc sử dụng Material đúng cách.
Listener
Listener cho phép theo dõi sự kiện đầu vào ở cấp pointer mà không tự động cung cấp hệ thống nhận diện gesture cấp cao như onTap.
Listener(
onPointerDown: (event) {
print('Pointer down: ${event.position}');
},
onPointerMove: (event) {
print('Pointer move: ${event.position}');
},
onPointerUp: (event) {
print('Pointer up');
},
child: Container(
width: 250,
height: 150,
color: Colors.orange,
alignment: Alignment.center,
child: const Text('Theo dõi Pointer Event'),
),
)
Listener hữu ích khi cần lấy tọa độ đầu vào, theo dõi chuyển động chính xác hoặc xây dựng cơ chế tương tác cấp thấp.
1.6. Kiểm soát vùng nhận sự kiện với HitTestBehavior
HitTestBehavior quy định cách một số Widget tham gia vào quá trình hit testing.
Ba giá trị thường gặp là:
-
HitTestBehavior.deferToChild: Việc tham gia hit test phụ thuộc vào vùng được các Widget con nhận diện. -
HitTestBehavior.opaque: Widget nhận sự kiện trong vùng của nó và ngăn những đối tượng phía sau được hit test tại vị trí đó. -
HitTestBehavior.translucent: Widget có thể nhận sự kiện trong vùng của nó mà vẫn cho phép các đối tượng phía sau tham gia hit test.
Ví dụ:
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
print('Đã nhấn vùng tương tác');
},
child: const SizedBox(
width: 250,
height: 100,
),
)
Cách thiết lập này đặc biệt hữu ích khi Widget không có nội dung hiển thị nhưng vẫn cần một vùng chạm có kích thước xác định.
Tuy nhiên, opaque và translucent không có nghĩa là mọi recognizer đều được quyền thắng Gesture Arena. Hit testing quyết định các đối tượng tham gia xử lý, còn Gesture Arena giải quyết xung đột giữa các recognizer.
1.7. Ứng dụng thực tế và nguyên tắc triển khai
Gesture System thường được sử dụng để xây dựng giao diện thương mại điện tử, ứng dụng quản lý, trò chơi và những ứng dụng có nhiều thao tác tương tác.
Một số nguyên tắc triển khai:
-
Sử dụng
InkWellcho các thành phần theo phong cách Material khi cần phản hồi trực quan. -
Sử dụng
GestureDetectorkhi cần nhận diện những cử chỉ cụ thể. -
Sử dụng
Listenerkhi cần theo dõi pointer event cấp thấp. -
Tránh đặt quá nhiều gesture chồng lấn nếu không có nhu cầu rõ ràng.
-
Kiểm tra hành vi trong danh sách cuộn,
Stackvà những vùng giao diện có nhiều lớp. -
Kết hợp vùng tương tác hợp lý với phản hồi trực quan và thông tin accessibility.
Kết luận: Gesture System và Hit Testing là nền tảng của cơ chế tương tác trong Flutter. Hit Testing xác định những đối tượng liên quan đến vị trí đầu vào, còn Gesture Recognizer và Gesture Arena quyết định cử chỉ nào được nhận diện. Hiểu rõ các cơ chế này giúp xây dựng giao diện phản hồi chính xác, hạn chế xung đột và nâng cao trải nghiệm người dùng.
2. Focus System và Keyboard Interaction

2.1. Tổng quan về Focus System
Focus System là hệ thống quản lý tiêu điểm trong Flutter, xác định Widget nào đang được ưu tiên tiếp nhận những thao tác đầu vào có liên quan đến bàn phím.
Khi sử dụng ứng dụng trên máy tính, người dùng có thể nhấn phím Tab để chuyển giữa các trường nhập liệu, sử dụng phím mũi tên để điều hướng, nhấn Enter để kích hoạt nút hoặc sử dụng phím Escape để đóng một thành phần giao diện.
Trên thiết bị di động, Focus System đặc biệt quan trọng đối với các trường nhập văn bản. Khi người dùng chọn một ô nhập liệu, Flutter có thể chuyển tiêu điểm đến ô đó và yêu cầu bàn phím ảo xuất hiện.
Focus System bao gồm một số thành phần chính:
-
FocusNode: Đại diện cho trạng thái và khả năng nhận tiêu điểm của một thành phần. -
Focus: Widget kết nối một vùng giao diện với hệ thống focus. -
FocusScope: Quản lý phạm vi tiêu điểm và việc di chuyển giữa các thành phần con. -
FocusTraversalGroup: Nhóm các Widget để kiểm soát quy tắc điều hướng. -
FocusTraversalPolicy: Xác định thứ tự di chuyển tiêu điểm. -
ShortcutsvàActions: Ánh xạ phím bấm thành những hành động của ứng dụng.
2.2. Cơ chế hoạt động của FocusNode
FocusNode là một đối tượng quản lý tiêu điểm, thường được giữ trong State của một StatefulWidget hoặc trong một đối tượng quản lý có vòng đời phù hợp.
Một FocusNode có thể biểu diễn trạng thái như đang có focus hay không, có thể nhận focus hay không và mối quan hệ với những thành phần focus khác.
Ví dụ:
class LoginForm extends StatefulWidget {
const LoginForm({super.key});
@override
State<LoginForm> createState() => _LoginFormState();
}
class _LoginFormState extends State<LoginForm> {
late final FocusNode emailFocusNode;
@override
void initState() {
super.initState();
emailFocusNode = FocusNode(
debugLabel: 'Email Input',
);
}
@override
void dispose() {
emailFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextField(
focusNode: emailFocusNode,
decoration: const InputDecoration(
labelText: 'Email',
),
);
}
}
Trong ví dụ trên, emailFocusNode được tạo một lần trong initState() và được giải phóng trong dispose().
Nếu tạo FocusNode trực tiếp trong phương thức build(), mỗi lần giao diện được xây dựng lại có thể tạo một đối tượng mới. Điều này gây ra việc quản lý tiêu điểm không ổn định, làm mất trạng thái hoặc tạo ra các đối tượng không cần thiết.
2.3. Chuyển và loại bỏ tiêu điểm
FocusNode cung cấp các phương thức giúp điều khiển trạng thái focus.
-
requestFocus(): Yêu cầu nhận tiêu điểm. -
unfocus(): Loại bỏ tiêu điểm khỏi node hiện tại. -
hasFocus: Kiểm tra node có focus trong phạm vi cây focus hay không. -
hasPrimaryFocus: Kiểm tra node có phải là node giữ primary focus hay không. -
addListener()vàremoveListener(): Theo dõi thay đổi trạng thái.
Ví dụ chuyển tiêu điểm giữa hai trường nhập liệu:
class LoginFields extends StatefulWidget {
const LoginFields({super.key});
@override
State<LoginFields> createState() => _LoginFieldsState();
}
class _LoginFieldsState extends State<LoginFields> {
late final FocusNode emailNode;
late final FocusNode passwordNode;
@override
void initState() {
super.initState();
emailNode = FocusNode();
passwordNode = FocusNode();
}
@override
void dispose() {
emailNode.dispose();
passwordNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
TextField(
focusNode: emailNode,
decoration: const InputDecoration(
labelText: 'Email',
),
textInputAction: TextInputAction.next,
onSubmitted: (_) {
passwordNode.requestFocus();
},
),
TextField(
focusNode: passwordNode,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Mật khẩu',
),
textInputAction: TextInputAction.done,
onSubmitted: (_) {
passwordNode.unfocus();
},
),
],
);
}
}
Ở đây, khi người dùng gửi trường Email, tiêu điểm được chuyển đến trường Mật khẩu. Sau khi gửi trường Mật khẩu, tiêu điểm được loại bỏ.
Cách làm này giúp biểu mẫu có quy trình nhập liệu rõ ràng và thuận tiện.
2.4. FocusScope và phạm vi tiêu điểm
FocusScope quản lý một nhóm các node tiêu điểm. Nó hỗ trợ điều hướng giữa những thành phần bên trong cùng một phạm vi và giúp giới hạn hoặc khôi phục tiêu điểm khi cần.
Ví dụ, trong một màn hình đăng nhập, các trường Email, Mật khẩu và nút đăng nhập có thể thuộc cùng một phạm vi điều hướng.
FocusScope(
child: Column(
children: [
TextField(
decoration: const InputDecoration(
labelText: 'Email',
),
),
TextField(
decoration: const InputDecoration(
labelText: 'Mật khẩu',
),
obscureText: true,
),
ElevatedButton(
onPressed: () {
FocusScope.of(context).unfocus();
},
child: const Text('Đăng nhập'),
),
],
),
)
Lệnh FocusScope.of(context).unfocus() yêu cầu loại bỏ tiêu điểm hiện tại trong phạm vi thích hợp. Đây là cách thường dùng để đóng bàn phím khi người dùng hoàn tất nhập liệu.
Trong những giao diện phức tạp hơn, chẳng hạn hộp thoại, menu hoặc biểu mẫu nhiều bước, FocusScope giúp kiểm soát việc chuyển tiêu điểm giữa các vùng giao diện khác nhau.
2.5. Điều hướng bằng bàn phím với Shortcuts và Actions
Shortcuts ánh xạ các tổ hợp phím thành những đối tượng Intent. Actions xác định cách thực thi Intent đó.
Cơ chế này tách việc nhận diện phím bấm khỏi phần logic thực hiện hành động, giúp quản lý phím tắt một cách có tổ chức.
Ví dụ:
class SaveIntent extends Intent {
const SaveIntent();
}
class KeyboardSaveExample extends StatelessWidget {
const KeyboardSaveExample({super.key});
@override
Widget build(BuildContext context) {
return Shortcuts(
shortcuts: const {
SingleActivator(
LogicalKeyboardKey.control,
includeRepeats: false,
): SaveIntent(),
},
child: Actions(
actions: {
SaveIntent: CallbackAction<SaveIntent>(
onInvoke: (intent) {
print('Đã thực hiện lưu');
return null;
},
),
},
child: const Focus(
autofocus: true,
child: Padding(
padding: EdgeInsets.all(20),
child: Text('Nhấn phím tắt để lưu'),
),
),
),
);
}
}
Ví dụ trên chỉ minh họa cấu trúc của Shortcuts và Actions; tổ hợp phím Ctrl+S hoàn chỉnh nên được khai báo bằng SingleActivator(LogicalKeyboardKey.keyS, control: true).
Một phiên bản triển khai đúng cho Ctrl+S là:
Shortcuts(
shortcuts: const {
SingleActivator(
LogicalKeyboardKey.keyS,
control: true,
): SaveIntent(),
},
child: Actions(
actions: {
SaveIntent: CallbackAction<SaveIntent>(
onInvoke: (intent) {
print('Đã lưu dữ liệu');
return null;
},
),
},
child: const Focus(
autofocus: true,
child: Text('Nhấn Ctrl + S để lưu'),
),
),
)
Để sử dụng LogicalKeyboardKey, cần import thư viện package:flutter/services.dart.
Đối với ứng dụng hỗ trợ nhiều hệ điều hành, nên cân nhắc sự khác nhau giữa phím Ctrl trên Windows/Linux và phím Command trên macOS. Có thể thiết kế phím tắt theo nền tảng hoặc sử dụng những cơ chế điều hướng bàn phím phù hợp với hệ thống.
2.6. Focus Traversal và thứ tự điều hướng
Focus Traversal là cơ chế xác định thứ tự di chuyển tiêu điểm giữa các thành phần có thể nhận focus.
Flutter cung cấp nhiều chính sách điều hướng, trong đó thường gặp là:
-
ReadingOrderTraversalPolicy: Ưu tiên thứ tự đọc của giao diện. -
WidgetOrderTraversalPolicy: Điều hướng theo thứ tự Widget trong cây. -
OrderedTraversalPolicy: Sử dụng thứ tự được chỉ định thông qua các thành phần hỗ trợ nhưFocusTraversalOrder.
Ví dụ:
FocusTraversalGroup(
policy: OrderedTraversalPolicy(),
child: Column(
children: [
FocusTraversalOrder(
order: const NumericFocusOrder(1),
child: TextField(
decoration: const InputDecoration(
labelText: 'Họ tên',
),
),
),
FocusTraversalOrder(
order: const NumericFocusOrder(2),
child: TextField(
decoration: const InputDecoration(
labelText: 'Email',
),
),
),
FocusTraversalOrder(
order: const NumericFocusOrder(3),
child: ElevatedButton(
onPressed: () {},
child: const Text('Gửi'),
),
),
],
),
)
Thứ tự này giúp người dùng chuyển tiêu điểm qua các thành phần theo trình tự dự kiến, thay vì phụ thuộc hoàn toàn vào cấu trúc bố trí.
2.7. Quản lý vòng đời và khả năng tiếp cận
FocusNode là đối tượng có vòng đời riêng. Khi được tạo thủ công, nó phải được quản lý và giải phóng đúng thời điểm.
Những nguyên tắc cần ghi nhớ:
-
Tạo FocusNode trong
initState()khi thuộc vòng đời của State. -
Không tạo lại FocusNode trong mỗi lần
build(). -
Gọi
dispose()khi không còn sử dụng. -
Không gọi
dispose()đối với FocusNode được một đối tượng khác quản lý. -
Kiểm tra node còn hợp lệ trước khi sử dụng trong những luồng bất đồng bộ có thể kéo dài.
-
Không tự động giành focus gây khó chịu khi người dùng đang tương tác với thành phần khác.
-
Cung cấp chỉ báo trực quan rõ ràng cho thành phần đang có focus.
Kết luận: Focus System là nền tảng cho việc nhập liệu và điều hướng bằng bàn phím trong Flutter. Việc sử dụng đúng FocusNode, FocusScope, FocusTraversal, Shortcuts và Actions giúp ứng dụng có trải nghiệm tương tác ổn định, hỗ trợ người dùng máy tính tốt hơn và tăng khả năng tiếp cận.
3. Overlay, OverlayEntry và Portal UI
3.1. Tổng quan về Overlay
Overlay là cơ chế cho phép hiển thị những thành phần giao diện nằm trên các nội dung thông thường của ứng dụng.
Trong giao diện Flutter, phần lớn Widget được bố trí theo cây giao diện và chịu ảnh hưởng từ các quy tắc layout của Widget cha. Tuy nhiên, một số thành phần cần xuất hiện nổi lên trên nội dung khác mà không chiếm vị trí trong bố cục chính.
Các ví dụ phổ biến gồm:
-
Menu thả xuống.
-
Tooltip.
-
Popup thông báo.
-
Danh sách gợi ý tìm kiếm.
-
Hộp thoại hoặc bảng điều khiển nổi.
-
Thành phần chọn ngày, chọn màu hoặc chọn mục.
-
Giao diện kéo thả và các thành phần tạm thời.
Overlay giải quyết nhu cầu này bằng cách quản lý những lớp giao diện độc lập với vị trí thông thường trong cây Widget.
3.2. Cấu trúc của Overlay và OverlayEntry
Overlay là Widget quản lý một tập hợp các lớp giao diện có thể được xếp chồng lên nhau.
OverlayEntry đại diện cho một mục giao diện được chèn vào Overlay. Mỗi entry có thể xây dựng một cây Widget riêng và được hiển thị phía trên những mục nằm bên dưới theo thứ tự xếp chồng.
Các phương thức và thuộc tính thường gặp gồm:
-
Overlay.of(context): Lấy Overlay phù hợp từ BuildContext. -
OverlayEntry(builder: ...): Tạo một entry. -
overlay.insert(entry): Chèn entry vào Overlay. -
entry.markNeedsBuild(): Yêu cầu entry xây dựng lại nội dung. -
entry.remove(): Loại bỏ entry khỏi Overlay. -
entry.dispose(): Giải phóng tài nguyên của entry khi vòng đời của nó kết thúc.
Cần phân biệt việc gọi remove() và dispose(). remove() loại entry khỏi Overlay; dispose() hoàn tất việc giải phóng entry khi không còn sử dụng. Không nên tiếp tục sử dụng entry sau khi đã dispose.
3.3. Tạo popup nổi bằng OverlayEntry
Ví dụ dưới đây xây dựng một nút có thể mở hoặc đóng menu nổi.
import 'package:flutter/material.dart';
class OverlayMenuExample extends StatefulWidget {
const OverlayMenuExample({super.key});
@override
State<OverlayMenuExample> createState() =>
_OverlayMenuExampleState();
}
class _OverlayMenuExampleState
extends State<OverlayMenuExample> {
OverlayEntry? _overlayEntry;
void _showMenu() {
if (_overlayEntry != null) return;
final overlay = Overlay.of(context);
_overlayEntry = OverlayEntry(
builder: (context) {
return Positioned(
top: 120,
right: 20,
child: Material(
elevation: 8,
borderRadius: BorderRadius.circular(12),
child: SizedBox(
width: 180,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: const Text('Hồ sơ'),
onTap: _hideMenu,
),
ListTile(
title: const Text('Cài đặt'),
onTap: _hideMenu,
),
ListTile(
title: const Text('Đăng xuất'),
onTap: _hideMenu,
),
],
),
),
),
);
},
);
overlay.insert(_overlayEntry!);
}
void _hideMenu() {
_overlayEntry?.remove();
_overlayEntry?.dispose();
_overlayEntry = null;
}
@override
void dispose() {
_hideMenu();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Overlay Example'),
),
body: Center(
child: ElevatedButton(
onPressed: _showMenu,
child: const Text('Mở menu'),
),
),
);
}
}
Đoạn mã trên minh họa nguyên lý quản lý một OverlayEntry. Vị trí top và right được đặt cố định để ví dụ dễ hiểu; trong ứng dụng thực tế, menu nên được định vị dựa trên vị trí của Widget kích hoạt và kích thước màn hình.
3.4. Định vị Overlay theo Widget kích hoạt
Trong ứng dụng thực tế, popup thường cần xuất hiện ngay bên dưới nút được nhấn.
Có thể sử dụng LayerLink, CompositedTransformTarget và CompositedTransformFollower để liên kết vị trí của popup với Widget gốc.
-
CompositedTransformTarget: Xác định đối tượng đích cần theo dõi. -
LayerLink: Liên kết giữa target và follower. -
CompositedTransformFollower: Định vị nội dung theo target.
Ví dụ:
class AnchoredPopupExample extends StatefulWidget {
const AnchoredPopupExample({super.key});
@override
State<AnchoredPopupExample> createState() =>
_AnchoredPopupExampleState();
}
class _AnchoredPopupExampleState
extends State<AnchoredPopupExample> {
final LayerLink _layerLink = LayerLink();
OverlayEntry? _entry;
void _togglePopup() {
if (_entry != null) {
_entry!.remove();
_entry!.dispose();
_entry = null;
return;
}
_entry = OverlayEntry(
builder: (context) {
return Positioned(
width: 200,
child: CompositedTransformFollower(
link: _layerLink,
showWhenUnlinked: false,
offset: const Offset(0, 48),
child: Material(
elevation: 8,
borderRadius: BorderRadius.circular(8),
child: const Padding(
padding: EdgeInsets.all(16),
child: Text('Nội dung popup'),
),
),
),
);
},
);
Overlay.of(context).insert(_entry!);
}
@override
void dispose() {
if (_entry != null) {
_entry!.remove();
_entry!.dispose();
_entry = null;
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return CompositedTransformTarget(
link: _layerLink,
child: ElevatedButton(
onPressed: _togglePopup,
child: const Text('Mở popup'),
),
);
}
}
Cách tiếp cận này phù hợp khi popup phải đi theo nút kích hoạt trong quá trình giao diện thay đổi vị trí.
Tuy nhiên, ví dụ trên chưa xử lý tất cả tình huống như popup vượt khỏi mép màn hình, tự đóng khi chạm bên ngoài hoặc phản ứng với việc cuộn. Những trường hợp này cần được bổ sung tùy theo yêu cầu giao diện.
3.5. Cập nhật và quản lý OverlayEntry
OverlayEntry có thể cần cập nhật khi nội dung thay đổi. Ví dụ, một popup hiển thị danh sách gợi ý tìm kiếm cần phản ánh dữ liệu mới khi người dùng nhập ký tự.
Có hai hướng triển khai phổ biến:
-
Sử dụng
markNeedsBuild()khi nội dung được cung cấp trực tiếp từ builder của entry và cần được xây dựng lại. -
Đặt một Widget có trạng thái riêng bên trong entry để quản lý dữ liệu và các thay đổi giao diện.
Cần tránh chèn nhiều OverlayEntry trùng lặp khi người dùng nhấn liên tục. Đồng thời, cần loại bỏ entry khi popup đóng, khi màn hình bị hủy hoặc khi thành phần kích hoạt không còn hợp lệ.
Nếu OverlayEntry sử dụng các đối tượng như FocusNode, AnimationController hoặc ScrollController, cần quản lý vòng đời của các đối tượng đó riêng biệt.
3.6. Portal UI và những giải pháp thay thế
Portal UI là cách tiếp cận trong đó một thành phần giao diện được hiển thị ở một vị trí khác so với vị trí thông thường của nó trong cây bố cục, nhưng vẫn duy trì mối quan hệ logic với Widget gốc.
Trong Flutter, nhu cầu này có thể được giải quyết bằng:
-
OverlayvàOverlayEntry. -
CompositedTransformTargetvàCompositedTransformFollower. -
showDialog()cho hộp thoại. -
showModalBottomSheet()cho bảng điều khiển từ dưới màn hình. -
Tooltipcho thông tin trợ giúp. -
Các thư viện Portal chuyên dụng khi cần cơ chế quản lý phức tạp hơn.
Không nên xem Portal là một Widget chuẩn duy nhất có sẵn trong mọi phiên bản Flutter. Đây còn là tên gọi cho một mô hình kiến trúc giao diện, được hiện thực hóa thông qua các API hoặc thư viện khác nhau.
3.7. Nguyên tắc sử dụng Overlay
Khi xây dựng giao diện nổi, cần quan tâm đến các vấn đề sau:
-
Vị trí popup so với Widget kích hoạt.
-
Khả năng hiển thị ngoài giới hạn bố cục thông thường.
-
Thứ tự xếp chồng của nhiều entry.
-
Việc đóng popup khi nhấn ra ngoài.
-
Hỗ trợ bàn phím và tiêu điểm.
-
Xử lý khi màn hình thay đổi kích thước hoặc cuộn.
-
Giải phóng tài nguyên đúng thời điểm.
-
Tránh che khuất những nội dung quan trọng.
Kết luận: Overlay và OverlayEntry giúp Flutter xây dựng các thành phần nổi linh hoạt mà không cần đưa chúng trực tiếp vào bố cục thông thường. Kết hợp Overlay với cơ chế liên kết vị trí và quản lý vòng đời hợp lý sẽ giúp tạo ra menu, tooltip và popup có tính ổn định cao.
4. InheritedTheme, Theme và DefaultTextStyle
4.1. Tổng quan về cơ chế kế thừa dữ liệu
Trong Flutter, nhiều Widget con cần sử dụng chung cấu hình từ những Widget tổ tiên. Ví dụ, các thành phần giao diện cần biết màu sắc chủ đạo, kiểu chữ, kích thước màn hình hoặc chế độ sáng tối hiện tại.
Nếu phải truyền từng giá trị qua mọi cấp Widget bằng constructor, mã nguồn sẽ trở nên dài, phức tạp và khó bảo trì.
Flutter giải quyết vấn đề này bằng các cơ chế cung cấp dữ liệu xuống cây Widget, tiêu biểu là InheritedWidget và những lớp được xây dựng dựa trên nó.
Một số thành phần thường gặp gồm:
-
Theme: Cung cấp cấu hình thiết kế chung. -
MediaQuery: Cung cấp thông tin môi trường hiển thị. -
DefaultTextStyle: Cung cấp kiểu chữ mặc định. -
Localizations: Cung cấp dữ liệu bản địa hóa. -
Directionality: Cung cấp hướng bố trí văn bản. -
InheritedTheme: Cơ sở cho những Theme có khả năng được truyền qua một số ranh giới giao diện.
Cơ chế kế thừa giúp các Widget truy cập dữ liệu cần thiết mà không phải truyền tham số qua tất cả những cấp trung gian.
4.2. InheritedWidget và InheritedTheme
InheritedWidget là một loại Widget được thiết kế để cung cấp dữ liệu cho các Widget con trong cây giao diện.
Một Widget con có thể truy cập dữ liệu bằng phương thức như dependOnInheritedWidgetOfExactType<T>(). Khi phụ thuộc vào dữ liệu của một InheritedWidget, Widget con có thể được thông báo để cập nhật khi dữ liệu đó thay đổi.
InheritedTheme là lớp cơ sở chuyên biệt dành cho các Theme có khả năng được thu thập và truyền qua một số ranh giới giao diện, chẳng hạn những ngữ cảnh Overlay hoặc route cần giữ lại cấu hình chủ đề.
Không phải mọi InheritedWidget đều là InheritedTheme. Chúng có mục đích và cách sử dụng khác nhau.
Ví dụ về một InheritedWidget tùy chỉnh:
import 'package:flutter/material.dart';
class AppColorScope extends InheritedWidget {
final Color primaryColor;
const AppColorScope({
super.key,
required this.primaryColor,
required super.child,
});
static AppColorScope? maybeOf(BuildContext context) {
return context
.dependOnInheritedWidgetOfExactType<AppColorScope>();
}
@override
bool updateShouldNotify(AppColorScope oldWidget) {
return primaryColor != oldWidget.primaryColor;
}
}
Một Widget con có thể lấy dữ liệu như sau:
class CustomColorText extends StatelessWidget {
const CustomColorText({super.key});
@override
Widget build(BuildContext context) {
final scope = AppColorScope.maybeOf(context);
return Text(
'Văn bản sử dụng màu được kế thừa',
style: TextStyle(
color: scope?.primaryColor ?? Colors.black,
),
);
}
}
Khi primaryColor thay đổi và updateShouldNotify() trả về true, những Widget phụ thuộc vào dữ liệu này có thể được cập nhật.
4.3. Theme và ThemeData
Theme là cơ chế quản lý cấu hình thiết kế dùng chung trong Flutter. ThemeData chứa những thuộc tính mô tả giao diện, chẳng hạn bảng màu, kiểu chữ, hình dạng nút, cấu hình trường nhập liệu và nhiều thành phần Material khác.
Một ứng dụng thường khai báo Theme ở cấp MaterialApp:
MaterialApp(
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
),
useMaterial3: true,
textTheme: const TextTheme(
bodyMedium: TextStyle(
fontSize: 16,
),
),
),
home: const HomePage(),
)
Những Widget con có thể truy cập cấu hình bằng Theme.of(context):
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
backgroundColor: theme.colorScheme.surface,
appBar: AppBar(
title: const Text('Trang chủ'),
),
body: Center(
child: Text(
'Xin chào Flutter',
style: theme.textTheme.headlineSmall,
),
),
);
}
}
Lợi ích quan trọng của Theme là tập trung hóa thiết kế. Khi màu chủ đạo hoặc kiểu chữ thay đổi, ứng dụng có thể cập nhật cấu hình ở một nơi thay vì sửa từng Widget.
4.4. Theme lồng nhau và phạm vi cấu hình
Flutter cho phép đặt một Theme bên trong Theme khác. Khi đó, Widget con sẽ sử dụng cấu hình của Theme gần nhất có thể truy cập từ BuildContext.
Ví dụ:
Theme(
data: Theme.of(context).copyWith(
colorScheme: Theme.of(context)
.colorScheme
.copyWith(
primary: Colors.deepPurple,
),
),
child: Builder(
builder: (context) {
return ElevatedButton(
onPressed: () {},
child: const Text('Nút màu tím'),
);
},
),
)
copyWith() giúp tạo một cấu hình mới dựa trên cấu hình hiện tại, chỉ thay đổi những thuộc tính được chỉ định.
Trong ví dụ trên, Builder tạo một BuildContext nằm bên trong Theme mới, vì vậy nút có thể nhận cấu hình đã được cập nhật.
Nếu gọi Theme.of(context) bằng một BuildContext nằm bên ngoài Theme mới, kết quả vẫn có thể là Theme của cấp cha. Đây là một điểm quan trọng khi làm việc với cơ chế kế thừa dữ liệu.
4.5. MediaQuery và thông tin môi trường hiển thị
MediaQuery cung cấp những thông tin liên quan đến môi trường hiển thị, bao gồm kích thước cửa sổ, tỷ lệ chữ, hướng màn hình, thông tin padding hệ thống và một số đặc điểm hỗ trợ accessibility.
Ví dụ:
class ResponsivePage extends StatelessWidget {
const ResponsivePage({super.key});
@override
Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
final width = mediaQuery.size.width;
return Scaffold(
body: Center(
child: Container(
width: width < 600 ? width * 0.9 : 500,
padding: const EdgeInsets.all(20),
child: const Text(
'Giao diện thích ứng với kích thước màn hình',
),
),
),
);
}
}
Cách sử dụng này giúp giao diện thích ứng với những thiết bị có kích thước khác nhau.
Tuy nhiên, không phải mọi quyết định layout đều nên dựa trực tiếp vào kích thước toàn màn hình. Đối với các thành phần nằm trong một vùng bố cục giới hạn, những công cụ như LayoutBuilder thường phù hợp hơn vì chúng cung cấp ràng buộc thực tế của vùng cha.
4.6. DefaultTextStyle và kế thừa kiểu chữ
DefaultTextStyle cung cấp kiểu chữ mặc định cho những Widget văn bản bên trong phạm vi của nó.
Khi một Text không khai báo đầy đủ kiểu chữ, Flutter có thể sử dụng kiểu chữ được kế thừa từ DefaultTextStyle, kết hợp với các giá trị mặc định phù hợp.
Ví dụ:
DefaultTextStyle(
style: const TextStyle(
fontSize: 18,
color: Colors.black87,
fontWeight: FontWeight.w500,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Text('Tên sản phẩm'),
Text('Mô tả sản phẩm'),
Text('Giá bán'),
],
),
)
Trong ví dụ trên, các Text được áp dụng kiểu chữ chung nếu không ghi đè những thuộc tính tương ứng.
Có thể tùy chỉnh riêng một thành phần:
Text(
'Giá bán',
style: const TextStyle(
color: Colors.red,
fontWeight: FontWeight.bold,
),
)
Việc kế thừa giúp giảm sự lặp lại và hỗ trợ xây dựng hệ thống typography nhất quán.
4.7. Cơ chế cập nhật và hiệu năng
Các Widget sử dụng dữ liệu kế thừa có thể được cập nhật khi giá trị mà chúng phụ thuộc thay đổi.
Tuy nhiên, điều đó không có nghĩa toàn bộ ứng dụng luôn được xây dựng lại khi một thuộc tính bất kỳ thay đổi. Flutter sử dụng hệ thống dependency để xác định những thành phần có liên quan.
Một số nguyên tắc quan trọng:
-
Truy cập dữ liệu bằng BuildContext nằm trong phạm vi mong muốn.
-
Dùng
Theme.of(context)thay vì khai báo lại cấu hình ở mọi nơi. -
Sử dụng
MediaQuerycho thông tin môi trường hiển thị. -
Sử dụng
DefaultTextStylecho kiểu chữ chung. -
Dùng
InheritedWidgettùy chỉnh khi cần chia sẻ dữ liệu xuyên suốt một nhánh giao diện. -
Tránh cập nhật những dữ liệu kế thừa quá thường xuyên nếu không cần thiết.
-
Không sử dụng cơ chế kế thừa để thay thế mọi hình thức quản lý trạng thái.
Kết luận: InheritedTheme, Theme, MediaQuery và DefaultTextStyle là những thành phần quan trọng giúp Flutter quản lý dữ liệu và cấu hình giao diện theo phạm vi. Nắm vững cơ chế kế thừa giúp giảm sự phụ thuộc giữa các Widget, duy trì thiết kế nhất quán và xây dựng ứng dụng dễ mở rộng hơn.
5. Animation cơ bản ở cấp Widget
5.1. Tổng quan về Animation trong Flutter
Animation là cơ chế tạo ra sự thay đổi theo thời gian trong giao diện. Thay vì thay đổi một thuộc tính ngay lập tức, ứng dụng có thể chuyển dần từ giá trị ban đầu sang giá trị đích trong một khoảng thời gian xác định.
Animation giúp giao diện có phản hồi trực quan hơn, hỗ trợ người dùng hiểu mối quan hệ giữa các thành phần và tạo cảm giác chuyển động tự nhiên.
Những ứng dụng phổ biến của Animation gồm:
-
Hiệu ứng mở rộng hoặc thu nhỏ thẻ.
-
Hiển thị và ẩn nội dung.
-
Chuyển đổi màu sắc.
-
Di chuyển Widget.
-
Hiệu ứng chuyển trang.
-
Animation tải dữ liệu.
-
Hiệu ứng phản hồi khi người dùng tương tác.
Flutter cung cấp hai nhóm cơ chế thường được sử dụng là implicit animation và explicit animation.
5.2. Implicit Animation
Implicit Animation là nhóm animation trong đó Flutter tự quản lý phần lớn quá trình chuyển đổi giữa giá trị cũ và giá trị mới.
Lập trình viên chỉ cần thay đổi thuộc tính đích và cung cấp thời lượng, đường cong chuyển động. Flutter sẽ tự xử lý quá trình nội suy và cập nhật giao diện.
Một số Widget phổ biến:
-
AnimatedContainer. -
AnimatedOpacity. -
AnimatedPadding. -
AnimatedAlign. -
AnimatedPositioned. -
AnimatedDefaultTextStyle. -
TweenAnimationBuilder.
Ví dụ sử dụng AnimatedContainer:
class AnimatedBoxExample extends StatefulWidget {
const AnimatedBoxExample({super.key});
@override
State<AnimatedBoxExample> createState() =>
_AnimatedBoxExampleState();
}
class _AnimatedBoxExampleState
extends State<AnimatedBoxExample> {
bool expanded = false;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: GestureDetector(
onTap: () {
setState(() {
expanded = !expanded;
});
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOut,
width: expanded ? 250 : 120,
height: expanded ? 250 : 120,
decoration: BoxDecoration(
color: expanded
? Colors.deepPurple
: Colors.blue,
borderRadius: BorderRadius.circular(
expanded ? 32 : 12,
),
),
),
),
),
);
}
}
Khi người dùng nhấn vào hình vuông, biến expanded thay đổi và setState() yêu cầu Flutter xây dựng lại giao diện. AnimatedContainer nhận được những giá trị mới và tự động chuyển đổi kích thước, màu sắc cùng bán kính bo góc.
Implicit Animation thích hợp cho những hiệu ứng đơn giản, nơi chỉ cần xác định trạng thái ban đầu và trạng thái đích.
5.3. AnimatedOpacity và hiệu ứng trong suốt
AnimatedOpacity giúp chuyển đổi độ trong suốt của Widget theo thời gian.
Thuộc tính opacity nằm trong khoảng từ 0.0 đến 1.0:
-
0.0: Hoàn toàn trong suốt. -
1.0: Hoàn toàn hiển thị. -
Giá trị trung gian: Hiển thị một phần tùy theo độ trong suốt.
Ví dụ:
class FadeExample extends StatefulWidget {
const FadeExample({super.key});
@override
State<FadeExample> createState() => _FadeExampleState();
}
class _FadeExampleState extends State<FadeExample> {
bool visible = true;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedOpacity(
opacity: visible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 300),
child: Container(
width: 200,
height: 100,
color: Colors.orange,
alignment: Alignment.center,
child: const Text('Nội dung'),
),
),
ElevatedButton(
onPressed: () {
setState(() {
visible = !visible;
});
},
child: const Text('Ẩn hoặc hiện'),
),
],
);
}
}
Cần lưu ý rằng opacity bằng 0.0 không tự động loại bỏ Widget khỏi bố cục hoặc ngăn nó nhận tương tác. Nếu cần loại bỏ tương tác khi nội dung ẩn, có thể kết hợp với IgnorePointer, Visibility hoặc cơ chế quản lý phù hợp.
5.4. Explicit Animation
Explicit Animation là nhóm animation mà lập trình viên kiểm soát tiến trình thông qua các đối tượng như AnimationController, Animation<T> và Tween<T>.
Cơ chế này phù hợp với những hiệu ứng cần điều khiển chi tiết, chẳng hạn:
-
Chạy animation theo hướng tiến hoặc lùi.
-
Lặp lại liên tục.
-
Dừng hoặc tiếp tục theo điều kiện.
-
Phối hợp nhiều animation.
-
Thay đổi tốc độ theo thời gian.
-
Phản ứng theo gesture hoặc dữ liệu bên ngoài.
AnimationController quản lý tiến trình animation, thường có giá trị từ 0.0 đến 1.0. Tween<T> ánh xạ tiến trình đó thành một dải giá trị khác, chẳng hạn kích thước, màu sắc hoặc vị trí.
5.5. AnimationController và Tween
Ví dụ sau sử dụng AnimationController để tạo hiệu ứng phóng to và thu nhỏ:
import 'package:flutter/material.dart';
class ScaleAnimationExample extends StatefulWidget {
const ScaleAnimationExample({super.key});
@override
State<ScaleAnimationExample> createState() =>
_ScaleAnimationExampleState();
}
class _ScaleAnimationExampleState
extends State<ScaleAnimationExample>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 800),
);
_scaleAnimation = Tween<double>(
begin: 0.5,
end: 1.5,
).animate(
CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: ScaleTransition(
scale: _scaleAnimation,
child: const Icon(
Icons.favorite,
size: 80,
color: Colors.red,
),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
if (_controller.isCompleted) {
_controller.reverse();
} else {
_controller.forward();
}
},
child: const Icon(Icons.play_arrow),
),
);
}
}
Trong ví dụ trên:
-
AnimationControllerquản lý thời gian và tiến trình. -
Tween<double>xác định giá trị bắt đầu và kết thúc. -
CurvedAnimationáp dụng đường cong chuyển động. -
ScaleTransitionsử dụng giá trị animation để điều chỉnh tỷ lệ. -
SingleTickerProviderStateMixincung cấp ticker cho controller. -
dispose()giải phóng controller khi State bị loại bỏ.
vsync giúp đồng bộ animation với chu kỳ hiển thị và tránh tiếp tục tạo tick không cần thiết khi animation không được hiển thị theo cách thông thường.
5.6. AnimatedBuilder và tối ưu quá trình cập nhật
AnimatedBuilder cho phép xây dựng giao diện dựa trên giá trị của một animation mà không cần gọi setState() thủ công mỗi lần animation thay đổi.
Ví dụ:
class AnimatedBuilderExample extends StatefulWidget {
const AnimatedBuilderExample({super.key});
@override
State<AnimatedBuilderExample> createState() =>
_AnimatedBuilderExampleState();
}
class _AnimatedBuilderExampleState
extends State<AnimatedBuilderExample>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
child: const Icon(
Icons.star,
size: 60,
color: Colors.amber,
),
builder: (context, child) {
return Transform.rotate(
angle: _controller.value * 6.283185307,
child: child,
);
},
);
}
}
Widget được truyền qua tham số child không cần được tạo lại trong mỗi lần callback builder chạy. Điều này hữu ích khi một phần giao diện tĩnh nằm bên trong hiệu ứng animation.
Tuy nhiên, AnimatedBuilder không tự động bảo đảm mọi trường hợp đều nhanh hơn. Hiệu năng còn phụ thuộc vào cấu trúc Widget, chi phí layout, vẽ và các thao tác được thực hiện trong builder.
5.7. Mối quan hệ giữa Animation, Build và Render
Khi animation thay đổi, Flutter cập nhật những phần giao diện phụ thuộc vào giá trị animation. Tùy Widget được sử dụng, quá trình thay đổi có thể tác động đến cấu hình Widget, layout hoặc giai đoạn vẽ.
Ví dụ:
-
Thay đổi màu nền có thể làm thay đổi nội dung cần vẽ.
-
Thay đổi kích thước bằng
AnimatedContainercó thể làm phát sinh layout mới. -
Sử dụng
Transform.scalehoặcTransform.translatethường thay đổi cách hiển thị mà không yêu cầu bố trí lại toàn bộ cây theo cách tương tự thay đổi kích thước layout. -
Sử dụng opacity có thể cần một lớp compositing và ảnh hưởng đến chi phí dựng hình.
Để tối ưu animation:
-
Ưu tiên implicit animation khi hiệu ứng đơn giản.
-
Sử dụng explicit animation khi cần kiểm soát tiến trình.
-
Giải phóng AnimationController trong
dispose(). -
Tránh xây dựng lại những cây Widget lớn không cần thiết.
-
Tách phần giao diện tĩnh bằng
childkhi dùngAnimatedBuilder. -
Kiểm tra hiệu năng trên thiết bị thật nếu animation phức tạp.
-
Tôn trọng tùy chọn giảm chuyển động khi thiết kế trải nghiệm accessibility.
Kết luận: Animation trong Flutter có thể được triển khai thông qua implicit animation hoặc explicit animation. Nhóm implicit giúp xây dựng hiệu ứng nhanh và đơn giản, trong khi nhóm explicit cho phép kiểm soát chi tiết tiến trình. Hiểu mối quan hệ giữa animation, build, layout và render giúp tạo hiệu ứng mượt mà mà vẫn kiểm soát được hiệu năng.
6. Semantics và Accessibility
6.1. Tổng quan về Accessibility
Accessibility là khả năng giúp nhiều nhóm người dùng khác nhau tiếp cận và sử dụng ứng dụng, bao gồm người khiếm thị, người gặp khó khăn về vận động, người cần phóng to văn bản hoặc người sử dụng công nghệ hỗ trợ.
Một giao diện có thể đẹp về mặt thị giác nhưng vẫn khó sử dụng nếu không cung cấp thông tin phù hợp cho trình đọc màn hình hoặc không hỗ trợ điều hướng bằng bàn phím.
Flutter cung cấp hệ thống Semantics để biểu diễn ý nghĩa của những thành phần giao diện. Thông tin này được chuyển đến các công nghệ hỗ trợ của nền tảng, chẳng hạn VoiceOver trên iOS và TalkBack trên Android.
Accessibility không chỉ là yêu cầu kỹ thuật. Đây còn là một phần của chất lượng sản phẩm, giúp ứng dụng dễ sử dụng, dễ hiểu và phù hợp với nhiều hoàn cảnh sử dụng hơn.
6.2. Semantics Tree
Bên cạnh Widget Tree, Element Tree và Render Tree, Flutter còn xây dựng Semantics Tree để mô tả ý nghĩa và khả năng tương tác của giao diện đối với công nghệ hỗ trợ.
Semantics Tree có thể chứa những thông tin như:
-
Nhãn của thành phần.
-
Giá trị hiện tại.
-
Loại thành phần.
-
Trạng thái bật hoặc tắt.
-
Khả năng tương tác.
-
Hành động có thể thực hiện.
-
Trạng thái được chọn hoặc được đánh dấu.
-
Quan hệ giữa những phần nội dung.
Ví dụ, một biểu tượng trái tim có thể được hiển thị mà không có văn bản. Người dùng nhìn thấy biểu tượng nhưng trình đọc màn hình có thể không hiểu ý nghĩa của nó nếu ứng dụng không cung cấp nhãn phù hợp.
Có thể sử dụng Semantics để bổ sung thông tin:
Semantics(
label: 'Thêm sản phẩm vào danh sách yêu thích',
button: true,
child: IconButton(
onPressed: () {
print('Đã thêm vào yêu thích');
},
icon: const Icon(Icons.favorite_border),
),
)
Trong trường hợp thực tế, cần tránh khai báo trùng thông tin với semantics mà Widget con đã tự cung cấp. Nếu IconButton đã có nhãn thông qua tooltip, cần cân nhắc cấu trúc semantics để không khiến trình đọc màn hình đọc thông tin lặp lại.
6.3. Sử dụng Semantics
Semantics cho phép định nghĩa những thuộc tính mô tả vai trò và trạng thái của một thành phần.
Ví dụ:
Semantics(
label: 'Trạng thái đơn hàng',
value: 'Đang giao hàng',
child: const Text(
'Đang giao hàng',
),
)
Ở đây, nhãn mô tả đối tượng, còn giá trị mô tả trạng thái hiện tại.
Đối với thành phần có thể tương tác, thông tin semantics cần phản ánh đúng hành vi. Không nên đánh dấu một Widget là nút nếu nó không thực sự hoạt động như nút, hoặc khai báo trạng thái chọn sai với trạng thái hiển thị.
Một ví dụ khác:
Semantics(
label: 'Chế độ thông báo',
toggled: true,
child: Switch(
value: true,
onChanged: (value) {},
),
)
Ví dụ này minh họa trạng thái bật. Trong ứng dụng thực tế, nên để những Widget như Switch cung cấp semantics mặc định khi đã đầy đủ, tránh tạo ra thông tin trùng lặp.
6.4. MergeSemantics và ExcludeSemantics
Hai Widget này giúp kiểm soát cách thông tin được tổ chức trong Semantics Tree.
MergeSemantics
MergeSemantics hợp nhất thông tin semantics từ nhiều thành phần con thành một nút semantics phù hợp.
Ví dụ:
MergeSemantics(
child: ListTile(
leading: const Icon(Icons.notifications),
title: const Text('Thông báo'),
subtitle: const Text('Bật thông báo cho tài khoản'),
trailing: Switch(
value: true,
onChanged: (value) {},
),
),
)
Trong trường hợp phù hợp, việc hợp nhất giúp công nghệ hỗ trợ hiểu hàng giao diện như một đơn vị có ý nghĩa.
Tuy nhiên, nếu các thành phần con cần được điều hướng hoặc tương tác độc lập, hợp nhất semantics có thể làm mất sự phân biệt cần thiết. Vì vậy, cần kiểm tra hành vi thực tế bằng trình đọc màn hình.
ExcludeSemantics
ExcludeSemantics loại bỏ thông tin semantics của các Widget con khỏi cây semantics được công bố.
Ví dụ:
Semantics(
label: 'Đánh giá sản phẩm: 5 trên 5 sao',
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('5.0'),
const SizedBox(width: 4),
ExcludeSemantics(
child: Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(
5,
(_) => const Icon(
Icons.star,
color: Colors.amber,
size: 18,
),
),
),
),
],
),
)
Ví dụ trên sử dụng nhãn tổng hợp cho cả thành phần đánh giá, đồng thời loại bỏ thông tin riêng lẻ từ những biểu tượng ngôi sao. Cách này giúp tránh việc trình đọc màn hình phải xử lý từng biểu tượng trang trí.
6.5. Nhãn truy cập và thông tin mô tả
Nhãn truy cập cần ngắn gọn, rõ ràng và mô tả đúng chức năng.
Ví dụ về nhãn phù hợp:
-
“Tìm kiếm sản phẩm”.
-
“Đóng hộp thoại”.
-
“Thêm vào giỏ hàng”.
-
“Xóa sản phẩm khỏi danh sách”.
-
“Mở cài đặt tài khoản”.
Nhãn không nên chỉ mô tả hình dạng trực quan nếu hình dạng đó không thể hiện đầy đủ ý nghĩa.
Ví dụ, biểu tượng kính lúp nên được mô tả là “Tìm kiếm” thay vì “Biểu tượng kính lúp”.
Đối với hình ảnh sản phẩm, nhãn nên mô tả nội dung có ý nghĩa. Ngược lại, những hình ảnh trang trí không cần thiết có thể được loại khỏi semantics để giảm thông tin dư thừa.
6.6. Accessibility cho biểu mẫu và nội dung
Đối với biểu mẫu, ứng dụng cần cung cấp nhãn rõ ràng cho các trường nhập liệu, thông báo lỗi có ý nghĩa và trạng thái phù hợp.
Ví dụ:
TextField(
decoration: const InputDecoration(
labelText: 'Địa chỉ email',
hintText: '[email protected]',
),
)
Việc sử dụng labelText giúp xác định mục đích của trường nhập liệu rõ ràng hơn so với chỉ sử dụng một placeholder có thể biến mất khi người dùng bắt đầu nhập.
Đối với văn bản, cần hỗ trợ kích thước chữ theo cài đặt hệ thống, tránh cố định kích thước khiến nội dung bị cắt khi người dùng tăng cỡ chữ.
Đối với giao diện có nhiều thao tác, cần cung cấp những vùng tương tác đủ lớn và khoảng cách hợp lý để người dùng dễ chọn chính xác.
6.7. Kiểm tra Accessibility trong Flutter
Flutter cung cấp nhiều công cụ để kiểm tra khả năng tiếp cận.
Một số phương pháp thường dùng:
-
Kiểm tra semantics trong Flutter DevTools.
-
Sử dụng
SemanticsDebuggerkhi cần quan sát cấu trúc semantics. -
Kiểm thử bằng TalkBack trên Android.
-
Kiểm thử bằng VoiceOver trên iOS.
-
Kiểm tra điều hướng bằng bàn phím trên desktop và web.
-
Kiểm tra khả năng phóng to văn bản.
-
Sử dụng các kiểm tra accessibility trong widget test.
Ví dụ kiểm tra cơ bản trong widget test:
testWidgets(
'Nút có nhãn truy cập phù hợp',
(tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: Semantics(
label: 'Tìm kiếm sản phẩm',
button: true,
child: Text('Tìm kiếm'),
),
),
),
);
expect(
find.bySemanticsLabel('Tìm kiếm sản phẩm'),
findsOneWidget,
);
},
);
Đây là ví dụ kiểm tra một nhãn semantics cụ thể. Những ứng dụng thực tế nên kiểm tra thêm trạng thái, hành động và trải nghiệm tương tác của các thành phần quan trọng.
Kết luận: Semantics và Accessibility giúp Flutter cung cấp thông tin giao diện cho những công nghệ hỗ trợ, cải thiện khả năng điều hướng và mở rộng khả năng sử dụng ứng dụng. Việc xây dựng semantics đúng, tránh thông tin trùng lặp và kiểm thử bằng công nghệ hỗ trợ thực tế là những yếu tố cần thiết để tạo ra sản phẩm có chất lượng.
7. Image, Asset và Image Cache
7.1. Tổng quan về hệ thống hình ảnh
Hình ảnh là một thành phần quan trọng trong giao diện Flutter, đặc biệt với các ứng dụng thương mại điện tử, mạng xã hội, ứng dụng đọc nội dung và hệ thống quản lý dữ liệu trực quan.
Flutter hỗ trợ hiển thị hình ảnh từ nhiều nguồn khác nhau:
-
Asset được đóng gói cùng ứng dụng.
-
Tệp hình ảnh trong bộ nhớ thiết bị.
-
Hình ảnh tải từ Internet.
-
Dữ liệu ảnh dạng byte.
-
Hình ảnh được cung cấp thông qua một
ImageProvidertùy chỉnh.
Các Widget thường được sử dụng gồm Image.asset, Image.network, Image.file, Image.memory và Widget Image tổng quát.
Bên cạnh việc hiển thị, hệ thống hình ảnh còn phải quản lý quá trình tải dữ liệu, giải mã ảnh, lưu trữ bộ nhớ đệm và tiêu thụ RAM.
7.2. Image.asset và quản lý tài nguyên
Image.asset được sử dụng để hiển thị hình ảnh nằm trong hệ thống asset của ứng dụng.
Trước khi sử dụng, cần khai báo thư mục hoặc tệp trong pubspec.yaml.
flutter:
assets:
- assets/images/
Ví dụ sử dụng:
Image.asset(
'assets/images/product.png',
width: 200,
height: 200,
fit: BoxFit.cover,
)
Ưu điểm của asset là hình ảnh được đóng gói cùng ứng dụng, không cần tải từ mạng khi sử dụng.
Flutter cũng hỗ trợ các phiên bản hình ảnh theo mật độ điểm ảnh, chẳng hạn thư mục 2.0x hoặc 3.0x, giúp lựa chọn tài nguyên phù hợp với thiết bị.
Cần lưu ý rằng những hình ảnh có kích thước lớn có thể làm tăng dung lượng ứng dụng và tiêu thụ nhiều bộ nhớ khi giải mã. Do đó, nên tối ưu kích thước và định dạng hình ảnh trước khi đưa vào dự án.
7.3. Image.network và tải hình ảnh từ Internet
Image.network hỗ trợ hiển thị ảnh từ một URL.
Image.network(
'https://example.com/product.jpg',
width: 250,
height: 250,
fit: BoxFit.cover,
loadingBuilder: (context, child, progress) {
if (progress == null) {
return child;
}
return const Center(
child: CircularProgressIndicator(),
);
},
errorBuilder: (context, error, stackTrace) {
return const Center(
child: Icon(
Icons.broken_image,
size: 48,
),
);
},
)
Trong ví dụ trên, loadingBuilder cho phép hiển thị giao diện tải trong lúc ảnh chưa sẵn sàng, còn errorBuilder cung cấp nội dung thay thế khi xảy ra lỗi.
Những nguyên nhân khiến ảnh không hiển thị có thể bao gồm:
-
URL không hợp lệ.
-
Máy chủ không phản hồi.
-
Kết nối Internet bị gián đoạn.
-
Máy chủ từ chối yêu cầu.
-
Định dạng ảnh không được hỗ trợ.
-
Quá trình giải mã hình ảnh thất bại.
Trong ứng dụng thực tế, nên có giao diện dự phòng để tránh những khoảng trống khó hiểu khi ảnh bị lỗi.
7.4. ImageProvider và quy trình cung cấp hình ảnh
ImageProvider là lớp cơ sở của cơ chế cung cấp dữ liệu hình ảnh trong Flutter.
Các lớp thường gặp gồm:
-
AssetImage: Cung cấp ảnh từ asset. -
NetworkImage: Cung cấp ảnh từ URL. -
FileImage: Cung cấp ảnh từ tệp. -
MemoryImage: Cung cấp ảnh từ dữ liệu byte. -
ResizeImage: Bọc một ImageProvider để yêu cầu giải mã ảnh theo kích thước mục tiêu phù hợp.
Widget Image sử dụng ImageProvider để lấy dữ liệu và xây dựng giao diện hiển thị ảnh.
Quy trình tổng quát có thể mô tả như sau:
1. Xác định nguồn ảnh
Asset, Network, File hoặc Memory
2. ImageProvider phân giải dữ liệu
Tạo ImageStream và tìm tài nguyên phù hợp
3. Tải dữ liệu và giải mã
Đọc dữ liệu ảnh và tạo biểu diễn phù hợp
4. ImageCache và ImageStream
Quản lý ảnh đã tải hoặc đang được xử lý
5. Hiển thị lên giao diện
Ảnh được bố trí và vẽ trong giao diện
Đây là mô hình tổng quát. Chi tiết tải, giải mã và lưu bộ nhớ đệm phụ thuộc vào loại ImageProvider và cách sử dụng hình ảnh.
7.5. ImageCache và cơ chế lưu trữ bộ nhớ đệm
ImageCache là hệ thống cache hình ảnh được Flutter sử dụng để tránh tải hoặc giải mã lại những hình ảnh đã có sẵn trong bộ nhớ đệm khi điều kiện cache cho phép.
Khi một ảnh được yêu cầu nhiều lần bằng những khóa tương thích, Flutter có thể sử dụng dữ liệu đã được cache thay vì thực hiện toàn bộ quy trình từ đầu.
Lợi ích gồm:
-
Giảm thời gian hiển thị lại hình ảnh.
-
Hạn chế công việc tải và giải mã lặp lại.
-
Cải thiện trải nghiệm khi cuộn danh sách có nhiều ảnh.
-
Giảm một phần chi phí xử lý của ứng dụng.
Tuy nhiên, cache cũng tiêu thụ bộ nhớ. Nếu ứng dụng hiển thị quá nhiều ảnh lớn, lượng bộ nhớ sử dụng có thể tăng đáng kể.
Flutter phân biệt những ảnh đang được cache, những ảnh đang chờ xử lý và những ảnh được giữ lại do vẫn có người sử dụng. Vì vậy, việc một ảnh không còn nằm trong danh sách cache thông thường không nhất thiết có nghĩa dữ liệu của nó đã được giải phóng ngay lập tức.
7.6. ResizeImage và giảm tiêu thụ RAM
Một hình ảnh có độ phân giải rất lớn có thể tiêu thụ nhiều RAM sau khi giải mã, ngay cả khi giao diện chỉ hiển thị ảnh ở kích thước nhỏ.
Ví dụ, một ảnh có kích thước 4000 × 3000 pixel có 12 triệu pixel. Nếu được giải mã thành dữ liệu RGBA 8 bit cho mỗi kênh, lượng dữ liệu pixel thô có thể xấp xỉ:
Tương đương khoảng 48 MB theo đơn vị thập phân, chưa tính các chi phí bổ sung. Bộ nhớ thực tế còn phụ thuộc định dạng, nền tảng, cách giải mã và biểu diễn ảnh.
ResizeImage cho phép yêu cầu ImageProvider giải mã ảnh ở kích thước mục tiêu, giúp giảm bộ nhớ sử dụng trong những trường hợp phù hợp.
Image(
image: ResizeImage(
const AssetImage('assets/images/product.png'),
width: 400,
),
fit: BoxFit.cover,
)
Trong ví dụ trên, ảnh được yêu cầu giải mã với chiều rộng mục tiêu khoảng 400 pixel, trong khi chiều cao có thể được xác định theo tỷ lệ của ảnh.
Việc lựa chọn kích thước giải mã nên dựa trên kích thước hiển thị thực tế và mật độ điểm ảnh của thiết bị. Không nên giảm ảnh quá nhỏ nếu hình ảnh cần được hiển thị lớn hoặc phóng to.
7.7. precacheImage và tải ảnh trước
precacheImage() cho phép yêu cầu tải và đưa hình ảnh vào hệ thống cache trước khi ảnh được sử dụng trong một vị trí giao diện quan trọng.
Cơ chế này hữu ích khi cần hạn chế độ trễ trong những tình huống như:
-
Chuyển sang màn hình chi tiết sản phẩm.
-
Hiển thị ảnh đại diện sau khi mở trang.
-
Chuẩn bị hình ảnh cho hiệu ứng chuyển cảnh.
-
Tải trước những tài nguyên có khả năng được sử dụng ngay sau đó.
Ví dụ:
class ProductPage extends StatefulWidget {
const ProductPage({super.key});
@override
State<ProductPage> createState() => _ProductPageState();
}
class _ProductPageState extends State<ProductPage> {
@override
void didChangeDependencies() {
super.didChangeDependencies();
precacheImage(
const AssetImage('assets/images/product.png'),
context,
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Image.asset(
'assets/images/product.png',
width: 250,
),
),
);
}
}
Ví dụ trên minh họa cách gọi precacheImage() với asset. Vì didChangeDependencies() có thể được gọi nhiều lần, trong ứng dụng thực tế cần cân nhắc tránh thực hiện yêu cầu preload lặp lại không cần thiết.
Đối với ảnh mạng, có thể sử dụng NetworkImage làm ImageProvider. Cần xử lý lỗi tải và tránh preload quá nhiều ảnh cùng lúc, vì việc tải trước có thể làm tăng tiêu thụ bộ nhớ và băng thông.
7.8. Hiển thị nhiều ảnh trong danh sách lớn
Trong những ứng dụng thương mại điện tử, danh sách sản phẩm có thể chứa hàng trăm hoặc hàng nghìn ảnh.
Nếu tạo và tải tất cả ảnh cùng lúc, ứng dụng có thể tiêu thụ nhiều bộ nhớ và băng thông không cần thiết.
Một số nguyên tắc tối ưu:
-
Sử dụng
ListView.builderhoặcGridView.buildercho danh sách dài. -
Chỉ xây dựng những phần tử cần thiết theo cơ chế lazy loading.
-
Dùng ảnh thumbnail có kích thước phù hợp thay vì luôn tải ảnh gốc.
-
Sử dụng
ResizeImagekhi cần kiểm soát kích thước giải mã. -
Cung cấp placeholder và giao diện lỗi.
-
Tránh gọi preload cho quá nhiều ảnh.
-
Tận dụng cache nhưng vẫn theo dõi mức sử dụng RAM.
-
Hạn chế giữ tham chiếu đến những ảnh hoặc đối tượng không còn cần thiết.
Ví dụ:
GridView.builder(
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: 0.8,
),
itemCount: products.length,
itemBuilder: (context, index) {
final product = products[index];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Image.network(
product.imageUrl,
width: double.infinity,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return const Center(
child: Icon(Icons.broken_image),
);
},
),
),
const SizedBox(height: 8),
Text(
product.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
);
},
)
Đoạn mã giả định rằng products là một danh sách sản phẩm có thuộc tính imageUrl và name. Trong ứng dụng thực tế, cần khai báo model tương ứng và bảo đảm URL ảnh hợp lệ.
7.9. Theo dõi bộ nhớ và tối ưu hình ảnh
Để kiểm soát mức sử dụng bộ nhớ, Flutter cung cấp các công cụ chẩn đoán và những thuộc tính liên quan đến cache.
Có thể kiểm tra các thông tin của ImageCache thông qua PaintingBinding.instance.imageCache, chẳng hạn số lượng ảnh trong cache và lượng byte cache đang được theo dõi.
Ví dụ:
final imageCache = PaintingBinding.instance.imageCache;
debugPrint(
'Số ảnh trong cache: ${imageCache.currentSize}',
);
debugPrint(
'Dung lượng cache: ${imageCache.currentSizeBytes} bytes',
);
Các giá trị này phản ánh thông tin cache được theo dõi bởi Flutter, không phải tổng bộ nhớ RAM của toàn bộ ứng dụng.
Khi chẩn đoán vấn đề hiệu năng, nên kết hợp những thông tin trên với Flutter DevTools, theo dõi bộ nhớ trên thiết bị và kiểm thử các tình huống cuộn danh sách dài.
Kết luận: Image, Asset và Image Cache là những phần quan trọng trong hệ thống hiển thị hình ảnh của Flutter. Hiểu cách ImageProvider tải dữ liệu, cách ảnh được giải mã và cache, cùng những kỹ thuật như ResizeImage và precacheImage giúp cải thiện tốc độ hiển thị, giảm độ trễ và kiểm soát mức sử dụng RAM trong ứng dụng có nhiều hình ảnh.








Khoá học lập trình game con rắn cho trẻ em




