Vibe Coding 实战:Flutter 自定义路径布局

之前看到一个布局效果:手指在屏幕上画条曲线,View 就沿着曲线一个个排开,滑动时跟着弯弯绕绕地走。Android 端有这个效果,一直想给 Flutter 做一个,前面各种原因没动手。这次用 Vibe Coding 的方式,把这个效果做出来了。
全程用 AI 辅助编码(Vibe Coding),从想法到完成分阶段逐步跑通。这篇文章记录整个过程:遇到了什么问题、怎么解决的、哪些地方 AI 帮上忙了、哪些地方必须自己拿主意。
一、我想做什么效果
最终效果拆成几条:
- 手指在屏幕上画一条自由曲线,实时显示轨迹
- 画完确认后,一组 Item 沿曲线排列,跟随切线方向旋转
- Item 靠近路径中间的放大、靠近两端的缩小
- 手指沿路径方向滑动,Item 跟着滚,支持无限循环
- 松手自动吸附到最近的 Item
二、整体实现思路
想清楚要做什么之后,先想清楚怎么做。
整个组件的关键在于一个思维转换:把弯曲的路径想象成一条拉直的绳子。
用户在屏幕上画了一条弯弯曲曲的线,但我们可以假装把它拉直成一条水平直线。这条直线上有刻度------0px 是起点,500px 是终点(假设路径总长 500px)。所有计算都在这条直线上做:Item 按间距排列、滚动偏移量增减、吸附到最近位置。算完之后,再用一张查找表"弯回去"------查出每个刻度在屏幕上的真实 (x, y) 坐标和方向。
markdown
屏幕上的弯曲路径: 想象中拉直后的直线:
╭──╮ | | | | | | |
╭╯ ╰──╮ 0 80 160 240 320 400 (px)
╯ ╰── ↑ ↑ ↑ ↑ ↑ ↑ ↑
Item 沿直线等间距排列
这就是整个组件的本质:在一维直线上做列表计算,再映射回二维屏幕。
后面的章节就按这个思路展开:先把弯曲路径变成查找表,再在直线上排列 Item,再处理手指滑动和松手吸附。
三、从手指轨迹到路径数据
整体思路清楚了,第一个要解决的问题:怎么把手绘的弯曲路径变成可以查询的数据。
3.1 先验证一个技术风险
动手写之前,有一个问题必须先搞清楚:Flutter 的 PathMetric 能不能拿出来单独存着用?
Flutter 的 path.computeMetrics() 返回的 PathMetrics 是一次性的------遍历完就消费掉了,不能回头再取。如果 PathMetric 不能单独存成 final 字段,后面每次查切线都得重新 computeMetrics(),或者干脆换方案------预计算一个切线数组做查找表。
这个问题不能靠猜。写个最小 demo 跑一下:
dart
void main() {
final path = Path();
path.moveTo(0, 0);
path.lineTo(300, 0);
final PathMetric metric = path.computeMetrics().first;
print('pathLength: ${metric.length}');
// 延迟使用:模拟后续帧才调用
final tangent = metric.getTangentForOffset(150);
print('tangent at 150: position=${tangent?.position}');
}
结论:可以存储 。PathMetric 单独拿出来后仍然能正常调用 getTangentForOffset()。
简单说,getTangentForOffset(offset) 就是"在路径上走 offset 这么远,告诉我那个点的位置和方向"。它返回一个 Tangent,里面包含 position(x, y 坐标)和 angle(方向角度)。我们后面靠它算两件事:Item 放在哪、Item 怎么转。
路径确认后,怎么在运行时查询这些切线数据?我考虑过两种方式:
| 方案 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 每帧实时查询 | 每次需要位置时调getTangentForOffset() |
代码简单,精度高 | 滑动时每帧查好几个 Item,频繁调原生 API 有开销 |
| LUT 预采样 | 路径确认后每隔 2px 预计算存数组,运行时用索引取 | O(1) 查询,无原生调用 | 2px 精度有微小误差(肉眼看不出),占额外内存 |
手绘路径长度可能好几百像素,滑动过程中每帧要查好几个 Item 的位置。选了 LUT(Look Up Table)预采样方案:路径确认后,每隔 2px 预计算一个切线存到数组里,运行时用索引直接取。
dart
// 简化后大概是这样
// PathKeyframes --- 核心数据结构
class PathKeyframes {
final double pathLength;
final Path path;
final PathMetric metric;
final List<Tangent> lut; // 预采样切线查找表
static const double samplePrecision = 2.0; // 2px 一个采样点
Tangent? getTangent(double offset) {
if (offset < 0 || offset > pathLength || lut.isEmpty) {
return metric.getTangentForOffset(offset.clamp(0.0, pathLength));
}
final int index = (offset / samplePrecision).floor();
if (index >= lut.length - 1) return lut.last;
return lut[index]; // 直接用索引取,不插值,2px 精度够用
}
}
3.2 手绘画布
用 GestureDetector 采集手指轨迹,onPanStart 记起点,onPanUpdate 逐帧追加坐标到 Path.lineTo,CustomPaint 实时绘制蓝色轨迹。
这部分不复杂,有个小细节:画布暴露 currentPath 和 isValid 两个属性,让外部判断路径是否有效(太短的路径后续不处理)。
3.3 路径采样器
用户点击"确认路径"后,PathSampler 将手绘 Path 转为 PathKeyframes------就是上一章讲的 LUT 预采样过程。核心采样逻辑(简化后大概是这样):
dart
static PathKeyframes sample(Path path) {
final PathMetric metric = path.computeMetrics().first;
final double pathLength = metric.length;
final List<Tangent> lut = [];
for (double offset = 0; offset <= pathLength; offset += samplePrecision) {
final tangent = metric.getTangentForOffset(offset);
if (tangent != null) lut.add(tangent);
}
return PathKeyframes(
path: path,
pathLength: pathLength,
metric: metric,
lut: lut,
);
}
从 0 到路径总长,每 2px 取一个 Tangent,存进数组。一行代码对应一步:getTangentForOffset(offset) 是 Flutter 原生 API 调用,lut.add(tangent) 是存表。采样完成后,运行时查询只需要用索引,不再调用原生 API。
有个设计约束:metrics.first 只处理第一条 contour,这是"仅支持单笔画"的体现。多笔画会产生多个 contour,处理复杂度高且不是核心场景。
四、沿路径排列------布局引擎
路径数据准备好了,下一个问题:怎么让 Item 沿这条路径排列,并且随滚动实时更新位置?
这是整个组件的核心计算模块。输入一个 scrollOffset,输出当前可见的 Item 列表,每个 Item 带有位置、旋转角度、缩放比例。
4.1 哪些 Item 可见:列表虚拟化
不是遍历所有 Item,而是用数学公式直接算出可能落在可见路径范围 [0, pathLength] 内的 index 区间:
dart
// itemOffset = i * spacing - scrollOffset
// 需要 0 <= itemOffset <= pathLength
int firstIndex = (scrollOffset / spacing).floor();
int lastIndex = ((pathLength + scrollOffset) / spacing).ceil();
复杂度是 O(可见数量),不是 O(总数量)。在当前配置下(路径 500px、间距 80px),可见 Item 最多 7-8 个,怎么滑都不卡。
4.2 每个 Item 放在哪
对每个可见的虚拟 index i:
- 距离 :
itemOffset = i * spacing - scrollOffset - 位置 :
keyframes.getTangent(itemOffset).position--- 从 LUT 查出 (x, y) - 旋转 :
tangent.angle--- 路径在该点的切线方向 - 缩放:越靠近路径中心(fraction = 0.5)越大,越靠近两端越小
dart
// 缩放计算
final double fraction = itemOffset / pathLength;
final double distanceFromCenter = (fraction - 0.5).abs();
final double t = (distanceFromCenter * 2.0).clamp(0.0, 1.0);
final double scale = maxScale + (minScale - maxScale) * t;
循环模式下,realIndex = i % itemCount,取模后映射回真实数据索引。Dart 的 % 对负数可能返回负值,所以额外修正:if (realIndex < 0) realIndex += itemCount。
4.3 CustomMultiChildLayout 落地
算出了每个 Item 的 (x, y) 坐标,怎么让 Flutter 把组件放到那个位置?Flutter 常用的布局容器都不行------Row 是水平排列,Column 是垂直排列,Stack 是层叠,都没法把子组件放到任意 (x, y) 坐标上。Flutter 提供了 CustomMultiChildLayout,专门做自由定位:你写一个 MultiChildLayoutDelegate,实现 performLayout 方法,对每个子组件调 layoutChild(id, constraints) 测量大小,再调 positionChild(id, offset) 放到指定坐标。每个子组件用 LayoutId(id: xxx) 标记一个 ID,这个 ID 就是根据位置算出来的序号(第几个 Item)。
LayoutEngine 算出了每个 Item 的中心坐标,Delegate 只管把 Item 按坐标摆上去(简化后大概是这样):
dart
class PathLayoutDelegate extends MultiChildLayoutDelegate {
@override
void performLayout(Size size) {
final halfW = config.itemWidth / 2;
final halfH = config.itemHeight / 2;
for (var info in items) {
if (hasChild(info.layoutId)) {
layoutChild(info.layoutId, BoxConstraints.tight(Size(config.itemWidth, config.itemHeight)));
// Item 中心对齐到路径上的 (x, y)
positionChild(info.layoutId, Offset(info.position.dx - halfW, info.position.dy - halfH));
}
}
}
}
这里有个设计细节:layoutId 和 index 是两个东西。layoutId 是展开后的绝对编号(虚拟 index),index 是取模后的真实数据索引。循环模式下两个 layoutId 可能映射到同一个 index,但 Flutter 的 MultiChildLayoutDelegate 需要每个子组件有唯一 ID,所以必须区分。
渲染层级:
平移由 positionChild 处理,旋转和缩放由 Transform 处理,各司其职。
五、手指滑动怎么映射到路径上
布局引擎能根据 scrollOffset 算出 Item 位置了。但 scrollOffset 从哪来?用户手指在屏幕上往任意方向滑,手绘路径又弯弯曲曲,方向不固定------怎么把二维位移转换成一维的路径偏移量?
这是整个组件最有趣的部分。
向量投影。
取路径中点处的切线作为全局参考方向,把手指的位移向量 delta 点积投影到这个方向上。
参考方向怎么选?我考虑过两种方式:
| 方案 | 做法 | 实际表现 |
|---|---|---|
| 当前滚动位置的切线 | 每帧取scrollOffset 对应位置的切线做投影 |
弯道处切线方向剧烈跳变,手指往一个方向滑,Item 却往回走 |
| 路径中点的固定切线 | 始终用路径中点处的切线做投影 | 整个滑动过程中投影方向稳定,手感一致 |
一开始选了第一种,调试时发现手感很差------尤其是经过弯道时,投影方向突然改变,Item 开始"倒着走"。改成路径中点的固定参考方向后就好了。这个是 AI 发现不了的,它不知道"手感"是什么。
具体怎么投影?想象一束光垂直于路径方向照射,手指滑动的向量在路径方向上会投下一个"影子"------这个影子就是我们要的一维偏移量:
markdown
手指滑动方向
↗
/│
/ │ 垂直分量(忽略)
/ │ 跟路径无关
/ │
──────────●━━━━━┿━━━━━━→ 路径切线方向
←───→
投影分量
这才是 scrollOffset 的变化量
举个例子:从 LUT 里取出路径中点处的方向,假设是向右偏下(大约 (0.9, 0.4))。手指往右滑了 10px,即 delta = (10, 0)。投影计算:10 * 0.9 + 0 * 0.4 = 9。说明这次滑动有 9px 是沿路径方向的,scrollOffset 加 9,Item 往前滚了 9px。如果手指是垂直于路径方向滑,投影接近 0,Item 不动。
dart
void handleDragUpdate(Offset delta) {
// 取路径中点处的切线作为全局参考方向
final tangent = keyframes.getTangent(keyframes.pathLength / 2);
final Offset tangentDir = tangent.vector;
// 二维向量点积:投影到切线方向
final double projectedDist = delta.dx * tangentDir.dx + delta.dy * tangentDir.dy;
// 更新偏移量
scrollOffset = _scrollOffset + projectedDist;
}
5.1 循环和边界
dart
double _applyConstraints(double offset) {
final double total = config.itemCount * config.itemSpacing;
if (config.enableLoop) {
double newOffset = offset % total;
if (newOffset < 0) newOffset += total; // 反向滑动加回
return newOffset;
} else {
final double maxOffset = total - keyframes.pathLength;
if (maxOffset <= 0) return 0;
return offset.clamp(0.0, maxOffset);
}
}
还有一个边界情况:当 Item 总长度小于路径长度(Item 铺不满路径)时,不允许滑动。用 canScroll 标志控制:canScroll = itemCount * spacing >= pathLength。
六、松手吸附------找到最近的 Item
手指滑动的问题解决了,还有一个体验细节:松手之后,列表不能停在两个 Item 之间,得自动滚到最近的 Item 对齐。
松手后,遍历当前可见 Item,找 fraction(Item 在路径上的位置百分比)最接近 0.5(路径中心)的那个。然后算出目标 scrollOffset,使这个 Item 正好出现在路径中间位置:
dart
static double calculateTargetOffset({
required ItemLayoutInfo target,
required double itemSpacing,
required double pathLength,
}) {
// 使目标 Item 出现在路径中间
return target.layoutId * itemSpacing - pathLength * 0.5;
}
用 AnimationController 驱动 scrollOffset 从当前值线性过渡到目标值。
循环模式下有个细节:吸附要选最短路径。比如当前在 offset=100,目标在 offset=990,总长 1000,直接从 100 滑到 990 要走 890 的距离,但反向只走 110。所以:
dart
if (_config.enableLoop) {
final double total = _scrollController!.totalContentLength;
final double diff = _snapToOffset - _snapFromOffset;
if (diff > total / 2) {
_snapToOffset -= total; // 反向绕近路
} else if (diff < -total / 2) {
_snapToOffset += total;
}
}
七、最终效果和代码结构
吸附也搞定了,所有模块都跑通了。看一下完整的代码组织。
项目结构:
bash
lib/
├── path_gesture/ # 手绘路径手势组件
│ ├── engine/ # 纯计算,无 Widget 依赖
│ │ ├── path_sampler.dart # 路径采样
│ │ ├── layout_engine.dart # 布局计算
│ │ ├── scroll_controller.dart # 滚动控制
│ │ └── snap_animation.dart # 吸附动画
│ ├── models/ # 数据模型
│ │ ├── path_keyframes.dart # 路径关键帧 + LUT
│ │ ├── item_layout_info.dart # Item 布局信息
│ │ └── path_gesture_config.dart # 全局配置
│ ├── widgets/ # 渲染组件
│ │ ├── drawing_canvas.dart # 手绘画布
│ │ ├── path_layout.dart # CustomMultiChildLayout
│ │ ├── path_gesture_list.dart # 路径列表组件
│ │ ├── path_item_widget.dart # 单个 Item
│ │ ├── config_panel.dart # 参数面板
│ │ └── debug_overlay.dart # 调试叠加层
│ └── path_list_page.dart # 主页面(状态管理)
模块依赖方向严格单向:path_list_page → engine + widgets,engine 内部全是 static 纯函数,不依赖任何 Widget。widgets 只接收数据并渲染,没有业务逻辑。
测试方面,写了 30+ 个测试用例,覆盖路径采样(短路径、长路径、单点路径)、布局计算(零偏移、中间偏移、超出范围)、滚动控制(正向、反向、循环取模)和吸附动画(正向吸附、反向吸附、最短路径选择),全部通过。
回头看,这个组件的核心就一句话------把弯曲的路径想象成拉直的绳子。后面所有计算都建立在这个思路转换上:采样是给绳子标刻度,布局是在绳子上排 Item,滑动是算手指在绳子方向上的投影,吸附是对齐到最近的刻度。