做图片画廊这件事,很多人以为难点在"把图转起来",实际动手才发现,真正的麻烦在于转起来之后画面变形、掉帧、点不准、边缘裁切。我第一次接到"做一个 Flutter 自定义图片 3D 画廊"的需求时,产品只丢过来一段十几秒的参考视频,连参数都没给,剩下的全靠自己反推。那段时间我把 Matrix4 的拼装顺序来回试了不下二十遍,也在性能面板上盯着时间线一根一根地看哪一帧超了预算。这篇就把我当时从零搭这套画廊的完整思路摊开讲:怎么把"3D 感"翻译成一组可调的数值,透视矩阵里那个最容易被写错的符号,滚动偏移量怎么映射成每张卡片的姿态,以及为什么大多数人加了一堆 RepaintBoundary 还是卡。适合已经会写 Flutter 页面、想往视觉效果方向再走一步的人,也适合单纯想抄一套能跑的 3D 画廊骨架的人。
1. 先别急着敲 Matrix4:把"3D 感"拆成四个能调的数值
1.1 画廊的立体感,其实是三件事叠出来的
很多人一上来就找Transform和Matrix4,代码能跑,但怎么看都"平"。问题不在变换本身,而在于只做了旋转,没做排布和明暗。我把参考视频逐帧截图之后,发现它至少叠了三层东西:第一层是透视投影,离视点近的卡片大、远的卡片小,这是"立体"的物理来源;第二层是纵深排布,卡片不是排在一个平面上,而是沿着 Z 轴有前后错位,形成一种向内收的走廊感;第三层是明暗与透明度衰减,越靠边的卡片越暗、越透明,眼睛会自动把暗的东西判读成"远"。
第三层最容易被忽略,但它性价比最高。哪怕你完全不做透视,只是给两侧的卡片加一层黑色遮罩做透明度衰减,观感上也会立刻"深"下去。原因是人眼判断距离时,明暗线索的权重比几何线索更高——这也是为什么很多 2.5D 的界面设计只靠渐变遮罩就能骗过大脑。所以我在正式搭结构之前,会先把这三层的参数各自列出来,分别调,调好再合并,避免所有变量搅在一起,调一个崩三个。
具体到参数,我会先确定四个数字:旋转上限、透视强度、卡片间距系数、边缘缩放比。这四个数字定下来,整个画廊的调性基本就定了,后面所有代码都是围绕它们做插值。
1.2 四个核心参数各自的取值范围和手感
旋转上限指的是最边上那张卡片绕 Y 轴转过的最大角度。这个值太小(比如 20 度)会显得没劲,太大(比如 70 度)会让卡片的横向尺寸被严重压缩,图片内容被挤成一条,读者根本看不清缩略图。我实测下来 40 到 55 度之间是最舒服的区间,超过 60 度就必须配合更强的纵深位移,否则卡片会像被压扁的纸片。
透视强度决定了"近大远小"有多猛。在 Flutter 里它体现为透视矩阵里那个小数,取值区间一般是 0.0008 到 0.003。数值越大,同样角度下卡片的远近透视差越夸张,画面越"广角";数值越小越接近正交投影,看起来像等距视角的工程图。做画廊我通常落在 0.0015 附近,这个值能给到明显的纵深感,又不至于让卡片边缘拉伸到失真。
卡片间距系数是相邻两张卡片中心点之间的距离,通常用卡片宽度的倍数表示。这个值如果等于 1.0,卡片正好首尾相接;小于 1.0 会互相重叠。做 3D 画廊一般会取 0.5 到 0.65,让相邻卡片有部分交叠,视觉上更紧凑。但要注意,一旦卡片绕 Y 轴旋转,它在水平方向上的投影宽度会变窄,重叠区域会比纸面计算的大,所以这个系数不能压得太狠,否则两张卡的边角会互相穿插,出现难看的锯齿。
边缘缩放比是最外侧卡片相对中心卡片的缩放。取 0.7 到 0.8 比较稳。它和透视是两种不同的"变小":透视是越远越小,缩放是无差别缩小。两者叠在一起才有层次,只靠透视的话,中心卡片会显得特别突兀。
| 参数 | 我常用的值 | 太小会怎样 | 太大会怎样 |
|---|---|---|---|
| 旋转上限 | 45° 到 50° | 平平的没立体感 | 图片被压成一条,认不出内容 |
| 透视强度 | 0.0015 | 像正交视图,假 3D | 边缘拉伸严重,像素感明显 |
| 间距系数 | 0.58 | 卡片互相穿插,边缘打架 | 排得太散,失去画廊的紧凑感 |
| 边缘缩放 | 0.75 | 层次不明显 | 中心卡片突兀,视觉断层 |
1.3 用一张手绘草图锁定坐标方向,能省掉一半返工
这一步听起来很土,但我强烈建议你在写代码之前先拿张纸画一个俯视图:屏幕在正上方,视线垂直往下看,中间那张卡片是平的,左右两侧的卡片向屏幕"内侧"倾倒。画完之后你会立刻问自己一个问题——左边的卡片是往左倒还是往右倒?答案取决于你想要"两侧向观众张开"还是"两侧向深处收拢",这两种是完全不同的观感。
前者像翻开的一本书,卡片朝观众打开,视觉上更有冲击力;后者像一条向内延伸的走廊,空间感更强但视觉焦点容易散。我做内容类的画廊一般选后者,因为收拢的方向能把视线自然引向中间那张,符合"当前选中"的语义。
定好方向之后,你还要确定旋转的正负号跟左右方向的对应关系。Flutter 里rotateY传正值和负值,画面上卡片转动的方向是相反的,这个符号一旦搞混,整条曲线会反过来,左边的卡往右倒、右边的往左倒,画面会变得非常诡异。我的习惯是在代码里写死一个常量const double kAxisSign = -1.0;,所有旋转都乘这个常量,将来要翻转整条曲线只改一处。
草图上还要标出三件事:中心卡片的判定区域(宽度占比)、透明度的衰减起点、以及卡片被裁切的边界。这三样如果没提前想清楚,后面调参数会陷入"改了透明度又发现裁切"的死循环。
2. Matrix4 的拼装顺序:一个符号写错,整块画面就废
2.1 setEntry(3, 2, x) 改的到底是个什么位置
Flutter 的Matrix4内部是 4 乘 4 的矩阵,用列主序存储。setEntry(row, col, value)里的 row 是行号、col 是列号,写入的是"第 row 行第 col 列"的元素。透视投影的关键就在第 3 行第 2 列这个位置上——注意这里的行列是从 0 开始数的,所以它是第四行第三列。
这个位置负责把顶点的 Z 分量按比例混进 W 分量里。渲染管线最终会用 W 去做透视除法,W 越大,投影后的坐标越小,视觉上就是"越远越小"。所以这个位置上的值本质上是一个"焦距的倒数",数值越大,焦距越短,广角效果越强。
符号问题必须说清楚:如果把这里的值写成负数,透视方向会反过来,近处的卡片反而变小、远处的反而变大,整个画面像被吸进一个漏斗里。我第一次就是这么写的,盯着屏幕看了十几分钟才反应过来是符号问题。所以看到画面"越是中心越小"这种反直觉的现象,第一反应就该去检查这个 entry。
// 正确方向:透视项取正,近大远小 final matrix = Matrix4.identity()..setEntry(3, 2, 0.0015);2.2 先旋转还是先投影,结果完全不是一回事
矩阵乘法不满足交换律,这一条在纸面上人人都会背,落到代码里就经常忘。Matrix4的链式调用是一个不断左乘的过程:你每调用一次变换方法,都是把新矩阵乘在已有矩阵的左边。也就是说,最终的结果是"最后一次调用的变换最先作用于顶点"。
Matrix4.identity() ..setEntry(3, 2, 0.0015) // 先写透视 ..rotateY(angle); // 再写旋转上面这段的等效表达式是P * R,顶点 v 先被 R 旋转,再被 P 投影,这是我们想要的效果——先把卡片摆到某个角度,再从这个固定视点去看它。如果把两行顺序对调,等效表达式变成R * P,顶点先被投影再被旋转,透视就不是从视点发出的,画面会出现非对称的拉伸,一侧被拉长、另一侧被压扁,而且这种变形随着角度变化会跳变,很难用参数调回来。
我在项目里立的规矩是:所有涉及透视的矩阵,透视项永远写在链的第一行,后面跟旋转、再跟平移、最后跟缩放。这个顺序写多了就成肌肉记忆,也不太会出错。
2.3 一份可以直接抄走的卡片变换函数
把上面的东西收敛成一个纯函数,输入是卡片相对视口中心的归一化距离,输出是完整的变换矩阵。这种"输入一个标量、输出一个矩阵"的写法好处是无状态,滚动到哪一帧都能直接算出来,配合AnimatedBuilder使用非常顺。
import 'dart:math' as math; import 'dart:ui' show lerpDouble; import 'package:flutter/material.dart'; Matrix4 buildCardTransform({ required double t, // -1 到 1,卡片中心相对视口中心的偏移比例 double maxAngle = 0.82, // 弧度,约 47 度 double perspective = 0.0015, double minScale = 0.75, double depth = 70.0, // 边缘卡片沿 Z 轴后退的距离 }) { final clamped = t.clamp(-1.0, 1.0); final angle = clamped * maxAngle; final scale = lerpDouble(1.0, minScale, clamped.abs())!; final dz = -clamped.abs() * depth; return Matrix4.identity() ..setEntry(3, 2, perspective) ..rotateY(-angle) // 负号决定左右倾倒方向 ..translate(0.0, 0.0, dz) ..scale(scale, scale); }注意..scale(scale, scale)这种两参数写法,第三个 z 方向默认不缩放。如果你希望深度方向也跟着缩,必须显式传三个参数,否则会出现"透视看着对但纵深位移没生效"的错觉,其实是缩放把位移抵消掉了一部分。
还有一个必写参数是alignment: Alignment.center。Transform的默认旋转原点是左上角,如果不设置,卡片会绕着左上角旋转,画面看起来像被甩出屏幕。这个坑我踩过一次,排查了半天以为是矩阵算错了,实际上是原点的问题。
3. 滚动状态机:从 offset 到每张卡片的姿态映射
3.1 为什么最后没用 PageView
一开始我很自然地选了PageView.builder配viewportFraction,因为它自带吸附、自带惯性,省事。但做到一半就卡住了:PageView只暴露"当前页和页内进度"这个维度的信息,而我需要的是每张卡片相对屏幕中心的连续距离。用PageController.page只能算出相邻两三张的偏移,再往外就取不到了,而那些正好是需要做淡出和大幅旋转的卡片。
第二个问题是无限循环。PageView做无限轮播的常规做法是把 itemCount 设成一个很大的数然后取模,但这会让索引和真实数据对不上,点击回调里还得反算一次,越写越别扭。
最后我的方案是:横向的ListView.builder配一个ScrollController,itemCount 设成一个足够大的奇数(比如 20001),初始滚动位置定位到正中间,数据索引用取模映射。这样每一张卡片的屏幕位置都能通过index * stride - controller.offset精确算出来,无论屏幕上有多少张卡都统一处理。代价是要自己实现吸附和惯性,但代码量其实不大,而且可控性高得多。
3.2 把"距离"归一化成 -1 到 1 的标量
整个画廊的核心就一行计算:卡片中心在屏幕上的横坐标,减去视口中心,再除以一个基准距离,得到一个归一化标量。这个标量我记作 t,t 等于 0 表示卡片正在屏幕正中,t 等于正负 1 表示卡片刚好在视口边缘。
double computeT({ required int index, required double offset, required double stride, required double cardWidth, required double viewportWidth, }) { final cardCenterInContent = index * stride + cardWidth / 2.0; final cardCenterOnScreen = cardCenterInContent - offset; final delta = cardCenterOnScreen - viewportWidth / 2.0; return delta / (viewportWidth / 2.0); }拿到 t 之后,一切都有了解释:旋转角度等于t * maxAngle,缩放等于lerp(1.0, minScale, |t|),透明度等于lerp(1.0, minOpacity, |t|),Z 轴位移等于-|t| * depth。所有视觉变化都从这一个标量里长出来,这意味着整个画廊的"运动曲线"是统一的,不会出现某张卡片转得快、某张转得慢的违和感。
这里有个细节值得说:stride和viewportWidth的关系决定了屏幕上一眼能看到几张卡片。如果stride是viewportWidth的 0.35 倍,那大概能看到三张;如果是 0.25 倍,能看到四到五张。我一般让宽屏(平板)用更小的系数,让卡片显得不那么空旷。
3.3 吸附、惯性、边界这三件事要分开处理
滚动行为实际上由三套机制叠加:惯性来自滚动物理引擎,吸附来自你在滚动结束时主动校正,边界来自列表本身的maxScrollExtent。这三件事如果混在一起写,很容易互相打架,比如吸附动画还没结束就被一次新的触摸打断,或者边界回弹把吸附的目标位置覆盖掉。
我的做法是给吸附加一个"进行中"的标志位,动画期间忽略新的吸附请求;同时用NotificationListener<ScrollEndNotification>而不是ScrollController.addListener来触发吸附,因为前者只在滚动真正停下来时触发一次,后者在每一帧都会回调。
void _onScrollEnd(ScrollEndNotification notification) { if (_snapping) return; final viewportCenter = _viewportWidth / 2.0; final rawIndex = (controller.offset + viewportCenter - _cardWidth / 2.0) / _stride; final targetIndex = rawIndex.round(); final targetOffset = targetIndex * _stride + _cardWidth / 2.0 - viewportCenter; final clamped = targetOffset.clamp( controller.position.minScrollExtent, controller.position.maxScrollExtent, ); if ((controller.offset - clamped).abs() < 0.5) return; _snapping = true; controller .animateTo(clamped, duration: const Duration(milliseconds: 300), curve: Curves.easeOutCubic) .whenComplete(() => _snapping = false); }那个"距离小于 0.5 就跳过"的判断很关键,少了它会出现动画自己触发自己、无限抖动的现象,因为吸附动画结束本身也会产生一个ScrollEndNotification。
阈值判断还有另一层含义:如果用户只是轻轻拨了一下、滚动距离不到半张卡片,按上面的逻辑它会被拉回去。想让它"拨过去就翻页",得把round()换成按方向和速度判断,速度超过某个值就往前翻一格。速度可以从position.activity?.velocity里取。
3.4 手感调参:曲线比时长更重要
吸附动画的观感,八成的功劳在曲线上,两成在时长上。我试过Curves.easeOut、Curves.easeOutCubic、Curves.easeOutQuint和自定义的弹簧曲线,最后落在easeOutCubic上,300 毫秒。easeOut收尾太软,会有"滑不到位再补一下"的拖沓感;easeOutQuint收得太急,像被吸铁石拽走。300 毫秒是我在不同尺寸设备上试出来的折中值,手机上感觉利落,平板上也不显得突兀。
还有一个容易被忽略的点是两端平台的滚动物理差异。iOS 默认是带回弹的弹性物理,Android 默认是钳制物理。如果你在两端都用同一套吸附逻辑,iOS 上会多出一次回弹,看起来像抖了一下。我的处理是显式指定physics: const ClampingScrollPhysics(),让两端行为一致,把"手感"这件事完全收到自己手里。
4. 掉帧排查:拖慢画廊的从来不是 3D 变换本身
4.1 AnimatedBuilder 的 child 参数,是整个性能优化的分水岭
3D 变换只是给顶点乘了一个矩阵,GPU 做这件事的成本低到可以忽略。真正让画廊卡的是每一帧都在重建整棵子树——尤其是重建Image。如果你把图片 widget 写在builder里面,每帧滚动都会重新构造一次Imagewidget,虽然图片本身有缓存不会重新下载,但 widget 的 diff、ImageProvider的解析、解码结果的绑定这一整套流程都要重跑一遍,卡顿就是这么来的。
正确的写法是把图片放到AnimatedBuilder的child参数里,这个 child 只会构建一次,之后每帧复用的都是同一个实例:
AnimatedBuilder( animation: controller, child: ClipRRect( borderRadius: BorderRadius.circular(16), child: Image.network( url, fit: BoxFit.cover, cacheWidth: (cardWidth * dpr).round(), gaplessPlayback: true, ), ), builder: (context, child) { final t = computeT(...); return Transform( alignment: Alignment.center, transform: buildCardTransform(t: t), filterQuality: FilterQuality.medium, child: Opacity( opacity: lerpDouble(1.0, 0.45, t.abs().clamp(0.0, 1.0))!, child: child, ), ); }, )这一个小改动,在我那个有二十多张图的画廊上直接把帧率从中位数四十多拉到了满帧。切身体会是:只要你在滚动的 builder 里看到Image、Text之外的复杂子树,就该怀疑它是不是被无谓地重建了。
filterQuality参数也值得单独提一句。3D 变换后的图片在采样时会出现锯齿或者模糊,FilterQuality.medium是个不错的平衡点,high更清晰但开销更大,none会在小角度旋转时出现明显的马赛克。我一般只在卡片数量少(十张以内)的时候用high。
4.2 RepaintBoundary 包错层,等于没包
网上关于RepaintBoundary的建议很多,但真正落地时最容易犯的错是包的位置不对。它的作用是把自己的子树隔离成一个独立的图层,父层的重绘不会传导进来。所以你要包的是内容稳定、但会被频繁变换的那一层。
具体到画廊,我包的是Transform里面的那个图片子树,而不是包整个Transform。原因很简单:Transform自己在每帧都变,包住它等于每帧都要重绘整个隔离层,反而多了一层合成开销。而里面的图片内容是静止的,隔离出来之后每帧只需要做一次合成变换。
RepaintBoundary( child: ClipRRect(... Image ...), )另外,包太多也会翻车。我见过项目里在列表的每一个 item 上都包一层RepaintBoundary,结果屏幕上同时存在十几个独立图层,GPU 的合成开销反而上去了。经验值是同屏可见的卡片在五到八张之间时,每张包一层是划算的;超过十几张就得重新权衡。
4.3 图片解码尺寸没控住,内存和带宽会一起爆
这条和 3D 无关,但在画廊场景下杀伤力最大。画廊里每张卡片实际显示宽度可能只有一百多逻辑像素,但原图是几千像素的高清图,直接解码出来就是几十兆的内存占用。同时存在十几张这样的解码结果,内存曲线会非常难看,严重时会被系统直接回收掉页面。
两个手段配合使用:一是cacheWidth/cacheHeight按实际显示尺寸乘以设备像素比来设,别拍脑袋给个固定值;二是服务端如果能出缩略图,优先拿缩略图,客户端再放大一点点,比拿原图缩小要省得多。
final dpr = MediaQuery.of(context).devicePixelRatio; final targetWidth = (cardWidth * dpr).round(); // 注意 cacheWidth 传的是物理像素,不是逻辑像素还有个容易忽略的点是gaplessPlayback: true。滚动过程中如果 widget 被复用到另一张图,不加这个参数会先闪一下空白再出现新图,视觉上像在闪烁。加上之后会保留上一帧的图直到新图解码完成,过渡平滑很多。
4.4 真正定位卡顿的那条排查链路
不看时间线全靠猜是没戏的。我的标准流程是:打开性能面板,先把"帧渲染时间"和"帧构建时间"分开看。如果构建时间超了,问题在 Dart 侧,往 widget 重建、同步计算、无谓的 setState 方向查;如果构建没问题但渲染时间超了,问题在光栅化,往图层数量、ClipPath、阴影、模糊这些方向查。
有一次我遇到的症状是"只有滚动到最左边那张卡时掉帧",构建时间是正常的。后来在渲染层看到,那张卡的Opacity值刚好落在 0.4 以下,而低于某个阈值时会有额外的图层合成操作。把透明度下限从 0.28 提到 0.45 之后,问题消失。这个结论不一定普适,但排查路径是有效的:先分清是构建还是渲染,再往下钻,别一上来就怀疑 3D 变换。
5. 画面质感:阴影、倒影、边缘渐隐这几处细节
5.1 阴影要跟着透视一起变形,否则会"浮"
卡片本身转了,阴影如果还是原来的矩形,观感会非常假,像卡片飘在阴影上面。解决办法是给阴影也应用同一个变换——把整个带boxShadow的装饰容器放在Transform内部,而不是放在外面。
Transform( alignment: Alignment.center, transform: buildCardTransform(t: t), child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), boxShadow: const [ BoxShadow( color: Color(0x33000000), blurRadius: 24, spreadRadius: -6, offset: Offset(0, 12), ), ], ), child: child, ), )blurRadius我用 24、spreadRadius用负值,是为了让阴影比卡片略小一圈,看起来更收敛。正值的 spread 会让阴影边缘溢出来,在深色背景上尤其脏。还有个细节是阴影的偏移方向应该和卡片后退的方向一致,也就是越往深处的卡片,阴影的偏移量也应该跟着变小,否则远处的卡片会拖一条很长的影子,显得很轻浮。
严格来说,真正物理正确的做法是用Material的 elevation 配合PhysicalModel,但那套的光照模型是固定的,没法跟着透视变化。画廊这种场景,手写BoxShadow反而更可控。
5.2 倒影三步走:翻转、衰减、裁切
倒影能给画廊加不少分,做起来也不难,本质就是复制一份内容,垂直翻转,压到下方,再用渐变把它淡出。三步分别是:Transform.scale(scaleY: -1)做镜像,ShaderMask或DecoratedBox做渐变衰减,ClipRect做高度裁切。
ClipRect( child: ShaderMask( shaderCallback: (rect) => const LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Color(0x66000000), Color(0x00000000)], ).createShader(rect), blendMode: BlendMode.dstIn, child: Transform( alignment: Alignment.center, transform: Matrix4.identity()..scale(1.0, -1.0, 1.0), child: child, ), ), )这里有个坑:倒影本身不要再套一层 3D 变换,否则会和主体的透视打架,出现两个不一致的灭点。正确的做法是把主卡片的变换结果作为整体,在外面统一处理倒影。
倒影的高度我一般压到主卡片的三分之一左右,太长会显得画面很重。透明度起点给 0.4 上下,太低看不见,太高会喧宾夺主。
5.3 背面镜像和边缘裁切
卡片转到接近 90 度的时候会出现背面。Transform默认是双面可见的,所以你会看到一张镜像反转的图片,非常出戏。较新的 Flutter 版本里Transform提供了backfaceVisible参数,直接设为false就能让背面自动剔除。
如果你的版本没这个参数,就用透明度硬切——当|angle|超过 90 度时把整体透明度设为 0。注意判断要用cos(angle) < 0而不是angle > 1.57,因为角度可能为负,用余弦的正负判断更稳妥。
边缘裁切是另一个高频问题。ListView默认clipBehavior是Clip.hardEdge,当卡片的变换超出 item 的原始边界时会被切掉一块,表现是卡片边缘出现一条突兀的直线。把列表的clipBehavior改成Clip.none能解决一半,另一半要看外层容器是不是也设了裁剪——很多时候外层套了个ClipRRect做圆角,实际裁切是它做的,改列表没用。
6. 点不准的卡片:3D 变换之后的命中测试
6.1 Transform 默认是怎么处理点击的
Transform默认transformHitTests是true,意思是点击事件会先被逆矩阵转换回变换前的坐标系,再做常规的命中判断。理论上这是对的,但 3D 变换的逆矩阵在透视除法那一步会有精度问题,尤其当卡片接近 90 度、W 分量接近零的时候,逆矩阵会变得非常不稳定,导致点击区域偏移甚至完全错位。
更常见的现象是重叠区域打架:两张卡片在屏幕上交叠,点击交叠处的时候,上层卡片不一定能拿到事件,因为它自己实际的命中矩形可能已经不是你以为的那个位置了。
6.2 三种修复方案,按场景选
第一种是把GestureDetector放在Transform的内部,也就是作为卡片内容的子节点。这样手势区域在变换前就是规整的矩形,逆变换之后依然规整。代价是交叠区域的优先级由绘制顺序决定,后画的在前。
第二种是把transformHitTests设为false,然后用外层的Stack手动做命中判断。这种适合"点击只认中间那张卡片"的交互——反正只有一张有效,直接在最外层拦一次点击,判断当前 t 值最接近 0 的索引,命中效率反而更高。
第三种是把每张卡片的点击区域用一个不参与 3D 变换的透明Positioned覆盖层来承载,覆盖层的位置根据卡片的屏幕投影计算。这种做法最精确,但维护成本也最高,卡片参数一改就得同步改覆盖层。
我最后选的是第二种,因为画廊的交互语义本来就是"点中间那张进详情",边缘卡片的点击价值很低,不如直接忽略,逻辑还简单。
GestureDetector( behavior: HitTestBehavior.opaque, onTap: () { final centerIndex = _currentCenterIndex(); onCardTap(centerIndex); }, child: /* 整个画廊 */, )6.3 键盘焦点与无障碍,别等到上线才发现
画廊在桌面端跑的时候,用户会本能地按左右方向键。用Shortcuts配Actions或者Focus加onKeyEvent都能实现,核心就是把方向键映射成"改变当前索引 + 调用一次吸附动画"。
无障碍方面,每张卡片需要Semantics包裹,给出图片的描述和"第几张,共几张"的上下文。用excludeSemantics: true关掉内部图片自带的语义节点,避免读屏器重复播报。另外,被遮挡在边缘的卡片建议从语义树里剔除,因为用户根本没法聚焦到它们。
还有一个容易被忽略的点:当系统开启了"减少动画"(MediaQuery.disableAnimations)时,应该把吸附动画的时长压到接近零,直接跳转。这个判断在MaterialApp层已经有了,但需要你主动去读,框架不会自动帮你把自定义动画降级。
7. 一套能直接起步的参数,和几个踩过的坑
7.1 起步参数表
下面这套是我在多个项目里反复调过之后沉淀下来的起始值,可以直接拿来用,再根据具体图片比例微调。
| 项目 | 建议值 | 备注 |
|---|---|---|
| 卡片宽度 | 视口宽度 × 0.58 | 手机上约 220 逻辑像素 |
| 卡片宽高比 | 3:4 或 4:5 | 竖图为主的内容更协调 |
| 间距系数 stride | 卡片宽度 × 0.58 | 相邻卡片约重叠四成 |
| 旋转上限 | 0.82 弧度(约 47 度) | 超过 1.0 就需要重新验证 |
| 透视强度 | 0.0015 | 平板可降到 0.001 |
| 边缘缩放 | 0.75 | 与透视叠加 |
| Z 轴后退 | 70 逻辑像素 | 和透视强度联动调 |
| 边缘透明度 | 0.45 | 低于 0.4 有额外合成开销 |
| 吸附时长 | 300 毫秒 | 曲线 easeOutCubic |
| 可见卡片数 | 5 到 7 张 | 超过 9 张要重新评估图层开销 |
调参的次序也有讲究:先定卡片尺寸和间距,再调旋转和透视,最后才是透明度和阴影。原因是前两者决定了几何骨架,后两者只是表面修饰。顺序反了会出现"修饰得很漂亮但骨架是歪的"的尴尬局面,改起来牵连一大片。
7.2 我踩过的几个坑,和它们真正的成因
第一个坑是"卡片绕左上角转"。症状是整排卡片像被一只手从左上角甩出去,越往边越歪。原因就是没设alignment。Transform的旋转原点默认是左上角,在 2D 旋转里这个坑还不明显,到了 3D 就非常刺眼。养成写alignment: Alignment.center的习惯,能省掉大量排查时间。
第二个坑是"图越滚越糊"。这个最迷惑,因为你盯着代码看半天找不到问题。后来发现是cacheWidth按固定值设的,大概是 200 逻辑像素,但在某些设备上卡片实际渲染宽度到了 260,解码出来的位图被放大,自然发虚。正确做法是按cardWidth * devicePixelRatio动态计算,并且在屏幕旋转、分屏变化时重新算一次。
第三个坑是"吸附时抽搐"。表现是松手之后画面抖两下才停稳。根因是我同时开了NotificationListener<ScrollEndNotification>吸附和列表自带的物理回弹,两套机制抢方向盘。加上标志位并且显式指定ClampingScrollPhysics之后就消失了。
第四个坑是"滚动到边缘白屏"。列表滚到两端时,超出范围的卡片位置算出来的 t 值是 ±1.5 甚至更大,如果没做 clamp,缩放和位移会被拉到一个荒谬的值,卡片直接缩成不可见的点,看起来像白屏。clamp 一定要做,而且是每个参数单独 clamp,不能只 clamp 最终的 t。
第五个坑是"热重载之后图片消失"。这是热重载和图片缓存的经典冲突,不是业务代码的问题。加gaplessPlayback: true能缓解,但根治还是得靠完整重启。
7.3 还能往下扩展的方向
这套骨架搭好之后,往上加东西的空间挺大。我自己后续做过两个延伸版本:一个是把滚动方向从水平换成弧形,参数从"水平距离"换成"极坐标角度",代码结构基本没变,因为核心还是那个归一化标量;另一个是给卡片加上基于设备陀螺仪的轻微视差,让整排卡片随着设备倾斜有几度的整体偏移,观感立刻高级一截,代价是要处理传感器权限和节流。
还有一个值得试的是把图片换成实时内容,比如视频缩略图或者可交互的小组件。这种情况下性能余量会小很多,得把同屏可见数量压到五张以内,并且考虑用Texture或者平台视图来降低开销。
做这套东西最深的体会是:3D 效果的难度从来不在数学推导上,公式网上到处都是。难的是把所有参数收敛成一个手感,以及在各个尺寸的设备上都保持这个手感。我调那套旋转曲线的时候,在手机、平板、折叠屏三种形态上分别试了不下十组数值,最后选的那个点不是任何单一设备上最惊艳的,而是三种设备上都不出错的那个。视觉这类需求,能做到"到处都不难看",比"某处特别惊艳"要难得多,也值钱得多。