前言
项目介绍请看上篇文章,今天来讲讲项目的实现。本文略长,可能讲得比较细,如果对一些细节不感兴趣可以挑重点阅读。
项目地址
实现原理
在看到这篇文章之前,如果你有看过一些其它粘性头部项目的源码,就会发现,实现方式无外乎两种:
- 通过在Rendering层计算并调整Header和Content的位置实现。这种实现方式,Header和Content需要看作是一个整体(组件)作为滚动组件的Item,粘性头部的判断只局限于当前的Header和Content。优点是实现稳定,缺点是功能扩展有局限性
- 通过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.
获取ScrollPosition对象
ListView、GridView或其它滚动组件的构造方法有一个controller
参数,这个参数的类型是ScrollController
,顾名思义,这参数用来传入一个自定义的滚动控制器(如果不传会通过PrimaryScrollController.of(context)
获取滚动控制器,详见ScrollView
的build方法),通过ScrollController
的position
方法可以获取到我们想要的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中找找看。
从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;
}
}
成为粘性头部的条件
滚动组件滚动到任意位置时,怎么判断当前的粘性头部应该是哪个头部组件呢?
头部组件成为粘性头部的条件:
- Header组件在滚动组件上部分可见或者完全不可见,准确来说是Header组件相对滚动组件的偏移量在对应滚动方向上必须小于0。上图中满足条件的有Header #0(完全不可见)、Header #1(部分可见),Header #5虽然完全不可见,但是相对滚动组件的偏移量是大于0的
- 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组件原点(左上角)到滚动组件原点(左上角)的偏移量。
以上的能正常获取偏移量的前提是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,
);
}
计算粘性头部
- 获取头部组件信息
// 头部信息缓存
// 缓存是必须的,特别是在无限列表中,头部组件可能早已销毁,_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));
- 清除粘性头部信息缓存
// 当前粘性头部信息
currentStickyHeaderInfo = null;
// 当前粘性头部偏移量
currentOffset = Offset.zero;
- 判断是否需要粘性头部
// 根据成为粘性头部的条件,首个头部组件在对应滚动方向上的偏移量必须是小于0的
bool _isNeedStickyHeader(List<StickyHeaderInfo> stickyHeaderInfoList) {
return stickyHeaderInfoList.first.offset.dy < 0;
}
- 判断是否满足成为粘性头部的条件
// 根据成为粘性头部的条件,头部组件在对应滚动方向上的偏移量必须是小于0的
bool _isValidStickyHeader(StickyHeaderInfo stickyHeaderInfo) {
return stickyHeaderInfo.offset.dy < 0.0;
}
- 确定当前粘性头部是哪个头部组件
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;
}
}
}
}
}
- 计算当前粘性头部偏移量
Offset _calculateOffset(StickyHeaderInfo stickyHeaderInfo, StickyHeaderInfo nextStickyHeaderInfo) {
// 当下一个头部组件的偏移量小于当前粘性头部的高度时,当前粘性头部需要往上偏移,超出滚动组件可见区域
var d = nextStickyHeaderInfo.offset.dy - stickyHeaderInfo.size.height;
d = min(0.0, d);
return Offset(0.0, d);
}
- 通知重新构建粘性头部
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(() {});
}
}
总结
一个简单易用的粘性头部就此实现啦👏!
项目实现的核心就是监听滚动位置变化,然后获取头部组件的偏移量等信息,最后计算粘性头部并构建。
当时刚实现项目的我还是太年轻了,暗自以为这是项目美好的开始,接下来可以大展身手扩展想要的其他功能,却没想到命运早已在暗中标好了价格。做完简单测了测项目就发现了以下两个棘手的问题😣:
如果你有兴趣了解这些问题是怎么解决的,欢迎查看后续文章。
最后
如果这个项目对你有所帮助,点赞👍加星🌟安排一下~
如果你发现了bug或想要新功能,欢迎在issue留言讨论,同时也欢迎你加入到这个项目中,为这项目出一份力。
系列文章
Flutter - 一个易用且功能强大的粘性头部组件库,适用于任何支持滚动的组件(一)
Flutter - 一个易用且功能强大的粘性头部组件库,适用于任何支持滚动的组件(二)
Flutter - 一个易用且功能强大的粘性头部组件库,适用于任何支持滚动的组件(三)
Flutter - 一个易用且功能强大的粘性头部组件库,适用于任何支持滚动的组件(四)
Flutter - 一个易用且功能强大的粘性头部组件库,适用于任何支持滚动的组件(五)