Flutter - 一个易用且功能强大的粘性头部组件库,适用于任何支持滚动的组件(二)


前言

项目介绍请看上篇文章,今天来讲讲项目的实现。本文略长,可能讲得比较细,如果对一些细节不感兴趣可以挑重点阅读。

项目地址

实现原理

在看到这篇文章之前,如果你有看过一些其它粘性头部项目的源码,就会发现,实现方式无外乎两种:

  1. 通过在Rendering层计算并调整Header和Content的位置实现。这种实现方式,Header和Content需要看作是一个整体(组件)作为滚动组件的Item,粘性头部的判断只局限于当前的Header和Content。优点是实现稳定,缺点是功能扩展有局限性
  2. 通过Stack布局在滚动组件上方额外增加一个组件用来显示当前的粘性头部,粘性头部的显示及位置由滚动组件中已构建过的Header决定。这种实现方式,粘性头部和滚动组件是解耦的,粘性头部的显示可以从整体考虑,局限性小。优点是耦合性低、功能扩展方便,缺点是问题比较多

两种实现方式的优缺点一对比,该选哪个大家应该心里有数了吧。当然首选第一种…才怪~我也想选第一种啊😭,谁不想要一个少掉头发的项目,况且第一种方式实现的项目已经有了,拿来直接用多好,想要的功能不好实现真是无解。言归正传,我们开始通过第二种方式来实现粘性头部。

监听滚动位置变化

根据第二种实现方式,我们首先要监听滚动组件的滚动位置变化。

每一个可滚动的组件必然会有一个ScrollPosition对象用于确定要显示哪一部分内容,这个对象保存着很重要的属性pixels(滚动偏移量,相对滚动组件起始位置的偏移量),同时ScrollPosition又是Listenable的子类,每当pixels发生改变时会执行notifyListeners方法,所以现在只需要给ScrollPosition对象添加一个监听回调就实现了监听滚动位置变化。

ScrollPosition部分注释:

/// Determines which portion of the content is visible in a scroll view.
///
/// The [pixels] value determines the scroll offset that the scroll view uses to
/// select which part of its content to display. As the user scrolls the
/// viewport, this value changes, which changes the content that is displayed.
///
/// This object is a [Listenable] that notifies its listeners when [pixels]
/// changes.

screenshot1

获取ScrollPosition对象

ListView、GridView或其它滚动组件的构造方法有一个controller参数,这个参数的类型是ScrollController,顾名思义,这参数用来传入一个自定义的滚动控制器(如果不传会通过PrimaryScrollController.of(context)获取滚动控制器,详见ScrollView的build方法),通过ScrollControllerposition方法可以获取到我们想要的ScrollPosition的对象。

ScrollController部分源码:

class ScrollController extends ChangeNotifier {
  /// Returns the attached [ScrollPosition], from which the actual scroll offset
  /// of the [ScrollView] can be obtained.
  ///
  /// Calling this is only valid when only a single position is attached.
  ScrollPosition get position {
    assert(_positions.isNotEmpty, 'ScrollController not attached to any scroll views.');
    assert(_positions.length == 1, 'ScrollController attached to multiple scroll views.');
    return _positions.single;
  }
  
  /// The current scroll offset of the scrollable widget.
  ///
  /// Requires the controller to be controlling exactly one scrollable widget.
  double get offset => position.pixels;
}

上面是ScrollController类源码的一部分,从注释和断言可以看出,如果滚动控制器用于控制多个滚动组件,那么position方法无法使用。这里要补充一点的是ScrollController也可以设置监听回调和获取pixels(对应的是offset属性),项目中不使用ScrollController实现监听滚动位置变化和获取ScrollPosition对象的原因主要是用于多个滚动组件时会有问题。

那么,除了ScrollController,是否还有其他方式可以获取到当前滚动组件的ScrollPosition呢?答案肯定是有的。前面提到每一个可滚动的组件必然会有一个ScrollPosition对象,那我们在ListView的Widget Tree中找找看。

screenshot2

从ListView的Widget Tree可以看到Scrollable组件,Scrollable的State中保存着
ScrollPosition对象,这个组件就是用来实现滚动功能的。我们可以通过该组件提供的方法获取ScrollPosition对象。

Scrollable部分源码:

class Scrollable extends StatefulWidget {
  /// The state from the closest instance of this class that encloses the given context.
  ///
  /// Typical usage is as follows:
  ///
  /// ```dart
  /// ScrollableState scrollable = Scrollable.of(context);
  /// ```
  ///
  /// Calling this method will create a dependency on the closest [Scrollable]
  /// in the [context], if there is one.
  static ScrollableState? of(BuildContext context) {
    final _ScrollableScope? widget = context.dependOnInheritedWidgetOfExactType<_ScrollableScope>();
    return widget?.scrollable;
  }
}

获取ScrollPosition:

ScrollPosition? scrollPosition = Scrollable.of(context)?.position;

实现滚动位置变化监听

从获取ScrollPosition对象的源码可以看出,这是基于InheritedWidget获取的,那这样只能在Header组件构建时获取。为了方便使用,创建StickyContainerWidget包裹需要粘性的Header组件,在StickyContainerWidget构建时获取ScrollPosition对象并设置监听回调。

如果有多个StickyContainerWidget,每个都监听滚动位置变化肯定是不合理的,所以创建StickyHeaderController用于保存ScrollPosition对象和设置监听回调,后续还可以扩展其他功能。为了方便StickyContainerWidget获取到控制器,创建StickyHeader包裹滚动组件,在StickyHeader内部创建InheritedWidget保存控制器。

StickyHeader部分源码:

class StickyHeader extends StatefulWidget {
  static StickyHeaderController? of(BuildContext? context) => context
      ?.dependOnInheritedWidgetOfExactType<_StickyHeaderControllerWidget>()
      ?.controller;
}

_StickyHeaderControllerWidge源码:

class _StickyHeaderControllerWidget extends InheritedWidget {
  const _StickyHeaderControllerWidget({
    Key? key,
    required this.controller,
    required Widget child,
  }) : super(key: key, child: child);

  final StickyHeaderController controller;

  
  bool updateShouldNotify(_StickyHeaderControllerWidget oldWidget) =>
      controller != oldWidget.controller;
}

StickyHeaderController部分源码:

class StickyHeaderController extends ChangeNotifier {
  ScrollPosition? _scrollPosition;
  
  set scrollPosition(ScrollPosition? newScrollPosition) {
    if (newScrollPosition != null && _scrollPosition != newScrollPosition) {
      ScrollPosition? oldScrollPosition = _scrollPosition;
      _scrollPosition = newScrollPosition;
      oldScrollPosition?.removeListener(scrollListener);
      newScrollPosition.addListener(scrollListener);
    }
  }

  ScrollPosition? get scrollPosition => _scrollPosition;
}

StickyContainerWidget部分源码:

class StickyContainerWidget extends SingleChildRenderObjectWidget {
  StickyHeaderController? _getController(BuildContext context) {
    var controller = StickyHeader.of(context);
    assert(
        controller != null,
        'Sticky header controller instance must not be null, '
        'confirm whether to use [StickyHeader] to wrap the widget.');
    var scrollPosition = Scrollable.of(context)?.position;
    assert(
        scrollPosition != null,
        'Scroll position instance must not be null, '
        'confirm whether the widget wrapped by [StickyHeader] is scrollable.');
    if (scrollPosition != null &&
        controller?.scrollPosition != scrollPosition) {
      controller?.scrollPosition = scrollPosition;
      WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
        controller?.scrollListener();
      });
    }
    return controller;
  }
}

成为粘性头部的条件

滚动组件滚动到任意位置时,怎么判断当前的粘性头部应该是哪个头部组件呢?

screenshot3

头部组件成为粘性头部的条件:

  1. Header组件在滚动组件上部分可见或者完全不可见,准确来说是Header组件相对滚动组件的偏移量在对应滚动方向上必须小于0。上图中满足条件的有Header #0(完全不可见)、Header #1(部分可见),Header #5虽然完全不可见,但是相对滚动组件的偏移量是大于0的
  2. Header组件满足第一点条件的情况下,如果不是最后一个Header组件,那么下一个Header组件必须是不满足第一点条件的。所以上图中只有Header #1满足成为粘性头部的条件

获取头部组件偏移量

判断头部组件是否满足第一点条件需要知道头部组件的偏移量,那么偏移量该怎么获取呢?

这里需要用到localToGlobal方法,localToGlobal用于将局部坐标系转换为全局坐标系。

RenderBox部分源码:

abstract class RenderBox extends RenderObject {
  /// Convert the given point from the local coordinate system for this box to
  /// the global coordinate system in logical pixels.
  ///
  /// If `ancestor` is non-null, this function converts the given point to the
  /// coordinate system of `ancestor` (which must be an ancestor of this render
  /// object) instead of to the global coordinate system.
  ///
  /// This method is implemented in terms of [getTransformTo]. If the transform
  /// matrix puts the given `point` on the line at infinity (for instance, when
  /// the transform matrix is the zero matrix), this method returns (NaN, NaN).
  Offset localToGlobal(Offset point, { RenderObject? ancestor }) {
    return MatrixUtils.transformPoint(getTransformTo(ancestor), point);
  }
}

将方法中的参数point设为Offset.zero,ancestor设为滚动组件(不设置时是转为全局坐标系,这边转为滚动组件坐标系),由Header组件调用该方法,得到的Offset即表示Header组件原点(左上角)到滚动组件原点(左上角)的偏移量。

screenshot4

以上的能正常获取偏移量的前提是Header组件是RenderBox的子类,同时还需要获取到滚动组件相关的RenderObject作为ancestor。基于这些,创建RenderStickyContainer作为StickyContainerWidget的RenderObject,在RenderStickyContainer中调用localToGlobal方法获取偏移量,滚动组件相关的RenderObject通过控制器保存的ScrollPosition对象获取。

RenderStickyContainer部分源码:

class RenderStickyContainer extends RenderProxyBox {
  Offset get _offset {
    var renderObject = _controller?.scrollPosition?.context.notificationContext
        ?.findRenderObject();
    var offset = Offset.zero;
    if (renderObject?.attached ?? false) {
      offset = localToGlobal(Offset.zero, ancestor: renderObject);
    }
    return offset;
  }
}

获取头部组件信息

判断头部组件是否满足第二点条件需要知道当前ViewPort中所有的头部组件信息。

现在滚动位置变化的监听回调在StickyHeaderController中,后续计算粘性头部的实现肯定也是在回调中进行的,那么我们可以在控制器中定义一个StickyHeaderInfoCallback回调以及回调的添加移除方法供RenderStickyContainer使用。

计算粘性头部时只需要遍历_stickyHeaderInfoCallbackList列表执行回调就能获取到所需的头部组件信息。

StickyHeaderInfoCallback源码:

typedef StickyHeaderInfoCallback = StickyHeaderInfo Function();

StickyHeaderController部分源码:

class StickyHeaderController extends ChangeNotifier {
  final List<StickyHeaderInfoCallback> _stickyHeaderInfoCallbackList =
      <StickyHeaderInfoCallback>[];

  void addCallback(StickyHeaderInfoCallback callback) {
    _stickyHeaderInfoCallbackList.add(callback);
  }

  void removeCallback(StickyHeaderInfoCallback callback) {
    _stickyHeaderInfoCallbackList.remove(callback);
  }
}

RenderStickyContainer部分源码:

class RenderStickyContainer extends RenderProxyBox {
  
  void attach(PipelineOwner owner) {
    super.attach(owner);
    _controller?.addCallback(_callback);
  }

  
  void detach() {
    _controller?.removeCallback(_callback);
    super.detach();
  }
  
  StickyHeaderInfo _callback() => StickyHeaderInfo(
        index: index,
        visible: visible,
        size: size,
        pixels: _pixels,
        offset: _offset,
        parentIndex: _parentIndex,
        overlapParent: overlapParent,
        widget: widget,
      );
}

计算粘性头部

  1. 获取头部组件信息
// 头部信息缓存
// 缓存是必须的,特别是在无限列表中,头部组件可能早已销毁,_stickyHeaderCallbackList是空的
final Map<int, StickyHeaderInfo> _stickyHeaderInfoMap =
      <int, StickyHeaderInfo>{};

// 获取当前ViewPort中所有的头部组件信息
for (var callback in _stickyHeaderCallbackList) {
  _stickyHeaderInfoMap[stickyHeaderInfo.index] = callback();
}
var stickyHeaderInfoList = _stickyHeaderInfoMap.values.toList();
// 按索引从小到大排序
stickyHeaderInfoList.sort((a, b) => a.index.compareTo(b.index));
  1. 清除粘性头部信息缓存
// 当前粘性头部信息
currentStickyHeaderInfo = null;
// 当前粘性头部偏移量
currentOffset = Offset.zero;
  1. 判断是否需要粘性头部
// 根据成为粘性头部的条件,首个头部组件在对应滚动方向上的偏移量必须是小于0的
bool _isNeedStickyHeader(List<StickyHeaderInfo> stickyHeaderInfoList) {
  return stickyHeaderInfoList.first.offset.dy < 0;
}
  1. 判断是否满足成为粘性头部的条件
// 根据成为粘性头部的条件,头部组件在对应滚动方向上的偏移量必须是小于0的
bool _isValidStickyHeader(StickyHeaderInfo stickyHeaderInfo) {
  return stickyHeaderInfo.offset.dy < 0.0;
}
  1. 确定当前粘性头部是哪个头部组件
if (stickyHeaderInfoList.isNotEmpty && _isNeedStickyHeader(stickyHeaderInfoList)) {
  // 遍历排序后的头部组件信息
  for (var i = 0; i < stickyHeaderInfoList.length; i++) {
    var stickyHeaderInfo = stickyHeaderInfoList[i];
    if (_isValidStickyHeader(stickyHeaderInfo)) {
      // 如果是最后一个头部组件,则直接满足成为粘性头部的条件
      if (i == stickyHeaderInfoList.length - 1) {
        currentStickyHeaderInfo = stickyHeaderInfo;
        break;
      } else {
      	// 不是最后一个头部组件,则下一个头部组件必须不满足成为粘性头部的条件
        if (!_isValidStickyHeader(stickyHeaderInfoList[i + 1])) {
          currentStickyHeaderInfo = stickyHeaderInfo;
          // 计算粘性头部偏移量
          // 粘性头部切换时需要跟随滚动变化,从完全可见变为部分可见
          currentOffset = _calculateOffset(stickyHeaderInfo, stickyHeaderInfoList[i + 1]);
          break;
        }
      }
    }
  }
}
  1. 计算当前粘性头部偏移量
Offset _calculateOffset(StickyHeaderInfo stickyHeaderInfo, StickyHeaderInfo nextStickyHeaderInfo) {
  // 当下一个头部组件的偏移量小于当前粘性头部的高度时,当前粘性头部需要往上偏移,超出滚动组件可见区域
  var d = nextStickyHeaderInfo.offset.dy - stickyHeaderInfo.size.height;
  d = min(0.0, d);
  return Offset(0.0, d);
}
  1. 通知重新构建粘性头部
notifyListeners();

想了解更多,请点击查看sticky_header_controller中的scrollListener方法。

构建粘性头部

借助已经计算好的当前粘性头部信息和偏移量,创建一个StickyHeaderWidget用于构建粘性头部。

StickyHeaderWidget的State源码:

class _StickyHeaderWidgetState extends State<StickyHeaderWidget> {
  
  void initState() {
    super.initState();
    // 添加重新构建监听
    widget.controller.addListener(_update);
  }

  
  void didUpdateWidget(covariant StickyHeaderWidget oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.controller != oldWidget.controller) {
      oldWidget.controller.removeListener(_update);
      widget.controller.addListener(_update);
    }
  }

  
  void dispose() {
    widget.controller.removeListener(_update);
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    var stickyHeaderInfo = widget.controller.currentStickyHeaderInfo;
    return Visibility(
      visible: stickyHeaderInfo != null && stickyHeaderInfo.visible,
      child: _buildStickyHeader(stickyHeaderInfo),
    );
  }

  Widget _buildStickyHeader(StickyHeaderInfo? stickyHeaderInfo) {
    if (stickyHeaderInfo != null) {
      return Stack(
        children: <Widget>[
          Positioned(
            left: widget.controller.currentOffset.dx,
            top: widget.controller.currentOffset.dy,
            right: 0.0,
            bottom: null,
            child: stickyHeaderInfo.widget,
          ),
        ],
      );
    }
    return Container();
  }

  void _update() {
    setState(() {});
  }
}

总结

一个简单易用的粘性头部就此实现啦👏!
项目实现的核心就是监听滚动位置变化,然后获取头部组件的偏移量等信息,最后计算粘性头部并构建。

当时刚实现项目的我还是太年轻了,暗自以为这是项目美好的开始,接下来可以大展身手扩展想要的其他功能,却没想到命运早已在暗中标好了价格。做完简单测了测项目就发现了以下两个棘手的问题😣:

  1. 粘性头部切换过程中偶尔会有空隙
  2. 粘性头部无法像头部组件一样滑动

如果你有兴趣了解这些问题是怎么解决的,欢迎查看后续文章。

最后

如果这个项目对你有所帮助,点赞👍加星🌟安排一下~

如果你发现了bug或想要新功能,欢迎在issue留言讨论,同时也欢迎你加入到这个项目中,为这项目出一份力。


系列文章

Flutter - 一个易用且功能强大的粘性头部组件库,适用于任何支持滚动的组件(一)
Flutter - 一个易用且功能强大的粘性头部组件库,适用于任何支持滚动的组件(二)
Flutter - 一个易用且功能强大的粘性头部组件库,适用于任何支持滚动的组件(三)
Flutter - 一个易用且功能强大的粘性头部组件库,适用于任何支持滚动的组件(四)
Flutter - 一个易用且功能强大的粘性头部组件库,适用于任何支持滚动的组件(五)

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值