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

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,再处理手指滑动和松手吸附。

flowchart TB subgraph 数据层 A[手绘 Path] --> B["PathSampler 采样"] B --> C["PathKeyframes + LUT"] end subgraph 计算层 D["scrollOffset"] --> E["LayoutEngine"] C -.->|"依赖"| E E --> F["可见 Item 列表\nposition + rotation + scale"] end subgraph 交互层 G[手指滑动] --> H["向量投影 → scrollOffset"] I[松手] --> J["SnapAnimation → 目标 offset"] end subgraph 渲染层 F --> K["CustomMultiChildLayout\n自由定位子组件"] end

三、从手指轨迹到路径数据

整体思路清楚了,第一个要解决的问题:怎么把手绘的弯曲路径变成可以查询的数据。

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 预计算一个切线存到数组里,运行时用索引直接取。

flowchart TB subgraph S1["路径确认时 - 一次性"] A[手绘 Path] --> B["computeMetrics()"] B --> C["每隔 2px 调 getTangentForOffset"] C --> D["LUT: Tangent 数组"] end subgraph S2["滑动时 - 每帧"] E["offset = 156.3"] --> F["index = 156.3/2 floor = 78"] F --> G["直接取 lut 78"] G --> H["position + angle"] end D -.-> G
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.lineToCustomPaint 实时绘制蓝色轨迹。

这部分不复杂,有个小细节:画布暴露 currentPathisValid 两个属性,让外部判断路径是否有效(太短的路径后续不处理)。

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 带有位置、旋转角度、缩放比例。

flowchart TB A["scrollOffset = 320"] --> B["计算可见 index 区间"] B --> C["firstIndex = (320/80).floor() = 4"] B --> D["lastIndex = ((500+320)/80).ceil() = 11"] C & D --> E["遍历 i = 4..11"] E --> F{"itemOffset = i * 80 - 320\n在 [0, 500] 内?"} F -->|是| G["LUT 查切线 → position"] G --> H["计算 rotation(切线角度)"] G --> I["计算 scale(距中心距离)"] H & I --> J["构建 ItemLayoutInfo"] F -->|否| K["跳过"] J --> L["返回可见 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));
      }
    }
  }
}

这里有个设计细节:layoutIdindex 是两个东西。layoutId 是展开后的绝对编号(虚拟 index),index 是取模后的真实数据索引。循环模式下两个 layoutId 可能映射到同一个 index,但 Flutter 的 MultiChildLayoutDelegate 需要每个子组件有唯一 ID,所以必须区分。

渲染层级:

flowchart TB A[CustomMultiChildLayout] --> B["LayoutId(id: layoutId)"] B --> C["Transform.rotate(angle)"] C --> D["Transform.scale(scale)"] D --> E["ItemWidget(index: realIndex)"] F["positionChild(layoutId, offset)"] -.->|"平移:由 Delegate 处理"| B G["Transform.rotate"] -.->|"旋转:由 Widget 处理"| C H["Transform.scale"] -.->|"缩放:由 Widget 处理"| D

平移由 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 不动。

flowchart LR subgraph 屏幕上的手指滑动 A["delta = (dx, dy)"] end subgraph 路径中点的切线 B["tangentDir = (tx, ty)"] end A --> C["点积:dx·tx + dy·ty"] B --> C C --> D["projectedDist\n(一维标量)"] D --> E["scrollOffset += projectedDist"]
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 正好出现在路径中间位置:

sequenceDiagram participant U as 用户手指 participant P as PathListPage participant S as SnapAnimation U->>P: onPointerUp(松手) P->>P: computeVisibleItems() P->>S: findSnapTarget(visibleItems) S-->>P: target = Item(fraction=0.48) P->>S: calculateTargetOffset(target) S-->>P: targetOffset = 480 P->>P: 检查最短路径(循环模式) P->>P: _snapFrom = 320, _snapTo = 480 P->>P: _snapController.forward() loop 每帧 P->>P: scrollOffset = lerp(320, 480, t) P->>P: setState() 触发重绘 end
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 + widgetsengine 内部全是 static 纯函数,不依赖任何 Widget。widgets 只接收数据并渲染,没有业务逻辑。

flowchart TB subgraph pages PLP[path_list_page.dart] end subgraph engine PS[path_sampler.dart] LE[layout_engine.dart] SC[scroll_controller.dart] SA[snap_animation.dart] end subgraph widgets DC[drawing_canvas.dart] PL[path_layout.dart] PIW[path_item_widget.dart] PGL[path_gesture_list.dart] CP[config_panel.dart] DO[debug_overlay.dart] end subgraph models PK[path_keyframes.dart] ILI[item_layout_info.dart] PGC[path_gesture_config.dart] end PLP --> PS PLP --> LE PLP --> SC PLP --> SA PLP --> DC PLP --> PGL PLP --> CP PLP --> DO PS --> PK LE --> PK LE --> ILI LE --> PGC SC --> PK SC --> PGC SA --> ILI PGL --> PL PL --> PIW PGL --> LE PGL --> PK PGL --> PGC

测试方面,写了 30+ 个测试用例,覆盖路径采样(短路径、长路径、单点路径)、布局计算(零偏移、中间偏移、超出范围)、滚动控制(正向、反向、循环取模)和吸附动画(正向吸附、反向吸附、最短路径选择),全部通过。

回头看,这个组件的核心就一句话------把弯曲的路径想象成拉直的绳子。后面所有计算都建立在这个思路转换上:采样是给绳子标刻度,布局是在绳子上排 Item,滑动是算手指在绳子方向上的投影,吸附是对齐到最近的刻度。

相关推荐
Captaincc4 小时前
近期感想,VibeCoding会放大内心的ego 非必要,坚决不造轮子。
vibecoding
程序员老刘6 小时前
Dart 3.12 更新要点:乏善可陈
flutter·ai编程·dart
●VON7 小时前
鸿蒙Flutter实战:水平滑动分类标签筛选栏
flutter·华为·harmonyos
●VON9 小时前
鸿蒙Flutter实战:24小时新建标签提示组件
android·flutter·华为·harmonyos·鸿蒙
●VON9 小时前
鸿蒙Flutter实战:MultiProvider多状态管理架构实践
flutter·华为·架构·harmonyos·鸿蒙
●VON11 小时前
鸿蒙Flutter实战:放弃sqflite选纯Dart JSON文件存储
flutter·华为·json·harmonyos·鸿蒙
J船长11 小时前
把该死的Provider再讲一遍
flutter
Fansi11 小时前
看着无解的 UI,其实只是没拆够 —— 以"凹角卡片"为例
flutter