利用RectGetter组件获取控件位置尺寸实现的几个高级效果和功能 | 掘金技术征文

6,609 阅读11分钟

Flutter作为现代的响应式UI框架,布局逻辑上推荐使用Flex布局来实现不同终端屏幕尺寸和比例的适配,具有非常强大的表现力和灵活性。

如果之前对Flex布局没有足够的了解认识,推荐先阅读Flex 布局语法教程,虽然Flutter中的flex与html中的flex不尽相同,但是花个十几分钟了解一下概念会对Flutter中布局的实现思路很有好处。

也就是说,不应该假定屏幕尺寸为特定值,并且尽量避免使用固定的大小和位置值,而应该分析UI组件的相对逻辑关系进行布局。Flutter提供了丰富的UI组件,在“组合”的设计思想下,利用Flex系列容器控件、各种处理父子关系的组件以及指定宽高值或宽高比的组件等等,足够满足大多数情况下的需求。但是很多时候,一些复杂的布局、动画效果或者UI相关的逻辑功能却必须以“获得约束布局渲染后部件的尺寸及位置”为前提才能实现。下面我将用自己写过的三个例子进行说明,希望可以对读者的学习或工作起到启发作用。

1.仿掌阅的开页动画效果

和很多初学者一样,我接触Flutter不久后也尝试过写一个完整的应用DEMO,结合当时公司项目需要,我选择了掌阅APP作为模仿的对象,以此来验证Flutter开发的实际体验和效果。在解决了诸如json解析(参考上一篇:快速生成json解析模板类的工具),利用Canvas实现自定义文字排版显示,SDK中颜色转换函数的BUG(参考:pr16872)等等问题后,实现的半成品演示动画如下:

其中,我碰到的第一个比较难的点就是选择GridView中某一个条目转跳到阅读器界面时模拟书本打开的效果,放慢动画速度的演示如下:

为了实现这个效果,首先考虑的就是直接使用Flutter提供的Animation组件(参考:Flutter中的动画)与GridView中的条目组件组合,并在条目布局上添加手势控制,在其被点击时借由动画控制器来驱动动画的播放来实现效果。然而,在简单的尝试后发现这种思路有很大的问题:

如上图所示,在点击序号为4的'Card'时,在控制器的驱动下该'Card'执行了缩放动画,但是能清楚地看到,它左上角放大部分确实是盖在0~3号'Card'之上的,但是其右下角的放大部分却被5~8号'Card'遮盖,也就是说其叠放次序是在3号和5号之间的。

其实不仅仅是ListView/GridView中的条目,界面上的各种UI组件根据其创建的顺序以及所处Stack等因素,都是存在叠放次序的问题的。

这样的动画明显与预期不符,不是'拿起一本书一边拿到眼前一边打开'的效果。我花了一段时间尝试能不能更改选定组件在视图树中的叠放次序以解决这个问题,最终却没能找到解决的方案,于是只能另辟蹊径尝试其他的思路——直到从Flutter中自带的Hero转场动画中获得了灵感。

从上面Hero动画的原理介绍我们知道,应用中每个页面的Navigator会默认创建一个悬浮于视图顶层的透明Stack,叫做Overlay,可以将UI组件通过OverlayEntry包装后添加到这个Overlay中从而实现组件的置顶显示。由于Overlay实际上就是个全屏的Stack,那么想要是实现组件以原始的位置和大小添加到其中显示就需要先获得组件当前的Rect信息,并通过可以限定Rect的组件对其进行包装约束后再添加到Overlay中。

通过阅读Hero的源码,可以看到有如下实现代码:

        ………前略………
        } else if (toHeroBox.hasSize) {
          // The toHero has been laid out. If it's no longer where the hero animation is
          // supposed to end up then recreate the heroRect tween.
          final RenderBox finalRouteBox = manifest.toRoute.subtreeContext?.findRenderObject();
          final Offset toHeroOrigin = toHeroBox.localToGlobal(Offset.zero, ancestor: finalRouteBox);
          if (toHeroOrigin != heroRect.end.topLeft) {
            final Rect heroRectEnd = toHeroOrigin & heroRect.end.size;
            heroRect = _doCreateRectTween(heroRect.begin, heroRectEnd);
          }
        }

        final Rect rect = heroRect.evaluate(_proxyAnimation);
        final Size size = manifest.navigatorRect.size;
        final RelativeRect offsets = new RelativeRect.fromSize(rect, size);

        return new Positioned(
          top: offsets.top,
          right: offsets.right,
          bottom: offsets.bottom,
          left: offsets.left,
          child: new IgnorePointer(
            child: new RepaintBoundary(
              child: new Opacity(
                key: manifest.toHero._key,
                opacity: _heroOpacity.value,
                child: child,
              ),
            ),
          ),
        );
      },
    );

由此得知指定Rect显示组件的方法是使用Positioned组件进行处理,而获取组件当前Rect的方式则是利用组件的Context的findRenderObject()方法获取组件在渲染树中的引用,再对这个RenderObject进行研究,得知对其进行坐标转换后可以获得左上角坐标,利用其semanticBounds属性信息则可以获得宽高,由此就得到了组件的Rect信息。

为了简化这个流程并减少多余重复的代码,我将这些步骤包装在了一个叫做'RectGetter'的组件中,用于方便快捷地完成获取组件Rect的功能,并上传到了pub仓库,请通过链接:rect_getter 查看其用法,后面的例子中都将直接引用这个pub库来实现相关功能。

于是现在整个过程的思路就是:

  1. 在GridView创建条目的itemBuilder中,利用RectGetter组件包装原始的Card,使其拥有动态获得Rect的能力
  2. 在某个Card被点击时,首先通过其RectGetter获得当前的Rect信息,并利用该Rect信息传递给Positioned来包装Card对象,再用OverlayEntry包装后添加到Overlay层
  3. 驱动Overlay层中的Card执行组合动画,包括平移、缩放和柱面投影变换(Matrix4.createCylindricalProjectionTransform()),并在动画过程中调整各个变形动画的计算原点,实现Card一边放大一边移动到屏幕中心的同时模拟3D开页的效果
  4. 当Card移动到屏幕中心时,3D开页由于播放到了90°与屏幕垂直而消失,此时借由上一个动画的播放完成回调,驱动'填充动画'开始播放,使得'书本的背景页'缩放至全屏显示
  5. 当填充动画执行完毕时,在其完成回调中使用移除了默认路由动画的自定义路由打开新页面,并为该路由添加页面关闭的回调函数
  6. 当新页面关闭时,回调函数执行,分别反向播放填充动画和开页动画变成合上书本的效果,所有的动画完成后将Card从Overlay中移除

这个效果的DEMO源码地址:flutter_openbookeffect

2. 获取列表可视Item/转跳到指定Index

由于有幸参与了 Flutter中文用户组(QQ:482462550)的管理工作,得以了解到很多对Flutter感兴趣的朋友的实际开发问题,其中一个便是"在任意时刻获得ListView可见条目的范围",类似于文章:Flutter 中 ListView 组件的子元素曝光统计中讨论的问题。不同于这篇文章中的方式,我这里提供一种利用前面RectGetter组件判断位置的解决思路:

  1. 首先用RectGetter组件包装ListView本身,从而可以获得ListView的Rect信息
  2. 创建列表的Item对象时,在itemBuilder中利用RectGetter组件包装原始的Item组件,使其拥有动态获得Rect的能力,并将RectGetter所使用的key记录在全局数组中
  3. 在需要获得可见条目时,遍历key数组,获得所有'可以获得Rect信息的条目的Rect',这包括了实际显示了的条目,预加载的条目和部分已经划出屏幕但是被缓存的条目
  4. 将上一步中获得的所有Rect与ListView的Rect进行比较,第一个rect.bottom>listViewRect.top的条目就是第一个显示的条目,最后一个rect.top<listViewRect.bottom的条目就是最后一个显示的条目,从而获得了当前所有显示的条目

完整DEMO代码(编译运行前注意添加rect_getter依赖):

import 'package:flutter/material.dart';
import 'package:rect_getter/rect_getter.dart';

void main() => runApp(new MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: 'Flutter Demo',
      theme: new ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: new MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);

  final String title;

  @override
  _MyHomePageState createState() => new _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  var _keys = {};

  @override
  Widget build(BuildContext context) {
    /// 给整个ListView设置Rect信息获取能力
    var listViewKey = RectGetter.createGlobalKey();
    var _ctl = new ScrollController();

    var listView = RectGetter(
      key: listViewKey,
      child: ListView.builder(
        controller: _ctl,
        itemCount: 1000,
        itemBuilder: (BuildContext context, int index) {
          /// 给每个build出来的item设置Rect信息获取能力
          /// 并将用于获取Rect的key及index存入map中备用
          _keys[index] = RectGetter.createGlobalKey();
          return RectGetter(
            key: _keys[index],
            child: Container(
              color: Colors.primaries[index % Colors.primaries.length],
              child: SizedBox(
                width: 100.0,
                /// 利用index创建伪随机高度的条目
                height: 50.0 + ((27 * index) % 15) * 3.14,
                child: Center(
                  child: Text('$index'),
                ),
              ),
            ),
          );
        },
      ),
    );

    List<int> getVisible() {
      /// 先获取整个ListView的rect信息,然后遍历map
      /// 利用map中的key获取每个item的rect,如果该rect与ListView的rect存在交集
      /// 则将对应的index加入到返回的index集合中
      var rect = RectGetter.getRectFromKey(listViewKey);
      var _items = <int>[];
      _keys.forEach((index, key) {
        var itemRect = RectGetter.getRectFromKey(key);
        if (itemRect != null && !(itemRect.top > rect.bottom || itemRect.bottom < rect.top)) _items.add(index);
      });

      /// 这个集合中存的就是当前处于显示状态的所有item的index
      return _items;
    }

    return new Scaffold(
      appBar: new AppBar(
        title: new Text(widget.title),
      ),
      body: NotificationListener<ScrollUpdateNotification>(
        onNotification: (notification) {
          /// 滚动时实时打印当前可视条目的index
          print(getVisible());
          return true;
        },
        child: listView,
      ),
    );
  }
}

效果演示:

相比之下,这种处理方式的好处是准确,能够适应高度不定条目的处理,不用设置ListView关闭预加载;缺点则是可能产生性能问题(但也有优化空间和手段),以及必须管理好额外的key数组与列表数据的是对应关系。

在这之后,群里又有人提出了新的疑问,即如何控制ListView转跳到指定的index条目显示。我们知道,在Android/iOS原生的api中都提供了控制列表转跳到指定item的函数,而Flutter中的ListView并没有提供该函数,代码控制列表滚动只能通过控制器的jumpTo(position)方法,而且这里的position是实际的滚动距离值而不是条目index值。

群里多数同学的思路还是利用类似上面文章中,先指定单个固定条目的高度,然后用高度×index的方式得出指定index的偏移值,但是实际操作效果总是不够理想。而我则是在上一个例子的基础上稍加扩展,用动态的方式比较'精准'地实现了需要的效果:

思路:

  1. 与上一例子的所有思路步骤相同,可以获得每个时刻可视条目的范围(即getVisible()这个方法)
  2. 对于指定的目标index,先获取一次当前可视条目的范围,如果:
    • 目标index在可视范围内,执行第4步
    • 如果目标不在可视范围内,比较index与第一个可视条目的大小,从而确定ListView的滚动方向
  3. 使用jumpTo()方法往目标方向滚动一个ListView的高度,再次执行步骤2逻辑检查目标index是否在可视条目中,如果不在则循环本步骤
  4. 此时目标条目已经出现在了可视条目范围内,获取目标条目当前的Rect信息,并用该rect.top减去listViewRect.top,使用这个差值向上滚动ListView一次,则目标条目就在ListView的第一个显示位置了

完整DEMO代码(编译运行前注意添加rect_getter依赖):

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:rect_getter/rect_getter.dart';

void main() => runApp(new MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: 'Flutter Demo',
      theme: new ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: new MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);

  final String title;

  @override
  _MyHomePageState createState() => new _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  var _keys = {};

  @override
  Widget build(BuildContext context) {
    /// 给整个ListView设置Rect信息获取能力
    var listViewKey = RectGetter.createGlobalKey();
    var _ctl = new ScrollController();

    var listView = RectGetter(
      key: listViewKey,
      child: ListView.builder(
        controller: _ctl,
        itemCount: 1000,
        itemBuilder: (BuildContext context, int index) {
          print('build : $index');

          /// 给每个build出来的item设置Rect信息获取能力
          /// 并将用于获取Rect的key及index存入map中备用
          _keys[index] = RectGetter.createGlobalKey();
          return RectGetter(
            key: _keys[index],
            child: Container(
              color: Colors.primaries[index % Colors.primaries.length],
              child: SizedBox(
                width: 100.0,
                height: 50.0 + ((27 * index) % 15) * 3.14,
                child: Center(
                  child: Text('$index'),
                ),
              ),
            ),
          );
        },
      ),
    );

    var _textCtl = TextEditingController(
      text: '0',
    );

    List<int> getVisible() {
      /// 先获取整个ListView的rect信息,然后遍历map
      /// 利用map中的key获取每个item的rect,如果该rect与ListView的rect存在交集
      /// 则将对应的index加入到返回的index集合中
      var rect = RectGetter.getRectFromKey(listViewKey);
      var _items = <int>[];
      _keys.forEach((index, key) {
        var itemRect = RectGetter.getRectFromKey(key);
        if (itemRect != null && !(itemRect.top > rect.bottom || itemRect.bottom < rect.top)) _items.add(index);
      });

      /// 这个集合中存的就是当前处于显示状态的所有item的index
      return _items;
    }

    void scrollLoop(int target, Rect listRect) {
      var first = getVisible().first;
      bool direction = first < target;
      Rect _rect;
      if (_keys.containsKey(target)) _rect = RectGetter.getRectFromKey(_keys[target]);
      if (_rect == null || (direction ? _rect.bottom < listRect.top : _rect.top > listRect.bottom)) {
        var offset = _ctl.offset + (direction ? listRect.height / 2 : -listRect.height / 2);
        offset = offset < 0.0 ? 0.0 : offset;
        offset = offset > _ctl.position.maxScrollExtent ? _ctl.position.maxScrollExtent : offset;
        _ctl.jumpTo(offset);
        Timer(Duration.zero, () {
          scrollLoop(target, listRect);
        });
        return;
      }

      _ctl.jumpTo(_ctl.offset + _rect.top - listRect.top);
    }

    void jumpTo(int target) {
      var visible = getVisible();
      if (visible.contains(target)) return;

      var listRect = RectGetter.getRectFromKey(listViewKey);
      scrollLoop(target, listRect);
    }

    return new Scaffold(
      appBar: new AppBar(
        title: new Text(widget.title),
      ),
      body: Column(
        mainAxisAlignment: MainAxisAlignment.start,
        children: <Widget>[
          Expanded(
            child: NotificationListener<ScrollUpdateNotification>(
              onNotification: (notification) {
                getVisible();
                return true;
              },
              child: listView,
            ),
          ),
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            children: <Widget>[
              SizedBox(
                width: 100.0,
                height: 50.0,
                child: TextField(
                  controller: _textCtl,
                ),
              ),
              FlatButton(
                onPressed: () {
                  print('${_textCtl.text}');
                  jumpTo(int.parse(_textCtl.text));
                },
                child: Text('JUMP'),
              )
            ],
          ),
        ],
      ),
    );
  }
}

效果演示:

而在这个例子的基础上,类似于'仿微信通讯录滑动拼音首字母定位联系人'等类似功能应该也就不难实现了

3.实现一个瀑布流(未完成,已坑)

这个题目最早也是群里同学提出的,Flutter的SDK中没有可以直接实现瀑布流效果的组件。于是我还是用动态获取Rect的思路尝试了能否实现一个可用的瀑布流。

说明: 我尝试解决这个问题时,网上只有一个flutter_staggered_grid_view插件是处理类似的问题,但是当时版本的插件在使用时必须预先提供每一个子布局的宽高,也就是说它并不是一个'正真的瀑布流',既不能根据子布局实际的动态尺寸显示,子布局尺寸变化时也不能自动更新,而且我测试下来发现其性能也不是很理想。

不过随着这个插件v0.2.0版本的更新,现在它已经解决了上面所说的问题,虽然我还没有仔细验证,如果有这方面需要的同学可以优先尝试这个插件能否满足需求;

而我下面的方法在经过一段时间的尝试后,虽然初步达到了效果,但是还有很多问题和bug没能解决,仅供有兴趣的同学参考吧

思路:

GridView的构造函数中有一个gridDelegate属性,接收的是一个SliverGridDelegate对象,而这个委托对象就决定了GridView如何布局其内部的子元素。通过查看GridView几种不同模式的构造函数中所使用的不同SliverGridDelegate子类代码,发现它实际只需要四个方法,分别是提供可滚动的offset的最大值、某一offset下需要build的child的最大和最小index,以及指定index的child在viewpoet中的rect(getGeometryForChildIndex)。

所以我的大致思路是:

  1. 假如一次向data数组中加入了100个child的信息,由于瀑布流不容易计算实际显示的child范围,所以干脆直接返回需要build的child的最大和最小index就是0~99,这样比较简单。然后创建一个内部用于存储所有child高度信息的数据结构,当这个数据结构中的某个index信息第一次被访问时getGeometryForChildIndex直接返回全屏的尺寸,这样这个child就会’自由地’被绘制出来,同时将容器中该index信息标记为已渲染。
  2. 在某个child绘制完成后,利用异步函数获取这个child自动绘制以后的宽高,并将这个宽高按照瀑布流中单个child允许的宽度缩放计算后得到的高度信息更新到数据结构中(比如三列的瀑布流的话数据结构中就有三个List,List中的每个item就记录了对应item的top和bottom值),然后执行setState触发重绘。
  3. 重绘时getGeometryForChildIndex函数中判断发现该index在数据结构中被标记为已绘制,那么就取出数据返回该item在瀑布流中应该的rect,由此循环就实现了瀑布流的效果
  4. 为每个item添加尺寸变化监听,一旦其尺寸变化就将新的Rect更新到容器中,并触发重绘,从而实现瀑布流的自动更新

效果演示:

源码地址:Flutter_Staggered_View_Demo

从 0 到 1:我的 Flutter 技术实践 | 掘金技术征文,征文活动正在进行中