Основы Flutter для начинающих (Часть III)

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Поздравляю, по крайней мере, всех живущих в Сибири с наступлением лета!)))

Сегодня довольно непростая тема - навигация.

Мы рассмотрим как устроена навигация в 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

До скорой встречи!

Источник: https://habr.com/ru/post/560646/


Интересные статьи

Интересные статьи

Wi-Fi 6 (или 802.11 ax) новый стандарт беспроводных сетей. Новый формат, который создавался с целью исправить баги прошлых стандартов. К 2021му году у WiFi накопилос...
Современные Web-сайты пишутся на HTML, JavaScript и CSS (и этот сайт в том числе). Наверно, вы сейчас прочитали это и подумали «да это же очевидно». А если я вам скажу, ч...
Последняя часть подборки историй из интернета о том, как у багов иногда бывают совершенно невероятные проявления. Первая часть, вторая часть. Читать дальше → ...
Технический директор Борис Горячев рассказывает, как «Медуза» работала над ним целый год и почему оно написано на Flutter 12 мая состоялся релиз новых мобильных приложений «Медузы» — почти через...
Предисловие В одной из прошлых статей я рассказывал какие библиотеки нам пригодятся и сравнили эту задумку с той неудачной. В этой части будем разрабатывать саму криптовалюту и настроим трекер. ...