第 12 章 绘制:Shape、Path 与 Canvas
10 分钟阅读
第 12 章:绘制
前十一章的界面都是"系统给的零件拼出来的"。这一章开始自己画——因为有些视觉效果,系统没提供现成的。
12.1 Shape:用代码描述一个图形
Shape 协议比 View 还简单,它只要求一件事:给定一个矩形,给出一个 Path。
| |
用起来和内置形状完全一样:
| |
🔥 关键理解:Shape 拿到的是别人分配给它的矩形(rect),它要在这个矩形内部描述自己。所以同一个 Triangle 可以是 10×10 也可以是 1000×1000——永远不要在 path(in:) 里写死坐标。
| |
Path 的常用指令
| 指令 | 作用 |
|---|---|
move(to:) | 抬笔移到某点(开始新子路径) |
addLine(to:) | 画直线到某点 |
addQuadCurve(to:control:) | 二次贝塞尔曲线(一个控制点) |
addCurve(to:control1:control2:) | 三次贝塞尔曲线(两个控制点) |
addArc(center:radius:startAngle:endAngle:clockwise:) | 圆弧 |
addEllipse(in:) / addRoundedRect(in:cornerRadius:) | 内置形状 |
closeSubpath() | 闭合当前子路径 |
addPath(_:) | 把另一条路径并进来 |
一个实用例子:带缺口的卡片
| |
💭 画形状时建议先在纸上标出各个点的坐标关系,再翻译成 rect.minX + r 这样的表达式。直接写代码很容易把方向搞反——iOS 坐标系 y 轴向下,所以 minY 是顶部。
12.2 stroke 与 strokeBorder:一个实测出来的差别
这是绘制里最容易踩的一个坑,而且光看代码看不出来——必须量。
🔬 实测:画布 100×100,形状是一个 60×60 的 Rectangle 居中,线宽 10。
| 写法 | 可见横向范围 | 宽度 |
|---|---|---|
Rectangle().stroke(.red, lineWidth: 10) | 15.0–85.0 pt | 70 |
Rectangle().strokeBorder(.red, lineWidth: 10) | 20.0–80.0 pt | 60 |
Rectangle().fill(.red)(对照) | 20.0–80.0 pt | 60 |
真相是:
| 线画在哪 | 结果 | |
|---|---|---|
.stroke(...) | 以路径为中心,向两侧各扩一半 | 向外溢出 5pt、向内占 5pt → 总宽 60 + 5×2 = 70 |
.strokeBorder(...) | 完全画在形状内部 | 总宽还是 60 |
🔥 结论:想让描边不超出你给的 frame,用 .strokeBorder。
⚠️ 而 .strokeBorder 只对遵守 InsettableShape 的形状可用。Rectangle、RoundedRectangle、Circle、Capsule、Ellipse 都支持;你自己写的 Shape 默认不支持——要加 InsettableShape 一致性(多一个 inset(by:) 方法)。自定义形状用不了 .strokeBorder 时,可以用 .stroke 配合内缩的 frame 来模拟:
| |
💭 这个坑为什么值得单列一节:它不报错、不警告,只是让图形比预期大了一圈。当你发现"两个形状怎么都对不齐"时,先检查是不是一个用了 stroke、一个用了 fill。
12.3 渐变:四种类型
SwiftUI 提供四种渐变,写法统一成 .xxxGradient(colors:...):
| |
⚠️ 两个容易记错的参数:
| 类型 | 半径怎么给 |
|---|---|
.radialGradient | startRadius / endRadius:绝对点数 |
.ellipticalGradient | startRadiusFraction / endRadiusFraction:相对于视图尺寸的比例(0~1) |
用 Gradient.Stop 控制颜色停在哪
只给颜色数组时,系统把颜色均匀分布。想控制位置就用 stops::
| |
🔬 这个技巧在实际项目里非常常用——两个 location 相同的相邻 stop 会形成一条"硬边界",而 location 相同的一对(如 0.4 和 0.4)则完全没有过渡。用它做进度条、双色背景、以及"模糊分隔线"都很好用。
渐变也能描边和当文字颜色
| |
💭 foregroundStyle 接受任何 ShapeStyle——纯色、渐变、材质(第 13 章)都行。这比老的 .foregroundColor 强得多,新代码一律用 foregroundStyle。
12.4 Canvas:当 Shape 不够用时
Shape 只能描述一条路径。如果要画很多个独立的图形(粒子、图表、签名板),就该用 Canvas。
| |
Canvas 的闭包签名是 (GraphicsContext, CGSize) -> Void。第二个参数是你能用的尺寸,第一个参数 context 是你的"画笔"。
GraphicsContext 的常用操作
| 操作 | 说明 |
|---|---|
context.stroke(path, with:style:) | 描边 |
context.fill(path, with:) | 填充 |
context.draw(_:at:anchor:) | 画一个 Text 或 Image |
context.opacity = 0.5 | 设置后续绘制的不透明度 |
context.blendMode = .multiply | 设置混合模式 |
context.clip(to:) | 裁剪到某个路径 |
context.translateBy / rotate(by:) / scaleBy | 变换坐标系(有累积效果) |
context.drawLayer { } | 开一个"图层",在里面改设置不影响外面 |
⚠️ 变换是有状态的,这点和 Shape 很不一样:
| |
🔬 drawLayer 是隔离状态的标准做法:在它内部的设置改动不会泄漏到外面。
Canvas 的性能边界
| 特点 | 说明 |
|---|---|
✅ 一个 Canvas = 一个视图 | 画 1000 个图形也不会创建 1000 个视图,开销远低于 ForEach |
✅ 支持 symbols 缓存 | 重复绘制的元素可以用 Canvas(opaque:colorMode:rendersAsynchronously:) + resolve 复用 |
| 🚧 内容不可交互 | Canvas 里画的东西不是视图,收不到点击——要交互必须自己算坐标 |
| 🚧 不参与无障碍 | 旁白读不到 Canvas 里的内容,需要额外用 .accessibilityLabel 描述整体 |
| ⚠️ 重绘成本 | Canvas 的内容变化会整块重画,不适合"只改一小部分"的场景 |
🔥 判据:
| 你要画什么 | 用什么 |
|---|---|
一条形状,能 fill / stroke | Shape(还能动画,见下) |
| 几十个以内、需要交互 | 用视图(ForEach + Shape) |
| 成百上千个、纯展示 | Canvas |
| 图表、粒子、签名板 | Canvas |
Shape 能动画,Canvas 不能(天然)
这是两者最重要的区别。你的 Shape 只要遵守 Animatable(第 5 章那个机制),就能补间:
| |
用法是把它接到一个状态上:
| |
⚠️ Canvas 没有 animatableData,所以它不会自己补间。想在 Canvas 里做动画,得用 TimelineView(.animation) 自己按时间算每一帧:
| |
💭 TimelineView(.animation) 会在每个显示帧回调你——这是最消耗资源的做法,只在 Shape 表达不了的时候用(比如粒子系统、复杂的逐帧计算)。普通的"值变化 → 平滑过渡"一律用 Shape + Animatable。
12.5 完整可运行文件
| |
📦 这一段是
View+#Preview,没有@main:新建Chapter12.swift放进 Xcode 的 App 项目,再把 App 文件里的WindowGroup { ContentView() }改成WindowGroup { DrawingLab() };只想看效果就直接看#Preview——预览不需要入口。
💭 跑起来试四件事:
- 点百分比按钮,看环形的圆弧如何补间(这是
Animatable在起作用); - 对比两个红色矩形——
stroke的那个明显更"胖",因为线画到了框外; - 把
ProgressRing的animatableData注释掉,看动画立刻失效(这能让你记住它是干嘛的); - 给
BarChart传 100 个值,感受Canvas一次画完的轻量。
12.6 本章易错点速查
| 你会怎么写 | 实际发生什么 | 正确做法 |
|---|---|---|
在 path(in:) 里写死坐标 | 换个尺寸就画错位置 | 全部用 rect 计算(rect.midX 等) |
用 .stroke 却想让它不超出边框 | 🔬 实测线会向外溢出半个线宽(60pt 形状 + 10pt 线 → 可见宽 70pt) | 用 .strokeBorder(需 InsettableShape),或手动 .padding(线宽/2) |
以为 .strokeBorder 自己写的形状也能用 | 它要求 InsettableShape,自定义 Shape 默认不满足 | 加 InsettableShape 一致性,或用内缩 + .stroke |
.radialGradient 的半径写比例 | 它要绝对点数 | 比例用 .ellipticalGradient 的 xxxFraction |
Canvas 里改 context.opacity 后画别的 | 设置一直生效到闭包结束 | 用 context.drawLayer { } 隔离 |
想让 Canvas 里的图形响应点击 | Canvas 的内容不是视图,收不到事件 | 自己用坐标做命中判断,或者改用视图 |
指望 Canvas 自动动画 | Canvas 没有 animatableData | 用 TimelineView(.animation) 逐帧算,或改用 Shape |
用 TimelineView(.animation) 做普通过渡 | 每帧回调,最耗资源 | 值变化型动画用 Shape + Animatable |
Canvas 里套用 ShapeStyle 的渐变写法 | 🔬 GraphicsContext.Shading.linearGradient 要 Gradient + CGPoint,而 ShapeStyle 上同名的方法要 colors + UnitPoint,搞混会报 no exact matches | 见 12.4 的柱状图例子 |
Canvas 没写无障碍描述 | 旁白读到一片空白 | 加 .accessibilityLabel(...) |
用 .foregroundColor | 老 API,不能接收渐变/材质 | 用 .foregroundStyle |
12.7 下一章
能自己画了。最后一章把这些手段组合起来做"有质感"的界面:
第 13 章 视觉特效——材质(Material)、模糊与阴影、混合模式、visualEffect,以及怎么在不引入 GeometryReader 的前提下做出"滚动时标题渐隐"这类效果。