Привет! Меня зовут Павел Шалимов, я flutter-разработчик в InstaDev. Делаем мобильные приложения, которые помогают бизнесу расти.

В этой статье подробно рассмотрим навигацию во Flutter, отличия и специфичные моменты под разные платформы, а также обсудим возможности Router.

Навигация в Flutter предоставляет разработчикам возможность создавать сложные и гибкие пользовательские интерфейсы, а также легко адаптировать приложения под разные платформы и устройства.

Основной идеей навигации в Flutter является стековая организация экранов, где каждый экран представлен своим виджетом. Переходы между экранами осуществляются путем добавления и удаления маршрутов из стека.

Основные компоненты навигации

  • Navigator: центральный компонент для управления навигацией в приложении. Он поддерживает стек маршрутов, где каждый маршрут представляет собой экран или виджет. Navigator позволяет добавлять, удалять и заменять маршруты, а также управлять анимациями перехода между ними.

  • Route: класс, который представляет собой один экран приложения. Реализация Route может быть стандартной (например, MaterialPageRoute) или пользовательской, в зависимости от требований приложения.

  • MaterialPageRoute: реализация Route, специфичная для мобильных приложений, основанная на материальном дизайне. Она обеспечивает стандартные анимации перехода между экранами и интегрируется с другими элементами Material Design.

  • PageRoute: базовый класс для пользовательских маршрутов. Разработчики могут создавать собственные реализации маршрутов для достижения специфического поведения навигации.

При написании приложения с навигацией, можно придерживаться одного из двух вариантов реализации навигации: императивный способ (Navigator Api) или декларативный (Pages Api, он же Navigator 2.0, он же Router, далее мы будем называть его Router).

Обычно разработчики используют Navigator Api из‑за его простоты и удобства, но если необходима бóльшая гибкость и глубокая поддержка веб приложений — то Router будет лучшим выбором.

Navigator API базируется на стеке маршрутов, где каждый маршрут представляет собой экран приложения. Navigator предоставляет методы для добавления, удаления и замены маршрутов в стеке, а также управляет анимациями перехода между экранами. При использовании Navigator API разработчики определяют маршруты напрямую внутри MaterialApp или Navigator‑а, и навигация осуществляется путем вызова методов Navigator.push(), Navigator.pop() и других.

Router — новый способ навигации, представленный командой Flutter относительно недавно, он предоставляет разработчикам свободу самим реализовывать навигацию. Router основан на классах RouteInformationParser, RouterDelegate и RouteInformationProvider, которые позволяют приложению работать с маршрутами на более абстрактном уровне и интегрироваться с механизмами маршрутизации URL в веб-приложениях.

Далее я приведу пример простого приложения с реализацией навигации с обоими api.

Пример приложения, использующего Navigator Api
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Navigation Example',
      // Начальный маршрут
      initialRoute: '/',
      // Вместо initialRoute можно указать home и тогда этот виджет становится '/'
      home: HomeScreen(),
      // Маршруты приложения
      routes: {
        // Главный экран
        '/': (context) => const HomeScreen(),
        // Экран подробностей
        '/details': (context) => const DetailsScreen(),
      },
    );
  }
}

// Главный экран
class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Home'),
      ),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            // Переход на экран с подробностями
            // Переход по имени роута (имя обязательно должно быть в routes в MaterialApp)
            Navigator.pushNamed(context, '/details');
            // Вместо перехода по имени можно переходить к конкретному экрану, 
            // используя конструктор MaterialPageRoute, который создаст новый модальный роут
            Navigator.push(context, MaterialPageRoute(builder: (context) => DetailsScreen())),
          },
          child: const Text('Go to Details'),
        ),
      ),
    );
  }
}

// Экран с подробностями
class DetailsScreen extends StatelessWidget {
  const DetailsScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Details'),
      ),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            // Возврат на предыдущий экран
            Navigator.pop(context);
          },
          child: const Text('Go Back'),
        ),
      ),
    );
  }
}

Здесь мы в виджете MaterialApp в поле routes определяем роуты приложения, по которым впоследствии сможем переходить, используя конструкции Navigator.pushNamed(context, ‘/something’), Navigator.pop(context), Navigator.pushReplacementNamed(context, ‘/something’) и другие.

Пример приложения, использующего Router
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'Pages API Example',
      routerDelegate: AppRouterDelegate(),
      routeInformationParser: AppRouteInformationParser(),
    );
  }
}

// Класс для парсинга информации о маршруте
class AppRouteInformationParser extends RouteInformationParser<MyRoutePath> {
  @override
  Future<MyRoutePath> parseRouteInformation(
      RouteInformation routeInformation) async {
    final uri = routeInformation.uri;

    // Определение маршрута на основе URL
    if (uri.pathSegments.isEmpty) {
      return MyRoutePath.home();
    } else if (uri.pathSegments.length == 1 &&
        uri.pathSegments[0] == 'details') {
      return MyRoutePath.details();
    } else {
      // Возвращаем маршрут по умолчанию
      return MyRoutePath.unknown();
    }
  }

  @override
  RouteInformation restoreRouteInformation(MyRoutePath configuration) {
    if (configuration.isUnknown) {
      return RouteInformation(uri: Uri.parse('/404'));
    }
    return RouteInformation(uri: Uri.parse(configuration.location));
  }
}

// Класс для определения маршрутов
class AppRouterDelegate extends RouterDelegate<MyRoutePath>
    with ChangeNotifier, PopNavigatorRouterDelegateMixin<MyRoutePath> {
  final GlobalKey<NavigatorState> _navigatorKey;

  AppRouterDelegate() : _navigatorKey = GlobalKey<NavigatorState>();

  @override
  GlobalKey<NavigatorState> get navigatorKey => _navigatorKey;

  MyRoutePath _routePath = MyRoutePath.home();

  @override
  MyRoutePath get currentConfiguration => _routePath;

  @override
  Widget build(BuildContext context) {
    return Navigator(
      key: navigatorKey,
      pages: [
        const MaterialPage(child: HomeScreen(), key: ValueKey('Home')),
        if (_routePath.isDetails)
          const MaterialPage(child: DetailsScreen(), key: ValueKey('Details')),
      ],
      onPopPage: (route, result) {
        if (!route.didPop(result)) {
          return false;
        }

        _routePath = MyRoutePath.home();
        notifyListeners();
        return true;
      },
    );
  }

  @override
  Future<void> setNewRoutePath(MyRoutePath path) async {
    _routePath = path;
  }
}

// Класс для определения структуры маршрутов
class MyRoutePath {
  final String location;

  MyRoutePath.home() : location = '/';

  MyRoutePath.details() : location = '/details';

  MyRoutePath.unknown() : location = '/404';

  bool get isHome => location == '/';

  bool get isDetails => location == '/details';

  bool get isUnknown => location == '/404';
}

// Главный экран
class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Home'),
      ),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            // Здесь нам необходимо изменит стейт приложения, 
           
          },
          child: const Text('Go to Details'),
        ),
      ),
    );
  }
}

// Экран с подробностями
class DetailsScreen extends StatelessWidget {
  const DetailsScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Details'),
      ),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            // Возврат на предыдущий экран
            
          },
          child: const Text('Go Back'),
        ),
      ),
    );
  }
}

Здесь нам необходимо создать 3 класса, благодаря которым и будут осуществляться навигация:

  1. RouteInformationParser: класс, который преобразует информацию о маршруте (например, URL) в конфигурацию состояния навигации.

  2. RouterDelegate: класс, который управляет состоянием навигации приложения. Он отслеживает текущий маршрут и уведомляет Router о необходимости перестроения навигации. Когда возникает необходимость изменить роут приложения, информация о маршруте парсится в RouteInformationParser в тип конфигурации навигации, RouterDelegate получает её и строит новый виджет (экран). Здесь мы можем реализовывать настолько сложную и кастомную навигацию, какую только захотим.

  3. RouteInformationProvider: этот класс предоставляет информацию о текущем маршруте приложения. Он отвечает за передачу информации о маршруте и уведомляет слушателей (виджет Router), когда доступна новая информация о маршруте.

Как вы, наверное, заметили Router гораздо более многословен и труден в освоении, как и сложен в поддержке, когда в приложении количество экранов переваливает за 10-15 штук, тогда конфигурация навигации становится чересчур громоздкой и поддерживать декларативность становится затруднительно.

Поэтому разработчики используют либо проверенный и простой (но, к сожалению, слабо конфигурируемый) Navigator, либо какую-нибудь библиотеку, основанную на Router.

Библиотеки и решения

Библиотеки обычно объединяют всё лучшее из обоих миров: простоту и доступность Navigator, и гибкость и, пусть и не полную, но очень ощутимую кастомизируемость Router. Здесь я расскажу про наиболее популярные и интересные библиотеки.

go_router одна из наиболее популярных библиотек для навигации, она интересна тем, что для всех переходов по страницам используется код context.go('/user/765/post/1'), которая сама заменит текущий стек навигации на стек, ведущий к этому роуту. И для работы диплинков нет необходимости что-то конфигурировать отдельно, кроме базовой конфигурации, необходимой для любого приложения, использующего диплинки.

Начальная конфигурация go_router крайне простая: необходимо создать экземпляр объекта GoRouter, в котором указаны все роуты приложения:

final _router = GoRouter(
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => HomeScreen(),
      routes: [
        GoRoute(
          path: 'profile',
          builder: (context, state) => ProfileScreen(),
        ),
      ],
    ),
  ],
);

И добавить в виджет App routerConfig:

class App extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routerConfig: _router,
    );
  }
}

auto_route ‑ ещё одна популярная библиотека, которая предоставляет довольно много полезных «фишек» для разработчиков. Тут и диплинки из коробки, и кодогенерация для кучи бойлерплейт‑кода, и guarded роуты.

Начальная конфигурация ничем не отличается от таковой в go_router, кроме того, что нужно пометить аннотацией @RoutePage() нужные страницы приложения:

part 'router.gr.dart';

@AutoRouterConfig()
class AppRouter extends _$AppRouter {
  @override
  final List<AutoRoute> routes = [
    AutoRoute(
      initial: true,
      page: LaunchRoute.page,
      path: '/launch',
    ),
    AutoRoute(
	    initial: true,
      page: AuthRoute.page,
      path: '/auth',
    ),
    AutoRoute(
      page: HomeRoute.page,
      path: 'home',
      guards: [AuthGuard],
      children: [
	      AutoRoute(
		      page: ProfileRoute.page,
		      path: 'profile',
	      )
      ],
    ),
  ];
}
class App extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routerConfig: AppRouter().config(),
    );
  }
}

А ‘защита’ роутов с помощью auto_route становится довольно тривиальной задачей:

// в конфигурации выше я уже добавил AuthGuard для одного из роутов
class AuthGuard extends AutoRouteGuard {
  @override
  void onNavigation(NavigationResolver resolver, StackRouter router) {
	  // Здесь будет логика проверки авторизации
    if (!isAuthenticated) {
      router.push(AuthRoute());
    } else {
      resolver.next(true);
    }
  }
}

beamer — библиотека, ключевой концепт которой заключается в упрощении работы с Router. Она предоставляет опыт, похожий на работу с Router, но вместо того, чтобы ответственность за генерацию всего стека роутов лежала на одн��м RouterDelegate, Beamer предлагает создать группу классов BeamLocation, которые будут ответственны за свою часть приложения.

Здесь для начальной конфигурации нужно будет вместо routerConfig явно указать routeInformationParser и routerDelegate, которые предоставляет библиотека:

class App extends StatelessWidget {
  final routerDelegate = BeamerDelegate(
    locationBuilder: RoutesLocationBuilder(
      routes: {
        // Указываем тут роуты и соответсвующие им экраны
        '/': (context, state, data) => HomeScreen(),
        '/films': (context, state, data) => FilmsScreen(),
        // Можно передавать данные в параметрах пути
        '/films/:filmId': (context, state, data) {
          // Берём из state парметры пути
          final filmId = state.pathParameters['filmId']!;
          // Используем BeamPage для дополнительных возможностей
          return BeamPage(
            key: ValueKey('film-$filmId'),
            popToNamed: '/',
            child: FilmDetailsScreen(filmId),
          );
        }
      },
    ),
  );

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routeInformationParser: BeamerParser(),
      routerDelegate: routerDelegate,
    );
  }
}

Или же лучше воспользоваться ключевым концептом всего пакета и создать BeamLocation для каждой части приложения:

class FilmsLocation extends BeamLocation<BeamState> {
@override List<Pattern>get pathPatterns => ['/films/:filmsId'];

@override List<BeamPage> buildPages(BuildContext context, BeamState state) {
	final pages = [
		const BeamPage(
			key: ValueKey('home'),
		  child: HomeScreen(),
		),
		if (state.uri.pathSegments.contains('films'))
			const BeamPage(
			  key: ValueKey('films'),
			  child: FilmsScreen(),
	    ),
	];
	final String? filmsIdParameter = state.pathParameters['filmsId'];
	if (filmsIdParameter !=null) {
		final filmsId = int.tryParse(filmsIdParameter);
	    pages.add(
	      BeamPage(
	        key: ValueKey('films-$filmsIdParameter'),
	        child: FilmsDetailsScreen(filmsId: filmsId),
	      ),
	    );
	  }
	return pages;
  }
}

Тогда RouterDelegate будет выглядеть так:

final routerDelegate = BeamerDelegate(
  locationBuilder: BeamerLocationBuilder(
    beamLocations: [
      HomeLocation(),
      FilmsLocation(),
      //...etc
    ],
  ),
);

И это ещё далеко не все библиотеки для навигации.

Существует огромное множество совершенно разных плагинов, с различными дополнительными возможностями и способами взаимодействия с ними. Ещё рекомендую обратить внимание на Fluro, Octopus, Qlevar_router, мне они показались интересными, но, к сожалению, в статью уже не помещаются.

Резюме

Навигация в Flutter не самая тривиальная вещь и подступиться к ней можно по-разному. 

Можно выбрать простой путь и использовать Navigator, это по-прежнему не самый плохой выбор, он просто выполняет свою работу и не требует большого количества кода, не ставит высокий порог для входа, но и не делает ничего дополнительного; а можно выбрать любую доступную библиотеку, они все предоставляют бóльшую функциональность и гибкость в обмен на изучение и дополнительную зависимость. 

Я бы только не рекомендовал использовать Router в его текущей версии, так как библиотеки выполняют его работу лучше.