Flutter 列表Item可见区域监听
前言
类似extended_list插件,viewportBuilder可以拿到当前视口的索引,collectGarbage可以拿到不可见(可回收的)children的索引。
但他需要使用他的组件替代常用的ListView、GridView组件,有侵入性,有没有不需要替换组件,直接套住就能用的方法呢?
NotificationListener
对于滚动列表的监听,立马就能想到的就是NotificationListener。那我们能不能够通过它来监听得到我们想要的东西呢。
NotificationListener(
  onNotification: onNotification,
  child: child,
)
滚动列表我们取ScrollNotification。
bool onNotification(ScrollNotification notice) {
  ...
}
从ScrollNotification中我们可以得到什么?
/// A description of a [Scrollable]'s contents, useful for modeling the state
/// of its viewport.
final ScrollMetrics metrics;
/// The build context of the widget that fired this notification.
///
/// This can be used to find the scrollable's render objects to determine the
/// size of the viewport, for instance.
final BuildContext? context;
一个是context,一个是ScrollMetrics
ScrollMetrics是视口状态的信息,主要可以得到minScrollExtent(最小滚动距离)、maxScrollExtent(最大滚动距离)、pixels(当前滚动位置)、viewportDimension(视口范围)、axisDirection(滚动方向)。
那也就是说,假如我们可以获取到Item的位置,就可以根据大于pixels,小于pixels + viewportDimension来判断Item是否在可视范围中。
Item的element
既然可以得到滚动列表的context,而context也就是Element,Element可以遍历他的子Element
element.visitChildElements((child) {
});
但需要知道,我们的目标Element是什么。
观察一下ListView、GridView,会发现它们最终的Layout构建,都是Sliver,且都继承自SliverMultiBoxAdaptorWidget。
@override
Widget buildChildLayout(BuildContext context) {
  if (itemExtent != null) {
    return SliverFixedExtentList(
      delegate: childrenDelegate,
      itemExtent: itemExtent!,
    );
  } else if (prototypeItem != null) {
    return SliverPrototypeExtentList(
      delegate: childrenDelegate,
      prototypeItem: prototypeItem!,
    );
  }
  return SliverList(delegate: childrenDelegate);
}
class SliverFixedExtentList extends SliverMultiBoxAdaptorWidget 
class SliverList extends SliverMultiBoxAdaptorWidget
class SliverGrid extends SliverMultiBoxAdaptorWidget
那可以尝试通过遍历child,来找到对应的SliverMultiBoxAdaptorWidget的Element。
/// 递归找到对应SliverMultiBoxAdaptorElement返回
SliverMultiBoxAdaptorElement? findSliverMultiBoxAdaptorElement(
    Element element) {
  if (element is SliverMultiBoxAdaptorElement) {
    return element;
  }
  SliverMultiBoxAdaptorElement? target;
  element.visitChildElements((child) {
    target ??= findSliverMultiBoxAdaptorElement(child);
  });
  return target;
}
用的递归的方法,不断遍历直到遇到SliverMultiBoxAdaptorElement。
然后访问他的children,也就是Item列表
sliverMultiBoxAdaptorElement.visitChildren((Element element) {});
是一个回调的形式,不断返回访问的child(element)。
element可以访问RenderObject,而RenderObject布局属性则有parentData决定。SliverMultiBoxAdaptorElement的parentData则为SliverMultiBoxAdaptorParentData,所以也可以拿到了Item的布局数据。
final SliverMultiBoxAdaptorParentData oldParentData =
    element.renderObject?.parentData as SliverMultiBoxAdaptorParentData;
滚动列表的Item的parentData,可以取到layoutOffset,基于滚动列表的Item的位置。
位置信息得到了,然后就可以判断他是否在可视范围内
/// 取得Item的element数据
final SliverMultiBoxAdaptorElement? sliverMultiBoxAdaptorElement =
    findSliverMultiBoxAdaptorElement(notice.context! as Element);
if (sliverMultiBoxAdaptorElement == null) return false;
/// 可见区域的距离
final viewportDimension = notice.metrics.viewportDimension;
/// 第一个可见Item 坐标
int firstIndex = 0;
/// 最后一个可见Item 坐标
int endIndex = 0;
/// 访问Children时判断Child是否可见,并计算相应的first和end
void onVisitChildren(Element element) {
  final SliverMultiBoxAdaptorParentData oldParentData =
      element.renderObject?.parentData as SliverMultiBoxAdaptorParentData;
  double layoutOffset = oldParentData.layoutOffset!;
  double pixels = notice.metrics.pixels;
  double all = pixels + viewportDimension;
  /// 判断是否在可见区域内
  if (layoutOffset >= pixels) {
    firstIndex = min(firstIndex, oldParentData.index!);
    if (layoutOffset <= all) {
      endIndex = max(endIndex, oldParentData.index!);
    }
    firstIndex = max(firstIndex, 0);
  } else {
    endIndex = firstIndex = oldParentData.index!;
  }
}
sliverMultiBoxAdaptorElement.visitChildren(onVisitChildren);
封装使用
然后再简单的封装一下
class ItemCollectListener extends StatefulWidget {
  const ItemCollectListener({
    Key? key,
    required this.child,
    this.indexCallBack,
    this.scrollDirection = Axis.vertical,
  }) : super(key: key);
scrollDirection可以限制只处理对应方向上的Notification。
ItemCollectListener(
  indexCallBack: (firstIndex, endIndex, notification)  {
    print("firstIndex: $firstIndex, endIndex: $endIndex");
  },
  child: ListView.builder(
    itemCount: 100,
    itemBuilder: (context, index) {
      return Container(
        width: double.maxFinite,
        height: 50,
        color: Colors.blue.withOpacity(index / 100),
      );
    },
  ),
)
这样就可以监听到哪些Item进入到了可视窗口中了,这里是将首个和末尾的下标返回。
不需要修改ListView,只需要套在需要监听的滚动列表上,就可以监听到他的可视状态,测试大部分都有效。
可以思考一下,移出的Item即回收的监听要怎么监听到?
转载自:https://juejin.cn/post/7109782363629944845




