Giới thiệu về Thanh Điều hướng Dưới cùng
Thanh điều hướng dưới cùng (Bottom Navigation Bar) là một thành phần giao diện người dùng quen thuộc trong hầu hết các ứng dụng di động có nhiều màn hình chính. Nó giúp người dùng dễ dàng chuyển đổi giữa các phần khác nhau của ứng dụng. Trong bối cảnh phát triển ứng dụng Flutter cho nền tảng OpenHarmony, việc xây dựng một thanh điều hướng hiệu quả đòi hỏi phải giải quyết một số thách thức nhất định, bao gồm đảm bảo hoạt ảnh mượt mà, duy trì trạng thái của các trang, hiển thị thông báo (badge) động và thích ứng với các đặc điểm riêng của OpenHarmony.
Bài viết này sẽ hướng dẫn chi tiết cách xây dựng một thanh điều hướng dưới cùng tùy chỉnh trong Flutter, tập trung vào các yêu cầu thường gặp như:
- Hỗ trợ 4 tab cơ bản (ví dụ: Sức Khỏe, Tin Nhắn, Cửa Hàng, Hồ Sơ).
- Mỗi tab có biểu tượng và văn bản.
- Tab hiện tại được làm nổi bật.
- Hiển thị số lượng tin nhắn chưa đọc hoặc dấu chấm thông báo.
- Giữ nguyên trạng thái của các trang khi chuyển đổi.
- Khả năng tích hợp nút hành động nổi ở giữa (tùy chọn).
Lựa chọn Phương pháp Triển khai
Mặc dù có nhiều thư viện bên thứ ba cung cấp các thanh điều hướng tùy chỉnh với hiệu ứng phong phú, việc sử dụng các thành phần gốc của Flutter kết hợp với các kỹ thuật quản lý trạng thái thường mang lại sự ổn định và kiểm soát tốt nhất. Với các yêu cầu trên, sự kết hợp giữa widget BottomNavigationBar cơ bản (hoặc một widget tùy chỉnh hoàn toàn) và IndexedStack là một lựa chọn mạnh mẽ để giữ trạng thái các trang và cung cấp trải nghiệm người dùng mượt mà.
Mô hình Dữ liệu cho Các Mục Điều hướng
Để quản lý cấu hình của từng tab, chúng ta cần một mô hình dữ liệu. Mô hình này sẽ lưu trữ thông tin về tiêu đề, biểu tượng mặc định, biểu tượng khi được chọn và thông tin huy hiệu (badge).
import 'package:flutter/material.dart';
// Cấu hình cho một mục trên thanh điều hướng
class TabEntry {
final String title; // Tiêu đề của tab
final IconData defaultIcon; // Biểu tượng mặc định
final IconData selectedIcon; // Biểu tượng khi tab được chọn
final String? badgeInfo; // Thông tin huy hiệu (ví dụ: '5', 'dot', 'red')
TabEntry({
required this.title,
required this.defaultIcon,
required this.selectedIcon,
this.badgeInfo,
});
// Phương thức copyWith để cập nhật thông tin huy hiệu một cách tiện lợi
TabEntry copyWith({
String? title,
IconData? defaultIcon,
IconData? selectedIcon,
String? badgeInfo,
}) {
return TabEntry(
title: title ?? this.title,
defaultIcon: defaultIcon ?? this.defaultIcon,
selectedIcon: selectedIcon ?? this.selectedIcon,
badgeInfo: badgeInfo ?? this.badgeInfo,
);
}
}
Quản lý Trạng thái Thanh Điều hướng
Để quản lý chỉ mục tab hiện tại và cập nhật huy hiệu một cách động, chúng ta sẽ sử dụng Provider thông qua lớp ChangeNotifier. Lớp TabManager sẽ chứa logic này.
import 'package:flutter/material.dart';
import 'tab_entry.dart'; // Import lớp TabEntry đã định nghĩa ở trên
class TabManager extends ChangeNotifier {
int _currentSelectedIdx = 0;
final List<TabEntry> _tabEntries = [
TabEntry(
title: 'Sức Khỏe',
defaultIcon: Icons.favorite_outline,
selectedIcon: Icons.favorite,
),
TabEntry(
title: 'Tin Nhắn',
defaultIcon: Icons.chat_bubble_outline,
selectedIcon: Icons.chat_bubble,
),
TabEntry(
title: 'Cửa Hàng',
defaultIcon: Icons.store_outlined,
selectedIcon: Icons.store,
),
TabEntry(
title: 'Hồ Sơ',
defaultIcon: Icons.person_outline,
selectedIcon: Icons.person,
),
];
int get currentSelectedIdx => _currentSelectedIdx;
List<TabEntry> get tabEntries => _tabEntries;
TabEntry get currentTabEntry => _tabEntries[_currentSelectedIdx];
// Cập nhật chỉ mục tab được chọn
void selectTabIndex(int index) {
if (_currentSelectedIdx != index && index >= 0 && index < _tabEntries.length) {
_currentSelectedIdx = index;
notifyListeners(); // Thông báo cho các widget nghe thay đổi
}
}
// Cập nhật huy hiệu cho một tab cụ thể
void updateTabBadge(int index, String? newBadgeInfo) {
if (index >= 0 && index < _tabEntries.length) {
_tabEntries[index] = _tabEntries[index].copyWith(badgeInfo: newBadgeInfo);
notifyListeners();
}
}
// Xóa tất cả các huy hiệu
void clearAllBadges() {
for (int i = 0; i < _tabEntries.length; i++) {
_tabEntries[i] = _tabEntries[i].copyWith(badgeInfo: null);
}
notifyListeners();
}
// Khởi tạo huy hiệu (ví dụ, tải từ dữ liệu ban đầu)
void _initializeBadges() {
updateTabBadge(1, '5'); // Tab Tin Nhắn có 5 tin chưa đọc
updateTabBadge(2, '1'); // Tab Cửa Hàng có 1 thông báo
}
}
Xây dựng Thanh Điều hướng Dưới cùng Tùy chỉnh
Tiếp theo, chúng ta sẽ tạo một widget CustomBottomTabBar hoàn toàn tùy chỉnh để hiển thị các tab, bao gồm cả huy hiệu thông báo.
import 'package:flutter/material.dart';
import 'tab_entry.dart'; // Import TabEntry
class CustomBottomTabBar extends StatelessWidget {
final List<TabEntry> tabItems;
final int selectedIndex;
final Function(int) onTabSelected;
final Color? activeTabColor;
final Color? inactiveTabColor;
final double barHeight;
const CustomBottomTabBar({
super.key,
required this.tabItems,
required this.selectedIndex,
required this.onTabSelected,
this.activeTabColor,
this.inactiveTabColor,
this.barHeight = 60,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final activeColor = activeTabColor ?? theme.primaryColor;
final inactiveColor = inactiveTabColor ?? const Color(0xFF999999);
return Container(
height: barHeight + MediaQuery.of(context).padding.bottom, // Đảm bảo an toàn vùng đáy
decoration: BoxDecoration(
color: Theme.of(context).bottomNavigationBarTheme.backgroundColor ?? Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 20,
offset: const Offset(0, -5),
),
],
),
child: SafeArea( // Chỉ xử lý vùng an toàn phía dưới
top: false,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: List.generate(tabItems.length, (index) {
return _buildTabItem(
context,
tabItems[index],
index,
selectedIndex == index,
activeColor,
inactiveColor,
);
}),
),
),
);
}
Widget _buildTabItem(
BuildContext context,
TabEntry item,
int index,
bool isActive,
Color activeColor,
Color inactiveColor,
) {
return Expanded( // Sử dụng Expanded để phân chia không gian đều
child: InkWell(
onTap: () => onTabSelected(index),
borderRadius: BorderRadius.circular(12),
child: Container(
alignment: Alignment.center, // Canh giữa nội dung
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Biểu tượng + Huy hiệu
Stack(
clipBehavior: Clip.none, // Cho phép widget con tràn ra ngoài
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 200),
child: Icon(
isActive ? item.selectedIcon : item.defaultIcon,
size: 26,
color: isActive ? activeColor : inactiveColor,
),
),
// Huy hiệu thông báo (số hoặc chấm đỏ)
if (item.badgeInfo != null)
Positioned(
right: -8, // Dịch sang phải 8 pixel so với biểu tượng
top: -4, // Dịch lên trên 4 pixel so với biểu tượng
child: _renderBadgeIndicator(item.badgeInfo!),
),
],
),
const SizedBox(height: 4),
// Văn bản tiêu đề
Text(
item.title,
style: TextStyle(
fontSize: 11,
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
color: isActive ? activeColor : inactiveColor,
),
),
],
),
),
),
);
}
// Widget hiển thị huy hiệu
Widget _renderBadgeIndicator(String info) {
final isNumeric = RegExp(r'^\d+$').hasMatch(info);
// Nếu là 'dot' hoặc 'red', hiển thị chấm đỏ
if (info == 'dot' || info == 'red') {
return Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Color(0xFFFF4D4F), // Màu đỏ
shape: BoxShape.circle,
),
);
}
// Nếu là số và có tối đa 2 chữ số
if (isNumeric && info.length <= 2) {
final count = int.tryParse(info) ?? 0;
final displayString = count > 99 ? '99+' : info;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFFFF4D4F),
borderRadius: BorderRadius.circular(10),
),
constraints: const BoxConstraints(
minWidth: 18, // Đảm bảo kích thước tối thiểu
minHeight: 18,
),
child: Text(
displayString,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
);
}
// Mặc định là chấm đỏ nếu không khớp các trường hợp trên
return Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Color(0xFFFF4D4F),
shape: BoxShape.circle,
),
);
}
}
Cấu trúc Trang Chính với Duy trì Trạng thái
Để đảm bảo các trang không bị tải lại mỗi khi chuyển tab, chúng ta sẽ sử dụng IndexedStack và ChangeNotifierProvider để quản lý TabManager.
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'tab_manager.dart'; // Import TabManager
import 'custom_bottom_tab_bar.dart'; // Import CustomBottomTabBar
// Import các trang con
import 'pages/health_dashboard_page.dart';
import 'pages/message_list_page.dart';
import 'pages/shop_catalog_page.dart';
import 'pages/user_profile_page.dart';
class MainShellScreen extends StatelessWidget {
const MainShellScreen({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => TabManager().._initializeBadges(), // Khởi tạo TabManager và badge
child: const _MainShellLayout(),
);
}
}
class _MainShellLayout extends StatelessWidget {
const _MainShellLayout();
@override
Widget build(BuildContext context) {
return Scaffold(
// resizeToAvoidBottomInset: true, // Thường hữu ích khi bàn phím xuất hiện
body: Consumer<TabManager>(
builder: (context, tabManager, _) {
// Sử dụng IndexedStack để duy trì trạng thái của các trang
return IndexedStack(
index: tabManager.currentSelectedIdx,
children: const [
HealthDashboardPage(),
MessageListPage(),
ShopCatalogPage(),
UserProfilePage(),
],
);
},
),
bottomNavigationBar: Consumer<TabManager>(
builder: (context, tabManager, _) {
return CustomBottomTabBar(
tabItems: tabManager.tabEntries,
selectedIndex: tabManager.currentSelectedIdx,
onTabSelected: (index) => tabManager.selectTabIndex(index),
activeTabColor: const Color(0xFFFF6600), // Màu cam nổi bật
inactiveTabColor: const Color(0xFF999999), // Màu xám mờ
);
},
),
);
}
}
Các Trang Tab Cơ bản
Dưới đây là cấu trúc khung của các trang con, dùng làm placeholder:
import 'package:flutter/material.dart';
// Trang Sức Khỏe
class HealthDashboardPage extends StatelessWidget {
const HealthDashboardPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
appBar: AppBar(
title: const Text('Sức Khỏe'),
backgroundColor: Colors.white,
foregroundColor: const Color(0xFF333333),
elevation: 0,
),
body: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('❤️', style: TextStyle(fontSize: 80)),
SizedBox(height: 20),
Text(
'Bảng điều khiển sức khỏe',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Color(0xFF333333),
),
),
],
),
),
);
}
}
// Trang Tin Nhắn
class MessageListPage extends StatelessWidget {
const MessageListPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
appBar: AppBar(
title: const Text('Tin Nhắn'),
backgroundColor: Colors.white,
foregroundColor: const Color(0xFF333333),
elevation: 0,
),
body: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('💬', style: TextStyle(fontSize: 80)),
SizedBox(height: 20),
Text(
'Danh sách tin nhắn của bạn',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Color(0xFF333333),
),
),
],
),
),
);
}
}
// Trang Cửa Hàng
class ShopCatalogPage extends StatelessWidget {
const ShopCatalogPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
appBar: AppBar(
title: const Text('Cửa Hàng'),
backgroundColor: Colors.white,
foregroundColor: const Color(0xFF333333),
elevation: 0,
),
body: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('🛒', style: TextStyle(fontSize: 80)),
SizedBox(height: 20),
Text(
'Duyệt sản phẩm tại đây',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Color(0xFF333333),
),
),
],
),
),
);
}
}
// Trang Hồ Sơ Người Dùng
class UserProfilePage extends StatelessWidget {
const UserProfilePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
appBar: AppBar(
title: const Text('Hồ Sơ'),
backgroundColor: Colors.white,
foregroundColor: const Color(0xFF333333),
elevation: 0,
),
body: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('👤', style: TextStyle(fontSize: 80)),
SizedBox(height: 20),
Text(
'Thông tin cá nhân',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Color(0xFF333333),
),
),
],
),
),
);
}
}
Thanh Điều hướng với Nút Trung tâm Đặc biệt
Trong một số thiết kế UI/UX, thanh điều hướng dưới cùng có thể có một nút nổi ở giữa, thường dành cho các hành động chính (ví dụ: "Đăng bài", "Thêm"). Dưới đây là cách triển khai một FloatingActionBottomBar để đáp ứng yêu cầu này.
import 'package:flutter/material.dart';
import 'tab_entry.dart'; // Import TabEntry
class FloatingActionBottomBar extends StatelessWidget {
final List<TabEntry> tabItems;
final int selectedIndex;
final Function(int) onTabSelected;
final Color? activeTabColor;
final Color? inactiveTabColor;
final VoidCallback? onCenterButtonPressed; // Callback cho nút trung tâm
const FloatingActionBottomBar({
super.key,
required this.tabItems,
required this.selectedIndex,
required this.onTabSelected,
this.activeTabColor,
this.inactiveTabColor,
this.onCenterButtonPressed,
}) : assert(tabItems.length == 4, 'FloatingActionBottomBar is designed for 4 tab items');
@override
Widget build(BuildContext context) {
final activeColor = activeTabColor ?? const Color(0xFFFF6600);
final inactiveColor = inactiveTabColor ?? const Color(0xFF999999);
return Container(
decoration: BoxDecoration(
color: Theme.of(context).bottomNavigationBarTheme.backgroundColor ?? Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 20,
offset: const Offset(0, -5),
),
],
),
child: SafeArea(
top: false,
child: SizedBox(
height: 60,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
// 2 tab bên trái
_buildSideTabItem(0, activeColor, inactiveColor),
_buildSideTabItem(1, activeColor, inactiveColor),
// Nút hành động nổi ở trung tâm
_buildCentralActionButton(activeColor),
// 2 tab bên phải
_buildSideTabItem(2, activeColor, inactiveColor),
_buildSideTabItem(3, activeColor, inactiveColor),
],
),
),
),
);
}
// Widget cho các tab bên cạnh nút trung tâm
Widget _buildSideTabItem(int itemIndex, Color activeColor, Color inactiveColor) {
final item = tabItems[itemIndex];
final isActive = selectedIndex == itemIndex;
return Expanded(
child: InkWell(
onTap: () => onTabSelected(itemIndex),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
isActive ? item.selectedIcon : item.defaultIcon,
size: 26,
color: isActive ? activeColor : inactiveColor,
),
const SizedBox(height: 4),
Text(
item.title,
style: TextStyle(
fontSize: 11,
color: isActive ? activeColor : inactiveColor,
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
),
),
],
),
),
);
}
// Widget cho nút hành động nổi ở trung tâm
Widget _buildCentralActionButton(Color accentColor) {
return GestureDetector(
onTap: onCenterButtonPressed,
child: Container(
width: 56,
height: 56,
margin: const EdgeInsets.symmetric(horizontal: 16), // Khoảng cách với các tab khác
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [accentColor, accentColor.withOpacity(0.7)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: accentColor.withOpacity(0.3),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: const Icon(
Icons.add, // Ví dụ: biểu tượng thêm
color: Colors.white,
size: 30,
),
),
);
}
}
Cập nhật Huy hiệu Động
Việc cập nhật số lượng thông báo chưa đọc hoặc trạng thái huy hiệu có thể được thực hiện dễ dàng thông qua TabManager. Ví dụ, trong một dịch vụ xử lý tin nhắn:
// Trong một dịch vụ hoặc ViewModel nào đó
class MessageUpdateService {
final TabManager tabManager;
MessageUpdateService(this.tabManager);
void onNewMessageReceived(int unreadCount) {
if (unreadCount > 0) {
tabManager.updateTabBadge(1, unreadCount.toString()); // Cập nhật huy hiệu cho tab Tin Nhắn (chỉ mục 1)
} else {
tabManager.updateTabBadge(1, null); // Xóa huy hiệu nếu không có tin chưa đọc
}
}
void onAllMessagesRead() {
tabManager.updateTabBadge(1, null);
}
}
Điểm Thích ứng Nền tảng OpenHarmony
1. Xử lý Vùng An toàn (Safe Area)
Mô tả vấn đề: Các thiết bị OpenHarmony, giống như các thiết bị di động hiện đại khác, có thể có các vùng an toàn (ví dụ: notch, thanh điều hướng cử chỉ ở đáy) mà nội dung ứng dụng không nên che khuất.
Giải pháp: Sử dụng widget SafeArea để đảm bảo thanh điều hướng không bị các yếu tố hệ thống che khuất.
bottomNavigationBar: Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border( // Thêm đường viền phía trên để phân tách
top: BorderSide(
color: Colors.grey.shade200,
width: 0.5,
),
),
),
child: SafeArea(
top: false, // Chỉ xử lý vùng an toàn phía dưới
child: CustomBottomTabBar(...),
),
)
2. Thích ứng khi Bàn phím Xuất hiện
Mô tả vấn đề: Khi bàn phím ảo xuất hiện (ví dụ: trong trang trò chuyện), thanh điều hướng dưới cùng có thể bị đẩy lên và che mất một phần màn hình hoặc gây ra lỗi bố cục.
Giải pháp: Sử dụng thuộc tính resizeToAvoidBottomInset của Scaffold để cho phép bố cục tự động điều chỉnh.
Scaffold(
resizeToAvoidBottomInset: true, // Bố cục sẽ tự động điều chỉnh khi bàn phím xuất hiện
body: ...,
bottomNavigationBar: ...,
)
Trong một số trường hợp phức tạp hơn, có thể cần lắng nghe trạng thái bàn phím hoặc điều chỉnh bố cục một cách thủ công.
3. Thích ứng Chuyển đổi Hướng màn hình (Ngang/Dọc)
Mô tả vấn đề: Khi thiết bị xoay ngang, thanh điều hướng dưới cùng có thể chiếm quá nhiều không gian hoặc hiển thị không đúng cách.
Giải pháp: Sử dụng OrientationBuilder để tùy chỉnh hiển thị thanh điều hướng dựa trên hướng màn hình. Ví dụ, có thể ẩn nó khi ở chế độ ngang.
body: ...,
bottomNavigationBar: OrientationBuilder(
builder: (context, orientation) {
if (orientation == Orientation.landscape) {
return const SizedBox.shrink(); // Ẩn thanh điều hướng khi màn hình ngang
}
return Consumer<TabManager>(
builder: (context, tabManager, _) {
return CustomBottomTabBar(
tabItems: tabManager.tabEntries,
selectedIndex: tabManager.currentSelectedIdx,
onTabSelected: (index) => tabManager.selectTabIndex(index),
);
},
);
},
),
Khắc phục Sự cố Thường gặp
1. Trang bị tải lại khi chuyển tab
Mô tả vấn đề: Khi người dùng chuyển sang một tab khác và sau đó quay lại tab ban đầu, nội dung của tab đó bị tải lại từ đầu, làm mất trạng thái (ví dụ: vị trí cuộn, dữ liệu nhập).
Nguyên nhân: Sử dụng cách render điều kiện (ví dụ: if (index == 0) return PageA() else return PageB()) hoặc PageView mà không giữ trạng thái.
Giải pháp: Sử dụng IndexedStack để chứa tất cả các trang con. IndexedStack giữ tất cả các widget con trong cây widget nhưng chỉ hiển thị widget tại chỉ mục được chọn. Điều này hiệu quả hơn so với việc loại bỏ và thêm lại các widget con.
IndexedStack(
index: tabManager.currentSelectedIdx,
children: const [
HealthDashboardPage(),
MessageListPage(),
ShopCatalogPage(),
UserProfilePage(),
],
)
Để đảm bảo rằng các trạng thái nội bộ của các trang cũng được duy trì (ví dụ: trạng thái của một StatefulWidget bên trong trang), hãy mixin AutomaticKeepAliveClientMixin vào State của các trang đó và ghi đè wantKeepAlive thành true.
class _HealthDashboardPageState extends State<HealthDashboardPage>
with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true; // Giữ trạng thái của trang này
// ... phần còn lại của State
}
2. Vị trí huy hiệu bị lệch
Mô tả vấn đề: Huy hiệu thông báo (chấm đỏ hoặc số) hiển thị không đúng vị trí, có thể bị che bởi biểu tượng hoặc nằm quá xa.
Giải pháp: Sử dụng Stack kết hợp với Positioned để định vị chính xác huy hiệu tương đối với biểu tượng. Đảm bảo clipBehavior: Clip.none trên Stack để huy hiệu có thể tràn ra ngoài giới hạn của Stack.
Stack(
clipBehavior: Clip.none, // Cho phép widget con tràn ra ngoài
children: [
Icon(...), // Biểu tượng chính
Positioned(
right: -8, // Dịch sang phải 8 pixel từ cạnh phải của biểu tượng
top: -4, // Dịch lên trên 4 pixel từ cạnh trên của biểu tượng
child: _renderBadgeIndicator(item.badgeInfo!),
),
],
)
3. Hoạt ảnh giật cục khi chuyển tab
Mô tả vấn đề: Khi người dùng nhấp vào một tab, có một độ trễ hoặc hiện tượng giật nhẹ trong hoạt ảnh chuyển đổi.
Nguyên nhân: Điều này có thể do các trang con đang được render lại hoặc tính toán kích thước lại mỗi khi IndexedStack chuyển chỉ mục.
Giải pháp: Kết hợp IndexedStack với AutomaticKeepAliveClientMixin như đã đề cập ở trên. Ngoài ra, đảm bảo rằng các widget con bên trong các trang tab được tối ưu hóa để render nhanh chóng và không thực hiện các phép tính phức tạp trong phương thức build của chúng.
Tổng kết
Việc xây dựng một thanh điều hướng dưới cùng tùy chỉnh trong Flutter cho các ứng dụng trên OpenHarmony, mặc dù không quá phức tạp, nhưng đòi hỏi sự chú ý đến từng chi tiết nhỏ. Các điểm chính cần ghi nhớ bao gồm:
- Sử dụng
IndexedStackkết hợp vớiAutomaticKeepAliveClientMixinđể giữ trạng thái của các trang. - Quản lý trạng thái và dữ liệu của thanh điều hướng (chỉ mục hiện tại, huy hiệu) bằng Provider (
ChangeNotifierProvider). - Thiết kế huy hiệu linh hoạt bằng cách sử dụng
StackvàPositioned. - Luôn kiểm tra và thích ứng với các vùng an toàn của thiết bị (
SafeArea). - Xử lý hành vi của bàn phím ảo (
resizeToAvoidBottomInset) và xoay màn hình (OrientationBuilder) để đảm bảo trải nghiệm người dùng liền mạch trên OpenHarmony.
Bằng cách tuân thủ các nguyên tắc này, bạn có thể tạo ra một thanh điều hướng dưới cùng mạnh mẽ, có thể tùy chỉnh và hiệu quả cho ứng dụng Flutter của mình trên nền tảng OpenHarmony.