看着无解的 UI,其实只是没拆够 —— 以"凹角卡片"为例

缘起:一个看着挺难的需求

最近做一个 Tab 选中态,设计稿长这样:

注意右边那两个 凹进去的圆角 ------ 卡片右上和右下被"咬"了一口,凹面朝里。

第一反应:这不就 ClipPath 画个异形路径完事吗?真上手才发现:

  • 路径要算 4 段直线 + 2 段反向弧线,半径一改全要重推
  • 卡片要做滑动动画,ClipPath 每帧重算性能堪忧
  • 调试时整张卡片一起渲染,错 1px 都不知道错在哪段

折腾两小时,越改越乱。退一步问自己:我是不是从一开始就想错了?


真正的解法不是"更聪明的画法",而是"更彻底的拆解"

最后做出来的代码每一段都极其简单 ------ 没有贝塞尔、没有三角函数、没有路径运算。 靠的不是技巧,是把一个复杂问题拆成五层小问题,每层都只解决一件事。

下面是完整的拆解链。看完你会发现,几乎所有看着"无解"的 UI 都能这么拆。


拆解第 1 层:卡片本体 和 凹角,根本不是同一个东西

直觉告诉你"卡片是一个整体",所以你想用一段路径把它整个画完。

但仔细看:左边两个角是普通圆角,右边两个角才是凹角。 它们的"难度"完全不同 ------ 一个是 BorderRadius 一行搞定,一个要自己画。

为什么要把简单的部分也搅进难的逻辑里?拆开:

markdown 复制代码
完整卡片 = 普通左圆角矩形(卡片本体)
        + 右上凹角拼块(独立 widget)
        + 右下凹角拼块(独立 widget)

卡片本体直接用最普通的 ClipRRect,凹角抽成两个独立的小组件放在卡片外部 。一个复杂问题立刻变成 1 + 2 = 3 个独立简单问题。

通用原则:一个 UI 里如果有"简单部分"和"复杂部分",先把简单的剥离干净,只攻坚剩下的硬骨头。


拆解第 2 层:凹角本身可以"反过来想"

现在聚焦"右上凹角"这一个小块。直觉还在让你"画一段凹进去的弧"。

继续陷在贝塞尔曲线里?不。翻转视角

把凹角位置看成一个 12×12 的小方块(边长 = 圆角半径)。在这个方块里,弧线把它切成两半:

核心认知 :你画的不是"凹弧",你画的是"一条弧把方块切成两块,两块填不同色"。 凹角是两块拼色拼出来的视觉错觉,弧本身只是分界线。

这一步是整个思路的关键转折点 ------ 一旦想通"凹是两个色块的拼接",剩下的全是体力活。

通用原则:画不出某个形状时,问自己"它的反面/补集是什么"。常常对面的形状反而是常规图形。


拆解第 3 层:单个小块的绘制再拆成两步

12×12 小块里要做的事,写成大白话就两步:

  1. 整个方块涂背景色
  2. 在上面盖一个扇形涂卡片色

对应的代码就是两行:

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(浅灰)。结果几何画错了肉眼根本看不出来 ------ 因为两个色差太小,弧线和接缝全糊在一起。我以为是颜色不对,跑去调色,越调越乱。

正确顺序:

  1. 几何阶段 :mainColor 用 大红 ,arcColor 用 亮蓝。色差越刺眼越好,弧线朝向、拼接位置、有没有漏边一目了然。
  2. 配色阶段:几何冻结后,再把颜色换回真实设计色。

把"形状对不对"和"颜色对不对"两件事隔离开,每次只调一个变量。这是科学方法在 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,别急着上手,先画拆解链。

相关推荐
李宏伟~12 小时前
flutter实现观看直播评论抽奖功能
flutter
●VON12 小时前
鸿蒙Flutter实战:自定义SearchDelegate应用内搜索
flutter·华为·harmonyos·鸿蒙
韩曙亮12 小时前
【错误记录】Flutter 编译 Android APK 文件安装包报错 ( 国内镜像源设置 )
android·flutter
李宏伟~12 小时前
flutter实现支付宝支付
flutter
●VON13 小时前
鸿蒙Flutter实战:待办事项三态筛选器
flutter·华为·harmonyos·鸿蒙
李宏伟~13 小时前
flutter实现直播推流端
flutter
●VON13 小时前
鸿蒙Flutter实战:多选批量删除模式的实现
flutter·华为·harmonyos·鸿蒙
坚果的博客14 小时前
Flutter 三方库(Flutter-New-Badge)适配开源鸿蒙教程
flutter·开源·harmonyos
二蛋和他的大花14 小时前
高德地图 Flutter 插件:跨 Android / iOS / HarmonyOS 的完整实现
android·flutter·ios