This commit is contained in:
guoshuyu 2022-08-09 10:03:38 +08:00
parent 555d1558c9
commit e38a07fcfa
9 changed files with 1755 additions and 54 deletions

View File

@ -57,4 +57,10 @@
* [Flutter 小技巧之玩转字体渲染和问题修复 ](Flutter-N3.md)
* [Flutter 小技巧之有趣的动画技巧](Flutter-N4.md)
* [Flutter 小技巧之 Dart 里的 List 和 Iterable 你真的搞懂了吗?](Flutter-N6.md)
* [Flutter 小技巧之 MediaQuery 和 build 优化你不知道的秘密](Flutter-N7.md)
* [Flutter 小技巧之 ListView 和 PageView 的各种花式嵌套](Flutter-N5.md)
* [Flutter 小技巧之优化你使用的 BuildContext](Flutter-N8.md)
* [如何利用 Flutter 实现炫酷的 3D 卡片和帅气的 360° 展示效果](Flutter-N9.md)
* [给掘金 Logo 快速添加动画效果,并支持全平台开发框架](Flutter-N10.md)
* [Flutter 实现 “真” 3D 动画效果,用纯代码实现立体 Dash 和 3D 掘金 Logo](Flutter-N11.md)

165
Flutter-N10.md Normal file
View File

@ -0,0 +1,165 @@
# 给掘金 Logo 快速添加动画效果,并支持全平台开发框架
![](http://img.cdn.guoshuyu.cn/20220731_N10/image1.gif)
我正在参加「创意开发 投稿大赛」详情请看:[掘金创意开发大赛来了!](https://juejin.cn/post/7120441631530549284)
**如果需要在 Android、 iOS、Web、Desktop 等平台快速实现如上图所示的动画效果,你第一考虑会怎么做**
也许你会说使用 Flutter ?不不不,如果还需要兼容多技术栈呢?例如支持 Flutter 、React、Vue、C++ 等不同语言和技术平台呢?
这时候也许你会想到 [Lottie](https://lottiefiles.com/) ,诚然 Lottie 的动画效果确实十分优秀,也支持 Android、 iOS、React Native、Web、 Windows 等平台,但是它的输入来源于 After Effects 动画特效,并且依赖于 `Bodymovin` 插件,这对于个人开发或者 UI 设计师来说,从 0 开始学习的门槛还是不低的。
![](http://img.cdn.guoshuyu.cn/20220731_N10/image2.gif)
**而本篇将给你推荐另外一个更简单又强大的动画开发平台: [rive](https://rive.app)** ,对于 rive 可能大家会感觉比较陌生,做过 Flutter 开发的可能对 rive 会有所耳闻,因为 rive 在此之前叫 flare ,是 2dimensions 公司的开源动画产品,在发布之初由于和 Flutter 团队有深入合作,所以在初期一直是 Flutter 官方推荐的动画框架。
后来由于 flare 项目被合并所以升级为 rive **升级后的 rive 开始把动画效果拓展到全平台,这个全平台不只是物理设备的全平台,还包括了跨语言和框架的全平台,不过可惜和第一代 flare 存在断档不兼容**。
| ![](http://img.cdn.guoshuyu.cn/20220731_N10/image3.gif) | ![](http://img.cdn.guoshuyu.cn/20220731_N10/image4.png) |
| ------------------------------------------ | ------------------------------------------------------------ |
本篇之所以推荐 rive 来实现多端动画,主要有以下几个原因:
- 支持手机端、Web 端 和PC 端等平台支持
- 支持 React 、Flutter、Unity 等多种框架Vue 和 Angular 也有社区支持
- 支持 JS、Dart、C++ 等多种语言
- **不用安装工具,直接 Web [Editor](https://editor.rive.app/) 就可以进行可视化开发,并附带工程管理**
| ![](http://img.cdn.guoshuyu.cn/20220731_N10/image5.png) | ![](http://img.cdn.guoshuyu.cn/20220731_N10/image6.png) |
| ------------------------------------------------------------ | ------------------------------------------------------------ |
**无需安装,打开即用,多平台多语言支持就是本次推荐 rive 的主要原因**,那么回到主体,接下来我们将通过 rive 来实现一个掘金动画 logo。
首先打开 rive 的 Web [Editor](https://editor.rive.app/) ,这里需要你有账户登陆,注册登陆是免费的,在登陆之后我们就可以进入到 rive 的动画编辑界面。
因为我们是要基于掘金的 logo 实现一个动画,所以开始之前可以先拿到掘金 logo 的 svg ,这里**只需要直接从文件夹把 svg 文件拖拽到 Web Editor 里就可以**,它会自己自动上传,上传成功之后就可以看到下图的界面效果。
![](http://img.cdn.guoshuyu.cn/20220731_N10/image7.png)
如果你说我没有 svg 文件怎么办?不用担心, rive 提供丰富又简单的绘制工具,如下图 1 所示,通过 Pen Tool 你就可以快速绘制出一些简单的图形,复杂的路径也可以如图 3 一样描绘出来。
| ![](http://img.cdn.guoshuyu.cn/20220731_N10/image8.png) | ![](http://img.cdn.guoshuyu.cn/20220731_N10/image9.gif) | ![](http://img.cdn.guoshuyu.cn/20220731_N10/image10.gif) |
| ------------------------------------------------------------ | -------------------------------------------------------- | ------------------------------------------------------------ |
回到上传完 svg 的界面,这时候主要看 3 部分:
- 红框 1 里的是 Artboards 画板(`brand-with-text.svg`) 和画板内的各种 Shape 图形
- 选中 Shape 图形,可以看到红框 2 里对应的图形进入可操作的状态
- 红框 3 是用于切换设计和动画界面在设计Design界面下是调整 UI 在动画Animate界面是调整动画效果。
![](http://img.cdn.guoshuyu.cn/20220731_N10/image11.png)
如下图所示,当我们选中一个 Shape 的时候,你就可以对图形进行移动、旋转、缩放等操作,从而来调整 UI 的变化,达到我们需要的动画效果。
![](http://img.cdn.guoshuyu.cn/20220731_N10/image12.gif)
接下来我们点击切换到 Animate 下,可以看到此时地步多了一个时间轴,**这个时间轴就是我们控制整个动画过程的关键**,这里为了实现前面的动画效果,首先需要把整个掘金 logo 挪动到了画布的外面,为后面的掉落动画做准备。
![](http://img.cdn.guoshuyu.cn/20220731_N10/image13.png)
接下开始我们的动画,开始之前我们随意调整 svg 里图形的位置或者角度 ,比如:
- 这里对【稀】字进行了55° 的旋转
- 对【掘】字进行了 -180° 的旋转
- 对 【金】字行了50° 的旋转
- 对 logo 上的小方块位置调整移动
![](http://img.cdn.guoshuyu.cn/20220731_N10/image14.png)
做完上面的操作之后,**可以看到时间轴上多了一排点,这些点就是当前动画 Shape 在这个时间戳上的状态** ,如果你觉得用鼠标控制不够精确,你也可以在右边的窗口上对参数进行精确调整。
| ![](http://img.cdn.guoshuyu.cn/20220731_N10/image15.png) | ![](http://img.cdn.guoshuyu.cn/20220731_N10/image16.png) | ![](http://img.cdn.guoshuyu.cn/20220731_N10/image17.png) |
| ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ |
另外如上图 2 所示,在时间轴上可以通过调整 Duration 来设定动画的总时长,还可以调整动画循环播放等等。
接下来就是体力活了, 比如我们需要掘金 logo 从顶部掉下来,那么我们可以在时间轴上拖动蓝色的进度到合适位置,然后挪动图形,然后就可以看到时间戳上多了新的状态点,接着点击播放就可以看到动画效果。
![](http://img.cdn.guoshuyu.cn/20220731_N10/image18.gif)
如果你需要两个 Shape 之间掉落存在时间差,那么如下图所示,你可以直接调整时间轴上对应的点位,就可以轻松实现动画里 Shape 的移动时间差。
![](http://img.cdn.guoshuyu.cn/20220731_N10/image19.gif)
这里有一个需要注意的是,当你选中时间戳上的某个节点时,在右侧是可以调整动画的插值状态的,默认情况下是线性 Linear ,但是我们可以根据需要设置想要的 Cubic 计算方式。
![](http://img.cdn.guoshuyu.cn/20220731_N10/image20.png)
不同 Interpolation 效果如下图所示,其中 Cubic 状态下是可以自定义调整动画的插值计算方式,所以一般情况下都会选择 Cubic 来调整动画的插值计算。
| Linear | Cubic | Hold |
| ------------------------------------------------ | ----------------------------------------------- | ---------------------------------------------- |
| ![](http://img.cdn.guoshuyu.cn/20220731_N10/image21.gif) | ![](http://img.cdn.guoshuyu.cn/20220731_N10/image22.gif) | ![](http://img.cdn.guoshuyu.cn/20220731_N10/image23.gif) |
通过调整动画的差值效果,就可以让生硬的动画过度变得更加自然,例如下图就通过调整 Cubic Points 之后,可以实现快进慢出的效果。
![](http://img.cdn.guoshuyu.cn/20220731_N10/image24.gif)
而在经历一系列【体力劳动】之后,你就可以看到类似下图的效果,通过对各种 Shape 进行移动,旋转,缩放,然后通过 Cubic Points 调整动画的丝滑程度,最后排布好时间戳,就可以完成最初的动画效果。
![](http://img.cdn.guoshuyu.cn/20220731_N10/image25.gif)
> 这不比你用代码和意念写来的香?
当然,这里还有一个需要注意的是,**如果你存在多个画板和动画,那么画板名字和动画名字的命名就很重要**,因为如可能会需要在代码里需要用它来指定动画效果,当然,**这也代表了你可以在一个 rive 文件你设置多组画板和多组动画效果**。
![](http://img.cdn.guoshuyu.cn/20220731_N10/image26.png)
然后你就可以导出 rive 文件到工程里去使用,同时 rive 文件是支持本地加载和远程加载的,官方贴心地提供了分享链接,你可以把动画通过 Embed link 或者 iframe 添加到 Web 里,甚至还贴心地提供了 React 代码复制。
| ![](http://img.cdn.guoshuyu.cn/20220731_N10/image27.png) | ![](http://img.cdn.guoshuyu.cn/20220731_N10/image28.png) |
| ------------------------------------------------------------ | ------------------------------------------------------------ |
例如在 Flutter 里,你可以通过 `RiveAnimation.network` 或者 `RiveAnimation.asset` 来加载动画文件,当然你也可以自定义 `RiveAnimationController` 来做一些自定义控制,比如通过 `animationName` 来指定对应的动画效果。
```js
class SimpleAnimation extends StatelessWidget {
const SimpleAnimation({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(
child: RiveAnimation.network(
'https://cdn.rive.app/animations/vehicles.riv',
),
),
);
}
}
```
当然前面介绍的只是简单的动画效果rive 其实可以实现很强的各种动画交互,比如:
- 通过 Bone 来设置骨骼交互
- 通过 Draw Order 动态设置层级交替
| ![](http://img.cdn.guoshuyu.cn/20220731_N10/image29.gif) | ![](http://img.cdn.guoshuyu.cn/20220731_N10/image30.gif) |
| -------------------------------------------------- | -------------------------------------------- |
甚至在 rive 里还有 **State Machine 模式,从而支持根据不同条件和逻辑状态触发不同的动画效果,节省可开发者需要在代码里进行逻辑判断的部分,并且这部分逻辑是可以跨平台跨语言支持**
| ![](http://img.cdn.guoshuyu.cn/20220731_N10/image31.gif) | ![](http://img.cdn.guoshuyu.cn/20220731_N10/image32.gif) |
| -------------------------------------------- | ---------------------------------------------------- |
> 更多 rive 的丰富功能可查阅 https://help.rive.app
那么到这里,相信大家最关心的问题就是:**rive 能不白嫖 ?答案是可以的!** rive 默认对于 free 用户来说支持 3 个文件免费这对于个人而言其实够用因为前面说的rive 支持一个文件下创建多个画板和多个动画,所以正常情况下个人使用 3 个免费的限制其实问题不大。
![](http://img.cdn.guoshuyu.cn/20220731_N10/image33.png)
同时 rive 社区也有很多免费开放的动画资源,对于懒癌患者来说也是不错的选择之一,**当然你也可以把文章转发给设计师,安利他们使用 rive将开发成本“嫁接”给他们你只负责岁月静安地用几行代码完成动画接入就可以了**。
![](http://img.cdn.guoshuyu.cn/20220731_N10/image34.gif)
**rive 就是这么一个将 “开发不行” 变成 “设计不行” 的工具,相比较 AE ,它不需要安装工具,而且操作更加简单支持,如果没有设计师也可以自己上手,这也是我最近喜欢上 rive 的原因**。
如果你对 rive 还有什么想法或者疑问,欢迎留言交流~

197
Flutter-N11.md Normal file
View File

@ -0,0 +1,197 @@
# Flutter 实现 “真” 3D 动画效果,用纯代码实现立体 Dash 和 3D 掘金 Logo
我正在参加「创意开发 投稿大赛」详情请看:[掘金创意开发大赛来了!](https://juejin.cn/post/7120441631530549284)
**本篇将给你带来更加炫酷动画效果,最后教你如何通过纯代码实现一只立体的 Flutter 的吉祥物 Dash 和 3D 的掘金 logo 动画**。
> ❤️ **本文正在参加征文投稿活动,还请看官们走过路过来个点赞一键三连,感激不尽~**
在之前的 [《炫酷的 3D 卡片和帅气的 360° 展示效果》](https://juejin.cn/post/7124064789763981326) 里,我们使用手势代码和角度切换,在 2D 画板里实现了“伪” 3D 的视觉效果,就在我觉得效果还不错时, 有一位掘友提出了一个关键性的问题:**卡片缺少厚度,也就是没有 3D 的质感** 。
| ![](http://img.cdn.guoshuyu.cn/20220806_N11/image1.gif) | ![](http://img.cdn.guoshuyu.cn/20220806_N11/image2.png) |
| -------------------------------------------- | ------------------------------------------------------------ |
确实,如下图所示,在之前的实现里,随着卡片角度的倾斜,有两个问题特别明显:
- 当卡片旋转到侧边时,卡片的缺少“厚度”的质感,**甚至出现了消失的情况**
- 卡片上的文字虽然做了类似凹凸的视觉效果,但是从侧面看时也是缺少立体质感
![](http://img.cdn.guoshuyu.cn/20220806_N11/image3.gif)
而为了在 2D 平面实现三唯的质感,在查阅相关资料时我发现了前端的 [Zdog](https://zzz.dog) 框架,**Zdog 是一个使用 `Canvas` 实现的伪 3D 引擎, 它支持通过 2D 的 `Canvas` API渲染出类似 3D 的效果**。
> [Zdog](https://zzz.dog) 作为一个 js 框架,它大概只有 2800 多行代码,并且其最小体积为 28KB ,可以说十分轻量级。
![](http://img.cdn.guoshuyu.cn/20220806_N11/image4.gif)
虽然 Zdog 是一个纯 js 框架, 但既然它是通过 `Canvas` 实现的逻辑,那就完全可以 “轻松” 迁移到 Flutter ,毕竟 Flutter 本身就是一个重度依赖于 `Canvas` 的框架,而恰巧在 Flutter 社区就有针对 Zdog 的移植版本: [zflutter](https://pub.flutter-io.cn/packages/zflutter) 。
> 虽然这个 package 作者已经两年不维护,也没有发布 null-safety 的 pub 支持,但是既然是开源项目,自己动手风衣足食,在经过一番“简单”的迁移适配之后, [zflutter 再次在 Flutter 3.0 下“焕发新春”](https://github.com/carguo/zflutter) 。
我们先看效果,在结合 zflutter 的实现之后,可以看到卡片的立体效果得到了全面的提升:
- **首先卡片有了厚度的质感,旋转到侧边也不会“消失”**
- **卡片上的字体在倾斜时也有了立体的效果**
![](http://img.cdn.guoshuyu.cn/20220806_N11/image5.gif)
那在讲解实现之前,我们要解决一个疑惑: **zflutter 究竟是如何在 2D 画板上实现 3D 的质感** ?而其实这个问题的关键就在于:**通过手势产生的矩阵变换是作用于画板还是作用于路径** 。
我们首先看一个例子,如下代码所示,我们创建了一个 `CustomPaint` ,然后在代码里绘制了 4 条相同红色直线,接着对其中 3 条直线的 `Canvas` 进行不同程度的矩阵旋转,如下图 2 可以看到有两条红线消失不见了:
- 当红线绕 Y 轴旋转 `pi / 2`90°因为此时画板恰好和我们呈垂直状态所以会出现看不到的情况
- 当红线绕 XY 轴旋转 `pi / 4` 时,可以看到画板此时和我们视觉成 45° 的情况
- 当红线绕 XY 轴旋转 `pi / 2`90° 时,因为此时画板还是和我们呈垂直状态,所以出现看不到的情况
| ![](http://img.cdn.guoshuyu.cn/20220806_N11/image6.png) | ![](http://img.cdn.guoshuyu.cn/20220806_N11/image7.png) |
| :----------------------------------------------------------- | ------------------------------------------------------------ |
如果觉得上面的描述太抽象,那么结合下面动图,可以看到当红线在围绕 XY 轴做旋转时,如果画布(`Canavas`)和我们呈 90° 垂直的时候,此时就会出现消失不见的情况,**因为画布是 2D 的平面,这也是为什么之前实现的卡片没有“厚度”的原因** 。
| ![](http://img.cdn.guoshuyu.cn/20220806_N11/image8.gif) | ![](http://img.cdn.guoshuyu.cn/20220723_N9/image6.png) |
| --------------------------------------------- | ------------------------------------------------------------ |
**那如果不对 `Canavs` ,而是对绘制路径 `Path` 进行矩阵变换呢** ?不对画布进行旋转,不就不会出现消失的情况了吗?
如下代码所示,同样是围绕 XY 轴进行旋转,但是此时是直接对 `Path` 进行 `path.transform` 操作,也就是此时画布`Canvas` 不会出现角度变换,出现变化的是绘制的 `Path` 路径,可以看到:
- 当红线绕 Y 轴旋转 `pi / 2`90°此时红线成了红点因为它此时它是“头正对着我们”
- 当红线绕 XY 轴旋转 `pi / 4` 时,可以看到此时红线整体成 45° 的情况对着我们
- 当红线绕 XY 轴旋转 `pi / 2`90° 时,可以看到此时红线是“垂直正对着我们”
| ![](http://img.cdn.guoshuyu.cn/20220806_N11/image9.png) | ![](http://img.cdn.guoshuyu.cn/20220806_N11/image10.png) |
| ------------------------------------------------------------ | ------------------------------------------------------------ |
结合下面的动图,可以看到对 `Path` 进行矩阵变换的旋转之后,整体的立体感就不一样了,**也就是一开始是调整我们和画布之间的角度,但是现在我们是改变了“笔”在画布上的绘制方式来产生的视差,这也是 zflutter 里实现 3D 立体感的关键:对 `Path` 做矩阵运算而不只是对 `Canvas`** 。
![](http://img.cdn.guoshuyu.cn/20220806_N11/image11.gif)
题外话,借着这个机会顺带普及个小知识点:**在前面的代码里可以看到会对矩阵进行 `leftTranslate``translate` 的操作** ,这是因为我们需要在不同位置绘制多条红线,所以它们的位置并非都在起点,而使用 `leftTranslate``translate` 来对矩阵进行平移,才能达到每次旋转时都是以红线的“中心”去旋转,举个例子:
- 如图 1 所示是红线没有绕 Z 轴旋转的情况
- 如图 2 所示是红线在绕 Z 轴旋转 `pi / 2` 时没有进行矩阵平移的情况,可以看到此时它们的中心点还在起始位置
- 如图 3 所示是红线在绕 Z 轴旋转 `pi / 2` 时,进行了 `leftTranslate``translate` 操作的情况
| ![](https://img.cdn.guoshuyu.cn/Simulator%20Screen%20Shot%20-%20iPhone%20SE%20(3rd%20generation)%20-%202022-08-04%20at%2010.18.23.png) | ![](https://img.cdn.guoshuyu.cn/Simulator%20Screen%20Shot%20-%20iPhone%20SE%20(3rd%20generation)%20-%202022-08-04%20at%2010.18.38.png) | ![](https://img.cdn.guoshuyu.cn/Simulator%20Screen%20Shot%20-%20iPhone%20SE%20(3rd%20generation)%20-%202022-08-04%20at%2010.18.12.png) |
| ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ |
> 完整代码可见: https://github.com/CarGuo/gsy_flutter_demo/blob/master/lib/widget/transform_canvas_demo_page.dart
>
> Web 体验地址PC 端记得开 Chrome 手机模式https://guoshuyu.cn/home/web/#%E5%B1%95%E7%A4%BA%20canvas%20transform 。
那么回到 zflutter 里,**在 zflutter 里就是通过组合各类图形和线条,然后利用对 `Path` 进行矩阵变换,从而实现类似 3D 立体的视觉效果** ,例如下面图 2 的立体正方形,就符合我们对增加厚度的需要。
| ![](http://img.cdn.guoshuyu.cn/20220806_N11/image12.gif) | ![](http://img.cdn.guoshuyu.cn/20220806_N11/image13.gif) |
| ----------------------------------------------- | ----------------------------------------- |
这里先简单介绍下 zflutter 里常用对象的作用:
- `ZIllustration` 类似于画板的作用,可以配置 `zoom` 属性来调整画板的缩放
- `ZPositioned` 用于配置位置和大小信息,例如 `scale` 、`translate` 、 `rotate` 等属性(其实它就是在内部将接收到的矩阵参数配置到 `ParentData` ,然后传递给 child)
- `ZDragDetector` 用于处理手势相关信息,主要是配置 `ZPositioned``rotate` 就可以快速实现上面的 360° 拖拽效果
- `ZGroup` 用于组合多个图形的层叠
- `ZToBoxAdapter` 用于嵌套普通的 Flutter 控件
- `ZRect` 、`ZRoundedRect` 、 `ZCircle` 、`ZEllipse` 、`ZPolygon` 、`ZCone` 、`ZCylinder` 、`ZHemisphere` 等是内置的形状,如下图
- `ZShape` 类似于 Canvas ,用于配合 `ZMove` 、`ZLine` 、`ZBezier` 、`ZArc` 等绘制自定义形状
![](http://img.cdn.guoshuyu.cn/20220806_N11/image14.png)
所以要实现卡片的 “真” 3D 效果,简单来说我们需要做的是:
- 添加一个 `ZIllustration` 画布
- 添加一个 `ZDragDetector ` 配合 `ZPositioned` 用于处理手势旋转
- 添加一个 `ZGroup` ,然后在里面通过 `ZToBoxAdapter` 添加银行卡的前后两张 png 图片
- 在两张图片之间添加一个 `ZRoundedRect` 做边框,配置颜色为 ` Color(0x8A000000);` 实现厚度效果
- 利用 `ZShape` 绘制数字,这样绘制出现的数字就会有立体的感觉
| ![](http://img.cdn.guoshuyu.cn/20220806_N11/image15.png) | ![](http://img.cdn.guoshuyu.cn/20220806_N11/image16.png) | ![](http://img.cdn.guoshuyu.cn/20220806_N11/image17.png) |
| ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ |
如上图所示,可以看到经过 zflutter 的处理之后,**不只是卡片本身有了“厚度”的质感,在倾斜也可以看到文字立体视觉,现在就算是如图 3 一样旋转到 90° 的情况,依然可以看到卡片和文字之间的层次关系** 。
> 完整代码可见: https://github.com/CarGuo/gsy_flutter_demo/blob/master/lib/widget/card_real_3d_demo_page.dart
>
> Web 体验地址PC 端记得开 Chrome 手机模式: https://guoshuyu.cn/home/web/#%E7%A1%AC%E6%A0%B8%203D%20%E5%8D%A1%E7%89%87%E6%97%8B%E8%BD%AC 。
详细源码可以直接看上方链接,那认识了 zflutter 之后,**我们还能利用 zflutter做什么呢** ?其实在官方的 Demo 里就有一个很有典型的示例,那就是 Flutter 的吉祥物 Dash **接下来我们看如何利用 zflutter 开始实现一只立体质感的 Dash** 。
首先我们利用 `ZCircle` 画一个圆,用于实现 Dash 的身体
| ![](http://img.cdn.guoshuyu.cn/20220806_N11/image18.png) | ![](http://img.cdn.guoshuyu.cn/20220806_N11/image19.png) |
| ------------------------------------------------------------ | ------------------------------------------------------------ |
然后我们通过 3 个不同位置和角度的 ` ZEllipse` 椭圆来组成 Dash 的头发,事实上 zflutter 里很多效果就是通过类似这样的图形组合来实现的。
| ![](http://img.cdn.guoshuyu.cn/20220806_N11/image20.png) | ![](http://img.cdn.guoshuyu.cn/20220806_N11/image21.png) |
| ------------------------------------------------------------ | ------------------------------------------------------------ |
接着我们在 `ZShape` 里利用 `ZArc` 实现不同角度的弧形组合实现尾巴,这里的关键是 z 轴上需要有部分落差,如下图展示是尾巴在 3 个不同角度的可视效果。
| ![](http://img.cdn.guoshuyu.cn/20220806_N11/image22.png) | ![](http://img.cdn.guoshuyu.cn/20220806_N11/image23.png) |
| ------------------------------------------------------------ | ------------------------------------------------------------ |
再通过调整两个 ` ZEllipse` 椭圆的角度来实现 Dash 的手部效果,在这一点上 zflutter 确实很考验开发者对于图形在平面上的空间感。
| ![](http://img.cdn.guoshuyu.cn/20220806_N11/image24.png) | ![](http://img.cdn.guoshuyu.cn/20220806_N11/image25.png) |
| ------------------------------------------------------------ | ------------------------------------------------------------ |
接着通过 `ZCone` 就可以快速实现 Dash 的嘴巴。
| ![](http://img.cdn.guoshuyu.cn/20220806_N11/image26.png) | ![](http://img.cdn.guoshuyu.cn/20220806_N11/image27.png) |
| ------------------------------------------------------------ | ------------------------------------------------------------ |
然后这部分相信不用代码大家也知道,就是通过组合多个 `ZEllipse``ZCircle` 堆叠来实现 Dash 的眼睛。
![](http://img.cdn.guoshuyu.cn/20220806_N11/image28.png)
最后,把上面的零部件组合到一起,在配置上循环的动画参数,当当当~一只生动立体的 Dash 就完成了。
| ![](http://img.cdn.guoshuyu.cn/20220806_N11/image29.gif) | ![](http://img.cdn.guoshuyu.cn/20220806_N11/image30.gif) |
| ----------------------------------------- | ----------------------------------------- |
> 完整代码可见: https://github.com/CarGuo/gsy_flutter_demo/blob/master/lib/widget/dash_3d_demo_page.dart
>
> Web 体验地址PC 端记得开 Chrome 手机模式: https://guoshuyu.cn/home/web/#3D%20Dash 。
对比实物 Dash ,可以看到利用 zflutter 实现的 Dash ,乍看之下形似度还是蛮高的,同时 zflutter 本身也只有 82k 左右的大小,作为一个超轻量级的伪 3D 动画框架,它在接入成本很低的情况下,尽可能做到了我们对 3D 空间所需的视觉效果,这里面的关键还是在于:**矩阵变换是作用于画板还是作用于路径** 。
![](http://img.cdn.guoshuyu.cn/20220806_N11/image31.png)
那在知道原理之后,**我们接下来就可以通过三个简单的 `ZShape` 组合,利用 `ZMove``ZLine` 就能组合出具有 3D 质感的掘金 Logo ,里面的参数直接从 SVG 的 path 映射过来就可以了** 。
| ![](http://img.cdn.guoshuyu.cn/20220806_N11/image32.png) | ![](http://img.cdn.guoshuyu.cn/20220806_N11/image33.png) | ![](http://img.cdn.guoshuyu.cn/20220806_N11/image34.png) | ![](http://img.cdn.guoshuyu.cn/20220806_N11/image35.gif) |
| ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ | ---------------------------------------------- |
因为我们的矩阵旋转改变的是 Path 而不是 Canvas ,所以 Logo 的立体效果可以通过 `skroke` 的粗细配合画布 `zoom` 放大来体现。
> 完整代码可见: https://github.com/CarGuo/gsy_flutter_demo/blob/master/lib/widget/juejin_3d_logo_demo_page.dart
>
> Web 体验地址PC 端记得开 Chrome 手机模式: https://guoshuyu.cn/home/web/#%E6%8E%98%E9%87%91%203d%20logo 。
**那可能就有人要说了,这个 logo 立体感还是不够强,因为它还是太扁平了** 确实,受制于 `stroke` 参数的影响,在侧面的立体感上确实有所缺失,而为了提升立体感,我们可以通过 zflutter 里的 `ZBoxToBoxAdapter` 来实现。
在 zflutter 里, `ZBoxToBoxAdapter` 可以通过配置 `front` 、`rear` 、`left` 、`right` 、`top` 、`bottom` 等参数来配置长方体每个面的 UI并且它本身就会根据 `width` 、`height` 、`depth` 参数生成一个立体长方形,如下图 1所示。
| ![](https://img.cdn.guoshuyu.cn/Simulator%20Screen%20Shot%20-%20iPhone%20SE%20(3rd%20generation)%20-%202022-08-05%20at%2016.34.37.png) | ![](http://img.cdn.guoshuyu.cn/20220806_N11/image36.png) | ![](http://img.cdn.guoshuyu.cn/20220806_N11/image37.gif) |
| ------------------------------------------------------------ | -------------------------------------------------------- | -------------------------------------------------------- |
接着我们简单通过图 2 的量角器确定掘金 logo 的角度,然后如下代码所示,利用不同位置和角度,通过 `ZBoxToBoxAdapter` 组合堆叠不同的长方体,从而形成如上图 3 所示的立体掘金 logo**当然,这个组合过程很明显是体力活**。
| ![](http://img.cdn.guoshuyu.cn/20220806_N11/image38.png) | ![](http://img.cdn.guoshuyu.cn/20220806_N11/image39.png) | ![](http://img.cdn.guoshuyu.cn/20220806_N11/image40.png) |
| ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ |
> 完整代码可见: https://github.com/CarGuo/gsy_flutter_demo/blob/master/lib/widget/juejin_3d_box_logo_demo_page.dart
>
> Web 体验地址PC 端记得开 Chrome 手机模式: https://guoshuyu.cn/home/web/#%E6%8E%98%E9%87%91%E6%9B%B4%203d%20logo 。
可以看到 zflutter 虽然没有之前 [用 rive 给掘金 Logo 快速添加动画效果 ](https://juejin.cn/post/7126661045564735519)来的强大和方便,**但是好在它体积够小,不需要加载任何资源,纯代码就可以实现各种立体的 3D 动画效果** ,这对于程序员来说更加可控,至少它不需要依赖于任何第三方设计工具,就是开发速度上确实不如 rive 来的高效,**需要一定的空间想象力** 。
好了,本篇动画特效就到此为止,**如果你有什么想法,欢迎留言评论,感谢大家耐心看完,也还请看官们走过路过的来个点赞一键三连,感激不尽**

569
Flutter-N5.md Normal file
View File

@ -0,0 +1,569 @@
# Flutter 小技巧之 ListView 和 PageView 的各种花式嵌套
这次的 Flutter 小技巧是 `ListView``PageView` 的花式嵌套,不同 `Scrollable` 的嵌套冲突问题相信大家不会陌生,今天就通过 `ListView``PageView` 的三种嵌套模式带大家收获一些不一样的小技巧。
# 正常嵌套
最常见的嵌套应该就是横向 `PageView` 加纵向 `ListView` 的组合,**一般情况下这个组合不会有什么问题,除非你硬是要斜着滑**。
最近刚好遇到好几个人同时在问:“斜滑 `ListView` 容易切换到 `PageView` 滑动” 的问题,如下 GIF 所示,当用户在滑动 `ListView` 时,滑动角度带上倾斜之后,可能就会导致滑动的是 `PageView` 而不是 `ListView`
![xiehuadong](http://img.cdn.guoshuyu.cn/20220703_N5/image1.gif)
虽然从我个人体验上并不觉得这是个问题,但是如果产品硬是要你修改,难道要自己重写 `PageView` 的手势响应吗?
我们简单看一下,不管是 `PageView` 还是 `ListView` 它们的滑动效果都来自于 `Scrollable` ,而 `Scrollable` 内部针对不同方向的响应,是通过 `RawGestureDetector` 完成:
- `VerticalDragGestureRecognizer` 处理垂直方向的手势
- `HorizontalDragGestureRecognizer` 处理水平方向的手势
所以简单看它们响应的判断逻辑,可以看到一个很有趣的方法 `computeHitSlop` **根据 pointer 的类型确定当然命中需要的最小像素,触摸默认是 kTouchSlop (18.0)**
![image-20220613103745974](http://img.cdn.guoshuyu.cn/20220703_N5/image2.png)
看到这你有没有灵光一闪:**如果我们把 `PageView` 的 touchSlop 修改了,是不是就可以调整它响应的灵敏度** 恰好在 `computeHitSlop` 方法里,它可以通过 `DeviceGestureSettings` 来配置,而 `DeviceGestureSettings` 来自于 `MediaQuery` ,所以如下代码所示:
```dart
body: MediaQuery(
///调高 touchSlop 到 50 ,这样 pageview 滑动可能有点点影响,
///但是大概率处理了斜着滑动触发的问题
data: MediaQuery.of(context).copyWith(
gestureSettings: DeviceGestureSettings(
touchSlop: 50,
)),
child: PageView(
scrollDirection: Axis.horizontal,
pageSnapping: true,
children: [
HandlerListView(),
HandlerListView(),
],
),
),
```
**小技巧一:通过嵌套一个 `MediaQuery` ,然后调整 `gestureSettings``touchSlop` 从而修改 `PageView` 的灵明度** ,另外不要忘记,还需要把 `ListView``touchSlop` 切换会默认 的 `kTouchSlop`
```dart
class HandlerListView extends StatefulWidget {
@override
_MyListViewState createState() => _MyListViewState();
}
class _MyListViewState extends State<HandlerListView> {
@override
Widget build(BuildContext context) {
return MediaQuery(
///这里 touchSlop 需要调回默认
data: MediaQuery.of(context).copyWith(
gestureSettings: DeviceGestureSettings(
touchSlop: kTouchSlop,
)),
child: ListView.separated(
itemCount: 15,
itemBuilder: (context, index) {
return ListTile(
title: Text('Item $index'),
);
},
separatorBuilder: (context, index) {
return const Divider(
thickness: 3,
);
},
),
);
}
}
```
最后我们看一下效果,如下 GIF 所示,现在就算你斜着滑动,也很触发 `PageView` 的水平滑动,只有横向移动时才会触发 `PageView` 的手势,当然, **如果要说这个粗暴的写法有什么问题的话,大概就是降低了 `PageView` 响应的灵敏度**
![xiehuabudong](http://img.cdn.guoshuyu.cn/20220703_N5/image3.gif)
# 同方向 PageView 嵌套 ListView
介绍完常规使用,接着来点不一样的,**在垂直切换的 `PageView` 里嵌套垂直滚动的 ` ListView`** 你第一感觉是不是觉得不靠谱,为什么会有这样的场景?
> 对于产品来说,他们不会考虑你如何实现的问题,他们只会拍着脑袋说淘宝可以,为什么你不行,所以如果是你,你会怎么做?
而关于这个需求,社区目前讨论的结果是:**把 `PageView``ListView` 的滑动禁用,然后通过 `RawGestureDetector` 自己管理**。
> **如果对实现逻辑分析没兴趣,可以直接看本小节末尾的 [源码链接 ](https://github.com/CarGuo/gsy_flutter_demo/blob/7838971cefbf19bb53a71041cd100c4c15eb6443/lib/widget/vp_list_demo_page.dart#L75)**
看到自己管理先不要慌,虽然要自己实现 `PageView``ListView` 的手势分发,但是其实并不需要重写 `PageView``ListView` ,我们可以复用它们的 `Darg` 响应逻辑,如下代码所示:
- 通过 `NeverScrollableScrollPhysics` 禁止了 `PageView``ListView` 的滚动效果
- 通过顶部 `RawGestureDetector ``VerticalDragGestureRecognizer` 自己管理手势事件
- 配置 `PageController``ScrollController` 用于获取状态
```dart
body: RawGestureDetector(
gestures: <Type, GestureRecognizerFactory>{
VerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<
VerticalDragGestureRecognizer>(
() => VerticalDragGestureRecognizer(),
(VerticalDragGestureRecognizer instance) {
instance
..onStart = _handleDragStart
..onUpdate = _handleDragUpdate
..onEnd = _handleDragEnd
..onCancel = _handleDragCancel;
})
},
behavior: HitTestBehavior.opaque,
child: PageView(
controller: _pageController,
scrollDirection: Axis.vertical,
///屏蔽默认的滑动响应
physics: const NeverScrollableScrollPhysics(),
children: [
ListView.builder(
controller: _listScrollController,
///屏蔽默认的滑动响应
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) {
return ListTile(title: Text('List Item $index'));
},
itemCount: 30,
),
Container(
color: Colors.green,
child: Center(
child: Text(
'Page View',
style: TextStyle(fontSize: 50),
),
),
)
],
),
),
```
接着我们看 `_handleDragStart` 实现,如下代码所示,在产生手势 `details` 时,我们主要判断:
- 通过 `ScrollController` 判断 `ListView` 是否可见
- 判断触摸位置是否在 `ListIView` 范围内
- 根据状态判断通过哪个 `Controller` 去生产 `Drag` 对象,用于响应后续的滑动事件
```dart
void _handleDragStart(DragStartDetails details) {
///先判断 Listview 是否可见或者可以调用
///一般不可见时 hasClients false ,因为 PageView 也没有 keepAlive
if (_listScrollController?.hasClients == true &&
_listScrollController?.position.context.storageContext != null) {
///获取 ListView 的 renderBox
final RenderBox? renderBox = _listScrollController
?.position.context.storageContext
.findRenderObject() as RenderBox;
///判断触摸的位置是否在 ListView 内
///不在范围内一般是因为 ListView 已经滑动上去了,坐标位置和触摸位置不一致
if (renderBox?.paintBounds
.shift(renderBox.localToGlobal(Offset.zero))
.contains(details.globalPosition) ==
true) {
_activeScrollController = _listScrollController;
_drag = _activeScrollController?.position.drag(details, _disposeDrag);
return;
}
}
///这时候就可以认为是 PageView 需要滑动
_activeScrollController = _pageController;
_drag = _pageController?.position.drag(details, _disposeDrag);
}
```
前面我们主要在触摸开始时,判断需要响应的对象时` ListView` 还是 `PageView` ,然后通过 `_activeScrollController` 保存当然响应对象,并且通过 Controller 生成用于响应手势信息的 `Drag` 对象。
> 简单说:滑动事件发生时,默认会建立一个 `Drag` 用于处理后续的滑动事件,`Drag` 会对原始事件进行加工之后再给到 `ScrollPosition` 去触发后续滑动效果。
接着在 `_handleDragUpdate` 方法里,主要是判断响应是不是需要切换到 `PageView `:
- 如果不需要就继续用前面得到的 ` _drag?.update(details) `响应 ` ListView` 滚动
- 如果需要就通过 `_pageController` 切换新的 `_drag` 对象用于响应
```dart
void _handleDragUpdate(DragUpdateDetails details) {
if (_activeScrollController == _listScrollController &&
///手指向上移动,也就是快要显示出底部 PageView
details.primaryDelta! < 0 &&
///到了底部,切换到 PageView
_activeScrollController?.position.pixels ==
_activeScrollController?.position.maxScrollExtent) {
///切换相应的控制器
_activeScrollController = _pageController;
_drag?.cancel();
///参考 Scrollable 里
///因为是切换控制器,也就是要更新 Drag
///拖拽流程要切换到 PageView 里,所以需要 DragStartDetails
///所以需要把 DragUpdateDetails 变成 DragStartDetails
///提取出 PageView 里的 Drag 相应 details
_drag = _pageController?.position.drag(
DragStartDetails(
globalPosition: details.globalPosition,
localPosition: details.localPosition),
_disposeDrag);
}
_drag?.update(details);
}
```
> 这里有个小知识点:**如上代码所示,我们可以简单通过 `details.primaryDelta` 判断滑动方向和移动的是否是主轴**
最后如下 GIF 所示,可以看到 `PageView` 嵌套 `ListView` 同方向滑动可以正常运行了,但是目前还有个两个小问题,从图示可以看到:
- **在切换之后 ` ListView` 的位置没有保存下来**
- **产品要求去除 `ListView` 的边缘溢出效果**
![7777777777777](http://img.cdn.guoshuyu.cn/20220703_N5/image4.gif)
所以我们需要对 `ListView` 做一个 KeepAlive ,然后用简单的方法去除 Android 边缘滑动的 Material 效果:
- 通过 `with AutomaticKeepAliveClientMixin``ListView` 在切换之后也保持滑动位置
- 通过 `ScrollConfiguration.of(context).copyWith(overscroll: false)` 快速去除 Scrollable 的边缘 Material 效果
```dart
child: PageView(
controller: _pageController,
scrollDirection: Axis.vertical,
///去掉 Android 上默认的边缘拖拽效果
scrollBehavior:
ScrollConfiguration.of(context).copyWith(overscroll: false),
///对 PageView 里的 ListView 做 KeepAlive 记住位置
class KeepAliveListView extends StatefulWidget {
final ScrollController? listScrollController;
final int itemCount;
KeepAliveListView({
required this.listScrollController,
required this.itemCount,
});
@override
KeepAliveListViewState createState() => KeepAliveListViewState();
}
class KeepAliveListViewState extends State<KeepAliveListView>
with AutomaticKeepAliveClientMixin {
@override
Widget build(BuildContext context) {
super.build(context);
return ListView.builder(
controller: widget.listScrollController,
///屏蔽默认的滑动响应
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) {
return ListTile(title: Text('List Item $index'));
},
itemCount: widget.itemCount,
);
}
@override
bool get wantKeepAlive => true;
}
```
所以这里我们有解锁了另外一个小技巧:**通过 `ScrollConfiguration.of(context).copyWith(overscroll: false)` 快速去除 Android 滑动到边缘的 Material 2效果**,为什么说 Material2 因为 Material3 上变了,具体可见: [Flutter 3 下的 ThemeExtensions 和 Material3](https://juejin.cn/post/7105869440985595912) 。
![000000000](http://img.cdn.guoshuyu.cn/20220703_N5/image5.gif)
> 本小节源码可见: https://github.com/CarGuo/gsy_flutter_demo/blob/7838971cefbf19bb53a71041cd100c4c15eb6443/lib/widget/vp_list_demo_page.dart#L75
# 同方向 ListView 嵌套 PageView
那还有没有更非常规的?答案是肯定的,毕竟产品的小脑袋,怎么会想不到**在垂直滑动的 `ListView` 里嵌套垂直切换的 ` PageView`** 这种需求。
有了前面的思路,其实实现这个逻辑也是异曲同工:**把 `PageView``ListView` 的滑动禁用,然后通过 `RawGestureDetector` 自己管理**,不同的就是手势方法分发的差异。
```dart
RawGestureDetector(
gestures: <Type, GestureRecognizerFactory>{
VerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<
VerticalDragGestureRecognizer>(
() => VerticalDragGestureRecognizer(),
(VerticalDragGestureRecognizer instance) {
instance
..onStart = _handleDragStart
..onUpdate = _handleDragUpdate
..onEnd = _handleDragEnd
..onCancel = _handleDragCancel;
})
},
behavior: HitTestBehavior.opaque,
child: ListView.builder(
///屏蔽默认的滑动响应
physics: NeverScrollableScrollPhysics(),
controller: _listScrollController,
itemCount: 5,
itemBuilder: (context, index) {
if (index == 0) {
return Container(
height: 300,
child: KeepAlivePageView(
pageController: _pageController,
itemCount: itemCount,
),
);
}
return Container(
height: 300,
color: Colors.greenAccent,
child: Center(
child: Text(
"Item $index",
style: TextStyle(fontSize: 40, color: Colors.blue),
),
));
}),
)
```
同样是在 `_handleDragStart` 方法里,这里首先需要判断:
- `ListView` 如果已经滑动过,就不响应顶部 `PageView` 的事件
- 如果此时 `ListView` 处于顶部未滑动,判断手势位置是否在 `PageView` 里,如果是响应 `PageView` 的事件
```dart
void _handleDragStart(DragStartDetails details) {
///只要不是顶部,就不响应 PageView 的滑动
///所以这个判断只支持垂直 PageView 在 ListView 的顶部
if (_listScrollController.offset > 0) {
_activeScrollController = _listScrollController;
_drag = _listScrollController.position.drag(details, _disposeDrag);
return;
}
///此时处于 ListView 的顶部
if (_pageController.hasClients) {
///获取 PageView
final RenderBox renderBox =
_pageController.position.context.storageContext.findRenderObject()
as RenderBox;
///判断触摸范围是不是在 PageView
final isDragPageView = renderBox.paintBounds
.shift(renderBox.localToGlobal(Offset.zero))
.contains(details.globalPosition);
///如果在 PageView 里就切换到 PageView
if (isDragPageView) {
_activeScrollController = _pageController;
_drag = _activeScrollController.position.drag(details, _disposeDrag);
return;
}
}
///不在 PageView 里就继续响应 ListView
_activeScrollController = _listScrollController;
_drag = _listScrollController.position.drag(details, _disposeDrag);
}
```
接着在 `_handleDragUpdate` 方法里,判断如果 `PageView` 已经滑动到最后一页,也将滑动事件切换到 `ListView`
```dart
void _handleDragUpdate(DragUpdateDetails details) {
var scrollDirection = _activeScrollController.position.userScrollDirection;
///判断此时响应的如果还是 _pageController是不是到了最后一页
if (_activeScrollController == _pageController &&
scrollDirection == ScrollDirection.reverse &&
///是不是到最后一页了,到最后一页就切换回 pageController
(_pageController.page != null &&
_pageController.page! >= (itemCount - 1))) {
///切换回 ListView
_activeScrollController = _listScrollController;
_drag?.cancel();
_drag = _listScrollController.position.drag(
DragStartDetails(
globalPosition: details.globalPosition,
localPosition: details.localPosition),
_disposeDrag);
}
_drag?.update(details);
}
```
当然,同样还有 KeepAlive 和去除列表 Material 边缘效果,最后运行效果如下 GIF 所示。
![22222222222](http://img.cdn.guoshuyu.cn/20220703_N5/image6.gif)
> 本小节源码可见https://github.com/CarGuo/gsy_flutter_demo/blob/7838971cefbf19bb53a71041cd100c4c15eb6443/lib/widget/vp_list_demo_page.dart#L262
最后再补充一个小技巧:**如果你需要 Flutter 打印手势竞技的过程,可以配置 ` debugPrintGestureArenaDiagnostics = true; `来让 Flutter 输出手势竞技的处理过程**。
```dart
import 'package:flutter/gestures.dart';
void main() {
debugPrintGestureArenaDiagnostics = true;
runApp(MyApp());
}
```
![image-20220613115808538](http://img.cdn.guoshuyu.cn/20220703_N5/image7.png)
# 最后
最后总结一下,**本篇介绍了如何通过 `Darg` 解决各种因为嵌套而导致的手势冲突**,相信大家也知道了如何利用 `Controller``Darg` 来快速自定义一些滑动需求,例如 `ListView` 联动 `ListView` 的差量滑动效果:
```dart
///listView 联动 listView
class ListViewLinkListView extends StatefulWidget {
@override
_ListViewLinkListViewState createState() => _ListViewLinkListViewState();
}
class _ListViewLinkListViewState extends State<ListViewLinkListView> {
ScrollController _primaryScrollController = ScrollController();
ScrollController _subScrollController = ScrollController();
Drag? _primaryDrag;
Drag? _subDrag;
@override
void initState() {
super.initState();
}
@override
void dispose() {
_primaryScrollController.dispose();
_subScrollController.dispose();
super.dispose();
}
void _handleDragStart(DragStartDetails details) {
_primaryDrag =
_primaryScrollController.position.drag(details, _disposePrimaryDrag);
_subDrag = _subScrollController.position.drag(details, _disposeSubDrag);
}
void _handleDragUpdate(DragUpdateDetails details) {
_primaryDrag?.update(details);
///除以10实现差量效果
_subDrag?.update(DragUpdateDetails(
sourceTimeStamp: details.sourceTimeStamp,
delta: details.delta / 30,
primaryDelta: (details.primaryDelta ?? 0) / 30,
globalPosition: details.globalPosition,
localPosition: details.localPosition));
}
void _handleDragEnd(DragEndDetails details) {
_primaryDrag?.end(details);
_subDrag?.end(details);
}
void _handleDragCancel() {
_primaryDrag?.cancel();
_subDrag?.cancel();
}
void _disposePrimaryDrag() {
_primaryDrag = null;
}
void _disposeSubDrag() {
_subDrag = null;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("ListViewLinkListView"),
),
body: RawGestureDetector(
gestures: <Type, GestureRecognizerFactory>{
VerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<
VerticalDragGestureRecognizer>(
() => VerticalDragGestureRecognizer(),
(VerticalDragGestureRecognizer instance) {
instance
..onStart = _handleDragStart
..onUpdate = _handleDragUpdate
..onEnd = _handleDragEnd
..onCancel = _handleDragCancel;
})
},
behavior: HitTestBehavior.opaque,
child: ScrollConfiguration(
///去掉 Android 上默认的边缘拖拽效果
behavior:
ScrollConfiguration.of(context).copyWith(overscroll: false),
child: Row(
children: [
new Expanded(
child: ListView.builder(
///屏蔽默认的滑动响应
physics: NeverScrollableScrollPhysics(),
controller: _primaryScrollController,
itemCount: 55,
itemBuilder: (context, index) {
return Container(
height: 300,
color: Colors.greenAccent,
child: Center(
child: Text(
"Item $index",
style: TextStyle(
fontSize: 40, color: Colors.blue),
),
));
})),
new SizedBox(
width: 5,
),
new Expanded(
child: ListView.builder(
///屏蔽默认的滑动响应
physics: NeverScrollableScrollPhysics(),
controller: _subScrollController,
itemCount: 55,
itemBuilder: (context, index) {
return Container(
height: 300,
color: Colors.deepOrange,
child: Center(
child: Text(
"Item $index",
style:
TextStyle(fontSize: 40, color: Colors.white),
),
),
);
}),
),
],
),
),
));
}
}
```
![44444444444444](http://img.cdn.guoshuyu.cn/20220703_N5/image8.gif)

334
Flutter-N7.md Normal file
View File

@ -0,0 +1,334 @@
# Flutter 小技巧之 MediaQuery 和 build 优化你不知道的秘密
**今天这篇文章的目的是补全大家对于 `MediaQuery` 和对应 rebuild 机制的基础认知,相信本篇内容对你优化性能和调试 bug 会很有帮助**。
Flutter 里大家应该都离不开 `MediaQuery ` ,比如通过 `MediaQuery.of(context).size` 获取屏幕大小 ,或者通过 `MediaQuery.of(context).padding.top` 获取状态栏高度,那随便使用 `MediaQuery.of(context)` 会有什么问题吗?
首先我们需要简单解释一下,通过 `MediaQuery.of` 获取到的 `MediaQueryData` 里有几个很类似的参数:
- `viewInsets` **被系统用户界面完全遮挡的部分大小,简单来说就是键盘高度**
- `padding` **简单来说就是状态栏和底部安全区域,但是 `bottom` 会因为键盘弹出变成 0**
- `viewPadding ` **和 `padding` 一样,但是 `bottom` 部分不会发生改变**
举个例子,在 iOS 上,如下图所示,在弹出键盘和未弹出键盘的情况下,可以看到 `MediaQueryData` 里一些参数的变化:
- `viewInsets` 在没有弹出键盘时是 0弹出键盘之后 `bottom` 变成 336
- `padding` 在弹出键盘的前后区别, `bottom` 从 34 变成了 0
- `viewPadding ` 在键盘弹出前后数据没有发生变化
![image-20220624115935998](http://img.cdn.guoshuyu.cn/20220628_N7/image1.png)
> **可以看到 `MediaQueryData` 里的数据是会根据键盘状态发生变化**,又因为 `MediaQuery ` 是一个 `InheritedWidget` ,所以我们可以通过 `MediaQuery.of(context)` 获取到顶层共享的 `MediaQueryData`
那么问题来了,**`InheritedWidget` 的更新逻辑,是通过登记的 `context` 来绑定的,也就是 `MediaQuery.of(context)` 本身就是一个绑定行为**,然后 `MediaQueryData` 又和键盘状态有关系,所以:键盘的弹出可能会导致使用 `MediaQuery.of(context)` 的地方触发 rebuild举个例子
如下代码所示,我们在 `MyHomePage` 里使用了 `MediaQuery.of(context).size` 并打印输出,然后跳转到 `EditPage` 页面,弹出键盘 ,这时候会发生什么情况?
```dart
class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
print("######### MyHomePage ${MediaQuery.of(context).size}");
return Scaffold(
body: Container(
alignment: Alignment.center,
child: InkWell(
onTap: () {
Navigator.of(context).push(CupertinoPageRoute(builder: (context) {
return EditPage();
}));
},
child: new Text(
"Click",
style: TextStyle(fontSize: 50),
),
),
),
);
}
}
class EditPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: new Text("ControllerDemoPage"),
),
extendBody: true,
body: Column(
children: [
new Spacer(),
new Container(
margin: EdgeInsets.all(10),
child: new Center(
child: new TextField(),
),
),
new Spacer(),
],
),
);
}
}
```
如下图 log 所示 可以看到在键盘弹起来的过程,因为 bottom 发生改变,所以 `MediaQueryData` 发生了改变,从而导致上一级的 `MyHomePage` 虽然不可见,但是在键盘弹起的过程里也被不断 build 。
![image-20220624121917686](http://img.cdn.guoshuyu.cn/20220628_N7/image2.png)
> 试想一下,如果你在每个页面开始的位置都是用了 `MediaQuery.of(context)` ,然后打开了 5 个页面,这时候你在第 5 个页面弹出键盘时,也触发了前面 4 个页面 rebuild自然而然可能就会出现卡顿。
**那么如果我不在 `MyHomePage` 的 build 方法直接使用 `MediaQuery.of(context)` ,那在 `EditPage` 里弹出键盘是不是就不会导致上一级的 `MyHomePage` 触发 build**
> 答案是肯定的,没有了 `MediaQuery.of(context).size` 之后, `MyHomePage` 就不会因为 `EditPage` 里的键盘弹出而导致 rebuild。
所以小技巧一:**要慎重在 `Scaffold` 之外使用 `MediaQuery.of(context)`** ,可能你现在会觉得奇怪什么是 `Scaffold` 之外,没事后面继续解释。
那到这里有人可能就要说了:我们通过 `MediaQuery.of(context)` 获取到的 `MediaQueryData` ,不就是对应在 `MaterialApp` 里的 `MediaQuery` 吗?那它发生改变,不应该都会触发下面的 child 都 rebuild 吗?
> **这其实和页面路由有关系,也就是我们常说的 `PageRoute` 的实现**
如下图所示,因为嵌套结构的原因,事实上弹出键盘确实会导致 `MaterialApp` 下的 child 都触发 rebuild ,因为设计上 `MediaQuery` 就是在 `Navigator` 上面,**所以弹出键盘自然也就触发 `Navigator` 的 rebuild**。
![image-20220624141749056](http://img.cdn.guoshuyu.cn/20220628_N7/image3.png)
**那正常情况下 `Navigator` 都触发 rebuild 了,为什么页面不会都被 rebuild 呢**
这就和路由对象的基类 `ModalRoute` 有关系,因为在它的内部会通过一个 `_modalScopeCache` 参数把 `Widget` 缓存起来,正如注释所说:
> **缓存区域不随帧变化,以便得到最小化的构建**
![](http://img.cdn.guoshuyu.cn/20220628_N7/image4.png)
举个例子,如下代码所示:
- 首先定义了一个 `TextGlobal` ,在 build 方法里输出 `"######## TextGlobal"`
- 然后在 `MyHomePage` 里定义一个全局的 ` TextGlobal globalText = TextGlobal();`
- 接着在 `MyHomePage` 里添加 3 个 globalText
- 最后点击 `FloatingActionButton` 触发 ` setState(() {});`
```dart
class TextGlobal extends StatelessWidget {
const TextGlobal({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
print("######## TextGlobal");
return Container(
child: new Text(
"测试",
style: new TextStyle(fontSize: 40, color: Colors.redAccent),
textAlign: TextAlign.center,
),
);
}
}
class MyHomePage extends StatefulWidget {
final String? title;
MyHomePage({Key? key, this.title}) : super(key: key);
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
TextGlobal globalText = TextGlobal();
@override
Widget build(BuildContext context) {
print("######## MyHomePage");
return Scaffold(
appBar: AppBar(),
body: new Container(
alignment: Alignment.center,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
globalText,
globalText,
globalText,
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
setState(() {});
},
),
);
}
}
```
那么有趣的来了,如下图 log 所示,`"######## TextGlobal"` 除了在一开始构建时有输出之外,剩下 ` setState(() {});` 的时候都没有在触发,也就是没有 rebuild ,这其实就是上面 `ModalRoute` 的类似行为:**弹出键盘导致了 `MediaQuery` 触发 `Navigator` 执行 rebuild但是 rebuild 到了 `ModalRoute` 就不往下影响**。
![](http://img.cdn.guoshuyu.cn/20220628_N7/image5.png)
其实这个行为也体现在了 `Scaffold` 里,如果你去看 `Scaffold` 的源码,你就会发现 `Scaffold` 里大量使用了 `MediaQuery.of(context)`
比如上面的代码,如果你给 `MyHomePage``Scaffold` 配置一个 3333 的 `ValueKey` ,那么在 `EditPage` 弹出键盘时,其实 `MyHomePage``Scaffold` 是会触发 rebuild ,但是因为其使用的是 `widget.body` ,所以并不会导致 `body` 内对象重构。
![](http://img.cdn.guoshuyu.cn/20220628_N7/image6.png)
> 如果是 `MyHomePage` 如果 rebuild ,就会对 build 方法里所有的配置的 `new` 对象进行 rebuild但是如果只是 `MyHomePage` 里的 `Scaffold` 内部触发了 rebuild ,是不会导致 `MyHomePage` 里的 body 参数对应的 child 执行 rebuild 。
是不是太抽象?举个简单的例子,如下代码所示:
- 我们定义了一个 `LikeScaffold` 控件,在控件内通过 `widget.body` 传递对象
- 在 `LikeScaffold` 内部我们使用了 `MediaQuery.of(context).viewInsets.bottom ` ,模仿 `Scaffold` 里使用 `MediaQuery`
- 在 `MyHomePage` 里使用 `LikeScaffold` ,并给 `LikeScaffold` 的 body 配置一个 `Builder` ,输出 `"############ HomePage Builder Text "` 用于观察
- 跳到 `EditPage` 页面打开键盘
```dart
class LikeScaffold extends StatefulWidget {
final Widget body;
const LikeScaffold({Key? key, required this.body}) : super(key: key);
@override
State<LikeScaffold> createState() => _LikeScaffoldState();
}
class _LikeScaffoldState extends State<LikeScaffold> {
@override
Widget build(BuildContext context) {
print("####### LikeScaffold build ${MediaQuery.of(context).viewInsets.bottom}");
return Material(
child: new Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [widget.body],
),
);
}
}
····
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
var routeLists = routers.keys.toList();
return new LikeScaffold(
body: Builder(
builder: (_) {
print("############ HomePage Builder Text ");
return InkWell(
onTap: () {
Navigator.of(context).push(CupertinoPageRoute(builder: (context) {
return EditPage();
}));
},
child: Text(
"FFFFFFF",
style: TextStyle(fontSize: 50),
),
);
},
),
);
}
}
```
可以看到,最开始 `"####### LikeScaffold build 0.0``############ HomePage Builder Text ` 都正常执行,然后在键盘弹出之后,`"####### LikeScaffold build` 跟随键盘动画不断输出 `bottom` 的 大小,但是 `"############ HomePage Builder Text ")` 没有输出,因为它是 `widget.body` 实例。
![](http://img.cdn.guoshuyu.cn/20220628_N7/image7.png)
**所以通过这个最小例子,可以看到虽然 `Scaffold` 里大量使用 `MediaQuery.of(context)` ,但是影响范围是约束在 `Scaffold` 内部**。
接着我们继续看修改这个例子,如果在 `LikeScaffold` 上嵌套多一个 `Scaffold` ,那输出结果会是怎么样?
```dart
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
var routeLists = routers.keys.toList();
///多加了个 Scaffold
return Scaffold(
body: new LikeScaffold(
body: Builder(
·····
),
),
);
}
```
答案是 `LikeScaffold` 内的 `"####### LikeScaffold build` 也不会因为键盘的弹起而输出,也就是: **`LikeScaffold` 虽然使用了 `MediaQuery.of(context)` ,但是它不再因为键盘的弹起而导致 rebuild** 。
因为此时 `LikeScaffold``Scaffold` 的 child ,所以在 `LikeScaffold` 内通过 `MediaQuery.of(context)` 指向的,其实是 `Scaffold` 内部经过处理的 `MediaQueryData`
![image-20220624150712453](http://img.cdn.guoshuyu.cn/20220628_N7/image8.png)
> 在 `Scaffold` 内部有很多类似的处理,例如 `body` 里会根据是否有 `Appbar``BottomNavigationBar` 来决定是否移除该区域内的 paddingTop 和 paddingBottom 。
所以,看到这里有没有想到什么?**为什么时不时通过 `MediaQuery.of(context)` 获取的 padding ,有的 top 为 0 ,有的不为 0 ,原因就在于你获取的 context 来自哪里**。
举个例子,如下代码所示, `ScaffoldChildPage` 作为 `Scaffold` 的 child ,我们分别在 ` MyHomePage ``ScaffoldChildPage` 里打印 `MediaQuery.of(context).padding`
```dart
class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
print("MyHomePage MediaQuery padding: ${MediaQuery.of(context).padding}");
return Scaffold(
appBar: AppBar(
title: new Text(""),
),
extendBody: true,
body: Column(
children: [
new Spacer(),
ScaffoldChildPage(),
new Spacer(),
],
),
);
}
}
class ScaffoldChildPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
print("ScaffoldChildPage MediaQuery padding: ${MediaQuery.of(context).padding}");
return Container();
}
}
```
如下图所示,可以看到,因为此时 `MyHomePage``Appbar` ,所以 `ScaffoldChildPage` 里获取到 paddingTop 是 0 ,因为此时 `ScaffoldChildPage` 获取到的 `MediaQueryData` 已经被 `MyHomePage` 里的 `Scaffold` 改写了。
![image-20220624151522429](http://img.cdn.guoshuyu.cn/20220628_N7/image9.png)
如果此时你给 `MyHomePage` 增加了 `BottomNavigationBar` ,可以看到 `ScaffoldChildPage` 的 bottom 会从原本的 34 变成 90 。
![image-20220624152008795](http://img.cdn.guoshuyu.cn/20220628_N7/image10.png)
到这里可以看到 `MediaQuery.of` 里的 context 对象很重要:
- **如果页面 `MediaQuery.of` 用的是 `Scaffold` 外的 `context ` ,获取到的是顶层的 `MediaQueryData` ,那么弹出键盘时就会导致页面 rebuild**
- **`MediaQuery.of` 用的是 `Scaffold` 内的 `context ` ,那么获取到的是 `Scaffold` 对于区域内的 `MediaQueryData`** ,比如前面介绍过的 body ,同时获取到的 `MediaQueryData` 也会因为 `Scaffold` 的配置不同而发生改变
所以,如下动图所示,**其实部分人会在 push 对应路由地方,通过嵌套 `MediaQuery` 来做一些拦截处理,比如设置文本不可缩放,但是其实这样会导致键盘在弹出和收起时,触发各个页面不停 rebuild** ,比如在 Page 2 弹出键盘的过程Page 1 也在不停 rebuild。
![1111333](http://img.cdn.guoshuyu.cn/20220628_N7/image11.gif)
所以,如果需要做一些全局拦截,推荐通过 `useInheritedMediaQuery` 这种方式来做全局处理。
```dart
return MediaQuery(
data: MediaQueryData.fromWindow(WidgetsBinding.instance!.window).copyWith(boldText: false),
child: MaterialApp(
useInheritedMediaQuery: true,
),
);
```
所以最后做个总结,本篇主要理清了:
- `MediaQueryData``viewInsets` \ ` padding` \ `viewPadding ` 的区别
- `MediaQuery` 和键盘状态的关系
- `MediaQuery.of` 使用不同 context 对性能的影响
- 通过 `Scaffold` 内的 `context ` 获取到的 `MediaQueryData` 受到 `Scaffold` 的影响
那么,如果看完本篇你还有什么疑惑,欢迎留言评论交流。

238
Flutter-N8.md Normal file
View File

@ -0,0 +1,238 @@
# Flutter 小技巧之优化你使用的 BuildContext
Flutter 里的 `BuildContext` 相信大家都不会陌生,虽然它叫 Context但是它实际是 Element 的抽象对象,而在 Flutter 里,它主要来自于 `ComponentElement`
关于 `ComponentElement` 可以简单介绍一下,在 Flutter 里根据 Element 可以简单地被归纳为两类:
- `RenderObjectElement` :具备 `RenderObject` ,拥有布局和绘制能力的 Element
- `ComponentElement` :没有 `RenderObject` ,我们常用的 `StatelessWidget``StatefulWidget` 里对应的 `StatelessElement``StatefulElement` 就是它的子类。
所以一般情况下,我们在 `build` 方法或者 State 里获取到的 `BuildContext` 其实就是 `ComponentElement`
*那使用 `BuildContext` 有什么需要注意的问题*
首先如下代码所示,在该例子里当用户点击 `FloatingActionButton` 的时候,代码里做了一个 2秒的延迟然后才调用 `pop` 退出当前页面。
```dart
class _ControllerDemoPageState extends State<ControllerDemoPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
floatingActionButton: FloatingActionButton(
onPressed: () async {
await Future.delayed(Duration(seconds: 2));
Navigator.of(context).pop();
},
),
);
}
}
```
正常情况下是不会有什么问题,但是当用户在点击了 `FloatingActionButton` 之后,又马上点击了 `AppBar` 返回退出应用,这时候就会出现以下的错误提示。
![](http://img.cdn.guoshuyu.cn/20220720_N8/image1.png)
可以看到此时 log 说Widget 对应的 Element 已经不在了,因为在 `Navigator.of(context)` 被调用时,`context` 对应的 Element 已经随着我们的退出销毁。
一般情况下处理这个问题也很简单,**那就是增加 `mounted` 判断,通过 `mounted` 判断就可以避免上述的错误**。
```dart
class _ControllerDemoPageState extends State<ControllerDemoPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
floatingActionButton: FloatingActionButton(
onPressed: () async {
await Future.delayed(Duration(seconds: 2));
if (!mounted) return;
Navigator.of(context).pop();
},
),
);
}
}
```
上面代码里的 `mounted` 标识位来自于 `State` **因为 `State` 是依附于 Element 创建,所以它可以感知 Element 的生命周期**,例如 `mounted` 就是判断 `_element != null;`
![](http://img.cdn.guoshuyu.cn/20220720_N8/image2.png)
那么到这里我们收获了一个小技巧:**使用 `BuildContext` 时,在必须时我们需要通过 `mounted` 来保证它的有效性**。
*那么单纯使用 `mounted` 就可以满足 context 优化的要求了吗*
如下代码所示,在这个例子里:
- 我们添加了一个列表,使用 `builder` 构建 Item
- 每个列表都有一个点击事件
- 点击列表时我们模拟网络请求,假设网络也不是很好,所以延迟个 5 秒
- 之后我们滑动列表让点击的 Item 滑出屏幕不可见
```dart
class _ControllerDemoPageState extends State<ControllerDemoPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: ListView.builder(
itemBuilder: (context, index) {
return ListItem();
},
itemCount: 30,
),
);
}
}
class ListItem extends StatefulWidget {
const ListItem({Key? key}) : super(key: key);
@override
State<ListItem> createState() => _ListItemState();
}
class _ListItemState extends State<ListItem> {
@override
Widget build(BuildContext context) {
return ListTile(
title: Container(
height: 160,
color: Colors.amber,
),
onTap: () async {
await Future.delayed(Duration(seconds: 5));
if(!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text("Tip")));
},
);
}
}
```
由于在 5 秒之内Item 被划出了屏幕,所以对应的 Elment 其实是被释放了,从而由于 `mounted` 判断,`SnackBar` 不会被弹出。
*那如果假设需要在开发时展示点击数据上报的结果,也就是 Item 被释放了还需要弹出,这时候需要如何处理*
我们知道不管是 `ScaffoldMessenger.of(context)` 还是 `Navigator.of(context)` ,它本质还是通过 `context` 去往上查找对应的 `InheritedWidget` 泛型,所以其实我们可以提前获取。
所以,如下代码所示,在 `Future.delayed` 之前我们就通过 `ScaffoldMessenger.of(context);` 获取到 `sm` 对象之后就算你直接退出当前的列表页面5秒过后 `SnackBar` 也能正常弹出。
```dart
class _ListItemState extends State<ListItem> {
@override
Widget build(BuildContext context) {
return ListTile(
title: Container(
height: 160,
color: Colors.amber,
),
onTap: () async {
var sm = ScaffoldMessenger.of(context);
await Future.delayed(Duration(seconds: 5));
sm.showSnackBar(SnackBar(content: Text("Tip")));
},
);
}
}
```
*为什么页面销毁了,但是 `SnackBar` 还能正常弹出*
因为此时通过 `of(context);` 获取到的 `ScaffoldMessenger` 是存在 `MaterialApp` 里,所以就算页面销毁了也不影响 `SnackBar` 的执行。
但是如果我们修改例子,如下代码所示,在 `Scaffold` 上面多嵌套一个 `ScaffoldMessenger` ,这时候在 Item 里通过 `ScaffoldMessenger.of(context)` 获取到的就会是当前页面下的 `ScaffoldMessenger`
```dart
class _ControllerDemoPageState extends State<ControllerDemoPage> {
@override
Widget build(BuildContext context) {
return ScaffoldMessenger(
child: Scaffold(
appBar: AppBar(),
body: ListView.builder(
itemBuilder: (context, index) {
return ListItem();
},
itemCount: 30,
),
),
);
}
}
```
这种情况下我们只能保证Item 不可见的时候 `SnackBar` 还能正常弹出, 而如果这时候我们直接退出页面,还是会出现以下的错误提示,因为 `ScaffoldMessenger` 也被销毁了 。
![](http://img.cdn.guoshuyu.cn/20220720_N8/image3.png)
所以到这里我们收获第二个小技巧:**在异步操作里使用 `of(context)` ,可以提前获取,之后再做异步操作,这样可以尽量保证流程可以完整执行**。
*既然我们说到通过 `of(context)` 去获取上层共享往下共享的 `InheritedWidget` ,那在哪里获取就比较好*
还记得前面的 log 吗在第一个例子出错时log 里就提示了一个方法,也就是 State 的 `didChangeDependencies` 方法。
![](http://img.cdn.guoshuyu.cn/20220720_N8/image1.png)
为什么是官方会建议在这个方法里去调用 `of(context)`
首先前面我们一直说,通过 `of(context)` 获取到的是 `InheritedWidget` ,而 当 `InheritedWidget` 发生改变时,就是通过触发绑定过的 Element 里 State 的`didChangeDependencies` 来触发更新,**所以在 `didChangeDependencies` 里调用 `of(context)` 有较好的因果关系**。
> 对于这部分内容感兴趣的,可以看 [Flutter 小技巧之 MediaQuery 和 build 优化你不知道的秘密](https://juejin.cn/post/7114098725600903175) 和 [全面理解State与Provider](https://juejin.cn/post/6844903866706706439#heading-5) 。
*那我能在 `initState` 里提前调用吗*
当然不行,首先如果在 `initState` 直接调用如 `ScaffoldMessenger.of(context).showSnackBar` 方法,就会看到以下的错误提示。
![](http://img.cdn.guoshuyu.cn/20220720_N8/image4.png)
这是因为 Element 里会判断此时的 `_StateLifecycle` 状态,如果此时是 ` _StateLifecycle.created` 或者 ` _StateLifecycle.defunct` ,也就是在 `initState``dispose ` ,是不允许执行 `of(context)` 操作。
![](http://img.cdn.guoshuyu.cn/20220720_N8/image5.png)
> `of(context)` 操作指的是 `context.dependOnInheritedWidgetOfExactTyp`
当然,如果你硬是想在 `initState` 下调用也行,增加一个 `Future` 执行就可以成功执行
```dart
@override
void initState() {
super.initState();
Future((){
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("initState")));
});
}
```
> 简单理解,因为 Dart 是单线程轮询执行,`initState` 里的 `Future` 相当于是下一次轮询,自然也就不在 ` _StateLifecycle.created` 的状态下。
*那我在 `build` 里直接调用不行吗*
直接在 `build` 里调用肯定可以,虽然 `build` 会被比较频繁执行,但是 `of(context)` 操作其实就是在一个 map 里通过 key - value 获取泛型对象,所以对性能不会有太大的影响。
**真正对性能有影响的是 `of(context)` 的绑定数量和获取到对象之后的自定义逻辑**,例如你通过 ` MediaQuery.of(context).size` 获取到屏幕大小之后,通过一系列复杂计算来定位你的控件。
```dart
@override
Widget build(BuildContext context) {
var size = MediaQuery.of(context).size;
var padding = MediaQuery.of(context).padding;
var width = size.width / 2;
var height = size.width / size.height * (30 - padding.bottom);
return Container(
color: Colors.amber,
width: width,
height: height,
);
}
```
例如上面这段代码,可能会导致键盘在弹出的时候,虽然当前页面并没有完全展示,但是也会导致你的控件不断重新计算从而出现卡顿。
> 详细解释可以参考 [Flutter 小技巧之 MediaQuery 和 build 优化你不知道的秘密](https://juejin.cn/post/7114098725600903175)
所以到这里我们又收获了一个小技巧: **对于 `of(context)` 的相关操作逻辑,可以尽量放到 `didChangeDependencies` 里去处理**
最后,今天主要分享了在使用 `BuildContext` 时的一些注意事项和技巧,如果你对于这方面还有什么疑问,欢迎留言评论。

234
Flutter-N9.md Normal file
View File

@ -0,0 +1,234 @@
# 如何利用 Flutter 实现炫酷的 3D 卡片和帅气的 360° 展示效果
我正在参加「创意开发 投稿大赛」详情请看:[掘金创意开发大赛来了!](https://juejin.cn/post/7120441631530549284)
本篇将带你在 Flutter 上快速实现两个炫酷的动画特效,希望最后的效果可以惊艳到你。
这次灵感的来源于更新 MIUI 13 时刚好看到的卡片效果,其中除了卡片会跟随手势出现倾斜之外,内容里的部分文本和绿色图标也有类似悬浮的视差效果,恰逢此时灵机一动,**我们也来用 Flutter 快速实现炫酷的 3D 视差卡片,最后再拓展实现一个支持帅气的 360° 展示的卡片效果**。
> ❤️ **本文正在参加征文投稿活动,还请看官们走过路过来个点赞一键三连,感激不尽~**
![](http://img.cdn.guoshuyu.cn/20220723_N9/image1.gif)
既然需要卡片跟随手势产生不规则形变,我们第一个想到的肯定是**矩阵变换**,在 Flutter 里我们可以使用 `Matrix4` 配合 `Transform` 来实现矩阵变换效果。
开始之前,首先我们创建用 `Transform` 嵌套一个 `GestureDetector` ,并绘制出一个 300x400 的圆角卡片,用于后续进行矩阵变换处理。
```dart
Transform(
transform: Matrix4.identity(),
child: GestureDetector(
child: Container(
width: 300,
height: 400,
padding: EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.blueGrey,
borderRadius: BorderRadius.circular(20),
),
),
),
);
```
![](http://img.cdn.guoshuyu.cn/20220723_N9/image2.png)
接着,如下代码所示,因为我们需要卡片跟随手势进行矩阵变换,所以我们可以直接在 `GestureDetector``onPanUpdate` 里获取到手势信息,例如 `localPosition` 位置信息,然后把对应的 `dx``dy`赋值到 `Matrix4``rotateX``rotateY` 上实现旋转。
```dart
child: Transform(
transform: Matrix4.identity()
..rotateX(touchY)
..rotateY(touchX),
alignment: FractionalOffset.center,
child: GestureDetector(
onPanUpdate: (details) {
setState(() {
touchX = details.localPosition.dx;
touchY = details.localPosition.dy;
});
},
child: Container(
```
这里有个需要注意的是:**上面代码里 `rotateX` 使用的是 `touchY` ,而 `rotateY` 使用的是 `touchX`** ,为什么要这样做呢?
> ⚠️举个例子,当我们手指左右移动时,是希望卡片可以围绕 Y 轴进行旋转,所以我们会把 `touchX` 传递给了 `rotateY` ,同样 `touchY` 传递给 `rotateX` 也是一个道理。
![](http://img.cdn.guoshuyu.cn/20220723_N9/image3.png)
但是当我们实际运行上述代码之后,如下图所示,可以看到基本上我们只是稍微移动手指,卡片就会陷入疯狂旋转的情况,并且实际的旋转速度会比 GIF 里快很多。
![](http://img.cdn.guoshuyu.cn/20220723_N9/image4.gif)
**问题的原因其实是因为 `rotateX``rotateY` 需要的是一个 `angle` 参数**,假设这里对 `rotateX``rotateY` 设置 `pi / 4` ,就可以看到卡片在 X 轴和 Y 轴上都产生了 45 度的旋转效果。
```dart
Transform(
transform: Matrix4.identity()
..rotateX(pi / 4)
..rotateY(pi / 4),
alignment: FractionalOffset.center,
```
![](http://img.cdn.guoshuyu.cn/20220723_N9/image5.png)
所以如果直接使用手势的 `localPosition` 作用于 `Matrix4` 肯定是不行的,我们首先需要对手势数据进行一个采样,**因为代码里我们设置了 `FractionalOffset.center` ,所以我们可以用卡片的中心点来计算手指位置,再进行压缩处理**。
如下代码所示,我们通过以卡片中心点为原点进行计算,其中 `/ 2` 就是得到卡片的中心点,`/ 100` 是对数据进行压缩采样,*但是为什么 `touchX``touchY` 的计算方式是相反的呢*
```dart
touchX = (cardWidth / 2 - details.localPosition.dx) / 100;
touchY = (details.localPosition.dy - cardHeight / 2 ) / 100;
```
如下图所示,**因为在设置 `rotateX``rotateY` 时,赋予 `> 0` 的数据时卡片就会以图片中的方向进行旋转**,由于我们是需要手指往哪边滑动,卡片就往哪边倾斜,所以:
- 当我们往左水平滑动时,需要卡片往左边倾斜,也就是图中绕 Y 轴转动的 `>0` 的方向,并且越靠近左边需要正向的 Angle 数值越大,由于此时 `localPosition.dx` 是越往左越小,所以需要利用 `CardWidth / 2 - details.localPosition.dx` 进行计算,得到越往左有越大的正向 Angle 数值
- 同理,当我们往下滑动时,需要卡片往下边倾斜,也就是图中绕 X 轴转动的 `>0` 的方向,并且越靠近下边需要正向 Angle 数值越大,由于此时 `localPosition.dy` 越往下越大,所以使用 `details.localPosition.dy - cardHeight / 2` 去计算得到正确数据
| ![](http://img.cdn.guoshuyu.cn/20220723_N9/image6.png) | ![](http://img.cdn.guoshuyu.cn/20220723_N9/image7.gif) |
| ----------------------------------------------------------- | ------------------------------------------ |
如果觉得太抽象,可以结合上边右侧的动图,和**大家买股票一样,图中显示红色时是正数,显示绿色时是负数**,可以看到:
- 手指往左移动时,第一行 TouchX 是红色正数,被设置给 `rotateY` 然后卡片绕 Y 轴正方向旋转
- 手指往下移动时,第二行 TouchY 是红色正数,被设置给 `rotateX` 然后卡片绕 X 轴正方向旋转
到这里我们就初步实现了卡片跟随手机旋转的效果,**但是这时候的立体旋转效果看起来其实“很别扭”,总感觉差了点什么,其实这是因为卡片在旋转时没有产生视觉上的深度感知**。
所以我们可以通过矩阵的透视变换调整视觉效果,而为了在 Z 方向实现深度感知,我们需要在矩阵中配置 `.setEntry(3, 2, 0.001)` ,这里的 3 表示第 3 列2 表示第 2 行,因为是从 0 开始排列,所以也就是图片中 Z 的位置。
![](http://img.cdn.guoshuyu.cn/20220723_N9/image8.png)
其实 `.setEntry(3, 2, 0.001)` 就是调整 Z 轴的视角,而在 Z 上的 0.001 就是需要的透视效果测量值,类似于相机上的对焦点进行放大和缩小的作用,这个数字越大就会让交点处看起来好像离你视觉更近,所以最终代码如下
```dart
Transform(
transform: Matrix4.identity()
..setEntry(3, 2, 0.001)
..rotateX(touchY)
..rotateY(touchX),
alignment: FractionalOffset.center,
```
运行之后,可以看到在增加了 Z 角度的视角调整之后,这时候看起来的立体效果就好了很多,并且也有了类似 3D 空间的感觉。
![](http://img.cdn.guoshuyu.cn/20220723_N9/image9.gif)
接着我们在卡片上放上一个添加一个 `13``Text` 文本,运行之后可以看到此时文本是跟随卡片发生变化,而接下来我们需要做的,就是**通过另外一个 `Transform` 来让 `Text` 文本和卡片之间产生视差,从而出现悬浮的效果**。
| ![](http://img.cdn.guoshuyu.cn/20220723_N9/image10.png) | ![](http://img.cdn.guoshuyu.cn/20220723_N9/image11.gif) |
| ----------------------------------------------------------- | ---------------------------------------- |
所以接下来需要给文本内容设置一个 `translate``Matrix4` ,让它向着倾斜角度的相反方向移动,然后对前面的 `touchX``touchY` 进行放大,然后再通过 `- 10` 操作来产生一个位差。
```dart
Transform(
transform: Matrix4.identity()
..translate(touchX * 100 - 10,
touchY * 100 - 10, 0.0),
```
> `-10` 这个是我随意写的,你也可以根据自己的需求调节。
例如,这时候当卡片往左倾斜时,文字就会向右移动,从而产生视觉差的效果,得到类似悬浮的感觉。
| ![](http://img.cdn.guoshuyu.cn/20220723_N9/image12.png) | ![](http://img.cdn.guoshuyu.cn/20220723_N9/image13.gif) |
| ----------------------------------------------------------- | ----------------------------------------- |
完成这一步之后,接下来可以我们对文本内容进行一下美化处理,例如增加渐变颜色,添加阴影,更换字体,目的是让字体看起来更加具备立体的效果,**这里使用的 `shader` ,也可以让文字在移动过程中出现不同角度的渐变效果**。
| ![](http://img.cdn.guoshuyu.cn/20220723_N9/image14.png) | ![](http://img.cdn.guoshuyu.cn/20220723_N9/image15.gif) |
| ----------------------------------------------------------- | ----------------------------------------- |
最后,我们还需要对卡片旋转进行一个范围约束,这里主要是通过卡片大小比例:
- 在 `onPanUpdate` 时对 `touchX``touchY` 进行范围约束,从而约束的卡片的倾斜角度
- 增加了 `startTransform` 标志位,用于在 `onTapUp` 或者 `onPanEnd` 之后,恢复卡片回到默认状态的作用。
```dart
Transform(
transform: Matrix4.identity()
..setEntry(3, 2, 0.001)
..rotateX(startTransform ? touchY : 0.0)
..rotateY(startTransform ? touchX : 0.0),
alignment: FractionalOffset.center,
child: GestureDetector(
onTapUp: (_) => setState(() {
startTransform = false;
}),
onPanCancel: () => setState(() => startTransform = false),
onPanEnd: (_) => setState(() {
startTransform = false;
}),
onPanUpdate: (details) {
setState(() => startTransform = true);
///y轴限制范围
if (details.localPosition.dx < cardWidth * 0.55 &&
details.localPosition.dx > cardWidth * 0.3) {
touchX = (cardWidth / 2 - details.localPosition.dx) / 100;
}
///x轴限制范围
if (details.localPosition.dy > cardHeight * 0.4 &&
details.localPosition.dy < cardHeight * 0.6) {
touchY = (details.localPosition.dy - cardHeight / 2) / 100;
}
},
child:
```
到这里,我们只需要在全局再进行一些美化处理,运行之后就会如下图所示,在配合阴影和渐变效果,整体的视觉立体感会更强烈,此时我们基本就实现了一开始想要的功能,
![](http://img.cdn.guoshuyu.cn/20220723_N9/image16.gif)
> 完整代码可见: [card_perspective_demo_page.dart](https://github.com/CarGuo/gsy_flutter_demo/blob/master/lib/widget/card_perspective_demo_page.dart)
>
> Web 体验地址PC 端记得开 Chrome 手机模式: [3D 视差卡片](http://guoshuyu.cn/home/web/#3D%20%E9%80%8F%E8%A7%86%E5%8D%A1%E7%89%87) 。
那有人可能就想问了: *学会了这个我们还可以实现什么*
举个例子,比如我们可以实现一个 “伪3D” 的 360° 卡片效果,利用堆叠实现立体的电子银行卡效果。
依旧是前面的手势旋转逻辑,只是这里我们可以把具有前后画面的银行卡图片,通过 `IndexedStack` 嵌套起来,**嵌套之后主要是根据旋转角度来调整 `IndexedStack` 里需要展示的图片,然后利用透视旋转来实现类似 3D 物体的 360° 旋转展示**。
![](http://img.cdn.guoshuyu.cn/20220723_N9/image17.png)
**这里的关键是通过手势旋转角度,判断当前需要展示 `IndexedStack` 里的哪个卡片**,因为 Flutter 使用的 Skia 是 2D 渲染引擎,如果没有这部分逻辑,你就只会看到单张图片画面的旋转效果。
```dart
if (touchX.abs() % (pi * 3 / 2) >= pi / 2 ||
touchY.abs() % (pi * 3 / 2) >= pi / 2) {
showIndex = 0;
} else {
showIndex = 1;
}
```
运行效果如下图所示,可以看到在视差和图片切换的作用下,我们用很低的成本在 Flutter 上实现了 “伪3D” 的卡片的 360° 展示,类似的实现其实还可以用于一些商品展示或者页面切换的场景,**本质上就是利用视差的效果,在 2D 屏幕上模拟现实中的画面效果,从而达到类似 3D 的视觉作用** 。
| ![](http://img.cdn.guoshuyu.cn/20220723_N9/image18.gif) | ![](http://img.cdn.guoshuyu.cn/20220723_N9/image19.gif) |
| ---------------------------------------------------- | ----------------------------------------- |
**最后我们只需要用 `Text` 在卡片上添加“模拟”凹凸的文字,就实现了我们现实中类似银行卡的卡面效果**。
![](http://img.cdn.guoshuyu.cn/20220723_N9/image20.gif)
> 完整代码可见: [card_3d_demo_page.dart](https://github.com/CarGuo/gsy_flutter_demo/blob/master/lib/widget/card_3d_demo_page.dart)
>
> Web 体验地址PC 端记得开 chrome 手机模式: [ 360° 可视化 3D 电子银行卡](http://guoshuyu.cn/home/web/#3D%20%E5%8D%A1%E7%89%87%E6%97%8B%E8%BD%AC)
好了,本篇动画特效就到为止,**如果你有什么想法,欢迎留言评论,感谢大家耐心看完,也还请看官们走过路过的来个点赞一键三连,感激不尽**

View File

@ -156,6 +156,12 @@
* [Flutter 小技巧之玩转字体渲染和问题修复 ](Flutter-N3.md)
* [Flutter 小技巧之有趣的动画技巧](Flutter-N4.md)
* [Flutter 小技巧之 Dart 里的 List 和 Iterable 你真的搞懂了吗?](Flutter-N6.md)
* [Flutter 小技巧之 MediaQuery 和 build 优化你不知道的秘密](Flutter-N7.md)
* [Flutter 小技巧之 ListView 和 PageView 的各种花式嵌套](Flutter-N5.md)
* [Flutter 小技巧之优化你使用的 BuildContext](Flutter-N8.md)
* [如何利用 Flutter 实现炫酷的 3D 卡片和帅气的 360° 展示效果](Flutter-N9.md)
* [给掘金 Logo 快速添加动画效果,并支持全平台开发框架](Flutter-N10.md)
* [Flutter 实现 “真” 3D 动画效果,用纯代码实现立体 Dash 和 3D 掘金 Logo](Flutter-N11.md)

View File

@ -68,114 +68,66 @@
* [番外](FWREADME.md)
* [Flutter 跨平台框架应用实战-2019极光开发者大会](Flutter-jg-meet.md)
* [Flutter 面试知识点集锦](Flutter-msjj.md)
* [全网最全 Flutter 与 ReactNative深入对比分析](qwzqdb.md)
* [Flutter 开发实战与前景展望 - RTC Dev Meetup](Flutter-rtc-meetup.md)
* [Flutter Interact 的 Flutter 1.12 大进化和回顾](Flutter-Interact-2019.md)
* [Flutter 升级 1.12 适配教程](Flutter-update-1.12.md)
* [Spuernova 是如何提升 Flutter 的生产力](Flutter-Supernova.md)
* [Flutter 中的图文混排与原理解析](Flutter-TWHP.md)
* [Flutter 实现视频全屏播放逻辑及解析](Flutter-Player-Full.md)
* [Flutter 上的一个 Bug 带你了解键盘与路由的另类知识点](Flutter-keyboard-rs.md)
* [Flutter 上默认的文本和字体知识点](Flutter-Font-Other.md)
* [带你深入理解 Flutter 中的字体“冷”知识](Flutter-Font-Cool.md)
* [Flutter 1.17 中的导航解密和性能提升](Flutter-nav+1_17.md)
* [Flutter 1.17 对列表图片的优化解析](Flutter-Image+1_17.md)
* [Flutter 1.20 下的 Hybrid Composition 深度解析](flutter-hy-composition.md)
* [2020 腾讯Techo Park - Flutter与大前端的革命](Flutter-TECHO.md)
* [带你全面了解 Flutter它好在哪里它的坑在哪里 应该怎么学?](Flutter-WHAT.md)
* [Flutter 中键盘弹起时Scaffold 发生了什么变化](Flutter-KEY.md)
* [Flutter 2.0 下混合开发浅析](Flutter-Group.md)
* [Flutter 搭建 iOS 命令行服务打包发布全保姆式流程](Flutter-iOS-Build.md)
* [不一样角度带你了解 Flutter 中的滑动列表实现](Flutter-N-Scroll.md)
* [带你深入 Dart 解析一个有趣的引用和编译实验](DEMO-INTEREST.md)
* [Dart 里的类型系统](Dart-SYS.md)
* [Dart VM 的相关简介与运行模式解析](Dart-VM.md)
* [Flutter 里的语法糖解析,知其所然方能潇洒舞剑](Flutter-SU.md)
* [Flutter 实现完美的双向聊天列表效果,滑动列表的知识点](Flutter-SC.md)
* [Flutter 启动页的前世今生适配历程](Flutter-LA.md)
* [Flutter 快速解析 TextField 的内部原理](Flutter-TE.md)
* [谷歌DevFest 2021 广州国际嘉年华-带你了解不一样的 Flutter](Flutter-DevFest2021.md)
* [Flutter for Web 2022 年:简单探讨](Flutter-W2022.md)
* [2021 年的 Flutter 状态管理:如何选择?](Flutter-StateM.md)
* [Flutter 2.10 升级填坑指南](Flutter-210-FIX.md)
* [Flutter Riverpod 全面深入解析,为什么官方推荐它?](Flutter-Riverpod.md)
* [ Flutter 2022 战略和路线解读与想法](Flutter-2022-roadmap.md)
* [原生开发如何学习 Flutter | 谷歌社区说](Flutter-SQS.md)
* [Fluttter 混合开发下 HybridComposition 和 VirtualDisplay 的实现与未来演进](Flutter-HV.md)
* [Flutter 双向聊天列表效果进阶优化](Flutter-Chat2.md)
* [Flutter 上字体的另类玩法FontFeature ](Flutter-FontFeature.md)
* [移动端系统生物认证技术详解](Flutter-BIO.md)
* [完整解析使用 Github Action 构建和发布 Flutter 应用](Flutter-GB.md)
* [Flutter 120hz 高刷新率在 Android 和 iOS 上的调研总结](Flutter-120HZ.md)
* [Flutter Festival | 2022 年 Flutter 适合我吗Flutter VS Other 量化对比](Flutter-FF.md)
* [Flutter 从 TextField 安全泄漏问题深入探索文本输入流程](Flutter-TL.md)
* [Flutter iOS OC 混编 Swift 遭遇动态库和静态库问题填坑](Flutter-BIOS.md)
* [Flutter Web 一个编译问题带你了解 Flutter Web 的打包构建和分包实现 ](Flutter-WP.md)
* [大前端时代的乱流:带你了解最全面的 Flutter Web](Flutter-Web-T.md)
* [Flutter 深入探索混合开发的技术演进](Flutter-DWW.md)
* [Flutter 3.0 之 PlatformView :告别 VirtualDisplay ,拥抱 TextureLayer](Flutter-P3.md)
* [Google I/O Extended | Flutter 游戏和全平台正式版支持下 Flutter 的现状](Flutter-Extended.md)
* [掘金x得物公开课 - Flutter 3.0下的混合开发演进](Flutter-DWN.md)
* [Flutter 小技巧之 ButtonStyle 和 MaterialStateProperty ](Flutter-N1.md)
* [Flutter 小技巧之 Flutter 3 下的 ThemeExtensions 和 Material3 ](Flutter-N2.md)
* [Flutter 小技巧之玩转字体渲染和问题修复 ](Flutter-N3.md)
* [Flutter 小技巧之有趣的动画技巧](Flutter-N4.md)
* [Flutter 小技巧之 Dart 里的 List 和 Iterable 你真的搞懂了吗?](Flutter-N6.md)
* [Flutter 小技巧之 MediaQuery 和 build 优化你不知道的秘密](Flutter-N7.md)
* [Flutter 小技巧之 ListView 和 PageView 的各种花式嵌套](Flutter-N5.md)
* [Flutter 小技巧之优化你使用的 BuildContext](Flutter-N8.md)
* [如何利用 Flutter 实现炫酷的 3D 卡片和帅气的 360° 展示效果](Flutter-N9.md)
* [给掘金 Logo 快速添加动画效果,并支持全平台开发框架](Flutter-N10.md)
* [Flutter 实现 “真” 3D 动画效果,用纯代码实现立体 Dash 和 3D 掘金 Logo](Flutter-N11.md)