PAYATHON 2026

如何让 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。代码延续了原实现向外偏移的方式,外侧波峰和描边可能超出布局边界。如果父视图裁剪内容,需要预留空间或调整绘制范围。

备注:内容仅供参考。