Как стать автором
Обновить

Тап в статус бар. Делаем простое сложно c помощью Flutter

Время на прочтение 9 мин
Количество просмотров 5.9K

Привет!

Я работаю над приложением для изучения английского языка Memo.

Недавно мы выложили первую версию в сторы и тут же получили отзыв от одного из активных пользователей:

Привет, Максим!
Привет, Максим!

Тут я вспомнил про аналогичную проблему у приложения Meduza (иностранный агент):

Я пользуюсь Android телефоном и даже не думал, что это может быть настолько востребовано. Открыв iOS версию приложения, я увидел, что действительно тап в статус бар не приводит ни к какой реакции.

О чем речь? В iOS есть такая фича - scrolls to top. Вот, как нам ее описывает документация:

The scroll-to-top gesture is a tap on the status bar. When a user makes this gesture, the system asks the scroll view closest to the status bar to scroll to the top

Перейдем коду

Посмотрим, может ли Flutter предоставить нам такую функциональность из коробки? Сделаем простой семпл с единственным экраном со списком:

import 'package:example/home_page.dart';
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}
class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return const MaterialApp(title: 'Scroll to top', home: HomePage());
  }
}
import 'package:flutter/material.dart';

class HomePage extends StatefulWidget {
  const HomePage({Key? key}) : super(key: key);

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: _list(),
      appBar: AppBar(title: const Text('Scroll to top')),
    );
  }

  Widget _list() {
    return ListView.builder(itemBuilder: _itemBuilder, itemCount: 100);
  }

    Widget _itemBuilder(BuildContext context, int index) {
    return Container(
      decoration: BoxDecoration(
        border: Border.all(color: Colors.blue),
      ),
      padding: const EdgeInsets.all(20),
      child: Text('$index', style: const TextStyle(fontSize: 20)),
    );
  }
}

Попробуем проскролить список и кликнуть в статус бар (заставить эмулятор показывать место тапа, неочевидная задача)

Круто, фича работает из коробки. Можем расходиться?

В реальных проектах нам иногда нужно иметь свой ScrollController для подскролов к элементам списка. Давайте попробуем добавить простой контроллер:

import 'package:flutter/material.dart';

class HomePage extends StatefulWidget {
  const HomePage({Key? key}) : super(key: key);
  @override
  State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
  late ScrollController _scrollController;
  @override
  void initState() {
    super.initState();
    _scrollController = ScrollController();
  }
  @override
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: _list(),
      appBar: AppBar(title: const Text('Scroll to top')),
    );
  }
  Widget _list() {
    return ListView.builder(
      itemBuilder: _itemBuilder,
      itemCount: 100,
      controller: _scrollController,
    );
  }
Widget _itemBuilder(BuildContext context, int index) {
return Container(
  decoration: BoxDecoration(
    border: Border.all(color: Colors.blue),
  ),
  padding: const EdgeInsets.all(20),
  child: Text('$index', style: const TextStyle(fontSize: 20)),
);

  }
}

В этом случае тап в статус бар перестает скролить список. Давайте разбираться. За счет чего вообще скроллится список? Я поискал по исходникам Flutter и нашел вот такой код из класса Scaffold:

// iOS FEATURES - status bar tap, back gesture

  // On iOS, tapping the status bar scrolls the app's primary scrollable to the
  // top. We implement this by looking up the  primary scroll controller and
  // scrolling it to the top when tapped.
  void _handleStatusBarTap() {
    final ScrollController? _primaryScrollController = PrimaryScrollController.of(context);
    if (_primaryScrollController != null && _primaryScrollController.hasClients) {
      _primaryScrollController.animateTo(
        0.0,
        duration: const Duration(milliseconds: 300),
        curve: Curves.linear, // TODO(ianh): Use a more appropriate curve.
      );
    }
  }

Есть определенный обработчик жестов, который в конце своей работы вызывает метод_handleStatusBarTap (обожаю TODO, которые висят в релизном коде по 4 года <3).

Итак, Flutter пытается найти ближайший к текущему контексту PrimaryScrollController и начать его скролить. Что такое PrimaryScrollController ?

/// Associates a [ScrollController] with a subtree.
///
/// When a [ScrollView] has [ScrollView.primary] set to true and is not given
/// an explicit [ScrollController], the [ScrollView] uses [of] to find the
/// [ScrollController] associated with its subtree.
///
/// This mechanism can be used to provide default behavior for scroll views in a
/// subtree. For example, the [Scaffold] uses this mechanism to implement the
/// scroll-to-top gesture on iOS.
///
/// Another default behavior handled by the PrimaryScrollController is default
/// [ScrollAction]s. If a ScrollAction is not handled by an otherwise focused
/// part of the application, the ScrollAction will be evaluated using the scroll
/// view associated with a PrimaryScrollController, for example, when executing
/// [Shortcuts] key events like page up and down.
///
/// See also:
///   * [ScrollAction], an [Action] that scrolls the [Scrollable] that encloses
///     the current [primaryFocus] or is attached to the PrimaryScrollController.
///   * [Shortcuts], a widget that establishes a [ShortcutManager] to be used
///     by its descendants when invoking an [Action] via a keyboard key
///     combination that maps to an [Intent].
class PrimaryScrollController extends InheritedWidget {
  /// Creates a widget that associates a [ScrollController] with a subtree.
  const PrimaryScrollController({
    Key? key,
    required ScrollController this.controller,
    required Widget child,
  }) : assert(controller != null),
       super(key: key, child: child);

PrimaryScrollController – это не ScrollController, как могло показаться из названия. Это виджет, который держит ScrollController, отвечающий за primary скролл в приложении.

Возникают три вопроса:

  1. Почему по умолчанию все работает?

  2. Откуда берется PrimaryScrollController?

  3. Будет ли это тап в статус бар работать, если у нас будет несколько вложенных Scaffold?

Почему по умолчанию все работает?

Заглянем в класс ScrollView:

final ScrollController? scrollController =
        primary ? PrimaryScrollController.of(context) : controller

В случае, если установлен флаг primary, то будет использован PrimaryScrollController – тем самым виджет начнет реагировать на события тапа в статус бар.

Что за флаг primary? Смотрим документацию:

/// Also when true, the scroll view is used for default [ScrollAction]s. If a
/// ScrollAction is not handled by an otherwise focused part of the application,
/// the ScrollAction will be evaluated using this scroll view, for example,
/// when executing [Shortcuts] key events like page up and down.
///
/// On iOS, this also identifies the scroll view that will scroll to top in
/// response to a tap in the status bar.
/// {@endtemplate}
///

Флаг primary позволяет нам пометить ScrollView в качестве дефолтного обработчика события тапа в статус бар. Окей. Но мы не выставляли это значение. Какое значение установлено по умолчанию?

/// Defaults to true when [scrollDirection] is [Axis.vertical] and
/// [controller] is null.
final bool primary;

Если мы явно не указали значение primary, то флаг будет выставлен в true, если ориентация списка вертикальная и controller == null. В нашем случае получаем значение false.

primary = primary ?? controller == null && identical(scrollDirection, Axis.vertical),

Откуда берется PrimaryScrollController

Запускаем дебагер и смотрим в метод static ScrollController? of(BuildContext context) класса PrimaryScrollController. Он находит PrimaryScrollController из routes(смотреть в значение _location):

Как именно происходит поиск виджета при помощи dependOnInheritedWidgetOfExactType , объясняет вот это видео. Если коротко: метод dependOnInheritedWidgetOfExactType позволяет пройтись вверх по дереву виджетов и найти ближайший виджет с определенным типом.

Если выше по контексту главного Scaffold нет другого PrimaryScrollController, то мы всегда придем к PrimaryScrollController из routes.dart:

//...
@override
Widget build(BuildContext context) {
  return AnimatedBuilder(
    animation: widget.route.restorationScopeId,
    builder: (BuildContext context, Widget? child) {
      assert(child != null);
      return RestorationScope(
        restorationId: widget.route.restorationScopeId.value,
        child: child!,
      );
    },
    child: _ModalScopeStatus(
      route: widget.route,
      isCurrent: widget.route.isCurrent, // _routeSetState is called if this updates
      canPop: widget.route.canPop, // _routeSetState is called if this updates
      child: Offstage(
        offstage: widget.route.offstage, // _routeSetState is called if this updates
        child: PageStorage(
          bucket: widget.route._storageBucket, // immutable
          child: Builder(
            builder: (BuildContext context) {
              return Actions(
                actions: <Type, Action<Intent>>{
                  DismissIntent: _DismissModalAction(context),
                },
                child: PrimaryScrollController( //<- Вот это место
//...

Как же нам тогда сохранить возможность скролла при тапе и при этом использовать свой контроллер? Нам нужно создать свой PrimaryScrollController над Scaffold, который будет реагировать на события скролла при тапе в статус бар:

  @override
  Widget build(BuildContext context) {
    return PrimaryScrollController(
      controller: _scrollController,
      child: Scaffold(
        body: _list(),
        appBar: AppBar(title: const Text('Scroll to top')),
      ),
    );
  }

Снова запустим дебагер. Действительно, в этом случае мы находим наш PrimaryScrollController:

Будет ли этот тап в статус бар работать, если у нас будет несколько вложенных Scaffold?

Возьмем типичный пример с экраном на котором несколько табов:

import 'package:example/content_page.dart';
import 'package:flutter/material.dart';

class HomePage extends StatefulWidget {
  const HomePage({Key? key}) : super(key: key);
  @override
  State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
    int _currentIndex = 0;
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Scroll to top')),
      body: IndexedStack(
        index: _currentIndex,
        children: const [
          ContentPage(backgroundColor: Colors.white),
          ContentPage(backgroundColor: Colors.deepOrange),
          ContentPage(backgroundColor: Colors.green),
        ],
      ),
      bottomNavigationBar: BottomNavigationBar(
        onTap: _onTabTapped,
        currentIndex: _currentIndex,
        items: const [
          BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
          BottomNavigationBarItem(icon: Icon(Icons.mail), label: 'Messages'),
          BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profile')
        ],
      ),
    );
  }
  void _onTabTapped(int index) {
    setState(() {
      _currentIndex = index;
    });
  }
}

В виджетах экранов будем использовать PrimaryScrollController :

...
Widget _list() {
  return ScrollsToTop(
    onScrollsToTop: _onScrollsToTop,
    child: ListView.builder(
      itemBuilder: _itemBuilder,
      itemCount: 100,
      controller: _scrollController,
    ),
  );
}
...

Тап в статус бар перестал работать. Все потому, что под статус баром у нас находится самый первый Scaffold, и он будет реагировать на события. Виджеты экранов располагаются ниже и уже не будут реагировать на тап. Что делать?

Я вижу два основных решения:

  1. заворачивать главный Scaffold в PrimaryScrollController и передавать события в нужный таб;

  2. в виджетах табов регистрировать свой лисенер скроллов для PrimaryScrollController из routes.dart

Второй вариант более гибкий, давайте его и сделаем. Для начала создадим ScrollContext (это абстрактный класс для работы со ScrollPosition), который сможет слушать события animateTo:

class _FakeScrollPositionWithSingleContext
    extends ScrollPositionWithSingleContext {
  _FakeScrollPositionWithSingleContext({
    required BuildContext context,
    required ScrollsToTopCallback callback,
  })  : _callback = callback,
        super(
          physics: const NeverScrollableScrollPhysics(),
          context: _FakeScrollContext(context),
        );

  final ScrollsToTopCallback _callback;

  @override
  Future<void> animateTo(
    double to, {
    required Duration duration,
    required Curve curve,
  }) {
    return _callback(
      ScrollsToTopEvent(to, duration: duration, curve: curve),
    );
  }
}

Далее нам нужно найти PrimaryScrollController и добавить в него свой лисенер. У ScrollController есть метод attach:

void _attach(BuildContext context) {
  final primaryScrollController = PrimaryScrollController.of(context);
  if (primaryScrollController == null) return;

  final scrollPositionWithSingleContext =
    _FakeScrollPositionWithSingleContext(
    context: context,
    callback: widget.onScrollsToTop,
  );
  primaryScrollController.attach(scrollPositionWithSingleContext);

  _primaryScrollController = primaryScrollController;
  _scrollPositionWithSingleContext = scrollPositionWithSingleContext;
}

И завернем написанный код в виджет:

/// Widget for catch scrolls-to-top event
class ScrollsToTop extends StatefulWidget {
  /// Creates new ScrollsToTop widget
  const ScrollsToTop({
    Key? key,
    required this.child,
    required this.onScrollsToTop,
  }) : super(key: key);

  /// Any child widget
  final Widget child;

  /// Callback for handle scrolls-to-top event
  final ScrollsToTopCallback onScrollsToTop;

  @override
  State<ScrollsToTop> createState() => _ScrollsToTopState();
}

Теперь мы можем оборачивать любой виджет в ScrollsToTop и реагировать на тапы в любом месте, где нам это нужно. Пример использования можно посмотреть тут.

Важное замечание: PrimaryScrollController может быть использован и другими участками кода, несвязанными с тапом в статус бар. Например, при клике page up / page down.

Весь написанный код я выделил в пакет и опубликовал в pub. Буду очень рад пул реквестам.

Как такой подход работает в реальном приложении, вы можете увидеть в приложении: Memo доступно под iOS и Android

Чао!

Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
+3
Комментарии 12
Комментарии Комментарии 12

Публикации

Истории

Работа

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн