Навігація з BLoC у Flutter

15 хв. читання

Без навігації не обходиться, мабуть, жоден мобільний застосунок. Якщо процес переходу між сторінками у ньому стабільний і надійний, користувачам простіше зорієнтуватись в інтерфейсі, а отже покращується користувацький досвід.

Автор матеріалу займається розробкою мобільних застосунків, тому бачив різні реалізації навігаційної панелі у Flutter. І більшість з них, на жаль, не зберігає стан. Тож користувач вимушений щоразу повертатись назад та обирати нову сторінку.

Навігація з BLoC у Flutter
Такий вигляд має навігація, коли на кожну сторінку створюється новий роут

Бачимо, що після кожного переходу за пунктом меню створюється нова сторінка. Звичайно, користувачі Android можуть натиснути кнопку «назад», аби повернутись на попередній екран. Однак це не те, що ми очікуємо від навігаційної панелі. Аби отримати бажану поведінку, застосунок повинен зберігати стан.

Дізнаємось, як можна реалізувати таку навігацію за допомогою патерну BLoC.

Навігація з BLoC у Flutter

Що таке BLoC

Абревіатура BLoC розшифровується як business logic component (пер. компонент бізнес-логіки). Це єдине джерело правди для окремої фічі вашого застосунку (наприклад, навігації, автентифікації, логіну, профайлу користувача тощо). BLoC реагує на події, відповідаючи на них змінами стану за допомогою async* функцій. Під капотом BLoC наслідується від класу Stream, який аналогічно приймає певні вхідні дані та відповідає на них змінами. Розглянемо взаємодію різних компонентів у застосунку з компонентами BLoC.

Навігація з BLoC у Flutter

Початок роботи

Насамперед треба імпортувати залежності до файлу pubsec.yaml, аби додати підтримку BLoC. Так ви зможете використовувати основні компоненти, зокрема BlocProvider, BlocBuilder, BlocListener, інструменти для тестування та багато іншого:

# bloc dependencies
bloc: ^4.0.0
flutter_bloc: ^4.0.0
bloc_test: ^5.1.0

Створюємо директорії, де розміщуватимуться файл компонента, файл зі станом, а також необов'язковий файл для впорядкування імпортів.

lib/bloc/navigation_drawer/blocs.dart
lib/bloc/navigation_drawer/nav_drawer_bloc.dart
lib/bloc/navigation_drawer/nav_drawer_event.dart
lib/bloc/navigation_drawer/nav_drawer_state.dart

Події

Як ми вже з'ясували, під подією ми розуміємо певну дію користувача, що призводить до зміни стану. Класи подій зазвичай називають дієсловами. Такі класи наслідуються від абстрактних класів і мають прості назви (наприклад, Login, Navigate).

// імпортуємо NavItem, 
// який показуватиме вибір користувача 
import 'nav_drawer_state.dart';
// важливо використовувати абстрактний клас, навіть якщо у вас лише одна 
// подія. Так пізніше ви зможете використати його у вашому компоненті, або ж протестувати 
abstract class NavDrawerEvent {
  const NavDrawerEvent();
}
// подія, яка викликається, коли користувач хоче змінити поточну сторінку 
class NavigateTo extends NavDrawerEvent {
  final NavItem destination;
  const NavigateTo(this.destination);
}

Стан

Це дані, які демонструються користувачу. В нашому випадку в стані зберігається сторінка, яку обрав юзер. Клас, що зберігає стан, зазвичай називають іменником. Наприклад, якщо ви створюєте BLoC для сторінки входу, класи-стани можуть називатись Loading, Authenticated, UnAuthenticated та Error.

// стан, який очікує побачити користувач
class NavDrawerState {
  final NavItem selectedItem;
  const NavDrawerState(this.selectedItem);
}
// сторінки навігації можна замінити на сторінки, які підтримуються вашим застосунком
enum NavItem {
  page_one,
  page_two,
  page_three,
  page_four,
  page_five,
  page_six,
}

Об'єднуємо все в BLoC

Тепер ми можемо об'єднати всі складові у клас компонента, який наслідується від Bloc:

import 'package:bloc/bloc.dart';
import 'package:nice_nav/bloc/blocs.dart';
class NavDrawerBloc extends Bloc<NavDrawerEvent, NavDrawerState> {
// У вас також може бути необов'язковий конструктор, який приймає
  // репозиторій для мережевих запитів 
	
// початковий стан, який користувач бачить при створенні компонента 
  @override
  NavDrawerState get initialState => NavDrawerState(NavItem.page_one);
  @override
  Stream<NavDrawerState> mapEventToState(NavDrawerEvent event) async* {
    // тут обробляються події. якщо ви хочете викликати метод,
    // ви можете виконати yield* замість yield, але переконайтесь, що
    // сигнатура вашого методу повертає Stream<NavDrawerState> і є асинхронною async*
    if (event is NavigateTo) {
      // перенаправляє на нову сторінку, лише якщо вона відрізняється від поточної 
      if (event.destination != state.selectedItem) {
        yield NavDrawerState(event.destination);
      }
    }
  }
}

Бажано, але не обов'язково

// Файл для експорту Bloc-файлів
export 'package:nice_nav/bloc/navigation_drawer/nav_drawer_bloc.dart';
export 'package:nice_nav/bloc/navigation_drawer/nav_drawer_event.dart';
export 'package:nice_nav/bloc/navigation_drawer/nav_drawer_state.dart';

Тестування компонентів

Процес тестування BLoC досить легкий. Для цього пропонується метод blocTest (детальніше за посиланням). Для демонстрації створимо декілька тестів, але зі зростанням вашого компонента, варто потурбуватись про більш ретельне тестування.

Використаємо метод build для створення BLoC. У методі ми можемо імітувати певні виклики функцій, а також певні дії над компонентом (наприклад, імітувати навігацію). У нашому прикладі ми додали подію NavigateTo для тестування навігації. Список expect допоможе з'ясувати, які зміни стану повинні відбуватись у компоненті. Тут ми очікуємо стан NavDrawerState без початкового стану компонента.

Наприкінці ми використовуємо метод verify, аби переконатись, що сторінку змінено правильно. Ви також можете здійснити всі перевірки, що стосуються поведінки компонента (наприклад, перевірити, чи здійснює він мережевий виклик).

import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:nice_nav/bloc/blocs.dart';
main() {
  blocTest<NavDrawerBloc, NavDrawerEvent, NavDrawerState>(
      'Emits [NavDrawerState] when NavigateTo(NavItem.page_five) is added',
      build: () async => NavDrawerBloc(),
      act: (bloc) async => bloc.add(NavigateTo(NavItem.page_five)),
      expect: [isA<NavDrawerState>()],
      verify: (bloc) async {
        expect(bloc.state.selectedItem, NavItem.page_five);
      });
}

Навігація

Тепер, коли ми реалізували панель навігації зі збереженням стану за допомогою BLoC, можемо показувати користувачу вміст кожної сторінки. Для цього ми створюємо контейнер для контенту, передаємо сам BLoC, а потім метод build використає цей стан. На відміну від звичайного підходу, тут нам знадобиться лише один шлях.

MaterialApp(
  debugShowCheckedModeBanner: false,
  title: 'Nice Navigation Demo',
  theme: ThemeData(
      primarySwatch: Colors.amber,
      scaffoldBackgroundColor: CoreColors.background),
  home: MainContainerWidget(),
);

MainContainerWidget

Контейнер основного віджета поводитиметься як сторінка навігації, з котрою взаємодіятиме користувач. Ми можемо використати BLoC, передавши його дочірнім компонентам:

@override
Widget build(BuildContext context) => BlocProvider<NavDrawerBloc>(
      create: (BuildContext context) => NavDrawerBloc(),
      child: BlocBuilder<NavDrawerBloc, NavDrawerState>(
        builder: (BuildContext context, NavDrawerState state) => Scaffold(
            drawer: NavDrawerWidget("Joe Shmoe", "shmoe@joesemail.com"),
            appBar: AppBar(
              title: Text(_getTextForItem(state.selectedItem)),
            ),
            body: _bodyForState(state),
            floatingActionButton: _getFabForItem(state.selectedItem)),
      ),
    );

DrawerWidget

Додамо вміст до панелі навігації NavDrawerWidget. Це буде список елементів для переходу. Оскільки ми використовуємо патерн BLoC, то компонент отримуємо, передавши віджет. Створимо список пунктів меню і додамо обробників натискань на елементи.

Щоб додати обробку подій до компонента, використовуємо провайдер:

BlocProvider.of<NavDrawerBloc>(context).add(NavigateTo(item));

Переконайтесь, що контекст, який ви використовуєте, є дочірнім для BlocProvider, інакше виникне помилка.

Розглянемо віджет DrawerWidget, який не зберігає стан.

class NavDrawerWidget extends StatelessWidget {
  final String accountName;
  final String accountEmail;
  final List<_NavigationItem> _listItems = [
    _NavigationItem(true, null, null, null),
    _NavigationItem(false, NavItem.page_one, "First Page", Icons.looks_one),
    _NavigationItem(false, NavItem.page_two, "Second Page", Icons.looks_two),
    _NavigationItem(false, NavItem.page_three, "Third Page", Icons.looks_3),
    _NavigationItem(false, NavItem.page_four, "Fourth Page", Icons.looks_4),
    _NavigationItem(false, NavItem.page_five, "Fifth Page", Icons.looks_5),
    _NavigationItem(false, NavItem.page_six, "Sixth Page", Icons.looks_6),
  ];
  NavDrawerWidget(this.accountName, this.accountEmail);
  @override
  Widget build(BuildContext context) => Drawer(
          // додаємо ListView. Так ми будемо впевнені, що користувач може скролити 
          // пунктами меню панелі, якщо вертикально не вистачає місця для їх розміщення 
          child: Container(
        color: CoreColors.background,
        child: ListView.builder(
            padding: EdgeInsets.zero,
            itemCount: _listItems.length,
            itemBuilder: (BuildContext context, int index) =>
                BlocBuilder<NavDrawerBloc, NavDrawerState>(
                  builder: (BuildContext context, NavDrawerState state) =>
                      _buildItem(_listItems[index], state),
                )),
      ));
  Widget _buildItem(_NavigationItem data, NavDrawerState state) => data.header
      // якщо елемент належить до заголовка, повертаємо відповідний віджет 
      ? _makeHeaderItem()
      // в іншому випадку створюємо та повертаємо звичайний пункт меню 
      : _makeListItem(data, state);
  Widget _makeHeaderItem() => UserAccountsDrawerHeader(
        accountName: Text(accountName, style: TextStyle(color: Colors.white)),
        accountEmail: Text(accountEmail, style: TextStyle(color: Colors.white)),
        decoration: BoxDecoration(color: Colors.blueGrey),
        currentAccountPicture: CircleAvatar(
          backgroundColor: Colors.white,
          foregroundColor: Colors.amber,
          child: Icon(
            Icons.person,
            size: 54,
          ),
        ),
      );
  Widget _makeListItem(_NavigationItem data, NavDrawerState state) => Card(
        color: data.item == state.selectedItem
            ? CoreColors.selectedNavItemRow
            : CoreColors.background,
        shape: ContinuousRectangleBorder(borderRadius: BorderRadius.zero),
        // вибраний елемент підсвічується 
        borderOnForeground: true,
        elevation: 0,
        margin: EdgeInsets.zero,
        child: Builder(
          builder: (BuildContext context) => ListTile(
            title: Text(
              data.title,
              style: TextStyle(
                color: data.item == state.selectedItem
                    ? Colors.blue
                    : Colors.blueGrey,
              ),
            ),
            leading: Icon(
              data.icon,
              // якщо елемент вибрано, змінюємо колір 
              color: data.item == state.selectedItem
                  ? Colors.blue
                  : Colors.blueGrey,
            ),
            onTap: () => _handleItemClick(context, data.item),
          ),
        ),
      );
  void _handleItemClick(BuildContext context, NavItem item) {
    BlocProvider.of<NavDrawerBloc>(context).add(NavigateTo(item));
    Navigator.pop(context);
  }
}
// допоміжний клас для елементів навігації 
class _NavigationItem {
  final bool header;
  final NavItem item;
  final String title;
  final IconData icon;
  _NavigationItem(this.header, this.item, this.title, this.icon);
}

Ми вже майже закінчили з реалізацією. Залишилась лише...

Анімація

Аби додати трохи динаміки до нашого компонента, налаштуємо анімацію та переходи в MainContainerWidget. Для цього використаємо BlocListener, який слідкуватиме за змінами стану і викликатиме setState у віджеті для зміни контексту. Про все інше подбає AnimatedSwitcher.

Не забудьте обробити початковий стан компонента, тому що BlocListener не займається цим.

Ви завжди можете налаштувати тривалість та інші параметри анімації (switchInCurve, switchOutCurve тощо) за допомогою бібліотеки Curves з Flutter SDK. І ніяких зайвих залежностей у вашому застосунку!

@override
void initState() {
  super.initState();
  _bloc = NavDrawerBloc();
  _content = _getContentForState(_bloc.state);
}
@override
Widget build(BuildContext context) => BlocProvider<NavDrawerBloc>(
    create: (BuildContext context) => _bloc,
    child: BlocListener<NavDrawerBloc, NavDrawerState>(
      listener: (BuildContext context, NavDrawerState state) {
        setState(() {
          _content = _getContentForState(state);
        });
      },
      child: BlocBuilder<NavDrawerBloc, NavDrawerState>(
        builder: (BuildContext context, NavDrawerState state) => Scaffold(
            drawer: NavDrawerWidget("Joe Shmoe", "shmoe@joesemail.com"),
            appBar: AppBar(
              title: Text(_getTextForItem(state.selectedItem)),
            ),
            body: AnimatedSwitcher(
              switchInCurve: Curves.easeInExpo,
              switchOutCurve: Curves.easeOutExpo,
              duration: Duration(milliseconds: 300),
              child: _content,
            ),
            floatingActionButton: _getFabForItem(state.selectedItem)),
      ),
    ));

Нарешті ми створили застосунок зі зручною навігацією між сторінками!

Навігація з BLoC у Flutter
Помітили помилку? Повідомте автору, для цього достатньо виділити текст з помилкою та натиснути Ctrl+Enter
Codeguida 5.6K
Приєднався: 8 місяців тому
Коментарі (0)

    Ще немає коментарів

Щоб залишити коментар необхідно авторизуватися.

Вхід / Реєстрація