Bạn đổi tab rồi quay lại, bộ đếm hoặc nội dung đang nhập trở về ban đầu. Một nguyên nhân là trang cũ đã bị loại khỏi cây widget khi tab khác được chọn. Nếu muốn giữ một số trang đơn giản cùng tồn tại, IndexedStack là một lựa chọn cần hiểu cả lợi ích lẫn giới hạn.
Bài đối chiếu Flutter API ngày 30/10/2026. Ví dụ dùng Material 3 NavigationBar và IndexedStack; cần Flutter SDK có các widget này. Code được rà theo API, chưa chạy build, widget test hoặc thiết bị. Các bước thao tác bên dưới là kế hoạch kiểm tra cho project thử nghiệm của bạn.
Giữ trang trong cây, chỉ đổi trang được vẽ
IndexedStack API mô tả một stack chỉ hiển thị child ở index được chọn. Các child vẫn thuộc cây widget, nên State của từng trang có thể tiếp tục tồn tại khi đổi index. Widget này không phải cơ chế tự ghi state xuống bộ nhớ bền vững.
Nếu thay cả danh sách child, đổi key hoặc loại IndexedStack khỏi cây, quy tắc giữ State lại phụ thuộc cấu trúc mới. Đừng diễn giải “giữ khi đổi tab” thành “giữ qua mọi thao tác điều hướng hoặc restart app”.
Ví dụ hai trang có bộ đếm riêng
Thay lib/main.dart trong project thử nghiệm bằng code sau. Mỗi CounterPage giữ count của chính nó; trang cha chỉ quản lý tab đang được chọn.
import 'package:flutter/material.dart';
void main() => runApp(MaterialApp(
theme: ThemeData(useMaterial3: true),
home: const TabsDemo(),
));
class TabsDemo extends StatefulWidget {
const TabsDemo({super.key});
@override
State<TabsDemo> createState() => _TabsDemoState();
}
class _TabsDemoState extends State<TabsDemo> {
int selected = 0;
final pages = const [
CounterPage(key: ValueKey('drafts'), label: 'Bản nháp'),
CounterPage(key: ValueKey('saved'), label: 'Đã lưu'),
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('State theo tab')),
body: IndexedStack(index: selected, children: pages),
bottomNavigationBar: NavigationBar(
selectedIndex: selected,
onDestinationSelected: (index) => setState(() => selected = index),
destinations: const [
NavigationDestination(icon: Icon(Icons.edit), label: 'Bản nháp'),
NavigationDestination(icon: Icon(Icons.bookmark), label: 'Đã lưu'),
],
),
);
}
}
class CounterPage extends StatefulWidget {
const CounterPage({super.key, required this.label});
final String label;
@override
State<CounterPage> createState() => _CounterPageState();
}
class _CounterPageState extends State<CounterPage> {
int count = 0;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('${widget.label}: $count'),
const SizedBox(height: 12),
FilledButton(
onPressed: () => setState(() => count += 1),
child: const Text('Tăng bộ đếm'),
),
],
),
);
}
}
selectedIndex và onDestinationSelected thuộc NavigationBar API. Callback chỉ đổi index; không gán count về 0 và không tạo một kho state dùng chung cho hai trang.
Kiểm tra state riêng và state được giữ
Ở tab Bản nháp, bấm tăng hai lần. Chuyển sang Đã lưu và kiểm tra bộ đếm trang này bắt đầu từ 0, rồi tăng một lần. Quay về Bản nháp: giá trị cần kiểm tra là 2, sau đó quay lại Đã lưu để kiểm tra giá trị 1.
Hai điều kiện cần được kiểm tra riêng: mỗi trang có State độc lập, và State đó vẫn còn khi child tạm không được hiển thị. Nếu chỉ bấm qua lại mà không thay dữ liệu, bạn chưa chứng minh được điều thứ hai.
Thử hot restart hoặc đóng rồi mở app. Ví dụ không lưu count xuống storage, nên không dùng việc giữ trong IndexedStack để kỳ vọng dữ liệu sống qua process mới. Đây là giới hạn có chủ đích của demo, không phải một lỗi cần thêm key để sửa.
Giữ sống có chi phí
IndexedStack không phải lazy loader. Nó giữ các child và tham gia layout; API còn ghi kích thước stack dựa trên child lớn nhất. Với nhiều màn hình nặng, việc giữ tất cả có thể tăng chi phí khởi tạo, bộ nhớ và layout. Bài không đo những chi phí đó hoặc đưa một số tab tối đa áp dụng cho mọi app.
Trang không được vẽ cũng chưa có nghĩa Timer, stream subscription hoặc công việc của ứng dụng đã tự dừng. Nếu trang có hoạt động nền, quản lý theo trạng thái tab hoặc vòng đời thích hợp. TickerMode chỉ liên quan ticker được cấu hình theo cơ chế của nó, không tự hủy mọi request hay Timer.
Offstage API cũng nhắc rằng việc ẩn child không đồng nghĩa child ngừng hoạt động. Đây là điểm cần rà khi chọn giải pháp giữ cây sống, thay vì chỉ quan sát giao diện không còn thấy trang đó.
Khi nào nên chọn cách khác?
Với hai hoặc vài trang nhỏ mà người dùng thường đổi qua lại, giữ cây là cách trực tiếp để giữ state cục bộ. Với trang tốn tài nguyên, có thể cần tách dữ liệu quan trọng ra khỏi widget, tạo trang khi cần hoặc dùng cơ chế điều hướng phù hợp hơn.
Nếu mỗi tab có lịch sử điều hướng riêng, bài toán còn bao gồm back stack của từng tab; ví dụ bộ đếm này chưa xây nested Navigator. Không coi một IndexedStack có hai child là lời giải đầy đủ cho mọi hệ thống navigation.
Lỗi thường gặp
- Chỉ giữ selectedIndex rồi vô tình loại trang cũ khỏi cây.
- Cho rằng const mới là thứ lưu State; State thực sự gắn với element còn tồn tại.
- Dùng cùng một biến count ở trang cha rồi tưởng hai tab có state riêng.
- Quên rà công việc đang chạy trong trang bị ẩn.
Chọn giải pháp theo vòng đời dữ liệu cần giữ. IndexedStack hữu ích khi bạn muốn các trang cùng tồn tại trong cây; dữ liệu cần sống lâu hơn cây widget vẫn phải có nơi quản lý riêng.

