iOS-Flutter 可滚动组件-自定义Sliver

434 阅读12分钟

Sliver 布局协议

Sliver的布局协议如下:

  1. Viewport将当前布局和配置信息通过SliverConstraints传递给Sliver。
  2. Sliver确定自身的位置、绘制等信息,保存在geometru中(一个SliverGeometry类型的对象)。
  3. Viewport读取geometry中的信息来对Sliver进行布局和绘制。

这个过程中有两个重要的对象:SliverConstraints和SliverGeometry。

SliverConstraints定义:

class SliverConstraints extends Constraints {
    //主轴方向
    AxisDirection? axisDirection;
    //Sliver 沿着主轴从列表的哪个方向插入?枚举类型,正向或反向
    GrowthDirection? growthDirection;
    //用户滑动方向
    ScrollDirection? userScrollDirection;
    //当前Sliver理论上(可能会固定在顶部)已经滑出可视区域的总偏移
    double? scrollOffset;
    //当前Sliver之前的Sliver占据的总高度,因为列表是懒加载,如果不能预估时,该值为double.infinity
    double? precedingScrollExtent;
    //上一个 sliver 覆盖当前 sliver 的大小,通常在 sliver 是 pinned/floating
    //或者处于列表头尾时有效,我们在后面的小节中会有相关的例子。
    double? overlap;
    //当前Sliver在Viewport中的最大可以绘制的区域。
    //绘制如果超过该区域会比较低效(因为不会显示)
    double? remainingPaintExtent;
    //纵轴的长度;如果列表滚动方向是垂直方向,则表示列表宽度。
    double? crossAxisExtent;
    //纵轴方向
    AxisDirection? crossAxisDirection;
    //Viewport在主轴方向的长度;如果列表滚动方向是垂直方向,则表示列表高度。
    double? viewportMainAxisExtent;
    //Viewport 预渲染区域的起点[-Viewport.cacheExtent, 0]
    double? cacheOrigin;
    //Viewport加载区域的长度,范围:
    //[viewportMainAxisExtent,viewportMainAxisExtent + Viewport.cacheExtent*2]
    double? remainingCacheExtent;
}

当列表滑动时,如果某个Sliver已经进入了需要构建的区域,则列表会将SliverConstraints信息传递给Sliver,Sliver就可以根据这些信息来确定自身的布局和绘制信息。

Sliver需要确定的是SliverGeometry:

const SliverGeometry({
  //Sliver在主轴方向预估长度,大多数情况是固定值,用于计算sliverConstraints.scrollOffset
  this.scrollExtent = 0.0, 
  this.paintExtent = 0.0, // 可视区域中的绘制长度
  this.paintOrigin = 0.0, // 绘制的坐标原点,相对于自身布局位置
  //在 Viewport中占用的长度;如果列表滚动方向是垂直方向,则表示列表高度。
  //范围[0,paintExtent]
  double? layoutExtent, 
  this.maxPaintExtent = 0.0,//最大绘制长度
  this.maxScrollObstructionExtent = 0.0,
  double? hitTestExtent, // 点击测试的范围
  bool? visible,// 是否显示
  //是否会溢出Viewport,如果为true,Viewport便会裁剪
  this.hasVisualOverflow = false,
  //scrollExtent的修正值:layoutExtent变化后,为了防止sliver突然跳动(应用新的layoutExtent)
  //可以先进行修正,具体的作用在后面 SliverFlexibleHeader 示例中会介绍。
  this.scrollOffsetCorrection,
  double? cacheExtent, // 在预渲染区域中占据的长度
}) 

布局模型和盒布局模型

两者的布局流程基本相同: 父组件告诉子组件约束信息>子组件根据父组件的约束却自身大小>父组件获得子组件大小调整其位置。

  1. 父组件传递给子组件的约束信息不同。和模型传递的是BoxConstraints,而Sliver传递的是SliverConstraints。
  2. 描述子组件布局信息的对象不同。和模型的布局信息通过Size和Offset描述,而Sliver的是通过SliverGeometry描述。
  3. 布局的起点不同。Sliver布局的点一般是Viewport,而盒模型布局的起点可以是任意的组件。

自定义Sliver

SliverFlexibleHeader

实现一个类似旧版微信朋友圈顶部头图的功能:默认显示顶部图片部分,下拉时逐渐显示剩余部分: image.gif

思路:实现一个Sliver,将它作为CustomScrollView的第一个子组件,然后根据用户的滑动来动态的调整Sliver的布局和显示。 实例:实现SliverFlexibleHeader,结合CustomScrollView。

@override
Widget build(BuildContext context) {
  return CustomScrollView(
    //为了能使CustomScrollView拉到顶部时还能继续往下拉,必须让 physics 支持弹性效果
    physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
    slivers: [
      //我们需要实现的 SliverFlexibleHeader 组件
      SliverFlexibleHeader(
        visibleExtent: 200,, // 初始状态在列表中占用的布局高度
        // 为了能根据下拉状态变化来定制显示的布局,我们通过一个 builder 来动态构建布局。
        builder: (context, availableHeight, direction) {
          return GestureDetector(
            onTap: () => print('tap'), //测试是否可以响应事件
            child: Image(
              image: AssetImage("imgs/avatar.png"),
              width: 50.0,
              height: availableHeight,
              alignment: Alignment.bottomCenter,
              fit: BoxFit.cover,
            ),
          );
        },
      ),
      // 构建一个list
      buildSliverList(30),
    ],
  );
}
现有组件很难组合实现,通过定制RenderObject的方式来实现,
为了根据下来位置的变化来动态调整,SliverFlexibleHeader中通过一个builder来动态构建布局,
当下拉发生变化时,build会被调用
class _SliverFlexibleHeader extends SingleChildRenderObjectWidget {
  const _SliverFlexibleHeader({
    Key? key,
    required Widget child,
    this.visibleExtent = 0,
  }) : super(key: key, child: child);
  final double visibleExtent;

  @override
  RenderObject createRenderObject(BuildContext context) {
   return _FlexibleHeaderRenderSliver(visibleExtent);
  }

  @override
  void updateRenderObject(
      BuildContext context, _FlexibleHeaderRenderSliver renderObject) {
    renderObject..visibleExtent = visibleExtent;
  }
}

StatelessWidget和StatefulWidget主要是组合Widget。 考虑到——SliverFlexibleHeader有一个子节点,这里继承自SingleChildRenderObjetWidget类,这样可以省去一些和布局无关的代码,比如绘制和事件点击。

核心代码在performLayout中

class _FlexibleHeaderRenderSliver extends RenderSliverSingleBoxAdapter {
    _FlexibleHeaderRenderSliver(double visibleExtent)
      : _visibleExtent = visibleExtent;
  
  double _lastOverScroll = 0;
  double _lastScrollOffset = 0;
  late double _visibleExtent = 0;


  set visibleExtent(double value) {
    // 可视长度发生变化,更新状态并重新布局
    if (_visibleExtent != value) {
      _lastOverScroll = 0;
      _visibleExtent = value;
      markNeedsLayout();
    }
  }

  @override
  void performLayout() {
    // 滑动距离大于_visibleExtent时则表示子节点已经在屏幕之外了
    if (child == null || (constraints.scrollOffset > _visibleExtent)) {
      geometry = SliverGeometry(scrollExtent: _visibleExtent);
      return;
    }

    // 测试overlap,下拉过程中overlap会一直变化.
    double overScroll = constraints.overlap < 0 ? constraints.overlap.abs() : 0;
    var scrollOffset = constraints.scrollOffset;

    // 在Viewport中顶部的可视空间为该 Sliver 可绘制的最大区域。
    // 1. 如果Sliver已经滑出可视区域则 constraints.scrollOffset 会大于 _visibleExtent,
    //    这种情况我们在一开始就判断过了。
    // 2. 如果我们下拉超出了边界,此时 overScroll>0,scrollOffset 值为0,所以最终的绘制区域为
    //    _visibleExtent + overScroll.
    double paintExtent = _visibleExtent + overScroll - constraints.scrollOffset;
    // 绘制高度不超过最大可绘制空间
    paintExtent = min(paintExtent, constraints.remainingPaintExtent);

    //对子组件进行布局,关于 layout 详细过程我们将在本书后面布局原理相关章节详细介绍,现在只需知道
    //子组件通过 LayoutBuilder可以拿到这里我们传递的约束对象(ExtraInfoBoxConstraints)
    child!.layout(
      constraints.asBoxConstraints(maxExtent: paintExtent),
      parentUsesSize: false,
    );

    //最大为_visibleExtent,最小为 0
    double layoutExtent = min(_visibleExtent, paintExtent);

    //设置geometry,Viewport 在布局时会用到
    geometry = SliverGeometry(
      scrollExtent: layoutExtent,
      paintOrigin: -overScroll,
      paintExtent: paintExtent,
      maxPaintExtent: paintExtent,
      layoutExtent: layoutExtent,
    );
  }
}

在performLayout中通过Viewport传来的SliverConstraints结合子组件的高度,最终确定了——SliverFlexibleHeader的布局、绘制信息,它们被保存在geometry中,之后,Viewport就可以读取geometry来确定_SliverFlexibleHeader在Viewport中的位置,然后进行绘制。

_SliverFlexibleHeader的performLayout方法中,每当下拉位置发生变化,都会对其子组件进行重新layout,因此,可以创建一个LayoutBuilder用于在子组件重新布局时来动态构建child。

SliverFlexibleHeader最终效果:

typedef SliverFlexibleHeaderBuilder = Widget Function(
  BuildContext context,
  double maxExtent,
  //ScrollDirection direction,
);

class SliverFlexibleHeader extends StatelessWidget {
  const SliverFlexibleHeader({
    Key? key,
    this.visibleExtent = 0,
    required this.builder,
  }) : super(key: key);

  final SliverFlexibleHeaderBuilder builder;
  final double visibleExtent;

  @override
  Widget build(BuildContext context) {
    return _SliverFlexibleHeader(
      visibleExtent: visibleExtent,
      child: LayoutBuilder(
        builder: (BuildContext context, BoxConstraints constraints) {
          return builder(
            context,
            constraints.maxHeight
          );
        },
      ),
    );
  }
}

当SliverFlexibleHeader中每次对子组件进行布局时,都会触发LayoutBuilder来重新构建子Widget,LayoutBuilder中收到的constraints就是SliverFlexibleHeader中对子组件进行布局时,传入的constraints即:

child!.layout(
  //对子组件进行布局
  constraints.asBoxConstraints(maxExtent: paintExtent),
  parentUsesSize: true,
);

传递额外的布局信息

在实际使用SliverFlexibleHeader时,构建子widget可能会依赖当前列表的滑动方向,我们可以在SliverFlexibleHeader的builder中记录前后的availableHeight的差来确定滑动方向,但是比较麻烦,需要手动处理。我们知道在滑动时,Sliver的SliverConstraints中已经包含userScrollDirection,如果我们能将它经过统一的处理后透传给LayoutBuilder就非常好,这样不需要在使用时维护滑动方向。

问题1:LayoutBuilder接收的参数无法指定。

  • 方案1:在上面场景中,在对子组件进行布局时,传给子组件的约束只使用了最大长度,最小长度是没有用到的,我们可以将滑动方向通过最小长度传递给LayoutBuilder,然后在LayoutBuilder中取出即可。
  • 方案2:定义一个新类,让它继承自BoxConstraints,然后再添加一个可以保存scrollDirection的属性。

两种方案都可以实现,但是建议使用方案二,方案1有副作用,就是会影响子组件布局,LayoutBuilder是在子组件build阶段执行的,当我们设置了最小长度后,虽然在build阶段没有用到它,但是子组件在布局阶段仍然会应用此约束,最终影响子组件布局。

方案二实现实例:定义一个ExtraInfoBoxConstraints类,可以携带约束之外的信息。为了尽可能通用,使用泛型:

class ExtraInfoBoxConstraints<T> extends BoxConstraints {
  ExtraInfoBoxConstraints(
    this.extra,
    BoxConstraints constraints,
  ) : super(
          minWidth: constraints.minWidth,
          minHeight: constraints.minHeight,
          maxWidth: constraints.maxWidth,
          maxHeight: constraints.maxHeight,
        );

  // 额外的信息
  final T extra;
  
  @override
  bool operator ==(Object other) {
    if (identical(this, other)) return true;
    return other is ExtraInfoBoxConstraints &&
        super == other &&
        other.extra == extra;
  }

  @override
  int get hashCode {
    return hashValues(super.hashCode, extra);
  }
}

说明:重载了“==”运算符,因为Flutter在布局期间在特定的情况下会检测前后两次constraints是否相等来决定是否需要重新布局,所以,需要重载“==”运算符,否则可能会在最大最小宽高不变但extra发生变化时不会触发child重新布局,也就不会触发LayoutBuilder,这样不符合预期,我们希望的是extra发生变化时,会出发LayoutBuilder重新构建child。

  1. 修改_FlexibleHeaderRenderSliver的performLayout方法
//对子组件进行布局,子组件通过 LayoutBuilder可以拿到这里我们传递的约束对象(ExtraInfoBoxConstraints)
  child!.layout(
  ExtraInfoBoxConstraints(
    direction, //传递滑动方向
    constraints.asBoxConstraints(maxExtent: paintExtent),
  ),
  parentUsesSize: false,
);
  1. 修改SliverFlexibleHeader实现,在LayoutBuilder中可以获取到滑动方向:
typedef SliverFlexibleHeaderBuilder = Widget Function(
  BuildContext context,
  double maxExtent,
  ScrollDirection direction,
);

class SliverFlexibleHeader extends StatelessWidget {
  const SliverFlexibleHeader({
    Key? key,
    this.visibleExtent = 0,
    required this.builder,
  }) : super(key: key);

  final SliverFlexibleHeaderBuilder builder;
  final double visibleExtent;

  @override
  Widget build(BuildContext context) {
    return _SliverFlexibleHeader(
      visibleExtent: visibleExtent,
      child: LayoutBuilder(
        builder: (BuildContext context, BoxConstraints constraints) {
          return builder(
            context,
            constraints.maxHeight,
            // 获取滑动方向
            (constraints as ExtraInfoBoxConstraints<ScrollDirection>).extra,
          );
        },
      ),
    );
  }
}
  1. SliverFlexibleHeader中确定滑动方向的逻辑
// 下拉过程中overlap会一直变化.
double overScroll = constraints.overlap < 0 ? constraints.overlap.abs() : 0;
var scrollOffset = constraints.scrollOffset;
_direction = ScrollDirection.idle;

// 根据前后的overScroll值之差确定列表滑动方向。注意,不能直接使用 constraints.userScrollDirection,
// 这是因为该参数只表示用户滑动操作的方向。比如当我们下拉超出边界时,然后松手,此时列表会弹回,即列表滚动
// 方向是向上,而此时用户操作已经结束,ScrollDirection 的方向是上一次的用户滑动方向(向下),这是便有问题。
var distance = overScroll > 0
  ? overScroll - _lastOverScroll
  : _lastScrollOffset - scrollOffset;
_lastOverScroll = overScroll;
_lastScrollOffset = scrollOffset;

if (constraints.userScrollDirection == ScrollDirection.idle) {
  _direction = ScrollDirection.idle;
  _lastOverScroll = 0;
} else if (distance > 0) {
  _direction = ScrollDirection.forward;
} else if (distance < 0) {
  _direction = ScrollDirection.reverse;
}

高度修正scrollOffsetCorrection

当visibleExtent变化时会导致layoutExtent发生变化,也就是SliverFlexibleHeader在屏幕中所占的布局高度会发生变化,所以列表就出现跳动,效果太突兀。每一个Sliver的高度通过scrollExtent属性预估出来的,因此需要修正一下scrollExtent,但是不能直接修改scrollExtent的值,直接修改不会有任何动画效果,还是会跳动。因此SliverGeometry提供了一个scrollOffsetCorrection属性,它专门用于修正scrollExtent,只需将修正值传递给scrollOffsetCorrection,然后Sliver会自动执行一个动画效果帮助我们过渡到我们期望的高度。

 // 是否需要修正scrollOffset。当_visibleExtent值更新后,为了防止
  // 视觉上突然地跳动,要先修正 scrollOffset。
  double? _scrollOffsetCorrection;

  set visibleExtent(double value) {
    // 可视长度发生变化,更新状态并重新布局
    if (_visibleExtent != value) {
      _lastOverScroll = 0;
      _reported = false;
      // 计算修正值
      _scrollOffsetCorrection = value - _visibleExtent;
      _visibleExtent = value;
      markNeedsLayout();
    }
  }

  @override
  void performLayout() {
    // _visibleExtent 值更新后,为了防止突然的跳动,先修正 scrollOffset
    if (_scrollOffsetCorrection != null) {
      geometry = SliverGeometry(
        //修正
        scrollOffsetCorrection: _scrollOffsetCorrection,
      );
      _scrollOffsetCorrection = null;
      return;
    }
    ...
  } 

边界

在SliverFlexibleHeader构建子组件时,开发者可能会依赖当前的可用高度是否为0来做一些特殊处理,比如记录是否子组件已经离开屏幕。但是根据上面的实现,当用户滑动较快时,子组件离开屏幕时的最后一次布局时传递约束的maxExtent可能不为0,而当constraints.scrollOffset大于_visibleExtent时在performLayout的一开始就返回了,因此LayoutBuilder的builder中就有可能收不到maxExtent为0时的回调。为此,只需要在每次Sliver离开屏幕时调用一次child.layout同时将maxExtetn指定为0即可。

void performLayout() {
    if (child == null) {
      geometry = SliverGeometry(scrollExtent: _visibleExtent);
      return;
    }
    //当已经完全滑出屏幕时
    if (constraints.scrollOffset > _visibleExtent) {
      geometry = SliverGeometry(scrollExtent: _visibleExtent);
      // 通知 child 重新布局,注意,通知一次即可,如果不通知,滑出屏幕后,child 在最后
      // 一次构建时拿到的可用高度可能不为 0。因为使用者在构建子节点的时候,可能会依赖
      // "当前的可用高度是否为0" 来做一些特殊处理,比如记录是否子节点已经离开了屏幕,
      // 因此,我们需要在离开屏幕时确保LayoutBuilder的builder会被调用一次(构建子组件)。
      if (!_reported) {
        _reported = true;
        child!.layout(
          ExtraInfoBoxConstraints(
            _direction, //传递滑动方向
            constraints.asBoxConstraints(maxExtent: 0),
          ),
          //我们不会使用自节点的 Size, 关于此参数更详细的内容见本书后面关于layout原理的介绍
          parentUsesSize: false,
        );
      }
      return;
    }

    //子组件回到了屏幕中,重置通知状态
    _reported = false;
  
  ...
}

自定义SliverPersistentHeaderBox

上面介绍了SliverPersistentHeader使用需要遵守两个规则:

  • 必须显式的指定高度
  • 如果在使用SliverPersistentHeader构建子组件时需要依赖overlapsContent参数,则必须保证之前至少还有一个SliverOersistentHeader或SliverAppBar。

由于遵守两个规则对于开发者负担较重。比如对于规则1,大多数时候是不知道Header具体的高度,我们期望直接传递以恶搞widget,并且SliverPersistentHeader能自动算出来。 为此,定义一个SliverPersistentHeaderBox,它可以将任意的RenderBox适配为可以固定到顶部的Sliver而不用显式的指定高度,同时避免上面的问题。

  1. 定义SliverPersistentHeaderToBox
typedef SliverPersistentHeaderToBoxBuilder = Widget Function(
  BuildContext context,
  double maxExtent, //当前可用最大高度
  bool fixed, // 是否已经固定
);

class SliverPersistentHeaderToBox extends StatelessWidget {
  // 默认构造函数,直接接受一个 widget,不用显式指定高度
  SliverPersistentHeaderToBox({
    Key? key,
    required Widget child,
  })  : builder = ((a, b, c) => child),
        super(key: key);
 // builder 构造函数,需要传一个 builder,同样不需要显式指定高度
  SliverPersistentHeaderToBox.builder({
    Key? key,
    required this.builder,
  }) : super(key: key);

  final SliverPersistentHeaderToBoxBuilder builder;

  @override
  Widget build(BuildContext context) {
    return _SliverPersistentHeaderToBox(
      // 通过 LayoutBuilder接收 Sliver 传递给子组件的布局约束信息
      child: LayoutBuilder(
        builder: (BuildContext context, BoxConstraints constraints) {
          return builder(
            context,
            constraints.maxHeight,
            //约束中需要传递的额外信息是一个bool类型,表示 Sliver 是否已经固定到顶部
            (constraints as ExtraInfoBoxConstraints<bool>).extra,
          );
        },
      ),
    );
  }
}

和SliverFlexibelHeader很像,不同的是SliverPersistentHeaderToBox传递给child的约束中额外信息是一个bool类型,表示是否已经固定到顶部。 2. 实现_SliverPersistentHeaderToBox

class _RenderSliverPersistentHeaderToBox extends RenderSliverSingleBoxAdapter {
  @override
  void performLayout() {
    if (child == null) {
      geometry = SliverGeometry.zero;
      return;
    }
    child!.layout(
      ExtraInfoBoxConstraints(
        //只要 constraints.scrollOffset不为0,则表示已经有内容在当前Sliver下面了,即已经固定到顶部了
        constraints.scrollOffset != 0,
        constraints.asBoxConstraints(
          // 我们将剩余的可绘制空间作为 header 的最大高度约束传递给 LayoutBuilder
          maxExtent: constraints.remainingPaintExtent,
        ),
      ),
      //我们要根据child大小来确定Sliver大小,所以后面需要用到child的大小(size)信息
      parentUsesSize: true,
    );

    // 子节点 layout 后就能获取它的大小了
    double childExtent;
    switch (constraints.axis) {
      case Axis.horizontal:
        childExtent = child!.size.width;
        break;
      case Axis.vertical:
        childExtent = child!.size.height;
        break;
    }

    geometry = SliverGeometry(
      scrollExtent: childExtent,
      paintOrigin: 0, // 固定,如果不想固定应该传` - constraints.scrollOffset`
      paintExtent: childExtent,
      maxPaintExtent: childExtent,
    );
  }

  // 重要,必须重写,下面介绍。
  @override
  double childMainAxisPosition(RenderBox child) => 0.0;
}

注意:

  1. constraints.scrollOffset不为0时,表示已经固定到顶部
  2. 在布局阶段拿到子组件的size信息,然后通过子组件的大小来确定Sliver大小(设置geometry)。这样就不需要显式传递高度值。
  3. 通过给paintOrigin设为0来实现顶部固定效果;不固定顶部时应该传-constraints.scrollOffset.
  4. 必须要重新childMainAxisPosition,否则事件便会失效,该函数应该返回paintOrigin的位置。

应用实例:创建两个header

  1. 第一个Header,当没有滑动到顶部时,外观和正常列表项一样,当固定到顶部后显示一个阴影。通过SliverPersistentHeaderToBox.build来动态创建
  2. 第二个Header:一个普通的列表项,接收一个widget。
class SliverPersistentHeaderToBoxRoute extends StatelessWidget {
  const SliverPersistentHeaderToBoxRoute({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return CustomScrollView(
      slivers: [
        buildSliverList(5),
        SliverPersistentHeaderToBox.builder(builder: headerBuilder),
        buildSliverList(5),
        SliverPersistentHeaderToBox(child: wTitle('Title 2')),
        buildSliverList(50),
      ],
    );
  }

  // 当 header 固定后显示阴影
  Widget headerBuilder(context, maxExtent, fixed) {
    // 获取当前应用主题,关于主题相关内容将在后面章节介绍,现在
    // 我们要从主题中获取一些颜色。
    var theme = Theme.of(context);
    return Material(
      child: Container(
        color: fixed ? Colors.white : theme.canvasColor,
        child: wTitle('Title 1'),
      ),
      elevation: fixed ? 4 : 0,
      shadowColor: theme.appBarTheme.shadowColor,
    );
  }

  // 我们约定小写字母 w 开头的函数代表是需要构建一个 Widget,这比 buildXX 会更简洁
  Widget wTitle(String text) =>
      ListTile(title: Text(text), onTap: () => print(text));
}

效果: image.gif

可以看到,不需要显式指定高度,而且builder函数的第三个参数也正常(和SliverPersistentHeaderToBox数量无关)

注意:

要使用SliverAppBar,建议使用SliverPersistentHeader,因为SliverPersistentHeader设计的初衷就是为了实现SliverAppBar,所以他们一起使用会有更好的协同。如果将SliverPersistentHeaderToBox和SliverAppBar一起使用,则可能会导致其他问题,因此:在没有使用SliverAppBar时,用SliverPersistentHeaderToBox,使用了SliverAppBar则用SliverPersistentHeader。