From e38a07fcfab67541034f8b72fa70cc2ddb13dc29 Mon Sep 17 00:00:00 2001 From: guoshuyu <359369982@qq.com> Date: Tue, 9 Aug 2022 10:03:38 +0800 Subject: [PATCH] update --- FWREADME.md | 6 + Flutter-N10.md | 165 ++++++++++++++ Flutter-N11.md | 197 +++++++++++++++++ Flutter-N5.md | 569 +++++++++++++++++++++++++++++++++++++++++++++++++ Flutter-N7.md | 334 +++++++++++++++++++++++++++++ Flutter-N8.md | 238 +++++++++++++++++++++ Flutter-N9.md | 234 ++++++++++++++++++++ README.md | 6 + SUMMARY.md | 60 +----- 9 files changed, 1755 insertions(+), 54 deletions(-) create mode 100644 Flutter-N10.md create mode 100644 Flutter-N11.md create mode 100644 Flutter-N5.md create mode 100644 Flutter-N7.md create mode 100644 Flutter-N8.md create mode 100644 Flutter-N9.md diff --git a/FWREADME.md b/FWREADME.md index b2f8aa9..b529f08 100644 --- a/FWREADME.md +++ b/FWREADME.md @@ -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) diff --git a/Flutter-N10.md b/Flutter-N10.md new file mode 100644 index 0000000..7ebbac4 --- /dev/null +++ b/Flutter-N10.md @@ -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 还有什么想法或者疑问,欢迎留言交流~ + diff --git a/Flutter-N11.md b/Flutter-N11.md new file mode 100644 index 0000000..2e32a92 --- /dev/null +++ b/Flutter-N11.md @@ -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 来的高效,**需要一定的空间想象力** 。 + +好了,本篇动画特效就到此为止,**如果你有什么想法,欢迎留言评论,感谢大家耐心看完,也还请看官们走过路过的来个点赞一键三连,感激不尽** ~ \ No newline at end of file diff --git a/Flutter-N5.md b/Flutter-N5.md new file mode 100644 index 0000000..9bac1cf --- /dev/null +++ b/Flutter-N5.md @@ -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 { + @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: { + 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 + 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: { + 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 { + 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: { + 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) diff --git a/Flutter-N7.md b/Flutter-N7.md new file mode 100644 index 0000000..d3930c4 --- /dev/null +++ b/Flutter-N7.md @@ -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 { + 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 createState() => _LikeScaffoldState(); +} + +class _LikeScaffoldState extends State { + @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 { + @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 { + @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` 的影响 + +那么,如果看完本篇你还有什么疑惑,欢迎留言评论交流。 diff --git a/Flutter-N8.md b/Flutter-N8.md new file mode 100644 index 0000000..30a076b --- /dev/null +++ b/Flutter-N8.md @@ -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 { + @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 { + @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 { + @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 createState() => _ListItemState(); +} + +class _ListItemState extends State { + @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 { + @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 { + @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` 时的一些注意事项和技巧,如果你对于这方面还有什么疑问,欢迎留言评论。 \ No newline at end of file diff --git a/Flutter-N9.md b/Flutter-N9.md new file mode 100644 index 0000000..219cb54 --- /dev/null +++ b/Flutter-N9.md @@ -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) + +好了,本篇动画特效就到为止,**如果你有什么想法,欢迎留言评论,感谢大家耐心看完,也还请看官们走过路过的来个点赞一键三连,感激不尽**~ + diff --git a/README.md b/README.md index d85e05e..290a6dd 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/SUMMARY.md b/SUMMARY.md index ad8565e..df80358 100644 --- a/SUMMARY.md +++ b/SUMMARY.md @@ -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)