Flutter 带你从不一样的角度实现LoadingMore(加载更多)

1,963 阅读5分钟

LoadingMore 和 Sliver 结合

如果你是个 Flutter 新手,正在寻求解决方案,那么这篇文章不适合你。如果是个老🐦,那点个赞或者评论区随便讲两句吧,让我知道有大佬看了我的文章👀。

进入正题

缘起于 BouncingScrollPhysicsNotificationListener,因为 BouncingScrollPhysics 为了实现回弹的效果, apply 的值为0,导致 xxx 不会发出 OverScrolledNotification,为了获取 overscrolled 的信息,要么对ScrollUpdateNotification嗯造,要么改写BouncingScrollPhysics

大多数LoadingMore都是这么做的,再套个StackNotificationListener 实现一下刷新的组件。不过这里,并不使用上述两种方式,而是 类似 SliverPersistentHeaderDelegate 的那种通过给开发者 build 方法,并提供类似像 shrinkOffsetoverlapsContent的参数,自己去构建对应的 Sliver

SliverPersistentHeader 的 秘密

吸顶效果的爹,内容不多讲,因为没用过这个 Widget 的后面的也看不懂。

话不多说,直接干到 _SliverPersistentHeaderElement 看源码,我们可以得知,这个 elementrenderobj 是互相持有的。

先看看 element 更新 widget 的方法:

  // element 触发 delegate.build 的方法
  void _build(double shrinkOffset, bool overlapsContent) {
   owner!.buildScope(this, () {
     child = updateChild(
       child,
       floating
         ? _FloatingHeader(child: widget.delegate.build(
           this,
           shrinkOffset,
           overlapsContent
         ))
         : widget.delegate.build(this, shrinkOffset, overlapsContent),
       null,
     );
   });
 }

再看看对应的 renderobj 内部, 该 element 创建的 renderobj 的一个重要父类是 RenderSliverPersistentHeader, 然后层层继承下来,根据 Widgetpinnedfloating 属性生成对应的私有 _RenderSliverXXXX (我们并不关心),并带有_RenderSliverPersistentHeaderForWidgetsMixin(这个才是重点)。

这个 mixin 就是让 renderobj持有 element的原因,并且通过调用 updateChild ( renderobj 的方法) 触发 delegate 提供的 build

mixin _RenderSliverPersistentHeaderForWidgetsMixin on RenderSliverPersistentHeader {
  _SliverPersistentHeaderElement? _element;

  @override
  double get minExtent => _element!.widget.delegate.minExtent;

  @override
  double get maxExtent => _element!.widget.delegate.maxExtent;

  @override
  void updateChild(double shrinkOffset, bool overlapsContent) {
    assert(_element != null);
    _element!._build(shrinkOffset, overlapsContent);
  }

  @protected
  void triggerRebuild() {
    markNeedsLayout();
  }
}

那么整个触发 build 的流程是什么样的呢?这里帮你梳理一遍 Flutter 滑动部分的知识

推荐文章:

我就隐藏魔法了,过程大概是 Scrollable -> Viewport flush -> Sliver performLayout

比如现在轮到吸顶 RenderSliverPersistentHeader 进行 performLayout,看源码得知执行了layoutChild(触发吸顶) 和 updateGeometry(应该是吸顶效果,因为更新了主轴坐标)。

layoutChild (拨云见雾)

invokeLayoutCallback 中执行了 updateChild ,也就是我们上述 _RenderSliverPersistentHeaderForWidgetsMixin 的一个方法,触发了 delegatebuild,最后进行 layout

  void layoutChild(double scrollOffset, double maxExtent, { bool overlapsContent = false }) {

    final double shrinkOffset = math.min(scrollOffset, maxExtent);
    if (_needsUpdateChild || _lastShrinkOffset != shrinkOffset || _lastOverlapsContent != overlapsContent) {
      invokeLayoutCallback<SliverConstraints>((SliverConstraints constraints) {
        // 调用了 renderObj的 updateChild 再触发 element的 _build
        updateChild(shrinkOffset, overlapsContent);
      });
      _lastShrinkOffset = shrinkOffset;
      _lastOverlapsContent = overlapsContent;
      _needsUpdateChild = false;
    }

    double stretchOffset = 0.0;
    if (stretchConfiguration != null && constraints.scrollOffset == 0.0) {
      stretchOffset += constraints.overlap.abs();
    }

    child?.layout(
      constraints.asBoxConstraints(
        maxExtent: math.max(minExtent, maxExtent - shrinkOffset) + stretchOffset,
      ),
      parentUsesSize: true,
    );

    ......
  }

有人就问了,诶,你这个吸顶和 LoadingMore有什么关系捏?

确实关系并不大,但是没有不行,因为我们想要实现那种类似 delegatebuild 的方式实现,这里不得不了解内部实现,不过下面就正式进入 加载更多 的内容了

SliverConstraintsSliverGeometry 的 魔法

ViewPort 很聪明,它总是对 看得见缓冲区 内的 所有Sliver进行 performLayoutSliverConstraints 输入给 Sliver 布局, SliverGeometry 再输出给 ViewPort

我们就可以无脑利用这些属性,来判断是否溜到底部了捏,从而实现无 ScrollNotification 进行 LoadingMore!!!!!

SliverConstraints 属性介绍

  1. remainingPaintExtent : 可以简单的理解为还有多少 pixel 可以画(不一定对...但是保证,当你的 sliver 从底部滑出去了,这个值就为0)。

  2. precedingScrollExtent : 之前的Sliver一共消耗了多少滑动大小(对应下面的scrollExtent),可以用来判断Sliver是否充满 Viewport

  3. viewportMainAxisExtent : 如其名。

  4. overlap : 前一个 SliverpaintExtent - layoutExtent,常见于吸顶之类的效果,官方注释写得详细,这里几乎没使用。

SliverGeometry 属性介绍

  1. scrollExtent : 滑动体的长度,比如说的最后一个 sliver,高度 100,但是这个值为 0,那么只有在 overscroll的时候看得到,松手回弹后就溜到 viewport 底部外面了。

  2. paintExtent : 如其名。

  3. layoutExtent & maxPaintExtent : 如其名,打开 devtools 的 那个绘制界面布局信息,对应的就是的箭头➡️。

属性都给你了,怎么判断我就不用多讲了吧?上一份自己写的 performLayout 代码

  double overscrolled = 0;

  double get maxScrollExtent => _element!.widget.delegate.maxScrollExtent;

  @override
  void performLayout() {
    SliverConstraints constraints = this.constraints;
    // We never call build unless user overscrolled.
    if (constraints.remainingPaintExtent < 1) {
      geometry = SliverGeometry.zero;
      return;
    }

    // calculate remain space on viewport.
    // if slivers before this one not fill the viewportExtent, this value could
    // be < 0, which means this sliver is always visiable now, in this case, we
    // never performm any load more behavior.
    double extent =
        constraints.precedingScrollExtent - constraints.viewportMainAxisExtent;

    // the total overscrolled area in viewport.
    double maxExtent = constraints.remainingPaintExtent - min(constraints.overlap, 0.0);

    if (extent <= 0) {
      // we offer overscrolled 0 to builder, but the constraint to passed to
      // child still the remainingPaintExtent. you can use this constraint
      //to custom what you want.
      overscrolled = 0;
      invokeLayoutCallback((constraints) {
        updateChild();
      });
      child?.layout(constraints.asBoxConstraints(maxExtent: maxExtent));
      geometry = SliverGeometry(
        scrollExtent: 0,
        paintExtent: maxExtent,
        maxPaintExtent: maxExtent,
      );
      return;
    }

    // here, remainingPaintExtent is overscrolled.
    overscrolled = maxExtent;
    invokeLayoutCallback((constraints) {
      updateChild();
    });
    child?.layout(constraints.asBoxConstraints(maxExtent: maxExtent),
        parentUsesSize: true);

    geometry = SliverGeometry(
        scrollExtent: min(maxExtent, maxScrollExtent),
        paintExtent: maxExtent,
        maxPaintExtent: maxExtent);
  }

看看效果图

overscrolled.gif

缺个法师来触发更新了

当我思考不出怎么去写一份优雅的 build 和 加载相结合的代码时,不小心看了 CupertinoSliverRefreshControl 的源码。

法师就是 CupertinoSliverRefreshControl 的 状态机

很可惜,官方写的状态机代码是看不懂的,因为这东西是要画图自己搓的,我就自己搓了一张,在这里贴一下。

Screen Shot 2022-04-01 at 6.44.17 PM.png

接着,在自己创造的 delegate 设置一些必要的属性,把状态机的代码写进来,比如触发更新的阈值之类的东西,记住在执行异步 setState 的时候,应推迟或提前到 帧 build 之前,这里使用推迟到帧后的方法。

  ValueNotifier<RefreshState> loadingStateNotifier;

  bool isTriggered = false;

  bool canTiggerNext = false;

  @override
  final double triggerDistance;

  @override
  final double maxScrollExtent;

  final double ignoreRefreshDistance;

  RefreshCallback? onRefresh;

  @override
  Widget builder(BuildContext context, double overscrolled) {
    handleNextState(loadingStateNotifier, overscrolled);
    return ValueListenableBuilder<RefreshState>(
        valueListenable: loadingStateNotifier,
        builder: (context, state, child) {
          return handleStateBuild(state, overscrolled);
        });
  }

  // 状态机代码
  void handleNextState(ValueNotifier<RefreshState> currentState, double overscrolled) {
    switch (currentState.value) {
      case RefreshState.inactive:
        if (overscrolled < triggerDistance) {
          currentState.value = RefreshState.inactive;
          break;
        }

        isTriggered = true;
        currentState.value = RefreshState.refreshing;
        SchedulerBinding.instance!.addPostFrameCallback((timeStamp) {
          onRefresh!().whenComplete(
            () {
              isTriggered = false;
              currentState.value = RefreshState.done;
            },
          );
        });

        break;
      case RefreshState.refreshing:
        if (isTriggered) {
          currentState.value = RefreshState.refreshing;
          break;
        }
        currentState.value = RefreshState.done;
        break;
      case RefreshState.done:
        if (overscrolled < ignoreRefreshDistance) {
          // when done, wating overscroll to 0 or user make it to 0,
          // the state could be inactive, otherwise, keep
          currentState.value = RefreshState.inactive;
          break;
        }
        currentState.value = RefreshState.done;
    }
  }

  Widget handleStateBuild(RefreshState currentState, double overscrolled) {
    switch (currentState) {
      case RefreshState.inactive:
        // inactive 需要构建的 widget
      case RefreshState.refreshing:
        // refreshing 需要构建的 widget
      case RefreshState.done:
        // done 需要构建的 widget
    }
  }

实现效果图

loading_more.gif

使用方法就十分优雅了,类似 Pinterest 的更新效果

return  CustomScrollView(
          slivers: [
            // 假设这里是法法的 SliverWaterFallFlow ....
            LoadingMoreSliver(
              onRefresh: () async {
                await getDataFromServers();
                setState((){
                // 数据更新
                );
              },
            )
          ],
        );

小结

代码目前还不够健全,还有为了偷懒,我并没有设置一个类似 overlap 的属性,而是当 Sliver不能填充满一页时, 默认返回0了,以后再重新写一下(最近忙着找实习,没时间写),上述源码将在未来某个月当我把 设计模式 弄懂 或者 演示的这个 app 完善后,贴在个人仓库里,或者有大佬想帮忙完善。