转载请标明出处: juejin.im/post/684490…
sticky header效果
支付宝效果 | Demo效果 |
---|---|
这是一种比较常见的设计,有不同的实现思路。以下将主要关注上图中 TabBar 的悬浮效果。探讨比较常见的一种实现思路,并引出文章主题:监听 Widget 位置变化。
没有悬浮效果时 TabBar 只是列表上的一个普通 item(暂且简称 T),它会跟随列表滚动,但不能在列表边缘悬浮不动。
实现思路
常见的做法是在列表之上覆盖一个遮罩层(Flutter 中通过 Stack 可实现)放置一个展示悬浮效果的控件(通常和 T 长得一样, 暂且简称 M )。它初始是不可见的,当 T 滚动到和 M 相同的位置时使 M 可见。这样 M 就遮住了原本的 T,接下来即使 T 继续向上滑出屏幕, M 依然在那儿。
归根结底这就是一个联动效果,T 的位置影响 M 的显示与否。联动效果就是处理自变量与因变量的关系。
目前找到的 Flutter 工具
如何知道 T 何时滚动到列表上边缘呢?这就需要监听 T 的位置变化。
在 Flutter 上我暂且还未见到有什么简单的方法能够监听到控件的位置,但已知通过 RenderBox.localToGlobal
方法可以主动查询控件居于父类节点 的 Offset
虽然不能实时监听的 Widget 的位置,但有了这个函数不难想到可以通过定时器轮询 Widget 的位置。只要定时器的间隔够小,就可以近似实时的获取 Widget 的位置。
确实如此,被广泛使用的 flutter package:visibility_detector 就在内部包装了一个定时器,来查询 Widget 是否可见。
因此这里我也将使用类似的实现。
首先来看看 localToGlobal 函数,它属于 RenderBox 是 RenderObject 的子类。 感谢 Flutter 的架构设计,开发者绝大多数时候都在和简单的 Widget 打交道,复杂的实现被隐藏的很好,因此可能对 RenderObject 有些陌生。
有一些经验的开发者会知道,GlobalKey 有一个函数 findRenderObject
可以查询到 Widget 对应的 RenderObject ,将其强转为 RenderBox 即可拥有 localToGlobal。
担心强转有风险? 点进 localToGlobal 的实现会发现,它只是对 MatrixUtils.transformPoint
的包装而已,而 getTransformTo 是 RenderObject 的函数,因此只要把这句代码提出来稍改一下即可。例如:
MatrixUtils.transformPoint(renderObject.getTransformTo(null), Offset.zero);
ancestor 用于确定 Offset 计算的范围,为 null 时表示取 root 节点,一般是 App 窗口
接着实现一个定时器,来轮询控件位置。
Flutter 提供了一个很好的”定时装置”: WidgetsBinding.addPostFrameCallback
函数。它会在一帧绘制后回调。这不但意味着可以在位置改变后立即就能查询最新的位置,而且如果页面静止,它也不会触发无意义的回调。
void detectPosition(timeStamp) {
final renderObject = globalKey.currentContext.findRenderObject();
final offset = MatrixUtils.transformPoint(renderObject.getTransformTo(null), Offset.zero);
// 使用 offset
// ...
WidgetsBinding.instance.addPostFrameCallback(detectPosition);
}
addPostFrameCallback 会在每次调用后被舍弃,因此在末尾时需要重新添加监听。你可能发现了一些潜在的问题,别担心,这只是示例代码
代码实现
有了定时装置和获取控件位置的方法,问题已经被解决了大半。
回到 M 和 T 的问题,现在可以在每次回调触发时分别获取 M 和 T 相对于 root 节点的 Offset,进行比较来控制 M 的显示。通过计算他们之间的差值,还可以实现一些动画效果,这都随你。
思路有了,实现方式也有了,接着就是把这些工具拼起来,真正落到代码上。
以下是一个完整的示例
class Example extends StatefulWidget {
const Example({Key? key}) : super(key: key);
@override
State<Example> createState() => _ExampleState();
}
class _ExampleState extends State<Example> {
final GlobalKey _anchor = GlobalKey();
final GlobalKey _stickyHeader = GlobalKey();
final ValueNotifier<bool> stickyShown = ValueNotifier(false);
@override
void initState() {
super.initState();
_detectPosition();
}
void _detectPosition() {
if (!mounted) return;
final anchor = _anchor.currentContext?.findRenderObject();
final stickyHeader = _stickyHeader.currentContext?.findRenderObject();
if (anchor != null && stickyHeader != null) {
final anchorOffset = MatrixUtils.transformPoint(anchor.getTransformTo(null), Offset.zero);
final stickyHeaderOffset = MatrixUtils.transformPoint(stickyHeader.getTransformTo(null), Offset.zero);
stickyShown.value = anchorOffset.dy < stickyHeaderOffset.dy;
}
WidgetsBinding.instance.addPostFrameCallback((_) => _detectPosition);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Example 6')),
body: Stack(
children: [
ListView.builder(
itemBuilder: (context, index) {
if (index == 4) return Container(height: 100, color: Colors.blue, key: _anchor);
return Container(height: 100, color: Colors.accents[index % Colors.accents.length].shade400);
},
itemCount: 100,
),
ValueListenableBuilder<bool>(
valueListenable: stickyShown,
builder: (context, value, child) => Offstage(offstage: !value, child: child),
child: Container(key: _stickyHeader, height: 100, color: Colors.blue),
),
],
),
);
}
}
面临的问题
到这里,需求已经被基本解决,剩下的就是根据实际需求装填所需的控件。
但还有一些问题需要解决
耦合性
以上代码只是一个简单的示例,因此所有的逻辑都放在一起看起来没什么问题。 但在实际项目中,Widget 树可能存在非常复杂的嵌套。因此我希望能有更加松耦合的方式。
我目前想到的办法是让 T 拥有发送 Notification 的能力,就像 ListView 的各种滚动事件一样。 这样即使 T 处于嵌套结构的深处,也可以也在外层监听到它发出的关于位置改变的通知。
GlobalKey
前文是通过 GlobalKey 的方式获取 RenderObject ,并且需要创建一个 State 的来持有这个 GlobalKey 。这两样都是高开销的。
坐标系
前文讲 localToGlobal 时提到过它有一个可选参数 ancestor
用于确定位置所在的坐标。之前这里一直取的是 root 节点,也就是 App 窗口的坐标系。
通常列表都不会充满屏幕, 上面还有 AppBar 等。因此 Offset.dy=0 是窗口的顶部,但不一定是列表的顶部,而是早就滚出了列表视图。
这就是为啥除了获取 T 的位置, 还需要获取 M 的位置。
优化方案
我目前能想到的比较好的解决方案是用 SingleChildRenderObjectWidget
+ Notification
来实现一定的解耦
class PositionDetector extends SingleChildRenderObjectWidget {
const PositionDetector({Key? key, required Widget child}) : super(key: key, child: child);
@override
RenderObject createRenderObject(BuildContext context) {
return RenderPositionDetector()..context = context;
}
@override
void updateRenderObject(BuildContext context, RenderPositionDetector renderObject) => renderObject.context = context;
}
class RenderPositionDetector extends RenderProxyBox {
BuildContext? _context;
set context(BuildContext newValue) {
if (_context == newValue) return;
_context = newValue;
_detectPosition(Duration.zero);
}
Offset _lastOffset = Offset.zero;
RenderPositionDetector({RenderBox? child}) : super(child);
@override
void attach(PipelineOwner owner) {
super.attach(owner);
WidgetsBinding.instance.addPostFrameCallback(_detectPosition);
}
// 在 context 更新或 attach 之后调用,查询当前控件相对于 root 节点的 Offset
// 如果 Offset 发生变化,发送 Notification
_detectPosition(timeStamp) {
if (!attached || !hasSize) return;
final offset = localToGlobal(Offset.zero);
if (_lastOffset != offset) {
PositionUpdatedNotification(offset).dispatch(_context);
_lastOffset = offset;
}
WidgetsBinding.instance.addPostFrameCallback(_detectPosition);
}
}
class PositionUpdatedNotification extends LayoutChangedNotification {
final Offset globalOffset;
PositionUpdatedNotification(this.globalOffset);
}
将 T 包裹其中,就会在每次 T 的位置发生变化时发送 PositionUpdatedNotification 事件。
Stack(
children: [
ListView.builder(
itemBuilder: (context, index) {
if (index == 4) {
return NotificationListener<PositionUpdatedNotification>(
onNotification: (notification) {
// 收到控件位置变更的通知,和目标控件的位置对比,控制显示与否
final sticky = _stickyHeader.currentContext?.findRenderObject();
if (sticky == null) return false;
final stickyOffset = MatrixUtils.transformPoint(sticky.getTransformTo(null), Offset.zero);
stickyShown.value = notification.globalOffset.dy < stickyOffset.dy;
return true;
},
child: PositionDetector(
child: Container(height: 100, color: Colors.blue),
),
);
}
return Container(height: 100, color: Colors.accents[index % Colors.accents.length].shade400);
},
itemCount: 100,
),
ValueListenableBuilder<bool>(
valueListenable: stickyShown,
builder: (context, value, child) => Offstage(offstage: !value, child: child),
child: Container(key: _stickyHeader, height: 100, color: Colors.blue),
),
],
)
这种方法也不完美。可以看到虽然有一定解耦,但依然需要通过一个 GlobalKey 来获取 M 的位置,因此 State 也依然需要保留。再者对于将 BuildContext 传给 RenderObject 是否合理我也不能确定。
理想情况
如果能够以列表的坐标系来获取 T 的位置,那么 Offset.dy=0 那就标示在列表顶部边缘了, 也就不需要考虑 M 的位置了(除非悬浮的位置并非处于列表顶部边缘)。
ancestor 是一个 RenderObject 类型,那怎么才能得到 ListView 对应的 RenderObject 呢?难不成还是用 GlobalKey ?
RenderObject.parent
属性记录了这个节点的父节点,这是一个单向链表结构,可以一层一层向上追索。
就本文的示例来说,RenderPositionDetector.parent.parent 就是列表的 RenderObject
_detectPosition() {
if (!attached || !hasSize) return;
final offset = localToGlobal(Offset.zero, ancestor: parent?.parent as RenderObject?);
if (_lastOffset != offset) {
PositionUpdatedNotification(offset).dispatch(_context);
_lastOffset = offset;
}
WidgetsBinding.instance.addPostFrameCallback((_) => _detectPosition);
}
可这么做并不保险,可能不是 parent.parent,而是 parent.parent.parent 甚至 parent.parent…parent。还是不知道到底哪一个才是 ListView。甚至也可能要寻求的不是 ListView 而是一个别的什么。
因此这种方法显然不可取,这也是我发文的想要探讨的问题。
如果各位朋友有好的解决方式,希望不吝赐教。
感谢各位的时间。