Поздравляю, по крайней мере, всех живущих в Сибири с наступлением лета!)))
Сегодня довольно непростая тема - навигация.
Мы рассмотрим как устроена навигация в Flutter, что вообще нужно чтобы перейти с одного экраны на другой и конечно же не забудем о передачи аргументов между экранами.
И напоследок весьма распространенный use case: создание BottomNavigationBar.
'Ну что ж не будем терять ни минуты, начинаем!
Наш план
Часть 1 - введение в разработку, первое приложение, понятие состояния;
Часть 2 - файл pubspec.yaml и использование flutter в командной строке;
Часть 3 (текущая статья) - BottomNavigationBar и Navigator;
Часть 4 - MVC. Мы будем использовать именно этот паттерн, как один из самый простых;
Часть 5 - http пакет. Создание Repository класса, первые запросы, вывод списка постов;
Часть 6 - Работа с картинками, вывод картинок в виде сетки, получение картинок из сети, добавление своих в приложение;
Часть 7 - Создание своей темы, добавление кастомных шрифтов и анимации;
Часть 8 - Немного о тестировании;
Navigator и стэк навигации
Flutter довольно прост в плане навигации, здесь нет фрагментов и Activity.
Все довольно просто: каждая страница это виджет, который называется Route.
Навигация осуществляется через объект Navigator:
// Navigator.of(context) получает состояние Navigator
// виджета: NavigatorState, которое имеет push и pop методы
// push помещает новую страницу на вершину стека Navigator
// pop наоборот удаляет текущую страницу из вершины стэка
// MaterialPageRoute в основном используется для создания
// анимации между экранами
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => OurPage())
);
Рассмотрим стэк Navigator'a на конкретном примере.
У нас есть два экрана: список книг и информация о книге.
Первый экран, который появится при запуске приложения - это список книг:
Затем мы переходим на страницу с информацией об одной из книг:
В этот момент наша новая страница находится на вершине стэка и поэтому мы не имеем доступа к списку книг.
Далее мы нажимаем кнопку Back или Up (стрелка в левом верхнем углу) и снова возвращаемся к первоначальному состоянию:
В первом случае нужно использовать push(route)
, во втором pop()
метод.
Переходим непосредственно к практике!
Создание навигации между двумя экранами
Сделаем небольшой список персонажей из сериала My Little Pony с переходом на страницу описания каждого персонажа.
Для начала создадим новую страницу в папке pages:
Затем напишем немного кода:
import 'package:flutter/material.dart';
// класс пони, который будет хранить имя и описание, а также id
class Pony {
final int id;
final String name;
final String desc;
Pony(this.id, this.name, this.desc);
}
// создаем список пони
// final указывает на то, что мы больше
// никогда не сможем присвоить имени ponies
// другой список поняшек
final List<Pony> ponies = [
Pony(
0,
"Twillight Sparkle",
"Twilight Sparkle is the central main character of My Little Pony Friendship is Magic. She is a female unicorn pony who transforms into an Alicorn and becomes a princess in Magical Mystery Cure"
),
Pony(
1,
"Starlight Glimmer",
"Starlight Glimmer is a female unicorn pony and recurring character, initially an antagonist but later a protagonist, in the series. She first possibly appears in My Little Pony: Friends Forever Issue and first explicitly appears in the season five premiere."
),
Pony(
2,
"Applejack",
"Applejack is a female Earth pony and one of the main characters of My Little Pony Friendship is Magic. She lives and works at Sweet Apple Acres with her grandmother Granny Smith, her older brother Big McIntosh, her younger sister Apple Bloom, and her dog Winona. She represents the element of honesty."
),
Pony(
3,
"Pinkie Pie",
"Pinkie Pie, full name Pinkamena Diane Pie,[note 2] is a female Earth pony and one of the main characters of My Little Pony Friendship is Magic. She is an energetic and sociable baker at Sugarcube Corner, where she lives on the second floor with her toothless pet alligator Gummy, and she represents the element of laughter."
),
Pony(
4,
"Fluttershy",
"Fluttershy is a female Pegasus pony and one of the main characters of My Little Pony Friendship is Magic. She lives in a small cottage near the Everfree Forest and takes care of animals, the most prominent of her charges being Angel the bunny. She represents the element of kindness."
),
];
// PonyListPage не будет иметь состояния,
// т.к. этот пример создан только для демонстрации
// навигации в действии
class PonyListPage extends StatelessWidget {
// build как мы уже отметили, строит
// иерархию наших любимых виджетов
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("Pony List Page")),
// зададим небольшие отступы для списка
body: Padding(
// объект EdgeInsets хранит четыре важные double переменные:
// left, top, right, bottom - отступ слева, сверху, справа и снизу
// EdgeInsets.all(10) - задает одинаковый отступ со всех сторон
// EdgeInsets.only(left: 10, right: 15) - задает отступ для
// определенной стороны или сторон
// EdgeInsets.symmetric - позволяет указать одинаковые
// отступы по горизонтали (left и right) и по вертикали (top и bottom)
padding: EdgeInsets.symmetric(vertical: 15, horizontal: 10),
// создаем наш список
child: ListView(
// map принимает другую функцию, которая
// будет выполняться над каждым элементом
// списка и возвращать новый элемент (виджет Material).
// Результатом map является новый список
// с новыми элементами, в данном случае
// это Material виджеты
children: ponies.map<Widget>((pony) {
// Material используется для того,
// чтобы указать цвет элементу списка
// и применить ripple эффект при нажатии на него
return Material(
color: Colors.pinkAccent,
// InkWell позволяет отслеживать
// различные события, например: нажатие
child: InkWell(
// splashColor - цвет ripple эффекта
splashColor: Colors.pink,
// нажатие на элемент списка
onTap: () {
// добавим немного позже
},
// далее указываем в качестве
// элемента Container с вложенным Text
// Container позволяет указать внутренние (padding)
// и внешние отступы (margin),
// а также тень, закругление углов,
// цвет и размеры вложенного виджета
child: Container(
padding: EdgeInsets.all(15),
child: Text(
pony.name,
style: Theme.of(context).textTheme.headline4.copyWith(color: Colors.white)
)
),
),
);
// map возвращает Iterable объект, который необходимо
// преобразовать в список с помощью toList() функции
}).toList(),
)
),
);
}
}
Обратите внимание, что в Dart можно определять переменные вне классов, как в нашем случае со списком.
Теперь переходим к созданию PonyDetailPage:
import 'package:flutter/material.dart';
import 'pony_list_page.dart';
// также, как и PonyListPage наша страница
// не будет иметь состояния
class PonyDetailPage extends StatelessWidget {
// в качестве параметра мы будет получать id пони
final int ponyId;
// конструктор PonyDetailPage принимает ponyId,
// который будет присвоен нашему ранее
// объявленному полю
PonyDetailPage(this.ponyId);
@override
Widget build(BuildContext context) {
// получаем пони по его id
// обратите внимание: мы импортируем ponies
// из файла pony_list_page.dart
final pony = ponies[ponyId];
return Scaffold(
appBar: AppBar(
title: Text("Pony Detail Page"),
),
body: Padding(
// указываем отступ для контента
padding: EdgeInsets.all(15),
// Column размещает дочерние виджеты в виде колонки
// crossAxisAlignment - выравнивание по ширине (колонка) или
// по высоте (строка)
// mainAxisAlignment работает наоборот
// в данном случае мы растягиваем дочерние элементы
// на всю ширину колонки
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: EdgeInsets.all(10),
// вы не можете указать color для Container,
// т.к. свойство decoration было определено
// color: Colors.pinkAccent,
// BoxDecoration имеет дополнительные свойства,
// посравнению с Container,
// такие как: gradient, borderRadius, border, shape
// и boxShadow
// здесь мы задаем радиус закругления левого и правого
// верхних углов
decoration: BoxDecoration(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(15),
topRight: Radius.circular(15)
),
// цвет Container'а мы указываем в BoxDecoration
color: Colors.pinkAccent,
),
child: Text(
// указываем имя pony
pony.name,
style: Theme.of(context).textTheme.headline4.copyWith(color: Colors.white),
)
),
Container(
padding: EdgeInsets.all(10),
child: Text(
// указываем описание pony
pony.desc,
style: Theme.of(context).textTheme.bodyText1
)
)
],
),
)
);
}
}
Осталось только организовать саму навигацию.
Добавьте следующий код в PonyListPage:
// нажатие на элемент списка
onTap: () {
// Здесь мы используем сокращенную форму:
// Navigator.of(context).push(route)
// PonyDetailPage принимает pony id,
// который мы и передали
Navigator.push(context, MaterialPageRoute(
builder: (context) => PonyDetailPage(pony.id)
));
},
Также не забудем заменить домашнюю страницу:
@override
Widget build(BuildContext context) {
// виджет MaterialApp - главный виджет приложения, который
// позволяет настроить тему и использовать
// Material Design для разработки.
return MaterialApp(
// заголовок приложения
// обычно виден, когда мы сворачиваем приложение
title: 'Json Placeholder App',
// убираем баннер
debugShowCheckedModeBanner: false,
// настройка темы, мы ещё вернёмся к этому
theme: ThemeData(
primarySwatch: Colors.blue,
),
// теперь у нас домашная страница - PonyListPage
home: PonyListPage(),
);
}
Запуск
Теперь кликаем на любой элемент:
Та дам! Мы также можем вернуться обратно, если нажмем кнопку Back или стрелку в левом верхнем углу.
Также можно реализовать свою логику обратного перехода:
// Получаем NavigatorState и уничтожает последний элемент
// из стэка навигации (PonyDetailPage)
// мы можем передать второй аргумент, если хотим вернуть результат
Navigator.pop(context, result)
Пока на этом все. О навигации можно написать целый цикл статей.
Ещё пара слов о нововведениях: появился новый Navigator API 2.0, о котором есть довольно хорошая статья.
Мы останавливаться не будем и переходим к BottomNavigationBar.
BottomNavigationBar и свои Navigator'ы
Я 100% уверен, что вы встречали нижнее меню, по которому можно переходить на различные экраны:
Здесь вы можете увидеть четыре элемента меню (домашная страница, поиск, уведомления и сообщения).
Давайте реализуем что-нибудь похожее.
Сначала создадим новую папку models, а в ней файл tab.dart
:
Затем создадим класс Tab
и перечисление TabItem
:
import 'package:flutter/material.dart';
// будет хранить основную информацию
// об элементах меню
class MyTab {
final String name;
final MaterialColor color;
final IconData icon;
const MyTab({this.name, this.color, this.icon});
}
// пригодиться для определения
// выбранного элемента меню
// у нас будет три пункта меню и три страницы:
// посты, альбомы и задания
enum TabItem { POSTS, ALBUMS, TODOS }
Переходим к более сложной части, реализации главной страницы:
import 'package:flutter/material.dart';
import "../models/tab.dart";
// Наша главная страница будет содержать состояние
class HomePage extends StatefulWidget {
@override
_HomePageState createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
// GlobalKey будет хранить уникальный ключ,
// по которому мы сможем получить доступ
// к виджетам, которые уже находяться в иерархии
// NavigatorState - состояние Navigator виджета
final _navigatorKeys = {
TabItem.POSTS: GlobalKey<NavigatorState>(),
TabItem.ALBUMS: GlobalKey<NavigatorState>(),
TabItem.TODOS: GlobalKey<NavigatorState>(),
};
// текущий выбранный элемент
var _currentTab = TabItem.POSTS;
// выбор элемента меню
void _selectTab(TabItem tabItem) {
setState(() => _currentTab = tabItem);
}
@override
Widget build(BuildContext context) {
// WillPopScope переопределяет поведения
// нажатия кнопки Back
return WillPopScope(
// логика обработки кнопки back может быть разной
// здесь реализована следующая логика:
// когда мы находимся на первом пункте меню (посты)
// и нажимаем кнопку Back, то сразу выходим из приложения
// в противном случае выбранный элемент меню переключается
// на предыдущий: c заданий на альбомы, с альбомов на посты,
// и после этого только выходим из приложения
onWillPop: () async {
if (_currentTab != TabItem.POSTS) {
if (_currentTab == TabItem.TODOS) {
_selectTab(TabItem.ALBUMS);
} else {
_selectTab(TabItem.POSTS);
}
return false;
} else {
return true;
}
},
child: Scaffold(
// Stack размещает один элемент над другим
// Проще говоря, каждый экран будет находится
// поверх другого, мы будем только переключаться между ними
body: Stack(children: <Widget>[
_buildOffstageNavigator(TabItem.POSTS),
_buildOffstageNavigator(TabItem.ALBUMS),
_buildOffstageNavigator(TabItem.TODOS),
]),
// MyBottomNavigation мы создадим позже
bottomNavigationBar: MyBottomNavigation(
currentTab: _currentTab,
onSelectTab: _selectTab,
),
),);
}
// Создание одного из экранов - посты, альбомы или задания
Widget _buildOffstageNavigator(TabItem tabItem) {
return Offstage(
// Offstage работает следующим образом:
// если это не текущий выбранный элемент
// в нижнем меню, то мы его скрываем
offstage: _currentTab != tabItem,
// TabNavigator мы создадим позже
child: TabNavigator(
navigatorKey: _navigatorKeys[tabItem],
tabItem: tabItem,
),
);
}
}
Данной подход состоит в том, что мы создаем отдельный Navigator для постов, альбомов и заданий, у каждого из них будет свой стэк навигации.
Далее с помощью виджета Offstage
мы показывает только тот экран, который был выбран.
Также мы переопределили нажатие на кнопку back - WillPopScope
.
Теперь создадим нижнее меню в новом файле bottom_navigation.dart
:
import 'package:flutter/material.dart';
import '../models/tab.dart';
// создаем три пункта меню
// const обозначает, что tabs является
// постоянной ссылкой и мы больше
// ничего не сможем ей присвоить,
// иначе говоря, она определена во время компиляции
const Map<TabItem, MyTab> tabs = {
TabItem.POSTS : const MyTab(name: "Posts", color: Colors.red, icon: Icons.layers),
TabItem.ALBUMS : const MyTab(name: "Albums", color: Colors.blue, icon: Icons.image),
TabItem.TODOS : const MyTab(name: "Todos", color: Colors.green, icon: Icons.edit)
};
class MyBottomNavigation extends StatelessWidget {
// MyBottomNavigation принимает функцию onSelectTab
// и текущую выбранную вкладку
MyBottomNavigation({this.currentTab, this.onSelectTab});
final TabItem currentTab;
// ValueChanged<TabItem> - функциональный тип,
// то есть onSelectTab является ссылкой на функцию,
// которая принимает TabItem объект
final ValueChanged<TabItem> onSelectTab;
@override
Widget build(BuildContext context) {
// Используем встроенный виджет BottomNavigationBar для
// реализации нижнего меню
return BottomNavigationBar(
selectedItemColor: _colorTabMatching(currentTab),
selectedFontSize: 13,
unselectedItemColor: Colors.grey,
type: BottomNavigationBarType.fixed,
currentIndex: currentTab.index,
// пункты меню
items: [
_buildItem(TabItem.POSTS),
_buildItem(TabItem.ALBUMS),
_buildItem(TabItem.TODOS),
],
// обработка нажатия на пункт меню
// здесь мы делаем вызов функции onSelectTab,
// которую мы получили через конструктор
onTap: (index) => onSelectTab(
TabItem.values[index]
)
);
}
// построение пункта меню
BottomNavigationBarItem _buildItem(TabItem item) {
return BottomNavigationBarItem(
// указываем иконку
icon: Icon(
_iconTabMatching(item),
color: _colorTabMatching(item),
),
// указываем метку или название
label: tabs[item].name,
);
}
// получаем иконку элемента
IconData _iconTabMatching(TabItem item) => tabs[item].icon;
// получаем цвет элемента
Color _colorTabMatching(TabItem item) {
return currentTab == item ? tabs[item].color : Colors.grey;
}
}
И реализуем TabNavigator (tab_navigator.dart)
:
import 'package:flutter/material.dart';
import '../models/tab.dart';
import 'pony_list_page.dart';
class TabNavigator extends StatelessWidget {
// TabNavigator принимает:
// navigatorKey - уникальный ключ для NavigatorState
// tabItem - текущий пункт меню
TabNavigator({this.navigatorKey, this.tabItem});
final GlobalKey<NavigatorState> navigatorKey;
final TabItem tabItem;
@override
Widget build(BuildContext context) {
// наконец-то мы дошли до этого момента
// здесь мы присваиваем navigatorKey
// только, что созданному Navigator'у
// navigatorKey, как уже было отмечено является ключом,
// по которому мы получаем доступ к состоянию
// Navigator'a, вот и все!
return Navigator(
key: navigatorKey,
// Navigator имеет параметр initialRoute,
// который указывает начальную страницу и является
// всего лишь строкой.
// Мы не будем вдаваться в подробности, но отметим,
// что по умолчанию initialRoute равен /
// initialRoute: "/",
// Navigator может сам построить наши страницы или
// мы можем переопределить метод onGenerateRoute
onGenerateRoute: (routeSettings) {
// сначала определяем текущую страницу
Widget currentPage;
if (tabItem == TabItem.POSTS) {
// пока мы будем использовать PonyListPage
currentPage = PonyListPage();
} else if (tabItem == TabItem.POSTS) {
currentPage = PonyListPage();
} else {
currentPage = PonyListPage();
}
// строим Route (страница или экран)
return MaterialPageRoute(builder: (context) => currentPage,);
},
);
}
}
Также не забудьте заменить домашнюю страницу в main.dart
файле:
return MaterialApp(
//...
// Наша главная страница с нижнем меню
home: HomePage(),
);
Осталось только импортировать нужные классы в home_page.dart
и вуаля:
Также хорошей практикой является правильная организация кода, поэтому в папке pages создадим новую папку home и перетащим туда два наших файлика:
И напоследок сделаем три страницы заглушки: PostListPage, AlbumListPage и TodoListPage:
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
// Здесь все довольно очевидно
class PostListPage extends StatefulWidget {
@override
_PostListPageState createState() => _PostListPageState();
}
class _PostListPageState extends State<PostListPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("Post List Page"),
),
body: Container()
);
}
}
Та же структура и для двух остальных.
После этого укажим их в TabNavigator'e
:
onGenerateRoute: (routeSettings) {
// сначала определяем текущую страницу
Widget currentPage;
if (tabItem == TabItem.POSTS) {
// указываем соответствующие страницы
currentPage = PostListPage();
} else if (tabItem == TabItem.ALBUMS) {
currentPage = AlbumListPage();
} else {
currentPage = TodoListPage();
}
// строим Route (страница или экран)
return MaterialPageRoute(builder: (context) => currentPage);
},
Заключение
Поздравляю вас!
Искренне рад и благодарен вам за хорошие отзывы и за поддержку!
Полезные ссылки:
исходный код на Github
Navigator 2.0 API
Navigation Cookbook
До скорой встречи!