如何让 SwiftUI 波浪邮票 Shape 的四条边平滑连接?
结论
对于示例中的 phase = .pi / 2,需要将转角的二次贝塞尔控制点移到两条边端点切线的交点,并确保每条边都绘制到终点。如果还想消除波浪边缘的折线感,可以用三次贝塞尔曲线近似正弦波。
提供的材料没有效果图,下文按“四条波浪边在转角处沿相同切线方向连接”处理。
原来的转角为什么不平滑?
示例设置了 amplitude = 2、phase = .pi / 2。由于 wavesX 和 wavesY 都是整数,每条边起点和终点处的波浪偏移量都等于 amplitude。
以右上角为例,设振幅为 a,在采样确实覆盖终点的前提下:
let endTop = CGPoint(x: rect.maxX, y: rect.minY - a)
let startRight = CGPoint(x: rect.maxX + a, y: rect.minY)
此时上边的切线水平向右,右边的切线竖直向下。
二次贝塞尔曲线的起点切线沿“起点 → 控制点”,终点切线沿“控制点 → 终点”。原代码把控制点放在矩形顶点:
CGPoint(x: rect.maxX, y: rect.minY)
这样,转角曲线会从上边终点竖直出发,再水平进入右边起点,与两条波浪边的切线方向不一致。
在这个相位下,右上角应使用:
CGPoint(x: rect.maxX + a, y: rect.minY - a)
其他三个角同理。
另外,stride(from:through:by:) 不保证一定包含 through 指定的值。当边长不是 step 的整数倍时,最后一个采样点会停在终点之前,也会影响转角连接。
完整实现:覆盖端点,并按切线连接曲线
下面保留原有参数,将默认 phase 改为示例使用的 .pi / 2。四条边用分段三次贝塞尔曲线近似正弦波,转角仍使用二次贝塞尔曲线。
这组转角控制点适用于 phase = .pi / 2 及与它相差整数个 2π 的相位,并假设 amplitude > 0。如果需要在任意相位下都平滑连接,还需要重新计算转角切线。
import SwiftUI
struct WavyPostageStampShape: Shape {
var amplitude: CGFloat = 8
var wavesX: Int = 6
var wavesY: Int = 10
var phase: CGFloat = .pi / 2
var step: CGFloat = 2
func path(in rect: CGRect) -> Path {
var path = Path()
guard rect.width > 0,
rect.height > 0,
step > 0 else {
return path
}
let w = rect.width
let h = rect.height
let offset = amplitude * sin(phase)
func addWave(
from origin: CGPoint,
along travel: CGVector,
normal: CGVector,
waves: Int,
length: CGFloat
) {
let count = max(1, Int((length / step).rounded(.up)))
let dt = 1 / CGFloat(count)
let frequency = CGFloat(waves) * 2 * .pi
func point(_ t: CGFloat) -> CGPoint {
let displacement = amplitude * sin(phase + t * frequency)
return CGPoint(
x: origin.x + travel.dx * t + normal.dx * displacement,
y: origin.y + travel.dy * t + normal.dy * displacement
)
}
func tangent(_ t: CGFloat) -> CGVector {
let slope = amplitude * frequency * cos(phase + t * frequency)
return CGVector(
dx: travel.dx + normal.dx * slope,
dy: travel.dy + normal.dy * slope
)
}
for index in 0..<count {
let t0 = CGFloat(index) / CGFloat(count)
let t1 = CGFloat(index + 1) / CGFloat(count)
let p0 = point(t0)
let p1 = point(t1)
let d0 = tangent(t0)
let d1 = tangent(t1)
path.addCurve(
to: p1,
control1: CGPoint(
x: p0.x + d0.dx * dt / 3,
y: p0.y + d0.dy * dt / 3
),
control2: CGPoint(
x: p1.x - d1.dx * dt / 3,
y: p1.y - d1.dy * dt / 3
)
)
}
}
let first = CGPoint(x: rect.minX, y: rect.minY - offset)
path.move(to: first)
// TOP
addWave(
from: CGPoint(x: rect.minX, y: rect.minY),
along: CGVector(dx: w, dy: 0),
normal: CGVector(dx: 0, dy: -1),
waves: wavesX,
length: w
)
path.addQuadCurve(
to: CGPoint(x: rect.maxX + offset, y: rect.minY),
control: CGPoint(x: rect.maxX + offset, y: rect.minY - offset)
)
// RIGHT
addWave(
from: CGPoint(x: rect.maxX, y: rect.minY),
along: CGVector(dx: 0, dy: h),
normal: CGVector(dx: 1, dy: 0),
waves: wavesY,
length: h
)
path.addQuadCurve(
to: CGPoint(x: rect.maxX, y: rect.maxY + offset),
control: CGPoint(x: rect.maxX + offset, y: rect.maxY + offset)
)
// BOTTOM
addWave(
from: CGPoint(x: rect.maxX, y: rect.maxY),
along: CGVector(dx: -w, dy: 0),
normal: CGVector(dx: 0, dy: 1),
waves: wavesX,
length: w
)
path.addQuadCurve(
to: CGPoint(x: rect.minX - offset, y: rect.maxY),
control: CGPoint(x: rect.minX - offset, y: rect.maxY + offset)
)
// LEFT
addWave(
from: CGPoint(x: rect.minX, y: rect.maxY),
along: CGVector(dx: 0, dy: -h),
normal: CGVector(dx: -1, dy: 0),
waves: wavesY,
length: h
)
path.addQuadCurve(
to: first,
control: CGPoint(x: rect.minX - offset, y: rect.minY - offset)
)
path.closeSubpath()
return path
}
}
#Preview {
WavyPostageStampShape(
amplitude: 2,
wavesX: 6,
wavesY: 8,
phase: .pi / 2
)
.stroke(Color.blue, lineWidth: 2)
.frame(width: 120, height: 200)
}
代码先按边长计算分段数,再让参数从 0 走到 1,因此无论边长能否被 step 整除,都能覆盖端点。
每个三次贝塞尔片段的控制点都根据正弦波的端点及其导数计算。这些曲线近似正弦波,并保留片段端点处的切线方向,因此相邻片段能沿相同方向衔接。在指定相位下,四个转角也与波浪边相切。
使用时需要注意什么?
- 这组转角控制点不能直接用于任意相位。当
cos(phase) ≠ 0时,波浪端点的切线通常不再水平或竖直,需要根据实际端点切线计算控制点,或另行设计转角过渡段。只修改offset不能解决切线问题。 step控制近似精度。波浪越密,越需要足够的分段数。减小step可以改善近似,但会增加路径片段数量。- 相切不等于曲率连续。这个实现让连接处的切线方向一致,即几何上的 G1 连续,但不保证转角与波浪的曲率完全一致。
- 路径会超出
rect。代码延续了原实现向外偏移的方式,外侧波峰和描边可能超出布局边界。如果父视图裁剪内容,需要预留空间或调整绘制范围。
备注:内容仅供参考。
