缘起:一个看着挺难的需求
最近做一个 Tab 选中态,设计稿长这样:

注意右边那两个 凹进去的圆角 ------ 卡片右上和右下被"咬"了一口,凹面朝里。
第一反应:这不就 ClipPath 画个异形路径完事吗?真上手才发现:
- 路径要算 4 段直线 + 2 段反向弧线,半径一改全要重推
- 卡片要做滑动动画,
ClipPath每帧重算性能堪忧 - 调试时整张卡片一起渲染,错 1px 都不知道错在哪段
折腾两小时,越改越乱。退一步问自己:我是不是从一开始就想错了?
真正的解法不是"更聪明的画法",而是"更彻底的拆解"
最后做出来的代码每一段都极其简单 ------ 没有贝塞尔、没有三角函数、没有路径运算。 靠的不是技巧,是把一个复杂问题拆成五层小问题,每层都只解决一件事。
下面是完整的拆解链。看完你会发现,几乎所有看着"无解"的 UI 都能这么拆。
拆解第 1 层:卡片本体 和 凹角,根本不是同一个东西
直觉告诉你"卡片是一个整体",所以你想用一段路径把它整个画完。
但仔细看:左边两个角是普通圆角,右边两个角才是凹角。 它们的"难度"完全不同 ------ 一个是 BorderRadius 一行搞定,一个要自己画。
为什么要把简单的部分也搅进难的逻辑里?拆开:
markdown
完整卡片 = 普通左圆角矩形(卡片本体)
+ 右上凹角拼块(独立 widget)
+ 右下凹角拼块(独立 widget)
卡片本体直接用最普通的 ClipRRect,凹角抽成两个独立的小组件放在卡片外部 。一个复杂问题立刻变成 1 + 2 = 3 个独立简单问题。
通用原则:一个 UI 里如果有"简单部分"和"复杂部分",先把简单的剥离干净,只攻坚剩下的硬骨头。
拆解第 2 层:凹角本身可以"反过来想"
现在聚焦"右上凹角"这一个小块。直觉还在让你"画一段凹进去的弧"。
继续陷在贝塞尔曲线里?不。翻转视角:
把凹角位置看成一个 12×12 的小方块(边长 = 圆角半径)。在这个方块里,弧线把它切成两半:

核心认知 :你画的不是"凹弧",你画的是"一条弧把方块切成两块,两块填不同色"。 凹角是两块拼色拼出来的视觉错觉,弧本身只是分界线。
这一步是整个思路的关键转折点 ------ 一旦想通"凹是两个色块的拼接",剩下的全是体力活。
通用原则:画不出某个形状时,问自己"它的反面/补集是什么"。常常对面的形状反而是常规图形。
拆解第 3 层:单个小块的绘制再拆成两步
12×12 小块里要做的事,写成大白话就两步:
- 整个方块涂背景色
- 在上面盖一个扇形涂卡片色
对应的代码就是两行:
dart
canvas.drawRect(Offset.zero & size, Paint()..color = mainColor); // 第 1 步
canvas.drawPath(扇形路径, Paint()..color = arcColor); // 第 2 步
扇形怎么定义?取方块的一个角作为圆心,半径 = 边长,画 90°。这样扇形必定刚好填满方块的对角区域,不会有缝隙。
每一步都能独立验证:注释掉第 2 步,看背景色对不对;只画第 2 步,看扇形位置对不对。
通用原则:每个绘制函数只做一件事,分步累加。这样出问题时一眼看出错在哪一步。
拆解第 4 层:四个朝向 → 一个朝向 + 旋转
右上、右下凹角的扇形朝向不一样。如果给小组件加 isTop / isBottom 参数走 if/else,逻辑会重复。如果以后还要左侧也加凹角,又得加 isLeft ------ 排列组合越长越长。
继续拆:几何只画"左上为圆心"这一种朝向 ,其他方向交给外部 Transform.rotate 处理。

dart
// 类内:永远画同一种朝向
class _InvertedCorner { ... }
// 类外:调用时旋转到你要的方向
Transform.rotate(angle: math.pi, child: _InvertedCorner()) // 转 180°
Transform.rotate(angle: math.pi / 2, child: _InvertedCorner()) // 转 90°
类内不再增长,类外只是几个旋转角度。职责分离:组件只管"形状长啥样",调用方只管"放哪转多少"。
通用原则:对称 / 镜像 / 多朝向的 UI,永远只实现一种,剩下的用变换矩阵搞定。
拆解第 5 层:调试也要拆 ------ 几何和配色分两轮调
最后一层不是拆代码,是拆工作流。
我第一次调试时同时用真实的设计色 bgPrimary(白)和 bgSecondary(浅灰)。结果几何画错了肉眼根本看不出来 ------ 因为两个色差太小,弧线和接缝全糊在一起。我以为是颜色不对,跑去调色,越调越乱。
正确顺序:
- 几何阶段 :mainColor 用 大红 ,arcColor 用 亮蓝。色差越刺眼越好,弧线朝向、拼接位置、有没有漏边一目了然。
- 配色阶段:几何冻结后,再把颜色换回真实设计色。
把"形状对不对"和"颜色对不对"两件事隔离开,每次只调一个变量。这是科学方法在 UI 调试里的应用 ------ 控制变量法。
通用原则:调试期不要让两个未知变量同时存在。先撞色定形,再设计色定调。
拆解链总览
arduino
整张异形卡片
└─ 拆 1:卡片本体(左圆角矩形) + 凹角拼块(独立 widget)
└─ 拆 2:凹角 = 背景色块 + 扇形色块(反向思维:画"凸"当作"凹")
└─ 拆 3:单块绘制 = drawRect + drawPath(两步独立验证)
└─ 拆 4:四朝向 = 一种几何 + 外部 Transform.rotate
└─ 拆 5:调试流程 = 几何(撞色) + 配色(设计色)分两轮
最终代码每段都极其平凡,没有任何"高级技巧"。
几个真容易踩的坑(拆解过程中遇到的)
| 坑 | 现象 | 修法 |
|---|---|---|
| 扇形方向画反 | 弧朝错方向 | Flutter Canvas 坐标 y 朝下,数学里的第四象限在屏幕上其实是右上,别看名字,看视觉 |
arcToPoint(clockwise:) 选错 |
画出 270° 大弧 | 两点之间有短弧/长弧两条路径,方向反了就走长的 |
| 扇形半径不等于方块边长 | 漏 1px 白边 | 直接 radius: Radius.circular(size.width),不要硬编码 |
| 拼块背景色和容器背景色不完全一致 | AA 接缝出现 1px 模糊带 | mainColor 必须取容器真实背景色,别用"差不多的灰" |
| Stack 默认裁掉了溢出的拼块 | 拼块不显示 | clipBehavior: Clip.none |
完整 Flutter 代码
dart
class _SelectionCard extends StatelessWidget {
static const double _r = 12;
@override
Widget build(BuildContext context) {
return Stack(
clipBehavior: Clip.none, // 拆 1:允许拼块溢出
children: [
Positioned.fill( // 卡片本体(普通左圆角)
child: ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(_r),
bottomLeft: Radius.circular(_r),
),
child: ColoredBox(color: Colors.white),
),
),
Positioned( // 右上凹角拼块
top: -_r, right: 0, width: _r, height: _r,
child: Transform.rotate( // 拆 4:旋转复用
angle: math.pi,
child: const _InvertedCorner(),
),
),
Positioned( // 右下凹角拼块
bottom: -_r, right: 0, width: _r, height: _r,
child: Transform.rotate(
angle: math.pi / 2,
child: const _InvertedCorner(),
),
),
],
);
}
}
class _InvertedCorner extends StatelessWidget {
const _InvertedCorner();
@override
Widget build(BuildContext context) {
return SizedBox(
width: 12, height: 12,
child: CustomPaint(
painter: _InvertedCornerPainter(
mainColor: Colors.grey.shade200, // 容器背景色
arcColor: Colors.white, // 卡片色
),
),
);
}
}
class _InvertedCornerPainter extends CustomPainter {
const _InvertedCornerPainter({required this.mainColor, required this.arcColor});
final Color mainColor;
final Color arcColor;
@override
void paint(Canvas canvas, Size size) {
// 拆 3 第一步:整块填背景色
canvas.drawRect(Offset.zero & size, Paint()..color = mainColor);
// 拆 3 第二步:扇形填卡片色(左上为圆心,半径 = 边长)
final path = Path()
..moveTo(0, 0)
..lineTo(size.width, 0)
..arcToPoint(
Offset(0, size.height),
radius: Radius.circular(size.width),
clockwise: false,
)
..close();
canvas.drawPath(path, Paint()..color = arcColor);
}
@override
bool shouldRepaint(_InvertedCornerPainter old) =>
mainColor != old.mainColor || arcColor != old.arcColor;
}
写在最后:拆解是一种通用能力
这篇文章的代码只是个例子。真正想传递的是:
看着"无解"的 UI,本质上是没拆够。
不要在第一层就开始想"用什么 API 能一次画完"。先后退一步,问几个问题:
- 这个东西能不能拆成几块独立组件?(拆 1)
- 这块复杂的东西,它的"反面"是不是常规图形?(拆 2)
- 单块绘制能不能拆成"先涂底,再叠加"的累加步骤?(拆 3)
- 对称 / 多朝向的部分,能不能只实现一种 + 变换?(拆 4)
- 调试时能不能让一次只有一个变量是未知的?(拆 5)
每多拆一层,难度就指数下降一档。等你拆到最后,会发现剩下的全是初学者也能写的代码 ------ 这才是工程能力的体现,而不是 "我会用某个炫酷 API"。
下次再遇到看着头大的 UI,别急着上手,先画拆解链。