Flutter 如何优雅的实现滑动元素曝光

背景

要问当今移动端最火的跨平台应用是谁 ,那非Flutter莫属了,随着Flutter的热度上涨和应用范围扩大,开发者将面临更多的挑战. 如:怎么通过Flutter的一些机制来实现的获取ListView或GridView中的子元素曝光事件 ?网上的回答有很多,但大多数都不够精确或是需要较大的代价,举两个频率较高的解决方案:
1、 通过设置cacheExtent来禁止预加载,从而保证itemBuilder中构建的每个元素都是可见的,但这样的代价是牺牲了原有滑动性能.这是无法让人接受的
2、通过给子节点包裹上自定义的RenderObject在paint方法中计算当前是否可见,这样的方式实现起来比较麻烦并且性能不高,有较大开销
那么现在问题来了,怎么优雅的获取子元素的曝光呢?

效果

垂直ListView

水平ListView

垂直GridView

水平GridView

曝光原理

从效果中可以看出本次说的曝光方式可以支持ListView和GridView并且支持垂直和水平两种方向. 那么现在我们来分析一下曝光原理,在说曝光原理之前,我们一定要明白ListView和GridView的一些原理

Flutter Sliver

对比Android和Flutter对于滑动元素的处理,Android是将滑动和布局等都汇聚于View中处理,而Flutter却是将滑动与布局进行分离,滑动由Scrollable来处理,而布局由Sliver元素处理.这样的设计使得Flutter在实现列表多布局上变得轻而易举,首先我们来看一下ListView和GridView的结构,通过源代码可以看出两者都继承了BoxScrollView,BoxScroller这个类的主要功能就是定义buildChildLayout,让子类返回对应Sliver实现类,并通过Scrollable包裹从而实现滑动列表效果,而我们的ListView和GridView正式通过此方法返回SliverList和SliverGrid.所以综上所述,我们能明白,想要实现曝光那必须从真实实现布局的Element中去寻找

SliverMultiBoxAdaptorElement

先看两段代码:
SliverList

class SliverList extends SliverMultiBoxAdaptorWidget {
  /// Creates a sliver that places box children in a linear array.
  const SliverList({
    Key key,
    @required SliverChildDelegate delegate,
  }) : super(key: key, delegate: delegate);

  @override
  RenderSliverList createRenderObject(BuildContext context) {
    final SliverMultiBoxAdaptorElement element = context;
    return RenderSliverList(childManager: element);
  }
}

SliverGrid

class SliverGrid extends SliverMultiBoxAdaptorWidget {
  @override
  RenderSliverGrid createRenderObject(BuildContext context) {
    final SliverMultiBoxAdaptorElement element = context;
    return RenderSliverGrid(childManager: element, gridDelegate: gridDelegate);
  }
}

细心的朋友估计已经从上述两段代码中看出了一些端倪,两个Widget在createRenderObject方法中使用了SliverMultiBoxAdaptorElement,并且如果细心的去看源代码,能发现包括SliverFixedExtentList、SliverFillViewport等在createRenderObject都是绑定了SliverMultiBoxAdaptorElement,明白Flutter Widget原理的同学都应该明白,Flutter中的Element扮演着十分重要的角色,framework正是通过Element将Widget树转为可绘制的RenderObject树,它是两者的桥梁,也持有两者的引用,是很多数据的范围入口,并且从创建RenderObject的方法中能看到此element的参数名为childManager,自然的能想到其负责列表中的子节点管理,那它肯定也知道很多的相关信息,继续深入查看源代码

class SliverMultiBoxAdaptorElement {
  final SplayTreeMap _childElements = SplayTreeMap();

  @override
  void createChild(int index, { @required RenderBox after }) {
    assert(_currentlyUpdatingChildIndex == null);
    owner.buildScope(this, () {
      final bool insertFirst = after == null;
      assert(insertFirst || _childElements[index-1] != null);
      _currentBeforeChild = insertFirst ? null : _childElements[index-1].renderObject;
      Element newChild;
      try {
        _currentlyUpdatingChildIndex = index;
        newChild = updateChild(_childElements[index], _build(index), index);
      } finally {
        _currentlyUpdatingChildIndex = null;
      }
      if (newChild != null) {
        _childElements[index] = newChild;
      } else {
        _childElements.remove(index);
      }
    });
  }
}

  @override
  void removeChild(RenderBox child) {
    final int index = renderObject.indexOf(child);
    assert(_currentlyUpdatingChildIndex == null);
    assert(index >= 0);
    owner.buildScope(this, () {
      assert(_childElements.containsKey(index));
      try {
        _currentlyUpdatingChildIndex = index;
        final Element result = updateChild(_childElements[index], null, index);
        assert(result == null);
      } finally {
        _currentlyUpdatingChildIndex = null;
      }
      _childElements.remove(index);
      assert(!_childElements.containsKey(index));
    });
  }

从上述代码中可以看到SliverMultiBoxAdaptorElement中有一个_childElements用于保存子节点元素,并且结合RenderSliverMultiBoxAdaptor中的源代码可以得出在新子节点滑入时根据index创建或更新子节点元素,并且在子节点划出屏幕时调用removeChild方法移除不可见元素.看到这,是不是感觉有点激动? 从上述的阐述中可以得知这个_childElements中保存的正是当前ListView和GridView中屏幕可见+缓存区域的全部子节点信息,那自然的就可以想到通过遍历这些元素来获取当前可见的item,但是先别急,现在还存在两个问题

  • _childElements是私有的,得找出访问入口
  • 如果在遍历_childElements时判断某个子节点可见
    那我们继续看一下源代码
  @override
  void visitChildren(ElementVisitor visitor) {
    // The toList() is to make a copy so that the underlying list can be modified by
    // the visitor:
    assert(!_childElements.values.any((Element child) => child == null));
    _childElements.values.toList().forEach(visitor);
  }

   @override
  Element updateChild(Element child, Widget newWidget, dynamic newSlot) {
    final SliverMultiBoxAdaptorParentData oldParentData = child?.renderObject?.parentData;
    final Element newChild = super.updateChild(child, newWidget, newSlot);
    final SliverMultiBoxAdaptorParentData newParentData = newChild?.renderObject?.parentData;

    // Preserve the old layoutOffset if the renderObject was swapped out.
    if (oldParentData != newParentData && oldParentData != null && newParentData != null) {
      newParentData.layoutOffset = oldParentData.layoutOffset;
    }
    return newChild;
  }

功夫不负有心人,我们还是有办法来解决上述的两个问题的,首先,我们可以看到在visitChildren方法中, SliverMultiBoxAdaptorElement抛出了一个入口让我们遍历_childElements,这样第一个问题解决了,那第二个问题可以从updateChild方法中得出答案,从此方法中我们可以看到在更新子元素时给新的子元素的parentData赋值了layoutOffset,这个layoutOffset就是每个item距离ListView布局开始位置的偏移量.如下计算公式所示,通过layoutOffset再结合当前的滑动位置即可计算当前子节点是否完全可见,

    double listViewTop = 当前滑动位置; 
    double listViewBottom = 当前滑动位置 + ListView高度
    double itemTop = layoutOffset;
    double itemBottom = layoutOffset + item高度;
    bool isVisible = itemTop >= listViewTop && itemBottom <= listViewBottom;

对于滑动位置的获取我们可以通过监听滑动事件回调方法中的ScrollNotification获取,即ScrollNotification.ScrollMetrics.pixels. 对于ListView的高度我们可以通过
ScrollNotification.ScrollMetrics.viewportDimension获取,对于item的高度我们可以通过遍历_childElements时通过element.renderObject.paintBounds.height获得,所以根据上述替换计算公式可得出

    double listViewTop = notice.metrics.pixels; 
    double listViewBottom = notice.metrics.pixels + notice.metrics.viewportDimension
    double itemTop = layoutOffset;
    double itemBottom = layoutOffset + element.renderObject.paintBounds.height;
    bool isVisible = itemTop >= listViewTop && itemBottom <= listViewBottom;

最后我们再结合上查找SliverMultiBoxAdaptorElement和遍历_childElements的流程可以封装成Widget来实现获取滑动后的ListView或GridView中的可见元素范围

typedef ExposureCallback = void Function(
    int firstIndex, int lastIndex, ScrollNotification scrollNotification);

class ExposureListener extends StatelessWidget {
  final Widget child;
  final ExposureCallback callback;
  final Axis scrollDirection;

  const ExposureListener(
      {Key key,
      this.child,
      this.callback,
      this.scrollDirection})
      : super(key: key);

  @override
  Widget build(BuildContext context) {
    return NotificationListener(child: child, onNotification: _onNotification);
  }

  bool _onNotification(ScrollNotification notice) {
    final sliverMultiBoxAdaptorElement = findSliverMultiBoxAdaptorElement(notice.context);
    if (sliverMultiBoxAdaptorElement == null) return false;
    int firstIndex = sliverMultiBoxAdaptorElement.childCount;
    assert(firstIndex != null);
    int endIndex = -1;
    void onVisitChildren(Element element) {
      final SliverMultiBoxAdaptorParentData oldParentData =
          element?.renderObject?.parentData;
      if (oldParentData != null) {
        double boundFirst = oldParentData.layoutOffset;
        double itemLength = scrollDirection == Axis.vertical
            ? element.renderObject.paintBounds.height
            : element.renderObject.paintBounds.width;
        double boundEnd = itemLength + boundFirst;
        if (boundFirst >= notice.metrics.pixels &&
            boundEnd <=
                (notice.metrics.pixels + notice.metrics.viewportDimension)) {
          firstIndex = min(firstIndex, oldParentData.index);

          endIndex = max(endIndex, oldParentData.index);
        }
      }
    }

    sliverMultiBoxAdaptorElement.visitChildren(onVisitChildren);
    callback(firstIndex, endIndex, notice);
    return false;
  }

  SliverMultiBoxAdaptorElement findSliverMultiBoxAdaptorElement(
      Element element) {
    if (element is SliverMultiBoxAdaptorElement) {
      return element;
    }
    SliverMultiBoxAdaptorElement target;
    element.visitChildElements((child) {
      target ??= findSliverMultiBoxAdaptorElement(child);
    });
    return target;
  }
}

然后将其包裹ListView或GridView即可事件监听滑动过程中可见的子元素曝光事件,代码如下所示

    ExposureListener(
      child: ListView.builder(
        controller: _scrollController,
        itemBuilder: _onItemBuilder,
        scrollDirection: axis,
        itemCount: 200,
      ),
      scrollDirection: axis,
      callback: (first, last, notice) {
        print('当前第一个完全可见元素下标 $first 当前最后一个完全可见元素下标 $last');
      },
    );

对,就这些代码就能实现对ListView中当前元素曝光的监听,但是这里有个小细节提及一下,因为所有的事件由ScrollNotification驱动,第一次进入时并不会触发曝光事件.针对这个问题可以通过ScrollController解决,在State 的initState方法中调用ScrollPosition的didScrollEnd来发送一个初始化的滑动信息,保证首次进入能正常曝光,代码如下所示:

 @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((_) {
      widget.scrollController.position.didEndScroll();
    });
  }

对比一下背景的实现方法,我们通过获取SliverMultiBoxAdaptorElement中缓存的子节点信息直接可以计算出当前可见的元素节点范围,效果是十分精确的(毕竟是framework层提供的数据),并且其速度和内存的消耗也是十分可观的.

曝光埋点实现

根据上述原理,我们可以轻松获取对ListView和GridView的滑动曝光事件,当然好刀也要应用到刀刃上,对此我们来实现一个通用的埋点曝光框架,并且需要支持CustomScrollView等,为此我们先来理解一下功能点

  • 滑动时可见元素范围通知
  • 子元素开始曝光事件
  • 子元素结束曝光事件(返回曝光时长)
  • 支持由业务判断是否曝光
  • 支持CustomScrollView中多section曝光
    确定好需求好,我们先来定义一下回调方法
class IndexRange {
  // 父节点下标
  final int parentIndex;
  // 第一个可见的元素下标
  final int firstIndex;
  // 最后一个可见元素下标
  final int lastIndex;
  IndexRange(this.parentIndex, this.firstIndex, this.lastIndex);
}

/// Scrollable中仅包含一个SliverList或SliverGrid等元素时使用
/// 返回滑动中可见元素下标范围和ScrollNotification
typedef SingleScrollCallback = void Function(
    IndexRange range, ScrollNotification scrollNotification);

/// Scrollable中包含多个Sliver元素时使用
/// 返回滑动中可见元素下标范围和ScrollNotification
/// 因为支持多section,所以返回的是List
typedef SliverScrollCallback = void Function(
    List, ScrollNotification scrollNotification);

class ExposureStartIndex {
  // 父节点下标
  final int parentIndex;
  // 曝光子节点下标
  final int itemIndex;
  // 曝光开始时间
  final int exposureStartTime;
  ExposureStartIndex(this.parentIndex, this.itemIndex, this.exposureStartTime);
}
/// 子元素开始曝光回调,返回子元素开始曝光的信息
typedef ExposureStartCallback = void Function(ExposureStartIndex index);

class ExposureEndIndex {
  // 父节点下标
  final int parentIndex;
  // 曝光子节点下标
  final int itemIndex;
  // 曝光结束时间
  final int exposureEndTime;
  // 曝光时长
  final int exposureTime;
  ExposureEndIndex(this.parentIndex, this.itemIndex, this.exposureEndTime, this.exposureTime);
}

/// 子元素结束曝光回调,返回子元素结束曝光的信息
typedef ExposureEndCallback = void Function(ExposureEndIndex index);

/// 根据当前子节点元素的状态判断此子节点是否处于曝光状态
/// [index]子节点位置信息,[paintExtent]子节点在屏幕中可见的范围
/// [maxPaintExtent]子节点完全展示时的范围,如果将子节点完全
/// 展示作为曝光的依据可返回 [paintExtent == maxPaintExtent]
typedef ExposureReferee = bool Function(
    ExposureStartIndex index, double paintExtent, double maxPaintExtent);

根据我们的需求定好回调方法后,我们就需要构思如果实现这些功能点了,首先我们通过SliverMultiBoxAdaptorElement可以解决掉SliverList和SliverGrid等单一sliver节点,但是面对CustomScrollView这种可能会出现多个sliver时却是有心无力,既然如此那我们继续来分析源代码吧

RenderSliver

针对SliverMultiBoxAdaptorElement这种我们有了解决方案就不再考虑了,所以先来看看其他sliver,如下所示

  • SliverAppBar
  • SliverPersistentHeader
  • SliverFillRemaining
  • SliverToBoxAdapter
  • SliverPadding
    还是老样子,我们先确定他们是否由共同点.经过查看源代码可以得出,他们的所产生的RenderObject都继承自RenderSliver,查看RenderSliver源代码,如下所示:
abstract class RenderSliver extends RenderObject {

  @override
  SliverConstraints get constraints => super.constraints;

  SliverGeometry get geometry => _geometry;
  
  @override
  Rect get semanticBounds => paintBounds;

  @override
  Rect get paintBounds {
    assert(constraints.axis != null);
    switch (constraints.axis) {
      case Axis.horizontal:
        return Rect.fromLTWH(
          0.0, 0.0,
          geometry.paintExtent,
          constraints.crossAxisExtent,
        );
      case Axis.vertical:
        return Rect.fromLTWH(
          0.0, 0.0,
          constraints.crossAxisExtent,
          geometry.paintExtent,
        );
    }
    return null;
  }
}

从上述源代码我们能看到两个引人注目的属性,constraints和geometry,查看SliverConstraints的属性如有如下几种

 const SliverConstraints({
    @required this.axisDirection, // 主轴方向
    @required this.growthDirection, // 增长方向
    @required this.userScrollDirection, // 用户尝试滚动的方向
    @required this.scrollOffset, // 滚动偏移量
    @required this.precedingScrollExtent, // 此[Sliver]之前的所有[Sliver]消耗的滚动距离。
    @required this.overlap, 
    @required this.remainingPaintExtent, // 剩余绘画距离
    @required this.crossAxisExtent, // 交叉轴所占距离
    @required this.crossAxisDirection, // 交叉轴方向
    @required this.viewportMainAxisExtent, // viewport在主轴方向所占长度
    @required this.remainingCacheExtent, // 剩余缓存距离
    @required this.cacheOrigin, // 相对于[scrollOffset]缓存区域开始的位置。
  }) : assert(axisDirection != null),
       assert(growthDirection != null),
       assert(userScrollDirection != null),
       assert(scrollOffset != null),
       assert(precedingScrollExtent != null),
       assert(overlap != null),
       assert(remainingPaintExtent != null),
       assert(crossAxisExtent != null),
       assert(crossAxisDirection != null),
       assert(viewportMainAxisExtent != null),
       assert(remainingCacheExtent != null),
       assert(cacheOrigin != null);

根据上述的属性我们能得出,如果获取到RenderSliver的SliverConstraints,我们可以根据precedingScrollExtent 和 当前的paintBound 以及scrollOffset确定当前RenderSliver是否可见.这是一种解决问题的办法,但是除此之外我们还有一个SliverGeometry,它是否能更便捷的获取到RenderSliver的可见性呢,让我们先看一下它的结构,代码如下

const SliverGeometry({
    this.scrollExtent = 0.0, // 滑动所占距离 一般就是maxPaintExtent
    this.paintExtent = 0.0, // 在当前页面中的可见范围
    this.paintOrigin = 0.0,  // 该条的第一个可见部分相对于其布局位置的视觉位置
    double layoutExtent, // 从该条的第一个可见部分到下一个条的第一个可见部分的距离
    this.maxPaintExtent = 0.0, // 完全展示时绘制的范围
    this.maxScrollObstructionExtent = 0.0, 
    double hitTestExtent, // 从该条开始绘制的位置到接受点击的底部的距离。
    bool visible, // 是否可见,当paintExtent为0时为false
    this.hasVisualOverflow = false, 
    this.scrollOffsetCorrection, 
    double cacheExtent, // 缓存区域
  }) : assert(scrollExtent != null),
       assert(paintExtent != null),
       assert(paintOrigin != null),
       assert(maxPaintExtent != null),
       assert(hasVisualOverflow != null),
       assert(scrollOffsetCorrection != 0.0),
       layoutExtent = layoutExtent ?? paintExtent,
       hitTestExtent = hitTestExtent ?? paintExtent,
       cacheExtent = cacheExtent ?? layoutExtent ?? paintExtent,
       visible = visible ?? paintExtent > 0.0;

根据上述构造方法,能够得到几个比较清晰的信息

  • visible 能判断当前节点是否能在屏幕上绘制
  • paintExtent 能够获取到当前节点在屏幕上绘制的区域
  • maxPaintExtent 能够获取当前节点完全展示绘制所需要的空间
    根据这三点即可对当前节点进行曝光判断,可根据visible判断当前区域在屏幕上是否有绘制区域,如果有将paintExtent和maxPaintExtent传递给业务进行曝光判断.这样就解决了其他sliver节点的判断问题

MultiChildRenderObjectElement

根据RenderSliver我们能解决掉其他sliver的曝光问题,但是现在问题来了,我们怎么去获取这些RenderSliver呢,如果拿不到对应的sliver list,那一切都变得不再可行,为此我们得找到保存这些sliver list元素的最近的父节点
首先得思考一个问题,Flutter把Scrollable和sliver进行了分离,那滑动窗口是谁来确定的呢?为此我们先看一下ScrollView的build方法

   @override
  Widget build(BuildContext context) {
    final List slivers = buildSlivers(context);
    final AxisDirection axisDirection = getDirection(context);

    final ScrollController scrollController = primary
      ? PrimaryScrollController.of(context)
      : controller;
    final Scrollable scrollable = Scrollable(
      dragStartBehavior: dragStartBehavior,
      axisDirection: axisDirection,
      controller: scrollController,
      physics: physics,
      semanticChildCount: semanticChildCount,
      viewportBuilder: (BuildContext context, ViewportOffset offset) {
        return buildViewport(context, offset, axisDirection, slivers);
      },
    );
    return primary && scrollController != null
      ? PrimaryScrollController.none(child: scrollable)
      : scrollable;
  }

从这里能看到返回的slivers被传入了Scrollable之后在viewportBuilder中返回了buildViewport(context, offset, axisDirection, slivers);所以我们继续看看buildViewport方法

  @protected
  Widget buildViewport(
    BuildContext context,
    ViewportOffset offset,
    AxisDirection axisDirection,
    List slivers,
  ) {
    if (shrinkWrap) {
      return ShrinkWrappingViewport(
        axisDirection: axisDirection,
        offset: offset,
        slivers: slivers,
      );
    }
    return Viewport(
      axisDirection: axisDirection,
      offset: offset,
      slivers: slivers,
      cacheExtent: cacheExtent,
      center: center,
      anchor: anchor,
    );
  }

从这个方法中我们能知道slivers的直接父类就是这个Viewport,那什么是Viewport呢,结合上述所说的滑动窗口,我们不难理解,这个Viewport就是Flutter对滑动窗口的一个实现,所以理论上说其下的子节点应该就是我们传入的slivers,当然Viewport只是一个Widget封装,我们还是要找到对应的Element,为此继续查看Viewport和ShrinkWrappingViewport的源代码,首先看Viewport的createElement方法

  @override
  _ViewportElement createElement() => _ViewportElement(this);

再看一下ShrinkWrappingViewport返回的元素

  @override
  MultiChildRenderObjectElement createElement() => MultiChildRenderObjectElement(this);

在这里可以看出两个类用了不一样的Element,但是不用急,我们先看看两者有没有关联点,查看源代码可以看出一下信息

class _ViewportElement extends MultiChildRenderObjectElement

那么形式就变得开朗起来了,Viewport的基础实现类就是MultiChildRenderObjectElement,所以,按照我们之前的设想只要获取到这个节点我们通过visitChildren就能访问到其下的所有子节点,也就是我们想要的slivers,对于怎么获取MultiChildRenderObjectElement节点,还是跟SliverMultiBoxAdaptorElement的获取方式一样,遍历子节点找出对应的MultiChildRenderObjectElement,为此我们抽象出一个方法,用于获取子节点中对应类型的元素,代码如下所示

  T findElementByType(Element element) {
    if (element is T) {
      return element;
    }
    T target;
    element.visitChildElements((child) {
      target ??= findElementByType(child);
    });
    return target;
  }

曝光实现

根据上述的信息的总结,我们已经获取到了对所需数据的访问入口,剩下的就是曝光的实现了,首先我们定义两个曝光监听一个是SingleExposureListener另一个是SliverExposureListener,第一个是针对单一的SliverList或SliverGrid形式的曝光监听,第二个是针对于复杂性多布局的曝光监听,先来分析一下SingleExposureListener的实现,根据之前总结的信息,曝光思路还是在onNotification中获取SliverMultiBoxAdaptorElement并遍历其下所有的子节点进行曝光判断,判断的流程如下


image.png

因为不管是SingleExposureListener还是SliverExposureListener都会牵扯到对SliverMultiBoxAdaptorElement的一个曝光计算,所以我们抽离出一个mixin,代码如下

mixin _ExposureMixin {
  IndexRange _visitSliverMultiBoxAdaptorElement(
      SliverMultiBoxAdaptorElement sliverMultiBoxAdaptorElement,
      double portF,
      double portE,
      Axis axis,
      ExposureReferee exposureReferee,
      int exposureTime,
      int parentIndex) {
    if (sliverMultiBoxAdaptorElement == null) return null;
    int firstIndex = sliverMultiBoxAdaptorElement.childCount;
    assert(firstIndex != null);
    int endIndex = -1;
    void onVisitChildren(Element element) {
      final SliverMultiBoxAdaptorParentData parentData =
          element?.renderObject?.parentData;
      if (parentData != null) {
        double boundF = parentData.layoutOffset;
        double itemLength = axis == Axis.vertical
            ? element.renderObject.paintBounds.height
            : element.renderObject.paintBounds.width;
        double boundE = itemLength + boundF;
        double paintExtent = max(min(boundE, portE) - max(boundF, portF), 0);
        double maxPaintExtent = itemLength;
        bool isExposure = exposureReferee != null
            ? exposureReferee(
                ExposureStartIndex(parentIndex, parentData.index, exposureTime),
                paintExtent,
                maxPaintExtent)
            : paintExtent == maxPaintExtent;

        if (isExposure) {
          firstIndex = min(firstIndex, parentData.index);

          endIndex = max(endIndex, parentData.index);
        }
      }
    }

    sliverMultiBoxAdaptorElement.visitChildren(onVisitChildren);
    return IndexRange(parentIndex, firstIndex, endIndex);
  }

  T findElementByType(Element element) {
    if (element is T) {
      return element;
    }
    T target;
    element.visitChildElements((child) {
      target ??= findElementByType(child);
    });
    return target;
  }
}

这里有一个改动提及一下,就是对于曝光范围的计算,因为我们定义的ExposureReferee,是根据paintExtent和maxPaintExtent来确定是否曝光的,所以这里我们的计算公式也要修改一下,计算公式应该改成如下公式

double portF // 当前ListView或GridView在Viewport中所占边界的开始的位置
double portE // portF + 当前ListView或GridView在Viewport中可见的范围(如果单ListView或GridView则为viewport的高度或宽度)
double boundF = parentData.layoutOffset; // 当前item所占范围边界开始的位置
double itemLength = axis == Axis.vertical
    ? element.renderObject.paintBounds.height
    : element.renderObject.paintBounds.width;
double boundE = itemLength + boundF;  // 当前item所占范围边界结束的位置
double paintExtent = max(min(boundE, portE) - max(boundF, portF), 0); // 根据父节点提供的可见窗口范围,和自身的范围确定当前item在viewport中所占的位置即可见的绘制区域
double maxPaintExtent = itemLength; // 最大绘制范围,即其高度
bool isExposure = exposureReferee != null
    ? exposureReferee(
    ExposureStartIndex(parentIndex, parentData.index, exposureTime),
    paintExtent,
    maxPaintExtent)
    : paintExtent == maxPaintExtent;

上面的说法可能有点不好理解,我们画图来说,如下图,这是一个很常见的多布局方式,顶部是SliverGrid,底部连接一个SliverList


image.png

那这样我们来计算SliverList中某个节点是否可见,比如SliverList中的第五个子节点,他的绘制范围应该如下图中红框所包围的区域,那这个区域该怎么计算呢


image.png

首先我们要明确一下parentData.layoutOffset的意义,这个是当前子节点顶部距离其父类边界的顶部的一个距离如下图所示中间的线则是描述layoutOffset的概念,红框包裹的范围为其父节点在viewport中所占的绘制区域,根据这些特点,我们明白,要想计算出item的paintExtent那就必须确定两个范围,第一个范围是当前item的完全展示的范围,即SliverList中节点5所占的白色范围,第二个范围则是当前item其父节点在viewport中的可见区域,即红框所示范围,获取到这两个Rect之后,进行交集区域的计算即可,计算出的交集区域则是子节点的可见绘制区域
image.png

多section支持

还是上面的示意图,那个就是一个很经典的section模式,我们如何区分SliderGrid中的子节点曝光和SliderList和的子节点曝光呢,对此肯定需要一个二维映射关系,即每个子节点对应一个parentIndex,和自身在父节点中所处于的itemIndex,父节点的确定也很简单,即在MultiChildRenderObjectElement.visitChild时,根据遍历顺序确定好parentIndex即可,这里注意一下,对于普通的RenderSliver节点,其属于一个单独的模块,所以其主要的区分点在与parentIndex,而itemIndex默认为0,根据上述我们可以定义出Slider的_onNotification方法,代码如下:

  bool _onNotification(ScrollNotification notice) {
    // 记录当前曝光时间,可作为开始曝光元素的曝光开始时间点和结束曝光节点的结束曝光时间点
    final int exposureTime = DateTime.now().millisecondsSinceEpoch;
    // 查找对应的Viewport节点MultiChildRenderObjectElement
    final viewPortElement =
        findElementByType(notice.context);
    assert(viewPortElement != null);
    // 定义parentIndex 用于确定外层节点位置,也作为SliverList或SlierGrid的parentIndex
    int parentIndex = 0;
    final indexRanges = [];
    // 保存上次完全可见的集合用于之后的结束曝光通知
    oldSet = Set.from(visibleSet);
    // 每个节点前面所有节点所占的范围,用于SliverList或SliverGrid确定
    // 自身在viewport中的可见区域
    double totalScrollExtent = 0;
    viewPortElement.visitChildElements((itemElement) {
      assert(itemElement.renderObject is RenderSliver);
      final geometry = (itemElement.renderObject as RenderSliver).geometry;
      // 判断当前子节点时是否可见,不可见无须处理曝光
      if (geometry.visible) {
        if (itemElement is SliverMultiBoxAdaptorElement) {
          // SliverList和SliverGrid进行子节点曝光判断
          final indexRange = _visitSliverMultiBoxAdaptorElement(
              itemElement,
              notice.metrics.pixels - totalScrollExtent,
              notice.metrics.pixels - totalScrollExtent + geometry.paintExtent,
              widget.scrollDirection,
              widget.exposureReferee,
              exposureTime,
              parentIndex);
          indexRanges.add(indexRange);
          _dispatchExposureStartEventByIndexRange(indexRange, exposureTime);
        } else {
          // 单一RenderSlider直接判断外层节点是否曝光即可
          bool isExposure = widget.exposureReferee != null
              ? widget.exposureReferee(
                  ExposureStartIndex(parentIndex, 0, exposureTime),
                  geometry.paintExtent,
                  geometry.maxPaintExtent)
              : geometry.paintExtent == geometry.maxPaintExtent;
          if (isExposure) {
            final indexRange = IndexRange(parentIndex, 0, 0);
            indexRanges.add(indexRange);
            _dispatchExposureStartEvent(parentIndex, 0, exposureTime);
          }
        }
      }
      totalScrollExtent += geometry.scrollExtent;
      parentIndex++;
    });
    // 根据上次曝光的元素集合找出当前已不可见的元素,进行曝光结束事件通过
    _dispatchExposureEndEvent(oldSet, exposureTime);
    // 调用scrollCallback返回当前可见元素位置
    widget.scrollCallback?.call(indexRanges, notice);
    return false;
  }

曝光流程也是跟上述流程图一致就不再赘述

曝光统计实现

对于曝光数据的收集,我们需要注意这两个方面的数据

  • 开始曝光的元素位置信息和曝光时间
  • 结束曝光的元素信息信息和曝光时间
    然后根据之前的流程定义好处理开始曝光和结束曝光的方法,因为需要支持多section,所有Set中保存的数据结构是通过parentIndex 和 itemIndex共同决定equals的返回值,在识别当前可见元素列表时直接比对_Point即可,如果已经存在即表明下次也存在,则从待结束曝光的Set中移除,反之加入曝光列表。这里需要注意的是得在dispose的时候结束曝光之前存在的曝光列表,保证每个开始曝光的元素都能正常的结束曝光。
class _Point {
  final int parentIndex;
  final int itemIndex;
  final int time;

  _Point(this.parentIndex, this.itemIndex, this.time);

  @override
  bool operator ==(other) {
    if (other is! _Point) {
      return false;
    }
    return this.parentIndex == other.parentIndex &&
        this.itemIndex == other.itemIndex;
  }

  @override
  int get hashCode => hashValues(parentIndex, itemIndex);
}

 void _dispatchExposureStartEvent(
      int parentIndex, int itemIndex, int exposureTime) {
    final point = _Point(parentIndex, itemIndex, exposureTime);
    if (!visibleSet.contains(point)) {
      visibleSet.add(point);
      widget.exposureStartCallback
          ?.call(ExposureStartIndex(parentIndex, itemIndex, exposureTime));
    } else {
      oldSet.remove(point);
    }
  }

  void _dispatchExposureEndEvent(Set<_Point> set, int exposureTime) {
    if (widget.exposureEndCallback == null) return;
    set.forEach((item) {
      widget.exposureEndCallback(ExposureEndIndex(item.parentIndex,
          item.itemIndex, exposureTime, exposureTime - item.time));
    });
    if (visibleSet == set) {
      visibleSet.clear();
    } else {
      visibleSet.removeAll(set);
    }
  }

  @override
  void dispose() {
    _dispatchExposureEndEvent(
        visibleSet, DateTime.now().millisecondsSinceEpoch);
    super.dispose();
  }

总结

根据上述的一些原理解释,我们利用了Flutter的一些机制实现了我们想要的元素曝光效果,除此之外还进行了扩展和完善,其实这些理论也并不是我直接知道的,只是在查找问题时,恰巧看见了而已,然后深入研究找出了的一套方案,从整个流程上看,只要我们合理的分析,找出一些能解决问题的点其实还是比较容易的,主要问题点还是在于得对Flutter的机制有一个大局方向的理解,真对于我们去寻找到正确的解决方向有很大的帮助。最后附上Demo,文章中的代码不全,有兴趣的可以看一下。

你可能感兴趣的:(Flutter 如何优雅的实现滑动元素曝光)