Flutter 小技巧之 ListView 和 PageView 的各种花式嵌套-灵析社区

chole

这次的 Flutter 小技巧是 ListViewPageView 的花式嵌套,不同 Scrollable 的嵌套冲突问题相信大家不会陌生,今天就通过  ListViewPageView 的三种嵌套模式带大家收获一些不一样的小技巧。

正常嵌套

最常见的嵌套应该就是横向  PageView  加纵向  ListView  的组合,一般情况下这个组合不会有什么问题,除非你硬是要斜着滑

最近刚好遇到好几个人同时在问:“斜滑 ListView 容易切换到 PageView  滑动” 的问题,如下 GIF 所示,当用户在滑动    ListView   时,滑动角度带上倾斜之后,可能就会导致滑动的是   PageView   而不是 ListView

我们简单看一下,不管是  PageView    还是   ListView  它们的滑动效果都来自于  Scrollable ,而    Scrollable  内部针对不同方向的响应,是通过 RawGestureDetector 完成:

  • VerticalDragGestureRecognizer 处理垂直方向的手势
  • HorizontalDragGestureRecognizer  处理水平方向的手势

所以简单看它们响应的判断逻辑,可以看到一个很有趣的方法 computeHitSlop根据 pointer 的类型确定当然命中需要的最小像素,触摸默认是 kTouchSlop (18.0)

看到这你有没有灵光一闪:如果我们把 PageView 的 touchSlop 修改了,是不是就可以调整它响应的灵敏度? 恰好在  computeHitSlop  方法里,它可以通过 DeviceGestureSettings 来配置,而  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(),
    ],
  ),
),

小技巧一:通过嵌套一个 MediaQuery ,然后调整 gestureSettings 的 touchSlop 从而修改 PageView 的灵明度 ,另外不要忘记,还需要把  ListView 的   touchSlop  切换会默认 的 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('Item $index'),
          );
        },
        separatorBuilder: (context, index) {
          return const Divider(
            thickness: 3,
          );
        },
      ),
    );
  }
}

最后我们看一下效果,如下 GIF 所示,现在就算你斜着滑动,也很触发  PageView  的水平滑动,只有横向移动时才会触发   PageView  的手势,当然, 如果要说这个粗暴的写法有什么问题的话,大概就是降低了 PageView 响应的灵敏度

同方向 PageView 嵌套 ListView

介绍完常规使用,接着来点不一样的,在垂直切换的 PageView 里嵌套垂直滚动的  ListView , 你第一感觉是不是觉得不靠谱,为什么会有这样的场景?

对于产品来说,他们不会考虑你如何实现的问题,他们只会拍着脑袋说淘宝可以,为什么你不行,所以如果是你,你会怎么做?

而关于这个需求,社区目前讨论的结果是:把 PageView 和 ListView 的滑动禁用,然后通过 RawGestureDetector 自己管理

如果对实现逻辑分析没兴趣,可以直接看本小节末尾的 源码链接 。

看到自己管理先不要慌,虽然要自己实现  PageView 和  ListView  的手势分发,但是其实并不需要重写   PageView 和  ListView  ,我们可以复用它们的 Darg 响应逻辑,如下代码所示:

  • 通过 NeverScrollableScrollPhysics 禁止了 PageViewListView 的滚动效果
  • 通过顶部 RawGestureDetector VerticalDragGestureRecognizer 自己管理手势事件
  • 配置 PageControllerScrollController  用于获取状态
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('List Item $index'));
        },
        itemCount: 30,
      ),
      Container(
        color: Colors.green,
        child: Center(
          child: Text(
            'Page View',
            style: TextStyle(fontSize: 50),
          ),
        ),
      )
    ],
  ),
),

接着我们看 _handleDragStart 实现,如下代码所示,在产生手势 details 时,我们主要判断:

  • 通过  ScrollController   判断 ListView 是否可见
  • 判断触摸位置是否在 ListIView 范围内
  • 根据状态判断通过哪个 Controller 去生产  Drag 对象,用于响应后续的滑动事件

  void _handleDragStart(DragStartDetails details) {
    ///先判断 Listview 是否可见或者可以调用
    ///一般不可见时 hasClients false ,因为 PageView 也没有 keepAlive
    if (_listScrollController?.hasClients == true &&
        _listScrollController?.position.context.storageContext != null) {
      ///获取 ListView 的  renderBox
      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;
      }
    }

    ///这时候就可以认为是 PageView 需要滑动
    _activeScrollController = _pageController;
    _drag = _pageController?.position.drag(details, _disposeDrag);
  }

前面我们主要在触摸开始时,判断需要响应的对象时 ListView 还是 PageView ,然后通过 _activeScrollController 保存当然响应对象,并且通过  Controller 生成用于响应手势信息的  Drag 对象。

简单说:滑动事件发生时,默认会建立一个 Drag 用于处理后续的滑动事件,Drag 会对原始事件进行加工之后再给到 ScrollPosition 去触发后续滑动效果。

接着在 _handleDragUpdate 方法里,主要是判断响应是不是需要切换到 PageView :

  • 如果不需要就继续用前面得到的  _drag?.update(details)响应 ListView 滚动
  • 如果需要就通过 _pageController 切换新的  _drag 对象用于响应
void _handleDragUpdate(DragUpdateDetails details) {
  if (_activeScrollController == _listScrollController &&

      ///手指向上移动,也就是快要显示出底部 PageView
      details.primaryDelta! < 0 &&

      ///到了底部,切换到 PageView
      _activeScrollController?.position.pixels ==
          _activeScrollController?.position.maxScrollExtent) {
    ///切换相应的控制器
    _activeScrollController = _pageController;
    _drag?.cancel();

    ///参考  Scrollable 里
    ///因为是切换控制器,也就是要更新 Drag
    ///拖拽流程要切换到 PageView 里,所以需要  DragStartDetails
    ///所以需要把 DragUpdateDetails 变成 DragStartDetails
    ///提取出 PageView 里的 Drag 相应 details
    _drag = _pageController?.position.drag(
        DragStartDetails(
            globalPosition: details.globalPosition,
            localPosition: details.localPosition),
        _disposeDrag);
  }
  _drag?.update(details);
}
这里有个小知识点:如上代码所示,我们可以简单通过 details.primaryDelta 判断滑动方向和移动的是否是主轴

最后如下 GIF 所示,可以看到 PageView 嵌套 ListView 同方向滑动可以正常运行了,但是目前还有个两个小问题,从图示可以看到:

  • 在切换之后  ListView 的位置没有保存下来
  • 产品要求去除 ListView 的边缘溢出效果

所以我们需要对 ListView 做一个 KeepAlive ,然后用简单的方法去除 Android 边缘滑动的 Material 效果:

  • 通过 with AutomaticKeepAliveClientMixinListView 在切换之后也保持滑动位置
  • 通过 ScrollConfiguration.of(context).copyWith(overscroll: false) 快速去除 Scrollable 的边缘  Material 效果
child: PageView(
  controller: _pageController,
  scrollDirection: Axis.vertical,
  ///去掉 Android 上默认的边缘拖拽效果
  scrollBehavior:
      ScrollConfiguration.of(context).copyWith(overscroll: false),


///对 PageView 里的 ListView 做 KeepAlive 记住位置
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('List Item $index'));
      },
      itemCount: widget.itemCount,
    );
  }

  @override
  bool get wantKeepAlive => true;
}

所以这里我们有解锁了另外一个小技巧:通过 ScrollConfiguration.of(context).copyWith(overscroll: false) 快速去除 Android 滑动到边缘的 Material 2效果,为什么说 Material2, 因为 Material3 上变了,具体可见: Flutter 3 下的 ThemeExtensions 和 Material3

阅读量:1954

点赞量:0

收藏量:0