1 В избранное 0 Ответвления 0

OSCHINA-MIRROR/CarGuo-GSYFlutterBook

Присоединиться к Gitlife
Откройте для себя и примите участие в публичных проектах с открытым исходным кодом с участием более 10 миллионов разработчиков. Приватные репозитории также полностью бесплатны :)
Присоединиться бесплатно
Клонировать/Скачать
Flutter-N5.md 29 КБ
Копировать Редактировать Web IDE Исходные данные Просмотреть построчно История
Отправлено 10.03.2025 00:06 5767d61

Небольшие хитрости Flutter: различные способы вложения ListView и PageView

Эта небольшая статья посвящена различным способам вложения компонентов ListView и PageView в Flutter. Проблемы конфликта различных Scrollable компонентов вам, вероятно, знакомы. Сегодня мы рассмотрим три разных способа вложения этих компонентов.

Обычное вложение

Наиболее распространённое вложение — это горизонтальное PageView, объединённое с вертикальным ListView. В большинстве случаев такое сочетание работает хорошо, но если вы пытаетесь скроллить под углом, могут возникнуть проблемы.

Недавно несколько человек одновременно столкнулись с проблемой: "При скролле ListView под углом управление передаётся на PageView". Это можно видеть на следующем GIF:

xiehuadong

Хотя лично мне эта проблема кажется не слишком важной, если продукт требует её решения, придётся либо переписывать обработчики событий для PageView, либо находить другой подход.

Рассмотрим внутреннюю структуру PageView и ListView: оба используют компонент Scrollable, который отвечает за скроллинг через RawGestureDetector.

  • VerticalDragGestureRecognizer отвечает за события вертикального скроллинга.
  • HorizontalDragGestureRecognizer отвечает за события горизонтального скроллинга.Один из методов, используемых для определения области действия указателя, называется computeHitSlop. Этот метод определяет минимальное расстояние между указателем и объектом, которое считается попаданием. По умолчанию для касаний используется значение kTouchSlop, равное 18.0 пикселей.image-20220613103745974

Идея состоит в том, что если мы увеличим значение 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`**.

![наклонный скролл не срабатывает](http://img.cdn.guoshuyu.cn/2bk/image3.gif)

# Вертикальный `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, это обычно указывает на то, что 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:

7777777777777

Как видно, 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.000000000> Исходный код этого раздела доступен по адресу: https://github.com/CarGuo/gsy_flutter_demo/blob/7838971cefbf19bb53a71041cd100c4c15eb6443/lib/widget/vp_list_demo_page.dart#L75

Направленное вниз вложение ListView в PageView

А есть ли ещё более нетрадиционные способы? Ответ — да, конечно, ведь продукт-менеджеры наверняка придумали бы вложить вертикально прокручиваемый 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);

![Результат](http://img.cdn.guoshuyu.cn/20220703_N5/image6.gif)

> Исходный код этого раздела доступен по адресу: 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 )

Вы можете оставить комментарий после Вход в систему

1
https://api.gitlife.ru/oschina-mirror/CarGuo-GSYFlutterBook.git
git@api.gitlife.ru:oschina-mirror/CarGuo-GSYFlutterBook.git
oschina-mirror
CarGuo-GSYFlutterBook
CarGuo-GSYFlutterBook
master