Привет!
Я работаю над приложением для изучения английского языка Memo.
Недавно мы выложили первую версию в сторы и тут же получили отзыв от одного из активных пользователей:
![Привет, Максим! Привет, Максим!](https://habrastorage.org/getpro/habr/upload_files/2fb/b48/fbc/2fbb48fbcc819e0f23b8f6f3f3ec7123.png)
Тут я вспомнил про аналогичную проблему у приложения 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)),
);
}
}
Попробуем проскролить список и кликнуть в статус бар (заставить эмулятор показывать место тапа, неочевидная задача)
![](https://habrastorage.org/getpro/habr/upload_files/84c/182/ee7/84c182ee72da459b26ec172bccda5080.gif)
Круто, фича работает из коробки. Можем расходиться?
В реальных проектах нам иногда нужно иметь свой 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).
![](https://habrastorage.org/getpro/habr/upload_files/53c/04e/610/53c04e610f9fac60f07147ab44af3f0f.png)
Итак, 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 скролл в приложении.
Возникают три вопроса:
Почему по умолчанию все работает?
Откуда берется PrimaryScrollController?
Будет ли это тап в статус бар работать, если у нас будет несколько вложенных
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
):
![](https://habrastorage.org/getpro/habr/upload_files/5b6/b92/f29/5b6b92f2935e7c753d911d5999de0b6b.png)
Как именно происходит поиск виджета при помощи 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
:
![](https://habrastorage.org/getpro/habr/upload_files/5d1/af8/f0b/5d1af8f0b50b68b5246143e16842cd5c.png)
Будет ли этот тап в статус бар работать, если у нас будет несколько вложенных Scaffold?
Возьмем типичный пример с экраном на котором несколько табов:
![](https://habrastorage.org/getpro/habr/upload_files/b89/9c2/904/b899c2904a0cf17ffbd321391c4e390b.png)
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
, и он будет реагировать на события. Виджеты экранов располагаются ниже и уже не будут реагировать на тап. Что делать?
Я вижу два основных решения:
заворачивать главный
Scaffold
вPrimaryScrollController
и передавать события в нужный таб;в виджетах табов регистрировать свой лисенер скроллов для
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
Чао!
![](https://habrastorage.org/getpro/habr/upload_files/45b/3a2/332/45b3a2332ad5108e831637e43f1aa52e.png)