Эта небольшая статья посвящена различным способам вложения компонентов ListView
и PageView
в Flutter. Проблемы конфликта различных Scrollable
компонентов вам, вероятно, знакомы. Сегодня мы рассмотрим три разных способа вложения этих компонентов.
Наиболее распространённое вложение — это горизонтальное PageView
, объединённое с вертикальным ListView
. В большинстве случаев такое сочетание работает хорошо, но если вы пытаетесь скроллить под углом, могут возникнуть проблемы.
Недавно несколько человек одновременно столкнулись с проблемой: "При скролле ListView
под углом управление передаётся на PageView
". Это можно видеть на следующем GIF:
Хотя лично мне эта проблема кажется не слишком важной, если продукт требует её решения, придётся либо переписывать обработчики событий для PageView
, либо находить другой подход.
Рассмотрим внутреннюю структуру PageView
и ListView
: оба используют компонент Scrollable
, который отвечает за скроллинг через RawGestureDetector
.
VerticalDragGestureRecognizer
отвечает за события вертикального скроллинга.HorizontalDragGestureRecognizer
отвечает за события горизонтального скроллинга.Один из методов, используемых для определения области действия указателя, называется computeHitSlop
. Этот метод определяет минимальное расстояние между указателем и объектом, которое считается попаданием. По умолчанию для касаний используется значение kTouchSlop
, равное 18.0 пикселей.Идея состоит в том, что если мы увеличим значение touchSlop
для PageView
, то можем сделать его менее чувствительным к горизонтальным движениям, тем самым решив проблему скроллинга под углом.
Для этого можно использовать метод DeviceGestureSettings
, доступный через MediaQuery
:
body: MediaQuery(
/// Увеличиваем touchSlop до 50, чтобы сделать pageview менее чувствительным к горизонтальным движениям
data: MediaQuery.of(context).copyWith(
gestureSettings: DeviceGestureSettings(
touchSlop: 50,
)),
child: PageView(
scrollDirection: Axis.horizontal,
pageSnapping: true,
children: [
HandlerListView(),
HandlerListView(),
],
),
),
Таким образом, мы можем эффективно решить проблему конфликта управления скроллингом при использовании ListView
и PageView
. Маленький совет №1: Вложите один MediaQuery
, а затем скорректируйте touchSlop
в gestureSettings
, чтобы изменить чувствительность PageView
. Также не забудьте вернуть touchSlop
списка ListView
обратно к значению по умолчанию (kTouchSlop
):
class HandlerListView extends StatefulWidget {
@override
_MyListViewState createState() => _MyListViewState();
}
class _MyListViewState extends State<HandlerListView> {
@override
Widget build(BuildContext context) {
return MediaQuery(
// Здесь touchSlop следует вернуть к значению по умолчанию
data: MediaQuery.of(context).copyWith(
gestureSettings: DeviceGestureSettings(touchSlop: kTouchSlop)),
child: ListView.separated(
itemCount: 15,
itemBuilder: (context, index) {
return ListTile(title: Text('Пункт $index'));
},
separatorBuilder: (context, index) {
return const Divider(thickness: 3);
},
),
);
}
}
```Давайте теперь посмотрим на результат, как показано на следующем GIF, даже если вы будете скроллить наклонно, это уже не вызовет горизонтального скроллинга `PageView`. Только при горизонтальном движении будет активирован скролл `PageView`. Конечно, **если говорить о недостатках такого подхода, то можно отметить снижение чувствительности `PageView`**.

# Вертикальный `PageView` с вложенным `ListView`
После рассмотрения обычного использования давайте рассмотрим что-то более необычное — **вертикальный `PageView` с вложенным вертикально прокручиваемым `ListView`**. Ваш первый ответ может быть отрицательным, но почему бы такой ситуации вообще возникнуть?
> Для продуктовых специалистов важно, что работает, а не как именно реализовано. Они могут спросить, почему ваш продукт не может работать так же, как, например, Taobao.
По поводу этого требования сообщество пока предлагает следующее решение: **отключить скролл `PageView` и `ListView`, а затем использовать `RawGestureDetector` для управления событиями гестов самостоятельно**.
> **Если вас интересует анализ логики реализации, вы можете посмотреть исходники в этом разделе [ссылка на исходники](https://github.com/CarGuo/gsy_flutter_demo/blob/7838971cefbf19bb53a71041cd100c4c15eb6443/lib/widget/vp_list_demo_page.dart#L75)**.Не бойтесь необходимости самостоятельного управления гестами. Хотя вам придётся самому распределять события гестов между `PageView` и `ListView`, нет необходимости полностью перезаписывать эти виджеты. Вы можете воспользоваться их существующими механизмами обработки `Drag`.
> Пример кода ниже демонстрирует использование механизма `Drag`:
```dart
// Пример кода, который использует механизм Drag
NeverScrollableScrollPhysics
запрещает прокрутку PageView
и ListView
.VerticalDragGestureRecognizer
, встроенного в верхний RawGestureDetector
.PageController
и ScrollController
для получения состояния.body: RawGestureDetector(
gestures: <Type, GestureRecognizerFactory>{
VerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<VerticalDragGestureRecognizer>(
() => VerticalDragGestureRecognizer(),
(VerticalDragGestureRecognizer instance) {
instance
..onStart = _handleDragStart
..onUpdate = _handleDragUpdate
..onEnd = _handleDragEnd
..onCancel = _handleDragCancel;
})
},
behavior: HitTestBehavior.opaque,
child: PageView(
controller: _pageController,
scrollDirection: Axis.vertical,
// Отключаем дефолтный скролл эффект
physics: const NeverScrollableScrollPhysics(),
children: [
ListView.builder(
controller: _listScrollController,
// Отключаем дефолтный скролл эффект
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) {
return ListTile(title: Text('Списковый элемент $index'));
},
itemCount: 30,
),
Container(
color: Colors.green,
child: Center(
child: Text(
'Page View',
style: TextStyle(fontSize: 50),
),
),
)
],
),
),
```Далее рассмотрим реализацию `_handleDragStart`, как показано ниже. При возникновении события жеста `details` мы проверяем:
- С помощью `ScrollController` проверяем видимость `ListView`.
- Проверяем находится ли позиция касания внутри диапазона `ListView`.
- Определяем через какой `Controller` создается объект `Drag`, чтобы отвечать за последующие события скролла.
```dart
void _handleDragStart(DragStartDetails details) {
/// Сначала проверяем видимость или возможность вызова ListView
/// Обычно при невидимости hasClients будет false, так как PageView также не имеет keepAlive
if (_listScrollController?.hasClients == true &&
_listScrollController?.position.context.storageContext != null) {
/// Получаем renderBox ListView
final RenderBox? renderBox = _listScrollController
?.position.context.storageContext
.findRenderObject() as RenderBox;
}
Если касание находится за пределами области ListView, это обычно указывает на то, что ListView был прокручен вверх, и координаты касания не совпадают с текущими координатами.
if ((renderBox?.paintBounds
?.shift(renderBox.localToGlobal(Offset.zero)))
?.contains(details.globalPosition) == true) {
_activeScrollController = _listScrollController;
_drag = _activeScrollController?.position.drag(details, _disposeDrag);
return;
}
При событии прокрутки создается объект Drag
, который будет использоваться для обработки последующих событий прокрутки.
Проще говоря: при событии прокрутки создается объект
Drag
, который обрабатывает исходное событие и передает егоScrollPosition
для дальнейшей обработки.#### Обработка обновления прокрутки Метод_handleDragUpdate
проверяет, требуется ли переключиться наPageView
.
_drag?.update(details)
для управления прокруткой ListView
._pageController
для создания нового объекта Drag
для управления прокруткой PageView
.void _handleDragUpdate(DragUpdateDetails details) {
if (_activeScrollController == _listScrollController &&
// При движении пальца вниз, близко к нижней границе PageView
details.primaryDelta! < 0 &&
// Достигнута нижняя граница ListView
_activeScrollController?.position.pixels ==
_activeScrollController?.position.maxScrollExtent) {
// Переключаемся на другой контроллер
_activeScrollController = _pageController;
_drag?.cancel();
// Создаем новый объект Drag для PageView
_drag = _pageController?.position.drag(
DragStartDetails(
globalPosition: details.globalPosition,
localPosition: details.localPosition),
_disposeDrag);
}
_drag?.update(details);
}
```> Вот небольшой факт: как показано вышеуказанной строкой кода, мы можем легко определить направление свайпа и перемещение главной оси с помощью details.primaryDelta
Итоговый результат представлен ниже в виде GIF:
Как видно, PageView
, содержащий ListView
, работает корректно при однородном направлении свайпов. Однако остаются два незначительных вопроса, как это видно из следующего списка:
Позиция ListView
не сохраняется после переключения
Требование продукта заключается в удалении эффекта вылета за границы ListView
```Поэтому нам требуется сделать ListView
"живым" (`KeepAlive`) и использовать простое решение для удаления эффекта Material скролла по краям на Android:
Используйте AutomaticKeepAliveClientMixin
, чтобы ListView
сохранял позицию после переключения
Используйте ScrollConfiguration.of(context).copyWith(overscroll: false)
, чтобы быстро удалить эффект Material скролла по краям
child: PageView(
controller: _pageController,
scrollDirection: Axis.vertical,
/// Удалите стандартный эффект прокрутки по краям на Android
scrollBehavior:
ScrollConfiguration.of(context).copyWith(overscroll: false),
),
/// Делаем ListView "живым", чтобы он запоминал положение
class KeepAliveListView extends StatefulWidget {
final ScrollController? listScrollController;
final int itemCount;
KeepAliveListView({
required this.listScrollController,
required this.itemCount,
});
@override
KeepAliveListViewState createState() => KeepAliveListViewState();
}
class KeepAliveListViewState extends State<KeepAliveListView>
with AutomaticKeepAliveClientMixin {
@override
Widget build(BuildContext context) {
super.build(context);
return ListView.builder(
controller: widget.listScrollController,
/// Отключаем стандартный эффект прокрутки
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) {
return ListTile(title: Text('Списковый элемент $index'));
},
itemCount: widget.itemCount,
);
}
@override
bool get wantKeepAlive => true;
}
Таким образом, мы используем ещё один маленький трюк: ScrollConfiguration.of(context).copyWith(overscroll: false)
для быстрого удаления эффекта Material 2 при достижении краев на Android, почему именно Material 2, потому что в Material 3 это изменилось, подробнее можно прочитать здесь: Flutter 3 и ThemeExtensions с Material3.> Исходный код этого раздела доступен по адресу: https://github.com/CarGuo/gsy_flutter_demo/blob/7838971cefbf19bb53a71041cd100c4c15eb6443/lib/widget/vp_list_demo_page.dart#L75
А есть ли ещё более нетрадиционные способы? Ответ — да, конечно, ведь продукт-менеджеры наверняка придумали бы вложить вертикально прокручиваемый ListView
в вертикально переключаемый PageView
.
Имея в виду предыдущие идеи, реализация этой логики также основана на том же подходе: отключите скролл для PageView
и ListView
, а затем используйте RawGestureDetector
для управления им самостоятельно. Разница заключается в различиях методов обработки жестов.```dart
RawGestureDetector(
gestures: <Type, GestureRecognizerFactory>{
VerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<
VerticalDragGestureRecognizer>(
() => VerticalDragGestureRecognizer(),
(VerticalDragGestureRecognizer instance) {
instance
..onStart = _handleDragStart
..onUpdate = _handleDragUpdate
..onEnd = _handleDragEnd
..onCancel = _handleDragCancel;
})
},
behavior: HitTestBehavior.opaque,
child: ListView.builder(
// Отключение дефолтной реакции на скролл
physics: NeverScrollableScrollPhysics(),
controller: _listScrollController,
itemCount: 5,
itemBuilder: (context, index) {
if (index == 0) {
return Container(
height: 300,
child: KeepAlivePageView(
pageController: _pageController,
itemCount: itemCount,
),
);
}
return Container(
height: 300,
color: Colors.greenAccent,
child: Center(
child: Text(
"Элемент $index",
style: TextStyle(fontSize: 40, color: Colors.blue),
),
));
}),
)
```dart
void _handleDragStart(DragStartDetails details) {
// Первым делом выполняется проверка
}
```- Если `ListView` уже был прокручен, он не реагирует на события верхнего `PageView`.
- Если `ListView` находится в начальном положении и не был прокручен, проверяется позиция касания, чтобы определить, находится ли она внутри `PageView`. Если да, то событие передается `PageView`.
```dart
void _handleDragStart(DragStartDetails details) {
// Если не находится в начале, не реагировать на события PageView
// Это работает только при горизонтальной прокрутке PageView в верхней части ListView
if (_listScrollController.offset > 0) {
_activeScrollController = _listScrollController;
_drag = _listScrollController.position.drag(details, _disposeDrag);
return;
}
// Если находится в верхней части ListView
if (_pageController.hasClients) {
// Получаем PageView
final RenderBox renderBox = _pageController.position.context.storageContext.findRenderObject() as RenderBox;
// Определяем, находится ли касание внутри границ PageView
final isDragPageView = renderBox.paintBounds.shift(renderBox.localToGlobal(Offset.zero)).contains(details.globalPosition);
// Если касание внутри PageView, переключаемся на PageView
if (isDragPageView) {
_activeScrollController = _pageController;
_drag = _activeScrollController.position.drag(details, _disposeDrag);
return;
}
}
// Если касание вне PageView, продолжаем реагировать на ListView
_activeScrollController = _listScrollController;
_drag = _listScrollController.position.drag(details, _disposeDrag);
}
Затем в методе _handleDragUpdate
проверяется, если PageView
достиг последней страницы, событие прокрутки переключается обратно на ListView
.
void _handleDragUpdate(DragUpdateDetails details) {
var scrollDirection = _activeScrollController.position.userScrollDirection;
} // Если активный контроллер — это _pageController и мы достигли конца,
if (_activeScrollController == _pageController && scrollDirection == ScrollDirection.reverse &&```markdown
/// Проверяем, не достигли ли последней страницы; если да, возвращаемся к `_pageController`
(_pageController.page != null && _pageController.page! >= (itemCount - 1))) {
/// Возвращаемся к `_listScrollController`
_activeScrollController = _listScrollController;
_drag?.cancel();
_drag = _listScrollController.position.drag(
DragStartDetails(globalPosition: details.globalPosition, localPosition: details.localPosition),
_disposeDrag);
}
_drag?.update(details);

> Исходный код этого раздела доступен по адресу: https://github.com/CarGuo/gsy_flutter_demo/blob/7838971cefbf19bb53a71041cd100c4c15eb6443/lib/widget/vp_list_demo_page.dart#L262
Дополнительно предлагается небольшой трюк: **если вам требуется вывод процесса конкуренции жестов Flutter, вы можете настроить `debugPrintGestureArenaDiagnostics = true;`, чтобы заставить Flutter выводить процесс конкуренции жестов**.
```dart
import 'package:flutter/gestures.dart';
void main() {
debugPrintGestureArenaDiagnostics = true;
runApp(MyApp());
}
Drag
для решения различных проблем конфликтов жестов из-за вложенного расположения, и теперь вы знаете, как использовать Controller
и Drag
для быстрого создания некоторых требуемых слайдовых эффектов, таких как синхронизация ListView
:```dart/// синхронизация ListView со списком ListView class ListViewLinkListView extends StatefulWidget { @override _ListViewLinkListViewState createState() => _ListViewLinkListViewState(); }
class _ListViewLinkListViewState extends State { ScrollController _primaryScrollController = ScrollController(); ScrollController _subScrollController = ScrollController();
Drag? _primaryDrag; Drag? _subDrag;
@override void initState() { super.initState(); }
@override void dispose() { _primaryScrollController.dispose(); _subScrollController.dispose(); super.dispose(); }
void _handleDragStart(DragStartDetails details) { _primaryDrag = _primaryScrollController.position.drag(details, _disposePrimaryDrag); _subDrag = _subScrollController.position.drag(details, _disposeSubDrag); }
void _handleDragUpdate(DragUpdateDetails details) { _primaryDrag?.update(details);
/// деление на 30 для реализации эффекта разницы
_subDrag?.update(DragUpdateDetails(
sourceTimeStamp: details.sourceTimeStamp,
delta: details.delta / 30,
primaryDelta: (details.primaryDelta ?? 0) / 30,
globalPosition: details.globalPosition,
localPosition: details.localPosition,
));
}
void _handleDragEnd(DragEndDetails details) { _primaryDrag?.end(details); _subDrag?.end(details); }
void _handleDragCancel() { _primaryDrag?.cancel(); _subDrag?.cancel(); }
void _disposePrimaryDrag() { _primaryDrag = null; }
void _disposeSubDrag() { _subDrag = null; } }
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("ListViewLinkListView"),
),
body: RawGestureDetector(
gestures: <Type, GestureRecognizerFactory>{
VerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<
VerticalDragGestureRecognizer>(
() => VerticalDragGestureRecognizer(),
(VerticalDragGestureRecognizer instance) {
instance
..onStart = _обработать_начало_перетаскивания
..onUpdate = _обработать_обновление_перетаскивания
..onEnd = _обработать_законченный_перетаскивание
..onCancel = _обработать_отмену_перетаскивания;
})
},
behavior: HitTestBehavior.opaque,
child: ScrollConfiguration(
/// Удаление дефолтного эффекта скроллинга с краев экрана на Android
behavior:
ScrollConfiguration.of(context).copyWith(overscroll: false),
child: Row(
children: [
new Expanded(
child: ListView.builder(```markdown
/// Блокировка стандартного отклика на прокрутку
physics: NeverScrollableScrollPhysics(),
controller: _основнойСкроллКонтроллер,
itemCount: 55,
itemBuilder: (context, index) {
return Container(
height: 300,
color: Colors.greenAccent,
child: Center(
child: Text(
"Элемент $index",
style: TextStyle(fontSize: 40, color: Colors.blue),
),
));
})),
new SizedBox(
width: 5,
),
new Expanded(
child: ListView.builder(
Примечание: В данном контексте были использованы специальные названия методов и переменных для удобства понимания, но в реальном коде такие названия могут быть переопределены согласно соглашению об именах (naming convention) используемого проекта.```markdown physics: NeverScrollableScrollPhysics(), controller: _mainScrollController, itemCount: 55, itemBuilder: (context, index) { return Container( height: 300, color: Colors.deepOrange, child: Center( child: Text( "Позиция $index", style: TextStyle(fontSize: 40, color: Colors.white), ), ), ); }), ), ], ), ), )); } }
Вы можете оставить комментарий после Вход в систему
Неприемлемый контент может быть отображен здесь и не будет показан на странице. Вы можете проверить и изменить его с помощью соответствующей функции редактирования.
Если вы подтверждаете, что содержание не содержит непристойной лексики/перенаправления на рекламу/насилия/вульгарной порнографии/нарушений/пиратства/ложного/незначительного или незаконного контента, связанного с национальными законами и предписаниями, вы можете нажать «Отправить» для подачи апелляции, и мы обработаем ее как можно скорее.
Опубликовать ( 0 )