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

作者:袖梨 2026-07-06

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) 坐标和方向。

屏幕上的弯曲路径:          想象中拉直后的直线:
    ╭──╮                   |  |  |  |  |  |  |
   ╭╯  ╰──╮               0  80 160 240 320 400  (px)
  ╯       ╰──              ↑  ↑  ↑  ↑  ↑  ↑  ↑
                            Item 沿直线等间距排列

这就是整个组件的本质:在一维直线上做列表计算,再映射回二维屏幕

后面的章节就按这个思路展开:先把弯曲路径变成查找表,再在直线上排列 Item,再处理手指滑动和松手吸附。

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

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

3.1 先验证一个技术风险

动手写之前,有一个问题必须先搞清楚:Flutter 的 PathMetric 能不能拿出来单独存着用?

Flutter 的 path.computeMetrics() 返回的 PathMetrics 是一次性的——遍历完就消费掉了,不能回头再取。如果 PathMetric 不能单独存成 final 字段,后面每次查切线都得重新 computeMetrics(),或者干脆换方案——预计算一个切线数组做查找表。

这个问题不能靠猜。写个最小 demo 跑一下:

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

// 简化后大概是这样
// 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 预采样过程。核心采样逻辑(简化后大概是这样):

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 区间:

// 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)越大,越靠近两端越小
// 缩放计算
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 按坐标摆上去(简化后大概是这样):

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,所以必须区分。

渲染层级:

平移由 positionChild 处理,旋转和缩放由 Transform 处理,各司其职。

五、手指滑动怎么映射到路径上

布局引擎能根据 scrollOffset 算出 Item 位置了。但 scrollOffset 从哪来?用户手指在屏幕上往任意方向滑,手绘路径又弯弯曲曲,方向不固定——怎么把二维位移转换成一维的路径偏移量?

这是整个组件最有趣的部分。

向量投影

取路径中点处的切线作为全局参考方向,把手指的位移向量 delta 点积投影到这个方向上。

参考方向怎么选?我考虑过两种方式:

方案做法实际表现
当前滚动位置的切线每帧取scrollOffset 对应位置的切线做投影弯道处切线方向剧烈跳变,手指往一个方向滑,Item 却往回走
路径中点的固定切线始终用路径中点处的切线做投影整个滑动过程中投影方向稳定,手感一致

一开始选了第一种,调试时发现手感很差——尤其是经过弯道时,投影方向突然改变,Item 开始"倒着走"。改成路径中点的固定参考方向后就好了。这个是 AI 发现不了的,它不知道"手感"是什么。

具体怎么投影?想象一束光垂直于路径方向照射,手指滑动的向量在路径方向上会投下一个"影子"——这个影子就是我们要的一维偏移量:

                      手指滑动方向
                        ↗
                       /│
                      / │  垂直分量(忽略)
                     /  │  跟路径无关
                    /   │
        ──────────●━━━━━┿━━━━━━→ 路径切线方向
                   ←───→
                   投影分量
                   这才是 scrollOffset 的变化量

举个例子:从 LUT 里取出路径中点处的方向,假设是向右偏下(大约 (0.9, 0.4))。手指往右滑了 10px,即 delta = (10, 0)。投影计算:10 * 0.9 + 0 * 0.4 = 9。说明这次滑动有 9px 是沿路径方向的,scrollOffset 加 9,Item 往前滚了 9px。如果手指是垂直于路径方向滑,投影接近 0,Item 不动。

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 循环和边界

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 正好出现在路径中间位置:

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。所以:

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;
  }
}

七、最终效果和代码结构

吸附也搞定了,所有模块都跑通了。看一下完整的代码组织。

项目结构:

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 只接收数据并渲染,没有业务逻辑。

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

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

相关文章

精彩推荐