Bottom Navigation (нижняя панель навигации) сейчас так популярна в мобильных приложениях, потому что наши телефоны становятся все больше, а пальцы - нет. Конструкция Material Design характеризует нижнюю навигационную панель как ряд из трех-пяти кнопок в нижней части экрана, которые используются для выбора одного из нескольких вариантов.
Flutter предоставляет виджет BottomNavigationBar, который состоит из ряда кнопок, отвечающих за определенные задачи (destination buttons), и функцию обратного вызова (callback), которой передается индекс нажатой кнопки. Все, что нужно сделать приложению, это просто настроить виджет навигационного представления, соответствующий той кнопке, которую нажал пользователь. Легко. В документации API даже есть небольшой пример, демонстрирующий это. Вы думаете, что на этом все?
Если бы все было так просто.
Destination Views с системой сохранения состояния
Destination Views - это универсальные типы навигационного представления, которые, как правило, имеют специальную систему сохранения состояния. Они могут содержать текстовые поля, элементы управления выбором, прокручиваемые элементы или другие виджеты, зависящие от состояния, которое не должно удалиться, если пользователь выберет другой destination (вид навигационного представления). Иными словами, когда пользователь возвращается к destination, его вид должен быть таким же, каким был до этого.
Чрезмерно простое устройство нижней навигации, в которой только выбранный Destination View является частью "Widget Tree" (дерева виджетов), не позволяет сохранять состояние других Destination View. Самый простой способ обеспечить сохранение состояния всех представлений - это сохранить их в дереве виджетов, показывая только выбранное представление. К счастью, для этого существует специальный виджет.
IndexedStack отображает только один из своих одиночных элементов. Все его одиночные элементы всегда являются частью дерева виджетов, поэтому их состояние никогда не удалится.
Чтобы продемонстрировать использование IndexedStack для отображения выбранного представления, я создал несколько небольших вспомогательных классов и переменных.
Класс Destination содержит несколько визуальных свойств, которые идентифицируют одно конкретное представление. Также есть список из четырех представлений приложения, allDestinations.
1. class Destination {
2. const Destination(this.title, this.icon, this.color);
3. final String title;
4. final IconData icon;
5. final MaterialColor color;
6. }
7.
8. const List<Destination> allDestinations = <Destination>[
9. Destination('Home', Icons.home, Colors.teal),
10. Destination('Business', Icons.business, Colors.cyan),
11. Destination('School', Icons.school, Colors.orange),
12. Destination('Flight', Icons.flight, Colors.blue)
13. ];
Посмотрите полную демонстрацию IndexedStack BottomNavigation.
Каждый DestinationView с системой сохранения состояния содержит TextField, чтобы продемонстрировать, что фокус клавиатуры и значение текстового поля сохраняются при переключении представлений.
Каждый DestinationView имеет свой собственный шаблон. Не будем преувеличивать: у нас здесь строительные леса. Это нормально.
1. class DestinationView extends StatefulWidget {
2. const DestinationView({ Key key, this.destination }) : super(key: key);
3.
4. final Destination destination;
5.
6. @override
7. _DestinationViewState createState() => _DestinationViewState();
8. }
9.
10. class _DestinationViewState extends State<DestinationView> {
11. TextEditingController _textController;
12.
13. @override
14. void initState() {
15. super.initState();
16. _textController = TextEditingController(
17. text: 'sample text: ${widget.destination.title}',
18. );
19. }
20.
21. @override
22. Widget build(BuildContext context) {
23. return Scaffold(
24. appBar: AppBar(
25. title: Text('${widget.destination.title} Text'),
26. backgroundColor: widget.destination.color,
27. ),
28. backgroundColor: widget.destination.color[100],
29. body: Container(
30. padding: const EdgeInsets.all(32.0),
31. alignment: Alignment.center,
32. child: TextField(controller: _textController),
33. ),
34. );
35. }
36.
37. @override
38. void dispose() {
39. _textController.dispose();
40. super.dispose();
41. }
42. }
Посмотрите полную демонстрацию IndexedStack BottomNavigation.
Наконец, раскрывается смысл всего процесса: главная страница приложения, представляющая собой строительные леса с BottomNavigationBar для представлений и IndexedStack для нескольких представлений. Как вы можете видеть, нажатие на представление (на BottomNavigationBarItem) приведет к обновлению домашней страницы с новым значением для_currentIndex. В Index Stack отображаются представления, выбранные по текущему индексу.
1. class HomePage extends StatefulWidget {
2. @override _HomePageState createState() =>
3. _HomePageState();
4. }
5.
6. class _HomePageState extends State<HomePage> with TickerProviderStateMixin<HomePage>
7. { int _currentIndex = 0;
8.
9. @override
10. Widget build(BuildContext context) {
11. return Scaffold(
12. body: SafeArea(
13. top: false,
14. child: IndexedStack(
15. index: _currentIndex,
16. children: allDestinations.map<Widget>((Destination destination) {
17. return DestinationView(destination: destination);
18. }).toList(),
19. ),
20. ),
21. bottomNavigationBar: BottomNavigationBar(
22. currentIndex: _currentIndex,
23. onTap: (int index) {
24. setState(() {
25. _currentIndex = index;
26. });
27. },
28. items: allDestinations.map((Destination destination) {
29. return BottomNavigationBarItem(
30. icon: Icon(destination.icon),
31. backgroundColor: destination.color,
32. title: Text(destination.title)
33. );
34. }).toList(),
35. ),
35. );
37. }
38. }
Посмотрите полную демонстрацию IndexedStack BottomNavigation.
Вот и все, теперь у вас есть нижняя навигация с Destination Views с системой сохранения состояния. Все довольны.
На самом деле, это еще не все. В описании Material Design четко сказано, что Destination Views должны перекрестно перетекать в представление. Демонстрация индексированного стека быстро вставляет выбранный Destination в один кадр. В описании также говорится, что если Destination View прокручивается, то нижняя навигационная панель должна по умолчанию уходить за пределы экрана при прокрутке вверх и появляться снова только при прокрутке вниз. И наконец, навигация: многие приложения требуют, чтобы каждое Destination View размещалось в собственном навигаторе, чтобы представление могло отображать сразу несколько маршрутов, а не одну интерактивную страницу.
Следующие разделы будут еще полезнее для Вас.
Навигатор для Destination View
Навигатор Flutter Navigator управляет объектами Route и оверлеев (overlays), которые отображаются сверху. Маршруты представляют собой объекты с виджетом. Виджет маршрута может быть либо полностью невидимым, либо небольшой частью пользовательского интерфейса, как диалог или меню.
К сожалению, этой статьи не хватит, чтобы полностью описать принцип работы навигаторов и маршрутов. Вот несколько фактов, которые помогут вам понять, как функционирует демонстрационная версия, в которой каждый Destination View имеет навигатор.
- Навигаторы имеют команды "push" и "pop" для управления стеком маршрутов.
- Навигаторы поддерживают простое создание маршрутов, которые идентифицируются по имени пути.
- По умолчанию навигатор отображает маршрут с именем '/'.
- Любой виджет может выполнять команды "push" и "pop", которые позволяют заносить маршруты в стек и извлекать их оттуда с помощью статических методов Navigator.push и Navigator.pop.
Следующий код - это версия Destination View, которая вместо создания текстового поля создает навигатор. Маршруты легко создаются при помощи функции обратного вызова (callback) onGenerateRoute навигатора, тем же способом создается и виджет каждого маршрута MaterialPageRoute builder.
1. class _DestinationViewState extends State<DestinationView> {
2. @override
3. Widget build(BuildContext context) {
4. return Navigator(
5. onGenerateRoute: (RouteSettings settings) {
6. return MaterialPageRoute(
7. settings: settings,
8. builder: (BuildContext context) {
9. switch(settings.name) {
10. case '/':
11. return RootPage(destination: widget.destination);
12. case '/list':
13. return ListPage(destination: widget.destination);
14. case '/text':
15. return TextPage(destination: widget.destination); 16. }
17. },
18. );
19. },
20. );
21. }
22.}
Посмотрите полную демонстрацию нижней навигации Navigator.
Виджет RootPage обрабатывает касания, занося маршрут '/list' в стек, который содержит виджет ListPage:
1. onTap: () {
2. Navigator.pushNamed(context, "/list");
3. },
Посмотрите полную демонстрацию нижней навигации Navigator.
Виджет ListPage работает аналогичным образом. Нажатие на любой элемент из списка вызовет переход по маршруту '/text', который будет содержать виджет TextPage, который работает примерно также, как и оригинальный DestinationView (они содержат одно текстовое поле).
Кроссфейдинг Destination Views
Итак, под кроссфейдингом понимается перекрестное затухание - явление, благодаря которому между двумя звуками создается плавный переход. Основная идея довольно проста: необходимо соединить Destination Views с затуханием выбранных и невыбранных элементов. Далее, как только они потухнут, мы их скроем. Для того чтобы поддерживать состояние скрытых элементов, установим Global Key (глобальный ключ).
Несколько слов о Global Keys и сохранении состояния виджета.
Каждый раз, когда дерево виджетов возобновляется, Flutter сохраняет состояние виджетов, которые находятся на том же месте и имеют тот же ключ, что и в предыдущем фрейме. Большинство виджетов создаются без ключа, поэтому эта задача упрощается: остается то же время выполнения и то же место. Иначе дело обстоит с виджетом Global Key. Его состояние и его "поддерево" меняются, если виджет с ключом перемещаются при обновление дерева.
Эта демонстрационная версия требует дополнительного использования системы сохранения состояния HomePage для каждых Destination Views: фейдер-анимация и Global Key
1. List<AnimationController> _faders;
2. List<Key> _destinationKeys;
3. int _currentIndex = 0;
4.
5. @override
6. void initState() {
7. super.initState();
8.
9. _faders = allDestinations.map<AnimationController>((Destination destination) {
10. return AnimationController(vsync: this, duration: Duration(milliseconds: 200));
11. }).toList();
12. _faders[_currentIndex].value = 1.0;
13. _destinationKeys = List<Key>.generate(allDestinations.length, (int index) => GlobalKey()).toList();
14. }
15. @override
16.
17. void dispose() {
18. for (AnimationController controller in _faders)
19. controller.dispose();
20. super.dispose();
21. }
Посмотрите полную демонстрацию Cross Fade.
В демонстрационной версии с перекрестным затуханием исходный индексированный стек был заменен обычным, где к каждому Destination Views был применен переход FadeTransition и Global Key с "поддеревом" KeyedSubtree. Каждый переход затухания управляется при помощи одного из контроллеров анимации в списке фейдеров, расположенном выше.
Фейдер для выбранного в данный момент Destination View активируется, что вызывает его затухание. Фейдеры для других видов приводятся в движение в обратном направлении, и те виды, которые полностью исчезли, скрываются. Пока вид затухает, он находится в IgnorePointer и не реагирует на жесты пользователя.
1. Stack(
2. fit: StackFit.expand,
3. children: allDestinations.map((Destination destination) {
4. final Widget view = FadeTransition(
5. opacity: _faders[destination.index].drive(CurveTween(curve: Curves.fastOutSlowIn)),
6. child: KeyedSubtree(
7. key: _destinationKeys[destination.index],
8. child: DestinationView(
9. destination: destination,
10. ),
11. ),
12. );
13. if (destination.index == _currentIndex) {
14. _faders[destination.index].forward();
15. return view;
16. } else {
17. _faders[destination.index].reverse();
18. if (_faders[destination.index].isAnimating) {
19. return IgnorePointer(child: view);
20. }
21. return Offstage(child: view);
22. }
23. }).toList(),
24. )
Посмотрите полную демонстрацию Cross Fade.
Вот и все с перекрестным затуханием. Надеюсь теперь стало понятнее, что можно легко заменить затухание другим переходом.
Как скрыть нижнюю панель навигации при прокрутке
В этой демонстрационной версии любое Destination view скрывает нижнюю навигационную панель при прокрутке содержимого вниз и возвращает обратно на экран при прокрутке наверх. Для каждого Destination View мы будем использовать NotificationListener<ScrollNotification> c целью обнаружения изменений в направлении прокрутки, а благодаря SizeTransition мы сможем анимировать нижнюю навигационную панель на экране и за его пределами.
Кроме того, эта демонстрационная версия требует добавления еще одного контроллера анимации для того, чтобы в зависимости от системы сохранения состояния Destination View можно было показать или скрыть нижнюю панель навигации.
1. // ...
2. AnimationController _hide;
3.
4.@override
5. void initState() {
6. super.initState();
7. // ...
8. _hide = AnimationController(vsync: this, duration: kThemeAnimationDuration);
9. }
10.
11. @override
12. void dispose() {
13. // ...
14. _hide.dispose();
15. super.dispose();
16. }
Посмотрите полную демонстрацию нижней навигационной панели.
Когда направление прокрутки меняется, уведомление функции обратного вызова о прокрутке запускает или останавливает контроллер анимации _hide , чтобы скрыть или показать нижнюю навигационную панель. Мы используем разные уведомления, чтобы отличить самую верхнюю прокручиваемую панель от остальных.
После изменения размера сохраняется выравнивание отдельной навигационной панели по верху при ее увеличении и уменьшении, задавая значение axisAlignment равное —1.
1. bool _handleScrollNotification(ScrollNotification notification) {
2. if (notification.depth == 0) {
3. if (notification is UserScrollNotification) {
4. final UserScrollNotification userScroll = notification;
5. switch (userScroll.direction) {
6. case ScrollDirection.forward:
7. _hide.forward();
8. break;
9. case ScrollDirection.reverse:
10. _hide.reverse();
11. break;
12. case ScrollDirection.idle:
13. break;
14. }
15. }
16. }
17. return false;
18. }
19.
20. Widget build(BuildContext context) {
21. return NotificationListener<ScrollNotification>(
22. onNotification: _handleScrollNotification,
23. child: Scaffold(
24. // ...
25. bottomNavigationBar:
26. sizeTransition( sizeFactor: _hide,
27. axisAlignment: -1.0,
28. child: BottomNavigationBar(
29. // ...
30. ),
31. ),
32. ),
33. );
34.}
Посмотрите полную демонстрацию нижней навигационной панели.
Вот, что касается добавления поддержки для изменения видимости нижней навигационной панели при прокрутке выбранного Destination. Если вы прочитаете полный текст демонстрационной версии, то увидите еще одну дополнительную настройку. Каждый раз, когда навигатор Destination Views выполняет команды "push" и "pop" для нового маршрута, появляется нижняя панель навигации. Для этого каждому навигатору Destination Views присваивается NavigatorObserver, который обеспечивает отображение нижней навигационной панели.
Краткое содержание
Эта статья объясняет, как создать приложение с нижней навигационной панелью при помощи Flutter. Эффект похож на просмотр веб-страниц с вкладками снизу: каждая вкладка (или "destination") выбирает представление, которое предоставляет навигационный стек.
В дополнение к виджету BottomNavigationBar в демонстрационной версии используются виджеты Stack, Navigator, IgnorePointer и Offstage для управления Destination Views, виджеты SizeTransition и FadeTransition для анимации, а также виджеты NotificationListener и NavigatorObserver для отслеживания изменений состояния.
Эта статья основана на докладе, который я сделал на NYC Flutter Meetup 22 мая 2019 года.
Многие примеры работают для Flutter 1.7.1 или новее.
Переведено на русский язык с сайта: https://medium.com/flutter/getting-to-the-bottom-of-navigation-in-flutter-b3e440b9386