☰
Flutter 3D画廊:Matrix4透视与Cover Flow实现
2026/10/27 5:02:43 网站建设 项目流程

1. 先把“3D 画廊”这件事拆开看清

1.1 一个真实的需求长什么样

前段时间接了个相册类的小需求,产品经理的原话是:“列表太单调,想要那种左右滑动、图片会转起来、有纵深感的展示方式。”翻译成技术语言就是:图片横向滚动、当前图片正对屏幕、两侧图片向内旋转并后退、整体有透视景深、滑动有吸附感、点击两侧图片能回到中间。这套东西业内一般叫Cover Flow,而我们要做的,就是一套Flutter 自定义图片 3D 画廊。

这个需求看起来花哨,但拆开以后其实只有三件事:滚动进度怎么拿、进度怎么变成一个 3D 姿态、3D 姿态怎么变成屏幕上的像素。第三件事 Flutter 已经替我们做好了,Transform加Matrix4就是标准答案;难点在第二件事,也就是那几个参数的映射关系,以及大量只有在真机上滑动才会暴露出来的坑。

这篇内容适合两类人:一类是刚学完 Flutter 基础组件、想找一个“有点挑战但不至于太玄”的练手项目的同学;另一类是已经在做业务、突然被要求加个炫酷轮播的开发者。我会把矩阵推导、参数取值、代码骨架、性能调优点和排查表都写全,你照着抄能跑起来,改动参数能做出自己风格的画廊。

1.2 三条路线怎么选,别一上来就上重武器

做 3D 画廊在 Flutter 里至少有三种落地路径,选错了后面全是补丁。

方案核心组成优点代价适用场景
PageView + viewportFraction + Transform分页控制器驱动,逐帧算偏移吸附免费、手势复用成熟、代码量小item 边界裁剪要处理,进度需监听卡片式画廊、封面流
ListView + Transform自由滚动列表滚动连续、无分页限制吸附要自己写,索引计算麻烦长列表、瀑布流变体
Stack + GestureDetector 手搓全手动手势 + 位置动画完全可控,能做出怪异效果惯性、回弹、多指都得自己实现特殊视觉装置、展示页

绝大多数情况选第一种。原因很实在:PageView已经把惯性滚动、边界回弹、手势仲裁这些又脏又细的活儿做完了,我们只需要在它的滚动进度上做文章。第二种方案看着自由,但你会发现“滑到一半松手要吸附到最近一张”这件事,自己写出来的手感总是差一口气,那种差一口气用户说不出来但能感觉到。

第三种是给特殊场景准备的,比如需要卡片沿曲线排布、需要拖拽时整组一起倾斜的那种。我个人的判断标准是:只要你的画廊是“逐张浏览”的语义,就用 PageView;只要它是“连续浏览”的语义,才考虑 ListView。

注意:PageView默认一次只让一个页面占据整个视口,直接拿它做画廊会看不到两侧的卡片。必须给PageController设置viewportFraction,常见取值 0.6 到 0.8,这个值决定了“露出来多少邻卡”,也直接影响后面透视参数的取值。

1.3 为什么不用 3D 引擎,矩阵到底能撑到什么程度

有人会问:要不要引入 3D 渲染库?我的答案是基本不需要。因为画廊的 3D 需求本质上只有绕 Y 轴旋转 + 透视投影 + 缩放位移这三板斧,没有任何光照、阴影贴图、mesh 变形。用Matrix4描述一个 4x4 齐次变换矩阵,完全够用,而且它走的是 Flutter 的Transform图层,不额外引入渲染管线,包体也不涨。

Matrix4在这里的角色,你可以理解成一张“拍照指令单”:告诉渲染引擎,这张照片要绕着它的竖直中心线转多少度、离镜头多远、镜头有多广角。Flutter 拿到这张单子以后,会算出四个角落在屏幕上的最终位置,然后把图片“贴”上去。图片本身还是平面的,但四个角的位置变了,人的眼睛就会自动脑补出厚度和纵深,这就是所有伪 3D 卡片的共同原理。

一句话概括这条路线的能力边界:能做绕轴旋转、能做透视缩放、能做层叠遮挡,但做不了真正的厚度、倒角和光照。如果你的设计稿要求卡片有明显的“侧面厚度”,那需要额外贴一层侧面图层,这属于后面扩展的内容了。


2. 矩阵、透视和相机的参数推导

2.1 四个动作怎么拼成一张“拍照指令单”

一个完整的画廊卡片变换,通常是四个动作的组合:透视、旋转、缩放、平移。

Matrix4.identity()得到的是单位矩阵,等价于“什么都不做”。往它身上叠加动作有两种写法,一种是..rotateY(a)..scale(s)这种级联调用,另一种是手动multiply。这里有个必须讲清楚的点:这些方法的叠加是右乘,后调用的动作先作用于图形。

我举个具体例子。写成这样:

final m = Matrix4.identity() ..setEntry(3, 2, -1 / cameraDistance) ..rotateY(angle) ..translate(dx, dy, 0);

实际的运算顺序是:先对卡片做translate,再rotateY,最后才是透视投影。也就是说,translate移动的是卡片自己旋转之后的局部坐标,dx会沿着卡片倾斜的方向走,看起来像是卡片被“推”进屏幕深处;如果你把translate挪到rotateY前面,那就是在世界坐标里平移,卡片会老老实实往左往右挪。

这个区别在调参时非常要命。我第一版就是把translate放前面,结果调dx想把侧边卡片往中间收,卡片却直接横着飘出去了,怎么调都不对,最后发现是顺序问题。

实操心得:不确定顺序对不对时,把cameraDistance调到 100000(近似正交投影)再滑一遍,旋转和位移会变得非常“平”,此时位移方向一眼就能看出来;确认方向没问题,再把相机距离调回正常值看透视效果。

2.2 透视矩阵那一行到底填什么数

透视是整个效果里最容易被“抄错”的一行。网上大量代码写的是setEntry(3, 2, 0.001),抄过来能跑,但很多人不知道这个 0.001 从哪来,改成 0.002 就发现卡片“塌”了。

先把原理说清楚。4x4 矩阵作用在齐次坐标(x, y, z, 1)上,输出的第四分量w才是关键。Flutter 在投影阶段会把前三个分量都除以w,得到最终的屏幕坐标。所以只要能让w随着z变化,就有了透视。

setEntry(3, 2, k)的意思是把第 4 行第 3 列(下标从 0 开始,即[3][2])设成k,这样w = k * z + 1。于是屏幕坐标变成x' = x / (1 + k*z)。

现在代入真实相机模型:相机放在 z 轴正方向、距离原点d的位置,看向原点,那么物体的投影缩放因子应该是d / (d - z)。当z趋近d(贴近镜头)时因子趋于无穷,当z为负(远离镜头)时因子趋近于 1 但小于 1,完全符合近大远小。

把d / (d - z)改写一下:1 / (1 - z/d)。对比1 / (1 + k*z),得到k = -1/d。

所以准确的写法是:

final double cameraDistance = 1000; final m = Matrix4.identity()..setEntry(3, 2, -1 / cameraDistance);

而社区里流传的+0.001,等价于把相机放在了 z 轴负方向距离 1000 的位置,这时z越大反而显得越小。视觉上会出现一种“反向透视”:靠近你的那一侧被压扁,远离你的那一侧被放大。有些暗黑风格的设计确实会故意这么干,营造一种压迫感,但如果你想要的是正常的“近大远小”,符号必须是负的。

注意:符号写反了不会报错,也不会崩,它只是“看起来有点怪”。这是最阴险的一类问题,建议在代码里写死注释,明确这台虚拟相机在+z方向、距离cameraDistance。

2.3 相机距离取多少,给一组可以照着抄的数

cameraDistance决定了透视的“强度”。数值越小,镜头越广角,边缘变形越夸张;数值越大,越接近正交投影,看起来越平。

我一般按卡片宽度来定基准。假设卡片宽度W = 240,旋转角度 55°,那么卡片边缘的 z 位移大约是(W/2) * sin(55°) ≈ 98,整张卡片的 z 跨度接近 200。相机距离至少要在这个量级之上若干倍,透视才不至于畸变。

相机距离相对卡片宽度的倍数视觉感受建议用途
400约 1.7 倍极强透视,边缘拉伸明显大屏展示、单卡铺满
700约 3 倍明显广角,纵深感强主视觉轮播
1000约 4 倍温和自然,最像“实物摆放”通用画廊,推荐起点
1600约 6.7 倍接近正交,纵深感弱商务风格、信息密集
3000 以上12 倍以上基本看不出透视只想要旋转不要纵深

我自己的默认值是 1000,然后用W / 1000的比例去微调。换成大屏平板,如果卡片也跟着变宽,这个距离要同步放大,否则透视会突然变得很猛。所以代码里别把 1000 写死,用cardWidth * 4.2这类相对值更稳。


3. 把滚动进度变成 3D 姿态

3.1 拿到连续的滚动进度,而不是离散的页码

如果只用PageView的onPageChanged,你拿到的是0, 1, 2这种整数,动画会一格一格跳,完全没有丝滑感。真正的做法是监听PageController的连续位置。

final _controller = PageController(viewportFraction: 0.72); double _page = 0; @override void initState() { super.initState(); _controller.addListener(() { final p = _controller.page; if (p != null && p != _page) { setState(() => _page = p); } }); }

controller.page是一个double?,在控制器还没 attach 到可滚动组件时会返回null,必须判空。另外它会随着滚动持续变化,直接setState会导致整棵子树重建,卡片多了就掉帧。后面性能那节会讲替代方案。

拿到_page之后,每张卡片的相对位置就好算了:

final double offset = (index - _page).clamp(-3.0, 3.0);

offset = 0表示正好在中间,offset = -1表示在左边一张,+1在右边一张。用clamp限制范围,是因为屏幕外很远的卡片即使算出巨大的角度也没有意义,还会带来无谓的计算。限制到 ±3 足够覆盖大部分viewportFraction的取值。

3.2 角度、缩放、位移、透明度的映射公式

有了offset,剩下就是四条映射曲线。这里我直接给出一组在真机上反复调过的经验值:

final double t = offset.abs().clamp(0.0, 1.0); // 归一化距离 final double angle = offset.clamp(-1.0, 1.0) * 55 * pi / 180; // 最大 55 度 final double scale = 1.0 - 0.18 * t; // 侧卡缩到 0.82 final double translateX = offset.clamp(-1.5, 1.5) * -26.0; // 往中间收 final double opacity = 1.0 - 0.45 * t; // 侧卡变淡

几个参数的选择理由值得说一下。

旋转角度 55°是一个甜点值。低于 40°,侧边卡片几乎还是正面朝你,纵深感出不来;高于 70°,卡片在视觉上被压得非常窄,用户根本看不清侧边的图片,浏览效率直线下降。50° 到 60° 之间是大多数封面流的舒适区。

缩放 0.18 的衰减是为了强化“后退”的感觉。纯靠透视其实已经有一点缩放了,但那个缩放不够强烈,加上额外的 scale 会让层次更分明。但衰减不能太大,超过 0.3 侧卡就小得像缩略图,会破坏整体的构图平衡。

平移 -26的作用是把侧边卡片往中间“挤”一点。因为透视会让侧卡往外飘,加一点向内的位移可以抵消这种飘,让三张卡片的视觉重心更紧凑。注意这里用的是负数乘以 offset,左边卡offset为负,位移为正,确实向右;右边卡位移为负,向左,方向正确。

透明度衰减 0.45是可选项。加上之后远处的卡片会淡下去,焦点更明确;但也有人觉得这样“脏”,喜欢全部保持不透明,只靠缩放和旋转区分层次。我通常做两个版本让设计同学挑。

3.3 层叠顺序和裁剪,两个必须提前处理的问题

层叠顺序。中间那张卡片必须盖在侧边卡片上面,否则会出现“右边卡片压住中间卡片一条边”的诡异画面。用PageView时它内部的RenderSliverFillViewport是按索引顺序绘制的,后面的索引会画在上面,所以右边那张永远压在中间那张上面。

解决办法有两个。一个是把中间卡片用Stack单独提出来,但这样手势就断了。另一个更轻便的方法是:给中间卡片加一点点向外的位移,让它“探出来”。我一般给offset接近 0 的卡片额外补偿+2px的 z 轴位移(translate(0, 0, 2)),依靠 Flutter 在 3D 变换后仍按绘制顺序合成的事实,视觉上能缓解大部分穿帮。

真正干净的解法是自己用Stack配合排序,但那意味着放弃PageView的手势,回到第三种方案。我的经验是:在 0.7 左右的 viewportFraction 下,穿帮非常轻微,大多数设计可以接受;如果设计对这一点很介意,再考虑换方案。

裁剪。这是新手最容易卡住的地方:卡片一转起来,边角被切掉了。原因是Transform并不会改变父组件给它分配的布局尺寸,它只是“画歪了”。如果父级的尺寸刚好等于卡片尺寸,旋转后的角就会超出边界,被父级的裁剪规则切掉。

处理办法是让卡片本身比槽位小一圈。PageView的一个 item 占屏幕宽度的viewportFraction,我们在这个 item 内部用Center加FractionallySizedBox把真实卡片缩小到槽位的 85% 左右,留出的空白就是旋转的“活动空间”。

PageView.builder( controller: _controller, clipBehavior: Clip.none, // 尽量放开滚动视图自身的裁剪 itemCount: images.length, itemBuilder: (context, index) { return Center( child: FractionallySizedBox( widthFactor: 0.86, child: _buildCard(index), ), ); }, )

clipBehavior: Clip.none能放开一部分,但PageView内部的 viewport 有时候仍然会裁,实测下来留白法才是根本解。先留白,再考虑 clipBehavior,顺序别反了。


4. 完整实现,从骨架到能跑起来

4.1 布局骨架和卡片结构

整个页面结构我习惯拆成三层:页面骨架、滚动视图、卡片。卡片内部再拆成“形状层”和“图片层”,形状层负责圆角、阴影、边框,图片层负责加载和裁剪。这样拆的好处是以后想换图片加载库,只动一层。

class GalleryPage extends StatefulWidget { const GalleryPage({super.key, required this.images}); final List<String> images; @override State<GalleryPage> createState() => _GalleryPageState(); } class _GalleryPageState extends State<GalleryPage> { late final PageController _controller; double _page = 0; @override void initState() { super.initState(); _controller = PageController(viewportFraction: 0.72, initialPage: 0); _controller.addListener(_onScroll); } void _onScroll() { final p = _controller.page; if (p == null) return; if ((p - _page).abs() < 0.001) return; // 过滤无效更新 _page = p; // 这里先不 setState,见性能章节 _rebuild.value++; } @override void dispose() { _controller.removeListener(_onScroll); _controller.dispose(); super.dispose(); } // ... }

_rebuild是一个ValueNotifier<int>,配合ValueListenableBuilder使用,作用是把重建范围收敛到卡片那一小块,而不是整个页面。这个改动在图片数量上到 20 张以上时效果非常明显。

4.2 核心变换函数,可以直接抄走

把矩阵计算抽成一个纯函数,好处是方便单测、方便复用、方便对比参数。

Matrix4 buildCardTransform({ required double offset, required double cardWidth, double maxAngleDeg = 55, double scaleDrop = 0.18, double pushIn = 26, }) { final double camera = cardWidth * 4.2; // 相机距离跟着卡片宽度走 final double t = offset.abs().clamp(0.0, 1.0); final Matrix4 m = Matrix4.identity() ..setEntry(3, 2, -1 / camera) // 透视,注意负号 ..rotateY(-offset.clamp(-1.0, 1.0) * maxAngleDeg * pi / 180) ..scale(1.0 - scaleDrop * t) ..translate(offset.clamp(-1.5, 1.5) * -pushIn, 0.0, 0.0); return m; }

rotateY的符号要注意:offset为正(卡片在右边)时,我希望它顺时针转、朝向观众,在 Flutter 的坐标系里rotateY正值是让右侧向后转,所以要用负号。这个符号反了的话,效果是“卡片向内卷”,看起来像翻书翻反了,非常别扭。

卡片的 Widget 部分:

Widget _buildCard(int index) { return ValueListenableBuilder<int>( valueListenable: _rebuild, builder: (context, _, __) { final double offset = (index - _page).clamp(-3.0, 3.0); final Matrix4 m = buildCardTransform(offset: offset, cardWidth: 240); final double opacity = (1.0 - 0.45 * offset.abs()).clamp(0.35, 1.0); return Transform( transform: m, alignment: Alignment.center, // 绕卡片中心变换,必须显式写 transformHitTests: true, // 命中区域跟随变换 child: Opacity( opacity: opacity, child: ClipRRect( borderRadius: BorderRadius.circular(18), child: Image.network( widget.images[index], fit: BoxFit.cover, cacheWidth: 480, // 降低解码尺寸,见性能章节 loadingBuilder: (c, child, p) => p == null ? child : const ColoredBox(color: Color(0xFFEEEEEE)), ), ), ), ); }, ); }

alignment: Alignment.center是必须显式写的。Transform的alignment默认是null,此时变换原点在左上角,卡片会绕左上角旋转,看起来像被甩出去一样。这个问题我见过至少三个人踩,因为不报错,只是“效果不对”。

4.3 点击回中、循环滚动和手势细节

点击侧边卡片让它回到中间,这是用户的肌肉记忆,必须做。

GestureDetector( onTap: () { _controller.animateToPage( index, duration: const Duration(milliseconds: 380), curve: Curves.easeOutCubic, ); }, child: /* 卡片 */, )

时长 380ms、曲线用easeOutCubic,是我试过手感最好的一组。小于 250ms 会显得突兀,超过 500ms 用户会觉得卡顿。加速快、收尾慢的曲线比线性曲线更“贵”。

GestureDetector的onTap和PageView的水平拖动不会冲突,因为一个是 tap 一个是 drag,GestureArena会自动仲裁。但如果你在卡片里嵌了InteractiveViewer或者自己写的onPanUpdate,就会打架,这种情况下要在内层手势上做条件判断,比如只有当前卡片才允许缩放。

循环滚动方面,PageView本身不支持从最后一张滑到第一张。常见做法是把itemCount设成一个很大的数(比如images.length * 2000),然后用取模映射到真实索引:

final realIndex = index % widget.images.length;

初始页设在中间某个位置,initialPage: widget.images.length * 1000。好处是代码简洁、手感连续;代价是index会一直增长,如果卡片内部有状态(比如动画控制器),要注意别让它无限累积。

注意:循环滚动的取模写法会让_page变成一个很大的数字,offset计算时必须用index - _page而不是用真实索引,否则一进页面所有卡片角度都是乱的。

4.4 图片加载:预加载和解码尺寸

画廊类页面对图片加载特别敏感,因为它同时展示好几张图,而且是缩放大图,最容易在滑动时突然白一下。

第一件事是控制解码尺寸。原图可能是 2000px 宽的相机照片,但我们只需要在 240px 宽的卡片上显示,如果按原图解码,每张图占的内存是几十 MB 级别,滑几下就 OOM 了。cacheWidth是性价比最高的一个参数:

Image.network(url, cacheWidth: (240 * 3).round())

乘 3 是为了适配高像素密度屏幕。代价是内存,收益是滑动不卡顿。这个值不是越大越好,超过实际显示尺寸 4 倍就没有意义了。

第二件事是预加载邻近的图。用户滑到中间卡片时,左右两张的图大概率下一秒要进来,提前把它们拉下来,滑动时就不会有加载态闪烁。用precacheImage就行:

void _precacheAround(int current) { for (int d = -2; d <= 2; d++) { final i = (current + d) % widget.images.length; precacheImage(NetworkImage(widget.images[i]), context); } }

在initState之后(用WidgetsBinding.instance.addPostFrameCallback)调用一次,然后每次onPageChanged时再调。注意precacheImage在页面销毁后回调会报错,要加mounted判断,或者用ImageStreamListener手动管理。


5. 性能调优和那些只有真机才暴露的问题

5.1 掉帧的根源几乎总是整树重建

第一版写完在模拟器上滑得挺顺,一上真机就一卡一卡的。用 DevTools 的 Performance 面板一抓,问题很清楚:每一帧都在重建整个PageView的所有可见 item,包括里面所有的Imagewidget。Image重建本身不算贵,但它会触发一堆resolve和图层检查,几十次叠加起来就超预算了。

改法是把滚动进度和重建范围解绑。具体来说:PageView的 item 用const或缓存好的 widget 树,只有真正需要变的那部分(Transform和Opacity)才订阅进度变化。

// 不推荐:整个 itemBuilder 依赖 _page itemBuilder: (context, index) { final offset = index - _page; // _page 变一次,整个 item 重建 return ...; } // 推荐:itemBuilder 只依赖 index,进度订阅下沉到卡片内部 itemBuilder: (context, index) => _CardView(index: index, controller: _controller),

_CardView内部用AnimatedBuilder(animation: _controller)包住Transform,这样重建的只有一层Transform,Image和ClipRRect都可以被 Flutter 的 element 复用机制跳过。

这一步改完,我这边 20 张卡片的场景从平均 42fps 提到了稳定 60fps。

5.2 三个几乎零成本但收益明显的开关

RepaintBoundary。给每张卡片的Transform外面套一层RepaintBoundary,可以让它在重绘时不影响兄弟节点。不过要小心:如果卡片本身每帧都在变(比如Transform在变),加边界反而会让每帧多一次图层合成。正确的用法是把它加在卡片内部图片那一层,让图片的绘制结果被缓存成图层,Transform只改变这个图层的呈现位置。

Transform( transform: m, alignment: Alignment.center, child: RepaintBoundary( // 加在这里,缓存图片本身 child: ClipRRect(... child: Image.network(...)), ), )

避免不必要的透明度图层。Opacity组件在 0 到 1 之间时会触发一次离屏合成,很贵。如果只是想要视觉变淡,可以试试用ColorFiltered或者直接叠加一层半透明的黑色遮罩,代价小得多。实测在 5 张以上卡片同时存在时,把Opacity换成遮罩能省下 2 到 3 毫秒每帧。

缓存矩阵。Offset和Matrix4的计算不是免费的,尤其sin/cos。如果offset没变,就没必要重新算。用一个简单的缓存判断:

Matrix4 _cachedMatrix = Matrix4.identity(); double _cachedOffset = double.nan; Matrix4 _getMatrix(double offset, double w) { if (offset == _cachedOffset) return _cachedMatrix; _cachedOffset = offset; _cachedMatrix = buildCardTransform(offset: offset, cardWidth: w); return _cachedMatrix; }

看起来微不足道,但在滚动静止时它能把这些开销完全省掉。

5.3 视觉细节:阴影、高光和边缘处理

纯Transform出来的卡片是“纸片贴在玻璃上”的感觉,缺一点实体感。补三样东西就能明显提升质感。

第一样是环境阴影,但不要用boxShadow,因为BoxShadow是矩形阴影,卡片一旋转阴影不跟着变,会很假。我的做法是给卡片底部加一个渐变的投影层,跟着卡片的offset调整透明度和位置,模拟“卡片离桌面越来越高”的错觉。

第二样是边缘高光。在卡片的Border上做文章,用一个ShaderMask或者简单的Border.all(color: Colors.white24, width: 1),加上一层非常淡的内阴影,卡片立刻就有了厚度感。这一像素的边框是整张卡片“从平面变立体”的关键。

第三样是背景光晕。把当前卡片的主色调提取出来(PaletteGenerator那种思路),做成一层模糊的径向渐变铺在底层,滚动时跟着切换。这个效果在展示型页面上非常出片,但注意别让它跟着每帧变化,只在onPageChanged时更新即可,否则又是一笔动画开销。

实操心得:阴影和高光这类细节,在浅色背景上要减弱,在深色背景上要增强。同一套参数在白色背景上看着刚好,放到深色背景里就完全看不见了。所以别写死颜色,用Theme.of(context).brightness判断一下,或者直接用带透明度的黑白两套值。


6. 常见问题速查和排查思路

6.1 变形、穿模和闪烁问题对照表

这一类问题最多,我把遇到过的整理成表,按现象倒查原因。

现象最可能的原因排查动作处理方式
卡片绕左上角旋转,像被甩飞Transform没设alignment检查是否写了Alignment.center显式指定alignment
旋转后边角被切掉父级尺寸恰好等于卡片尺寸把卡片宽度临时缩小一半再滑槽位留白 +FractionallySizedBox
中间卡片被右边卡片压住一条边绘制顺序由索引决定滑动到中间位置截图观察中间卡片translate(0,0,2)或换 Stack 排序
透视方向反了,近小远大setEntry(3,2)符号写反把相机距离调到极大再观察改成-1 / cameraDistance
旋转方向“向内卷”rotateY的角符号与 offset 方向不一致只保留一张卡看旋转方向角度乘-offset
滑动结束有轻微回弹抖动viewportFraction与 item 宽度不成整数比打印page值看是否在整数附近震荡用animateToPage收尾,或调用_controller.page!.round()对齐
图片滑动时白闪解码尺寸过大或未预加载DevTools 看内存曲线加cacheWidth+precacheImage
点击侧栏卡片无反应transformHitTests被关掉加个Container(color: red)看命中区保持默认true,检查上层是否有IgnorePointer
高倍缩放时边缘模糊变换后图层被栅格化截图放大看边缘加RepaintBoundary,或降低变换幅度
首次进入时角度全乱controller.page为null未判空打印_page初值用initialPage初始化_page

6.2 手势冲突和边界情况

手势冲突主要出现在两种场景。

一种是卡片内部有可交互元素,比如收藏按钮、查看原图。这时候GestureDetector的onTap会和PageView的拖动共存,一般没问题,但如果你用了onHorizontalDragUpdate来自定义什么,PageView的拖动就会被抢走,表现是“卡片拖不动了”。处理办法是把内层手势的判定条件收紧,比如只在垂直方向响应。

另一种是弹窗场景。画廊点开大图后如果是全屏浮层,两套手势都要存在,需要用一个状态位控制哪一套生效,否则会出现“图片放大了还在切换卡片”的诡异行为。

还有几个边界情况值得单独提。

只有一张图时:offset永远是 0,PageView没有可滚动的空间,视觉上就是一张静态卡片。如果想让它居中显示,得单独处理宽度,否则会因为viewportFraction而偏小。

只有两张图时:因为是循环滚动,左右两侧永远是同一张,看起来会重复。这时要么关掉循环,要么在itemCount小于 3 时退化成普通分页。

从其他页面返回时:PageController的page值可能已经变了但监听器没触发,导致卡片角度对不上。稳妥做法是在didChangeDependencies里同步一次_page = _controller.hasClients ? (_controller.page ?? 0) : 0。

6.3 我给后来者的几条实在建议

调这个效果最忌讳的是一次性把参数全堆上去。我第一次做的时候,旋转、缩放、平移、透明度、模糊、投影全部打开,结果滑起来像一团蠕动的色块,根本看不出哪一项在起作用。后来改成逐个开启:先只做旋转,确认方向对;再加缩放,看层次感;再加位移,看紧凑度;最后加透明度和阴影。每加一项都滑一遍,问题出在哪一步一目了然。

第二件事是准备一组测试图。不要用纯色块测试,纯色块看不出旋转方向;也不要用风景图,风景图里没有直线,透视失真不明显。最好是方形卡片、带清晰边框、中间有明显人物的图,边框能看到旋转角度,人物能看到透视变形。我用的是几张带白边的老照片,边框一歪立刻就知道参数不对。

第三件事是在目标机型上滑够五十次。模拟器上 60fps 不代表真机流畅,尤其是有大量透明度和阴影的时候。真机滑久了还会烫,烫了会降频,降频就掉帧,这些只有在真机上待够时间才能发现。如果发现滚一段时间后开始卡,基本就是内存或图层数量的问题,回去看看RepaintBoundary和cacheWidth有没有加对。

这套东西我后来又复用到几个不同场景:一个是用在商品图册,把maxAngle降到 40°、去掉透明度,走商务风;一个是用在活动页轮播,把角度拉到 65°、加上背景光晕,走视觉冲击风。同一套buildCardTransform函数,改几个参数就能切换气质,这也是我一开始坚持把它抽成纯函数的原因。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询