Chương 05 · Navigation

Navigation

App thật luôn nhiều screen. Navigator 1.0 ổn cho project nhỏ nhưng vỡ ở deep link và Web URL. go_router là chuẩn industry hiện tại (Flutter team maintain). Chương này cover cả 2 + khi nào dùng.

Độ dài: ~1170 dòng Bài tập: 5 Quiz: 8 Prerequisites: Ch 2, 3, 4
🎯 Mục tiêu chương
  • Phân biệt Navigator 1.0 (imperative) vs Navigator 2.0 (declarative).
  • Sử dụng Navigator.push/pop/pushNamed/pushReplacement/pushAndRemoveUntil.
  • Pass data giữa screen qua constructor (push) và Navigator.pop(result).
  • Named routes setup.
  • go_router package — recommend cho production app.
  • Deep linking + URL strategies cho Web.
  • Bottom navigation, drawer, tab bar.
  • Modal: dialog, bottom sheet, snackbar.
Navigator 1.0Navigator 2.0
StyleImperative — push/pop stackDeclarative — UI = f(URL state)
Learning curveThấpCao (raw); medium qua wrapper
Web URL syncKhông
Deep linkCustom codeBuilt-in
RecommendProject nhỏ, prototypeProduction app, đặc biệt có Web
Navigator.push(
  context,
  MaterialPageRoute(
    builder: (ctx) => DetailsPage(item: myItem),
  ),
);

// Bên trong DetailsPage
Navigator.pop(context);              // quay lại
Navigator.pop(context, 'result');     // pop kèm value

Caller nhận result:

final result = await Navigator.push<String>(
  context,
  MaterialPageRoute(builder: (ctx) => const EditPage()),
);
if (result != null) {
  print('Got: $result');
}
// Replace top route — pattern sau login
Navigator.pushReplacement(
  context,
  MaterialPageRoute(builder: (ctx) => const HomePage()),
);

// Pop till predicate match, push mới — pattern logout
Navigator.pushAndRemoveUntil(
  context,
  MaterialPageRoute(builder: (ctx) => const LoginPage()),
  (route) => false,    // pop tất cả
);

// Pop tới named route
Navigator.popUntil(context, ModalRoute.withName('/home'));

4. Named routes Navigator 1.0

MaterialApp(
  initialRoute: '/',
  routes: {
    '/': (ctx) => const HomePage(),
    '/login': (ctx) => const LoginPage(),
    '/profile': (ctx) => const ProfilePage(),
  },
)

// Navigate
Navigator.pushNamed(context, '/profile');

// Pass data qua arguments
Navigator.pushNamed(context, '/details', arguments: myItem);

// Đọc args trong page
final args = ModalRoute.of(context)!.settings.arguments as Item;

onGenerateRoute — dynamic route

MaterialApp(
  onGenerateRoute: (settings) {
    final uri = Uri.parse(settings.name ?? '/');
    if (uri.pathSegments.length == 2 && uri.pathSegments.first == 'user') {
      final id = int.parse(uri.pathSegments[1]);
      return MaterialPageRoute(builder: (_) => UserPage(id: id));
    }
    return null;   // fallback xuống onUnknownRoute
  },
  onUnknownRoute: (settings) => MaterialPageRoute(builder: (_) => const NotFoundPage()),
)
  • Không tự sync URL Web — URL bar không update khi navigate.
  • Deep link cần custom logic (handle FlutterDeepLinkApi).
  • Tab navigation phức tạp — mỗi tab cần Navigator riêng.
  • State recovery (app restore từ background) chỉ work cơ bản.

6. go_router — chuẩn industry

flutter pub add go_router
import 'package:go_router/go_router.dart';

final _router = GoRouter(
  initialLocation: '/',
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => const HomePage(),
    ),
    GoRoute(
      path: '/user/:id',                    // path param
      builder: (context, state) {
        final id = state.pathParameters['id']!;
        return UserPage(id: int.parse(id));
      },
    ),
    GoRoute(
      path: '/search',
      builder: (context, state) {
        final q = state.uri.queryParameters['q'] ?? '';
        return SearchPage(query: q);
      },
    ),
  ],
  errorBuilder: (context, state) => NotFoundPage(error: state.error),
);

MaterialApp.router(
  routerConfig: _router,
  theme: ThemeData(/*...*/),
)

Navigate:

context.go('/user/42');            // replace stack
context.push('/user/42');          // push lên stack
context.pop();
context.go('/search?q=flutter');

// Type-safe param (cần build_runner — package go_router_builder)
UserRoute(id: 42).go(context);

Redirect — auth flow

GoRouter(
  routes: [/*...*/],
  redirect: (context, state) {
    final loggedIn = AuthService.instance.isLoggedIn;
    final goingToLogin = state.matchedLocation == '/login';

    if (!loggedIn && !goingToLogin) return '/login';
    if (loggedIn && goingToLogin) return '/';
    return null;     // không redirect
  },
)

ShellRoute — persistent shell (vd bottom nav)

GoRouter(
  routes: [
    ShellRoute(
      builder: (context, state, child) => Scaffold(
        body: child,
        bottomNavigationBar: MyBottomNav(currentIndex: _indexFor(state.uri.path)),
      ),
      routes: [
        GoRoute(path: '/home', builder: (_, __) => const HomeTab()),
        GoRoute(path: '/profile', builder: (_, __) => const ProfileTab()),
        GoRoute(path: '/settings', builder: (_, __) => const SettingsTab()),
      ],
    ),
    GoRoute(path: '/login', builder: (_, __) => const LoginPage()),  // ngoài shell
  ],
)

7. Web URL strategy

import 'package:flutter_web_plugins/url_strategy.dart';

void main() {
  usePathUrlStrategy();    // bỏ # khỏi URL Web
  runApp(const MyApp());
}

// URL Web sẽ là /user/42 thay vì /#/user/42
// Yêu cầu server redirect 404 về index.html

8. Bottom navigation

class HomePage extends StatefulWidget {
  const HomePage({super.key});
  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  int _index = 0;

  final _pages = const [
    HomeTab(),
    SearchTab(),
    ProfileTab(),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: IndexedStack(           // giữ state mỗi tab
        index: _index,
        children: _pages,
      ),
      bottomNavigationBar: NavigationBar(
        selectedIndex: _index,
        onDestinationSelected: (i) => setState(() => _index = i),
        destinations: const [
          NavigationDestination(icon: Icon(Icons.home), label: 'Home'),
          NavigationDestination(icon: Icon(Icons.search), label: 'Search'),
          NavigationDestination(icon: Icon(Icons.person), label: 'Profile'),
        ],
      ),
    );
  }
}
💡 IndexedStack vs swap widget

IndexedStack giữ tất cả tab trong tree, hide non-active. Pros: giữ state, scroll position. Cons: tốn memory hơn. Swap widget (return _pages[_index]): tab inactive bị dispose, state reset khi switch lại.

9. Drawer & EndDrawer

Scaffold(
  appBar: AppBar(title: const Text('My App')),
  drawer: Drawer(
    child: ListView(
      children: [
        const DrawerHeader(
          decoration: BoxDecoration(color: Colors.blue),
          child: Text('Menu', style: TextStyle(color: Colors.white, fontSize: 24)),
        ),
        ListTile(
          leading: const Icon(Icons.home),
          title: const Text('Home'),
          onTap: () {
            Navigator.pop(context);   // đóng drawer
            context.go('/');
          },
        ),
      ],
    ),
  ),
  body: const Center(child: Text('Body')),
)

10. TabBar + TabBarView

DefaultTabController(
  length: 3,
  child: Scaffold(
    appBar: AppBar(
      title: const Text('Tabs'),
      bottom: const TabBar(
        tabs: [
          Tab(icon: Icon(Icons.cloud)),
          Tab(icon: Icon(Icons.beach_access)),
          Tab(icon: Icon(Icons.brightness_5)),
        ],
      ),
    ),
    body: const TabBarView(
      children: [CloudTab(), BeachTab(), SunTab()],
    ),
  ),
)
final result = await showDialog<bool>(
  context: context,
  builder: (ctx) => AlertDialog(
    title: const Text('Xác nhận'),
    content: const Text('Xóa item này?'),
    actions: [
      TextButton(
        onPressed: () => Navigator.pop(ctx, false),
        child: const Text('Hủy'),
      ),
      FilledButton(
        onPressed: () => Navigator.pop(ctx, true),
        child: const Text('Xóa'),
      ),
    ],
  ),
);

if (result == true) {
  _delete();
}
showModalBottomSheet(
  context: context,
  isScrollControlled: true,
  shape: const RoundedRectangleBorder(
    borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
  ),
  builder: (ctx) => Padding(
    padding: EdgeInsets.only(bottom: MediaQuery.of(ctx).viewInsets.bottom),
    child: Container(
      padding: const EdgeInsets.all(16),
      child: const Text('Bottom sheet content'),
    ),
  ),
);

13. SnackBar

ScaffoldMessenger.of(context).showSnackBar(
  const SnackBar(
    content: Text('Saved successfully'),
    duration: Duration(seconds: 3),
  ),
);

// Với action
ScaffoldMessenger.of(context).showSnackBar(
  SnackBar(
    content: const Text('Item deleted'),
    action: SnackBarAction(label: 'Undo', onPressed: _undo),
  ),
);
⚠️ ScaffoldMessenger chứ không Scaffold.of

Pre-Flutter 1.22 dùng Scaffold.of(context).showSnackBar(...) — context phải descendant của Scaffold. Modern dùng ScaffoldMessenger.of(context) — ancestor là MaterialApp, không cần Scaffold parent ngay.

14. Bài tập

3-screen app với Navigator 1.0 named routes

Build app 3 screen: Home → List → Detail. Dùng named routes. Pass item từ List → Detail qua arguments.

💡 Gợi ý đáp án
MaterialApp(
  initialRoute: '/',
  routes: {
    '/': (_) => const HomePage(),
    '/list': (_) => const ListPage(),
    '/detail': (ctx) {
      final item = ModalRoute.of(ctx)!.settings.arguments as String;
      return DetailPage(item: item);
    },
  },
)

// List → Detail
Navigator.pushNamed(context, '/detail', arguments: 'item-1');

Migrate sang go_router

Migrate bài 1 sang go_router. Path: /, /list, /list/:id. URL Web hiển thị đúng /list/42 khi xem item id=42.

💡 Gợi ý đáp án
final router = GoRouter(routes: [
  GoRoute(path: '/', builder: (_, __) => const HomePage()),
  GoRoute(path: '/list', builder: (_, __) => const ListPage()),
  GoRoute(
    path: '/list/:id',
    builder: (_, state) => DetailPage(id: state.pathParameters['id']!),
  ),
]);

// Navigate
context.push('/list/42');

ShellRoute bottom nav

Add ShellRoute với bottom nav 3 tab (Home, Profile, Settings). Mỗi tab có nested screen — switch tab giữ history.

💡 Gợi ý đáp án

Tham khảo section 6 (ShellRoute). Để giữ state mỗi tab, dùng StatefulShellRoute thay ShellRoute thường.

Auth redirect flow

Implement: chưa login → redirect /login. Login xong → /home. Logout → clear, redirect /login.

💡 Gợi ý đáp án

Tham khảo section 6 (redirect). Auth state cần ValueNotifier để go_router rebuild khi auth đổi.

Modal bottom sheet form

Show modal bottom sheet với form input (1 TextField + Submit button). Return data via pop. Caller hiển thị data trong snackbar.

💡 Gợi ý đáp án
final result = await showModalBottomSheet<String>(
  context: context,
  isScrollControlled: true,
  builder: (ctx) {
    final ctrl = TextEditingController();
    return Padding(
      padding: EdgeInsets.only(bottom: MediaQuery.of(ctx).viewInsets.bottom),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Padding(
            padding: const EdgeInsets.all(16),
            child: TextField(controller: ctrl),
          ),
          ElevatedButton(
            onPressed: () => Navigator.pop(ctx, ctrl.text),
            child: const Text('Submit'),
          ),
        ],
      ),
    );
  },
);

if (result != null && context.mounted) {
  ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(result)));
}

15. Quiz

Q1

Navigator.push return type?

Xem đáp án

Đáp án: Future<T?> — complete khi route pop. T là type result Navigator.pop(result).

Q2

Pass data qua named route — dùng property nào?

Xem đáp án

Đáp án: Navigator.pushNamed(context, '/x', arguments: data). Đọc: ModalRoute.of(context)!.settings.arguments.

Q3

pushReplacement khác pushAndRemoveUntil?

Xem đáp án

Đáp án: pushReplacement chỉ replace top. pushAndRemoveUntil pop nhiều route cho đến predicate match, sau đó push mới.

Q4

go_router path /user/:id — đọc id thế nào?

Xem đáp án

Đáp án: state.pathParameters['id'] trong builder. Query param: state.uri.queryParameters['q'].

Q5

usePathUrlStrategy() bỏ gì?

Xem đáp án

Đáp án: Bỏ #/ (hash routing) khỏi URL Web. Yêu cầu server config redirect 404 về index.html.

Q6

ShellRoute vs nested GoRoute?

Xem đáp án

Đáp án: ShellRoute = persistent shell widget (vd bottom nav) bao quanh inner route. Nested GoRoute = full screen swap không có persistent shell.

Q7

ScaffoldMessenger vs Scaffold.of cũ?

Xem đáp án

Đáp án: ScaffoldMessenger là pattern mới (Flutter 1.22+). Không phải descendant Scaffold ngay; snackbar persist qua navigation.

Q8

Modal bottom sheet scrollable — property nào?

Xem đáp án

Đáp án: isScrollControlled: true + dùng DraggableScrollableSheet cho behavior drag.

16. Tổng kết

  • ✅ Navigator 1.0: imperative push/pop stack. Đủ cho project nhỏ.
  • ✅ Named routes + onGenerateRoute cho dynamic.
  • ✅ Navigator 1.0 hạn chế: URL Web, deep link, tab navigation.
  • go_router = chuẩn industry. Declarative, URL-first.
  • ✅ Redirect — auth flow.
  • ✅ ShellRoute — persistent shell (bottom nav).
  • ✅ Web URL strategy: usePathUrlStrategy.
  • ✅ Bottom nav với IndexedStack để giữ state.
  • ✅ Drawer, TabBar.
  • ✅ Modal: dialog, bottom sheet, snackbar (ScaffoldMessenger).

17. Kết nối

  • Chương 6+: state management consistent across navigation.
  • Chương 11 — Local Storage: persistent auth state.
  • Chương 14 — Testing: test navigation flow.