Flutter-监听Widget位置变化及StickyHeader效果实现与探讨

转载请标明出处: juejin.im/post/684490…

sticky header效果

支付宝效果 Demo效果
sticky-effect.gif EX_Screenrecord-2022-08-01-17-32-50-466.gif

这是一种比较常见的设计,有不同的实现思路。以下将主要关注上图中 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 而是一个别的什么。

因此这种方法显然不可取,这也是我发文的想要探讨的问题。

如果各位朋友有好的解决方式,希望不吝赐教。

感谢各位的时间。

猜你喜欢

转载自juejin.im/post/7126834422342483981