flutter 效果实现 —— sliver 固定

效果:

说明:绿色块在向上滑动,距离顶部 103 的高度(即 AppBar 下面)时固定

注:示例4 有官方组件 PinnedHeaderSliver( flutter 3.4)

示例 1:

解决问题的关键是修正 paintOffset,以使最终的 offset 在 pinned 位置固定不变。缺点是由于该示例的 origin 等于 0,会导致其后面的一个 sliver 被覆盖。

class PinnedSliverPage extends StatefulWidget {
  const PinnedSliverPage({Key? key}) : super(key: key);

  @override
  State<PinnedSliverPage> createState() => _PinnedSliverPageState();
}

class _PinnedSliverPageState extends State<PinnedSliverPage> {
  @override
  Widget build(BuildContext context) {
    print('topBar: ${MediaQuery.of(context).padding.top}');
    return Scaffold(
        backgroundColor: Colors.blueGrey,
        body: CustomScrollView(
          physics:
              AlwaysScrollableScrollPhysics(parent: BouncingScrollPhysics()),
          slivers: [
            SliverAppBar(
              title: Text("Pinned Sliver"),
              pinned: true,
              backgroundColor: Colors.orange.withOpacity(0.8),
            ),
            SliverToBoxAdapter(
              child: Container(
                alignment: Alignment.center,
                height: 200,
                color: Colors.yellow,
                child: Text("200"),
              ),
            ),
            //距离顶部 103时,开始固定
            SliverPinHeader(
              pinnedOffset: 103,
              child: Container(
                alignment: Alignment.center,
                color: Colors.green.withOpacity(0.8),
                height: 300,
                child: Text("300"),
              ),
            ),
            SliverToBoxAdapter(
              child: Container(
                alignment: Alignment.center,
                height: 1200,
                color: Colors.red,
                child: Text("1200"),
              ),
            )
          ],
        ));
  }
}

class SliverPinHeader extends SingleChildRenderObjectWidget {
  const SliverPinHeader({
    Key? key,
    required this.pinnedOffset,
    required Widget child,
  }) : super(child: child);

  final double pinnedOffset;

  @override
  RenderObject createRenderObject(BuildContext context) {
    return RenderSliverPinHeader(pinnedOffset: pinnedOffset);
  }

  @override
  void updateRenderObject(BuildContext context, RenderSliverPinHeader renderObject) {
    renderObject.pinnedOffset = pinnedOffset;
  }
}

class RenderSliverPinHeader extends RenderSliverSingleBoxAdapter {
  RenderSliverPinHeader({required double pinnedOffset}): _pinnedOffset = pinnedOffset;

  late double _pinnedOffset = 0;

  set pinnedOffset(double value) {
    if (_pinnedOffset == value) {
      return;
    }
    _pinnedOffset = value;
    markNeedsLayout();
  }

  @override
  void setupParentData(RenderObject child) {
    if (child.parentData is! SliverPhysicalParentData) {
      child.parentData = SliverPhysicalParentData();
    }
  }

  @override
  void performLayout() {
    if (child == null) {
      geometry = SliverGeometry.zero;
      return;
    }
    // 1.对子节点进行约束 SliverConstraints
    child!.layout(constraints.asBoxConstraints(), parentUsesSize: true);

    double childExtent;
    switch (constraints.axis) {
      case Axis.horizontal:
        childExtent = child!.size.width;
        break;
      case Axis.vertical:
        childExtent = child!.size.height;
        break;
    }

    final paintedChildSize =
        calculatePaintOffset(constraints, from: 0.0, to: childExtent);

    final double cacheExtent =
        calculateCacheOffset(constraints, from: 0.0, to: childExtent);

    // 2.上报当前节点的布局信息给 viewport
    geometry = SliverGeometry(
      // 默认 paintExtent 大于 0时才显示当前 sliver。手动设置 visible: true 则总是可见,不受 paintExtent 影响。
      // visible: paintExtent > 0.0
      // 可以调整此值为 -constraints.scrollOffset,来影响 paintChild.offset。
      // paintOrigin: 0
      // 通常等于子组件的高度,并且不随着滚动而改变
      scrollExtent: childExtent,
      // 通过等于页面上实际显示的长度。滚动到顶部后,开始变小,直到等于 0。手动设置的话,须小于 maxPaintExtent
      paintExtent: childExtent,
      // 注:布局高度要逐渐变小,否则下一个 sliver 滚到顶部会有停顿(可尝试注释掉这段代码)。
      layoutExtent: paintedChildSize,
      cacheExtent: cacheExtent,
      // 最大可绘制长度,常等于 childExtent。取值必须小于 constraints.remainingPaintExtent。
      maxPaintExtent: childExtent,
    );

    // 3.设置并保存 paintOffset 到 parentData 中,待稍后确定子节点位置(offset)
    // 默认等于 (0,0)
    // setChildParentData(child!, constraints, geometry!);

    final SliverPhysicalParentData childParentData =
        child!.parentData! as SliverPhysicalParentData;
    final topOffset =
        constraints.viewportMainAxisExtent - constraints.remainingPaintExtent;
    final offset = topOffset + (geometry?.paintOrigin ?? 0);
    if (offset < _pinnedOffset) {
      final paintOffset = _pinnedOffset - offset;
      childParentData.paintOffset = Offset(0.0, paintOffset);
    } else {
      childParentData.paintOffset = Offset(0.0, -constraints.scrollOffset);
    }
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    if (child != null && geometry!.visible) {
      final SliverPhysicalParentData childParentData =
          child!.parentData! as SliverPhysicalParentData;
      print("offset: $offset, paintOffset: ${childParentData.paintOffset}");
      // 4.绘制子节点
      // topOffset = constraints.viewportMainAxisExtent - constraints.remainingPaintExtent;
      //其中 offset = topOffset(sliver 到达顶部后等于 0) + paintOrigin(默认等于0)
      //其中 paintOffset(默认等于0)
      //即到达顶部后,如果两者都等于0,并且 visible 不等于 false,则 child 保持当前位置不变。
      context.paintChild(child!, offset + childParentData.paintOffset);
    }
  }
}

示例2:

亦可调整 paintOrign 达到同样的效果。(过程太繁琐,不推荐使用。但相关注释可以作参考)

class RenderSliverStillHeader extends RenderSliverSingleBoxAdapter {
  RenderSliverStillHeader({double? pinnedOffset})
      : _pinnedOffset = pinnedOffset;

  final double? _pinnedOffset;

  @override
  void setupParentData(RenderObject child) {
    if (child.parentData is! SliverPhysicalParentData) {
      child.parentData = SliverPhysicalParentData();
    }
  }

  @override
  void performLayout() {
    if (child == null) {
      geometry = SliverGeometry.zero;
      return;
    }
    // 1.对子节点进行约束 SliverConstraints
    child!.layout(constraints.asBoxConstraints(), parentUsesSize: true);

    double childExtent;
    switch (constraints.axis) {
      case Axis.horizontal:
        childExtent = child!.size.width;
        break;
      case Axis.vertical:
        childExtent = child!.size.height;
        break;
    }

    final paintedChildSize =
        calculatePaintOffset(constraints, from: 0.0, to: childExtent);

    final double cacheExtent =
        calculateCacheOffset(constraints, from: 0.0, to: childExtent);

    ///策略:修改 origin, 让 sliver与相应方向的offset抵消,即保持相对静止
    /// 下拉:
    ///
    /// 如果是回弹:
    /// 因为 sliver 未到达顶部,所以 scrollOffset 等于 0
    /// overScroll 是过量下拉的高度大于 0,回弹后等于 0
    /// layoutExtent 代表 sliver 占据的高度,下拉时子节点的大小固定不变,如当前 child 长度是 300
    /// paintExtent 表示当前可绘制的长度,等于 layoutExtent + overScroll
    ///
    ///
    /// 上拉:
    ///
    /// overScroll 等于 0
    ///
    /// 在 sliver 未到达顶部之前:
    /// scrollOffset 等于 0
    /// 由于 remainingPaintExtent = viewportMainAxisExtent(表示 viewport 总滚动高度) - topOffset(上方 sliver 组件占据的滚动高度, 包括 overScroll的高度,如果有的话)
    /// 因此,当前 sliver 距离顶部的高度 topOffset = viewportMainAxisExtent - remainingPaintExtent。
    ///
    /// 经尝试发现:过量下拉回弹的高度也可通过 topOffset = viewportMainAxisExtent - remainingPaintExtent 计算,此时它的值是负数。
    ///
    /// 在 sliver 到达顶部之后:
    /// topOffset = 0
    /// scrollOffset 大于 0
    ///
    /// 另外:precedingScrollExtent 表示当前Sliver之前的Sliver占据的总高度,也可表示当前 sliver 的初始位置。但如果列表是懒加载,如果不能预估时,
    /// 该值为 double.infinity。
    ///
    /// 分析如何使当前 sliver 保持相对静止:由于当前 sliver 是在可视区域里,只需要在当前位置保持不动即可,即固定距离等于初始位置,等于 precedingScrollExtent
    ///
    /// 当组件向下滚动时,需要将 paintOrigin 向上偏移,偏移的距离是 -overScroll
    /// 当组件向上滚动时,若当前 sliver 距离顶部的距离 topOffset 小于初始位置 precedingScrollExtent,即表示当前组件向上滚动了。
    /// 那么,设置 paintOrigin 向下偏移相应的距离即可。偏移的距离是 precedingScrollExtent - topOffset
    /// 滚动超过顶部后,topOffset 等于 0,继续偏移的距离是 scrollOffset。因此,总的偏移距离是 precedingScrollExtent - topOffset + scrollOffset
    ///
    double overScroll = constraints.overlap < 0 ? constraints.overlap.abs() : 0;
    print('layoutExtent: ${geometry?.layoutExtent}, '
        'paintExtent: ${geometry?.paintExtent}, '
        'overScroll: $overScroll, '
        'scrollOffset: ${constraints.scrollOffset}, ');
    print('remainingPaintExtent: ${constraints.remainingPaintExtent},'
        'viewportMainAxisExtent: ${constraints.viewportMainAxisExtent},'
        'topOffset: ${constraints.viewportMainAxisExtent - constraints.remainingPaintExtent},'
        'precedingScrollExtent: ${constraints.precedingScrollExtent}');

    //固定的位置
    double? pinnedOffset = _pinnedOffset;
    pinnedOffset ??= constraints.precedingScrollExtent;

    final topOffset =
        constraints.viewportMainAxisExtent - constraints.remainingPaintExtent;
    double origin = 0;
    if (topOffset <= pinnedOffset!) {
      origin = pinnedOffset - topOffset +
          constraints.scrollOffset;
      print('origin: $origin');
    }

    // 2.上报当前节点的布局信息给 viewport
    geometry = SliverGeometry(
      // 可以调整此值为 -constraints.scrollOffset,来影响 paintChild.offset。
      paintOrigin: origin,
      // 默认 paintExtent 大于 0时才显示当前 sliver。手动设置 visible: true 则总是可见,不受 paintExtent 影响。
      // visible: paintExtent > 0.0
      visible: true,
      // 通常等于子组件的高度,并且不随着滚动而改变
      scrollExtent: childExtent,
      // 通过等于页面上实际显示的长度。滚动到顶部后,开始变小,直到等于 0。手动设置的话,须小于 maxPaintExtent
      // 因为有限制 paintOrigin + paintExtent <= remainingPaintExtent。而 paintOrigin 越来越大(因为 scrollOffset 变大),所以需要使 paintExtent 变小。
      paintExtent: paintedChildSize,
      layoutExtent: paintedChildSize,
      cacheExtent: cacheExtent,
      // 最大可绘制长度,常等于 childExtent。取值必须小于 constraints.remainingPaintExtent。
      maxPaintExtent: childExtent,
    );

    // 3.保存 paintOffset 到 parentData 中,待稍后确定子节点位置(offset)
    //如果不设置,则 paintOffset 默认等于 (0,0)
    setChildParentData(child!, constraints, geometry!);
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    if (child != null && geometry!.visible) {
      final SliverPhysicalParentData childParentData =
          child!.parentData! as SliverPhysicalParentData;
      print("offset: $offset, paintOffset: ${childParentData.paintOffset}");
      // 4.绘制子节点
      // topOffset = constraints.viewportMainAxisExtent - constraints.remainingPaintExtent;
      //其中 offset = topOffset(sliver 到达顶部后等于 0) + paintOrigin(默认等于0)
      //其中 paintOffset(默认等于0)
      //即到达顶部后,如果两者都等于0,并且 visible 不等于 false,则 child 保持当前位置不变。
      context.paintChild(child!, offset + childParentData.paintOffset);
    }
  }
}

示例 3:

移除示例 2中 setChildParentData(child!, constraints, geometry!); ,直接设置 origin 等于固定位置。

class SliverFixedHeader extends SingleChildRenderObjectWidget {
  const SliverFixedHeader({
    Key? key,
    required this.pinnedOffset,
    required Widget child,
  }) : super(child: child);

  final double pinnedOffset;

  @override
  RenderObject createRenderObject(BuildContext context) {
    return RenderSliverFixedHeader(pinnedOffset: pinnedOffset);
  }

  @override
  void updateRenderObject(
      BuildContext context, RenderSliverFixedHeader renderObject) {
    renderObject.pinnedOffset = pinnedOffset;
  }
}

class RenderSliverFixedHeader extends RenderSliverSingleBoxAdapter {
  RenderSliverFixedHeader({required double pinnedOffset})
      : _pinnedOffset = pinnedOffset;

  late double _pinnedOffset = 0;

  set pinnedOffset(double value) {
    if (_pinnedOffset == value) {
      return;
    }
    _pinnedOffset = value;
    markNeedsLayout();
  }

  @override
  void setupParentData(RenderObject child) {
    if (child.parentData is! SliverPhysicalParentData) {
      child.parentData = SliverPhysicalParentData();
    }
  }

  @override
  void performLayout() {
    if (child == null) {
      geometry = SliverGeometry.zero;
      return;
    }
    // 1.对子节点进行约束 SliverConstraints
    child!.layout(constraints.asBoxConstraints(), parentUsesSize: true);

    double childExtent;
    switch (constraints.axis) {
      case Axis.horizontal:
        childExtent = child!.size.width;
        break;
      case Axis.vertical:
        childExtent = child!.size.height;
        break;
    }

    final paintedChildSize =
        calculatePaintOffset(constraints, from: 0.0, to: childExtent);

    final double cacheExtent =
        calculateCacheOffset(constraints, from: 0.0, to: childExtent);

    final topOffset =
        constraints.viewportMainAxisExtent - constraints.remainingPaintExtent;
    double origin = 0;
    if (topOffset <= _pinnedOffset) {
      origin = _pinnedOffset - topOffset;
    }
    // 2.上报当前节点的布局信息给 viewport
    geometry = SliverGeometry(
      // visible: paintExtent > 0.0
      paintOrigin: origin,
      scrollExtent: childExtent,
      paintExtent: childExtent,
      layoutExtent: paintedChildSize,
      cacheExtent: cacheExtent,
      maxPaintExtent: childExtent,
    );

  }

  @override
  void paint(PaintingContext context, Offset offset) {
    if (child != null && geometry!.visible) {
      final SliverPhysicalParentData childParentData =
          child!.parentData! as SliverPhysicalParentData;
      // 4.绘制子节点
      // topOffset = constraints.viewportMainAxisExtent - constraints.remainingPaintExtent;
      //其中 offset = topOffset(sliver 到达顶部后等于 0) + paintOrigin(默认等于0)
      //其中 paintOffset(默认等于0)
      //即到达顶部后,如果两者都等于0,并且 visible 不等于 false,则 child 保持当前位置不变。
      context.paintChild(child!, offset + childParentData.paintOffset);
    }
  }

  @override
  double childMainAxisPosition(RenderBox child) {
    return 0;
  }
}

示例 4:

在使用中,常常直接在导航栏下面固定,而非手动设置固定距离。为此只需要将 paintOrigin 设置为 overlap 即可

class SliverPinnedHeader extends SingleChildRenderObjectWidget {
  const SliverPinnedHeader({
    Key? key,
    required Widget child,
  }) : super(child: child);

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

class RenderSliverPinnedHeader extends RenderSliverSingleBoxAdapter {
  @override
  void performLayout() {
    if (child == null) {
      geometry = SliverGeometry.zero;
      return;
    }
    // 1.对子节点进行约束 SliverConstraints
    child!.layout(constraints.asBoxConstraints(), parentUsesSize: true);

    double childExtent;
    switch (constraints.axis) {
      case Axis.horizontal:
        childExtent = child!.size.width;
        break;
      case Axis.vertical:
        childExtent = child!.size.height;
        break;
    }

    final paintedChildSize =
        calculatePaintOffset(constraints, from: 0.0, to: childExtent);

    final double cacheExtent =
        calculateCacheOffset(constraints, from: 0.0, to: childExtent);

    print('constraints.overlap: ${constraints.overlap}');

    final paintExtent = math.min(
        childExtent, constraints.remainingPaintExtent - constraints.overlap);

    // 2.上报当前节点的布局信息给 viewport
    geometry = SliverGeometry(
      // visible: paintExtent > 0.0
      //解决问题的关键是这个:将 origin 设置为被覆盖的高度
      paintOrigin: constraints.overlap,
      scrollExtent: childExtent,
      paintExtent: paintExtent,
      layoutExtent: paintedChildSize,
      cacheExtent: cacheExtent,
      maxPaintExtent: childExtent,
    );
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    if (child != null && geometry!.visible) {
      final SliverPhysicalParentData childParentData =
          child!.parentData! as SliverPhysicalParentData;
      // 4.绘制子节点
      // topOffset = constraints.viewportMainAxisExtent - constraints.remainingPaintExtent;
      //其中 offset = topOffset(sliver 到达顶部后等于 0) + paintOrigin(默认等于0)
      //其中 paintOffset(默认等于0)
      //即到达顶部后,如果两者都等于0,并且 visible 不等于 false,则 child 保持当前位置不变。
      context.paintChild(child!, offset + childParentData.paintOffset);
    }
  }

  @override
  double childMainAxisPosition(RenderBox child) {
    return 0;
  }
}

posted on 2022-08-19 18:08  Lemo_wd  阅读(1191)  评论(0编辑  收藏  举报

导航