Flutter: Quản lý điều hướng cục bộ với Navigator

Trong Flutter, việc điều hướng giữa các màn hình thường được thực hiện thông qua Navigator toàn cục. Tuy nhiên, có những trường hợp sử dụng đặc biệt yêu cầu quản lý điều hướng riêng lẻ cho một phần giao diện, ví dụ như biểu mẫu nhiều bước, hoặc khi bạn muốn mỗi tab trong thanh điều hướng dưới cùng có một ngăn xếp điều hướng độc lập.

Navigator là gì?

Navigator là một widget chịu trách nhiệm quản lý các routes (tuyến đường) của ứng dụng. Thông thường, chúng ta sử dụng Navigator.of(context).push(...) để chuyển sang màn hình mới. Điều này hoạt động bởi vì MaterialApp (widget gốc của hầu hết các ứng dụng Flutter) bao gồm WidgetsApp, và WidgetsApp tích hợp sẵn một Navigator.

Cấu trúc cơ bản của Navigator

Khi bạn cần một Navigator tùy chỉnh, bạn có thể định nghĩa nó như sau:

Navigator(
  initialRoute: '/', // Tuyến đường ban đầu khi Navigator được khởi tạo
  onGenerateRoute: (RouteSettings settings) {
    WidgetBuilder builder;
    switch (settings.name) {
      case 'home':
        builder = (context) => ScreenA();
        break;
      case 'user_profile':
        builder = (context) => ScreenB();
        break;
      default:
        // Xử lý trường hợp route không xác định
        throw Exception('Unknown route: ${settings.name}');
    }
    // Tạo MaterialPageRoute để hiển thị màn hình
    return MaterialPageRoute(builder: builder, settings: settings);
  },
)

Trong đó:

  • initialRoute: Chỉ định route mặc định sẽ được hiển thị khi Navigator này bắt đầu.
  • onGenerateRoute: Một hàm callback được gọi mỗi khi có yêu cầu điều hướng đến một route mới. Nó nhận RouteSettings và trả về một Route (thường là MaterialPageRoute).

Trường hợp sử dụng: Điều hướng trong một cửa sổ popup

Hãy xem xét ví dụ về tính năng báo cáo trong một ứng dụng tin tức. Khi người dùng nhấn vào biểu tượng "X" trên một bài báo, một cửa sổ nhỏ sẽ hiện ra. Nếu người dùng chọn "Báo cáo", một giao diện báo cáo sẽ hiển thị ngay bên trong cửa sổ đó, thay vì chuyển toàn màn hình.

Đây là một tình huống lý tưởng để sử dụng Navigator cục bộ. Giao diện chính của popup có thể được định nghĩa như sau:

@override
Widget build(BuildContext context) {
  return Center(
    child: Container(
      height: 350,
      width: 300,
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(8.0),
      ),
      child: Navigator(
        initialRoute: 'report_options', // Route ban đầu cho popup
        onGenerateRoute: (RouteSettings settings) {
          WidgetBuilder builder;
          switch (settings.name) {
            case 'report_options':
              builder = (context) => ReportOptionsScreen(
                onReportSelected: () {
                  // Khi người dùng chọn báo cáo, điều hướng đến màn hình báo cáo
                  Navigator.of(context).pushNamed('report_form');
                },
              );
              break;
            case 'report_form':
              builder = (context) => ReportFormScreen();
              break;
            default:
              throw Exception('Unknown route: ${settings.name}');
          }
          // Sử dụng MaterialPageRoute để có hiệu ứng chuyển trang
          return MaterialPageRoute(builder: builder, settings: settings);
        },
      ),
    ),
  );
}

Màn hình tùy chọn báo cáo (ReportOptionsScreen)

Màn hình này hiển thị các lựa chọn như "Không quan tâm", "Báo cáo", v.v.

class ReportOptionsScreen extends StatelessWidget {
  final VoidCallback onReportSelected;

  ReportOptionsScreen({this.onReportSelected});

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(16.0),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: <Widget>[
          _buildOptionItem(Icons.clear, 'Không quan tâm', 'Giảm nội dung tương tự'),
          Divider(),
          _buildOptionItem(Icons.access_alarm, 'Báo cáo', 'Tiêu đề sai, nội dung kém', isReport: true),
          Divider(),
          _buildOptionItem(Icons.perm_identity, 'Chặn tác giả', 'Chặn bài viết từ tác giả này'),
          Divider(),
          _buildOptionItem(Icons.account_circle, 'Ẩn bài viết', 'Ẩn bài viết về chủ đề này'),
        ],
      ),
    );
  }

  Widget _buildOptionItem(IconData icon, String title, String subtitle, {bool isReport = false}) {
    return InkWell(
      onTap: isReport ? onReportSelected : null, // Chỉ kích hoạt khi là tùy chọn báo cáo
      child: Padding(
        padding: const EdgeInsets.symmetric(vertical: 12.0),
        child: Row(
          children: <Widget>[
            Icon(icon, size: 24),
            SizedBox(width: 20),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  Text(title, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
                  if (subtitle.isNotEmpty)
                    Text(subtitle, style: TextStyle(color: Colors.grey[600], fontSize: 13)),
                ],
              ),
            ),
            if (isReport) Icon(Icons.arrow_forward_ios, size: 16, color: Colors.grey),
          ],
        ),
      ),
    );
  }
}

Màn hình nhập liệu báo cáo (ReportFormScreen)

Màn hình này sẽ hiển thị khi người dùng chọn "Báo cáo".

class ReportFormScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.all(16.0),
      color: Colors.grey[200],
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          Row(
            children: [
              IconButton(
                icon: Icon(Icons.arrow_back_ios),
                onPressed: () {
                  Navigator.of(context).pop(); // Quay lại màn hình trước
                },
              ),
              Text('Trở về', style: TextStyle(fontSize: 16)),
              Spacer(),
              Text('Báo cáo nội dung', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
              Spacer(),
            ],
          ),
          SizedBox(height: 20),
          Text('Lý do báo cáo:', style: TextStyle(fontSize: 14, color: Colors.grey[700])),
          SizedBox(height: 8),
          // Thêm các widget nhập liệu cho báo cáo tại đây
          TextField(
            decoration: InputDecoration(
              hintText: 'Nhập lý do chi tiết...',
              border: OutlineInputBorder(),
              filled: true,
              fillColor: Colors.white,
            ),
            maxLines: 4,
          ),
          SizedBox(height: 16),
          ElevatedButton(
            onPressed: () {
              // Xử lý gửi báo cáo
              ScaffoldMessenger.of(context).showSnackBar(
                SnackBar(content: Text('Đã gửi báo cáo!'))
              );
              Navigator.of(context).pop(); // Đóng màn hình báo cáo
            },
            child: Text('Gửi báo cáo'),
          ),
        ],
      ),
    );
  }
}

Kết quả là, chỉ phần nội dung của popup thay đổi, trong khi phần còn lại của ứng dụng không bị ảnh hưởng.

Trường hợp sử dụng: Điều hướng trong mỗi Tab

Một ứng dụng phổ biến khác cho Navigator cục bộ là khi bạn có thanh điều hướng dưới cùng (bottom navigation bar), và mỗi tab cần có một ngăn xếp điều hướng riêng biệt. Điều này có nghĩa là khi bạn chuyển đổi giữa các tab, trạng thái điều hướng của mỗi tab sẽ được bảo toàn.

Cấu trúc chính của phần này bao gồm:

  • Một widget quản lý trạng thái cho thanh điều hướng dưới cùng (TabMain).
  • Một widget IndexedStack để hiển thị nội dung của tab được chọn.
  • Một widget Navigator riêng biệt cho mỗi tab (TabNavigator).

Widget quản lý Tab (TabMain)

class TabMain extends StatefulWidget {
  @override
  _TabMainState createState() => _TabMainState();
}

class _TabMainState extends State<TabMain> {
  int _currentIndex = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: IndexedStack(
        index: _currentIndex,
        // Mỗi child là một Navigator riêng cho từng tab
        children: <Widget>[
          TabNavigator(tabIndex: 0), // Tab 0: Trang chủ
          TabNavigator(tabIndex: 1), // Tab 1: Sách
          TabNavigator(tabIndex: 2), // Tab 2: Cá nhân
        ],
      ),
      bottomNavigationBar: BottomNavigationBar(
        onTap: (int index) {
          setState(() {
            _currentIndex = index;
          });
        },
        currentIndex: _currentIndex,
        items: <BottomNavigationBarItem>[
          BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Trang chủ'),
          BottomNavigationBarItem(icon: Icon(Icons.book), label: 'Sách'),
          BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Cá nhân'),
        ],
      ),
    );
  }
}

Widget Navigator cho từng Tab (TabNavigator)

Widget này chứa một Navigator riêng, được cấu hình để quản lý các route cho tab cụ thể của nó.

class TabNavigator extends StatelessWidget {
  final int tabIndex;

  TabNavigator({this.tabIndex});

  @override
  Widget build(BuildContext context) {
    // Mỗi TabNavigator có một Navigator riêng
    return Navigator(
      // Sử dụng key để phân biệt các Navigator nếu cần
      // key: ObjectKey(tabIndex), // Ví dụ: sử dụng key dựa trên index
      initialRoute: '/', // Route ban đầu cho tab này
      onGenerateRoute: (RouteSettings settings) {
        WidgetBuilder builder;
        switch (settings.name) {
          case '/':
            // Trang danh sách ban đầu cho mỗi tab
            builder = (context) => TabListPage(index: tabIndex);
            break;
          case '/detail':
            // Route cho trang chi tiết
            builder = (context) => DetailScreen();
            break;
          default:
            throw Exception('Unknown route: ${settings.name}');
        }
        return MaterialPageRoute(builder: builder, settings: settings);
      },
    );
  }
}

Trang danh sách (TabListPage)

Đây là trang hiển thị nội dung ban đầu của mỗi tab. Nó có một nút để điều hướng đến trang chi tiết.

class TabListPage extends StatelessWidget {
  final int index;

  TabListPage({this.index});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Tab ${index + 1}')),
      body: Center(
        child: ElevatedButton(
          child: Text('Đi tới Chi tiết (Tab ${index + 1})'),
          onPressed: () {
            // Điều hướng trong Navigator của tab hiện tại
            Navigator.of(context).pushNamed('/detail');
          },
        ),
      ),
    );
  }
}

Trang chi tiết (DetailScreen)

Trang này đơn giản chỉ hiển thị nội dung chi tiết.

class DetailScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Trang Chi tiết')),
      body: Center(
        child: Text('Nội dung chi tiết', style: TextStyle(fontSize: 20)),
      ),
    );
  }
}

Với cách tiếp cận này, mỗi tab có bộ nhớ riêng về lịch sử điều hướng. Bạn có thể quay lại tab trước đó, và nội dung của nó vẫn được giữ nguyên.

Mặc dù Navigator cục bộ không được sử dụng thường xuyên như Navigator.of(context) toàn cục, nhưng nó cung cấp một giải pháp mạnh mẽ và linh hoạt cho các tình huống điều hướng phức tạp và có phạm vi hẹp.

Thẻ: Flutter navigator Routing local navigation tab navigation

Đăng vào ngày 29 tháng 9 lúc 07:07