Triển Khai Thanh Điều Hướng Dưới Cùng Tùy Chỉnh trong Flutter trên OpenHarmony: Giải Pháp Toàn Diện

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 IndexedStackChangeNotifierProvider để 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 IndexedStack kết hợp với AutomaticKeepAliveClientMixin để 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 StackPositioned.
  • 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.

Thẻ: Flutter openharmony Dart BottomNavigationBar IndexedStack

Đăng vào ngày 13 tháng 9 lúc 22:58