跳转至

绘制路径和形状

用户访问列表中的地标时会获得徽章,为此我们需要创建徽章。在本文中,我们将通过组合路径和形状来创建徽章,然后把它和另一个表示位置的形状叠在一起。

我们可以尝试使用叠加符号来给不同类型的地标创建多个徽章,修改它的重复次数或更改各种角度和比例。

下载项目文件并按照以下步骤操作,也可以打开已完成的项目自行浏览代码。

  • 预计完成时间:25 分钟
  • 项目文件:下载

1. 创建一个徽章视图

首先我们创建一个使用 SwiftUI 中矢量绘图 API 的徽章视图。

1.1 选择 File > New > File ,从 iOS Templates 中选择 SwiftUI View 。单击 Next ,命名为 Badge ,单击 Create

1.2 在定义徽章的视图前,先让徽章视图显示 Badge

Badge.swift

import SwiftUI

struct Badge: View {
    var body: some View {
        Text("Badge")
    }
}

struct Badge_Previews: PreviewProvider {
    static var previews: some View {
        Badge()
    }
}

2. 绘制徽章背景

SwiftUI 中的图形 API 绘制自定义的徽章形状。

2.1 查看 HexagonParameters.swift 文件中的代码。

HexagonParameters 结构体定义了绘制徽章的六边形 shape 的细节,我们不用修改这些数据,直接使用它们来指定绘制徽章的线条和曲线的控制点。

HexagonParameters.swift

import SwiftUI

struct HexagonParameters {
    struct Segment {
        let useWidth: (CGFloat, CGFloat, CGFloat)
        let xFactors: (CGFloat, CGFloat, CGFloat)
        let useHeight: (CGFloat, CGFloat, CGFloat)
        let yFactors: (CGFloat, CGFloat, CGFloat)
    }

    static let adjustment: CGFloat = 0.085

    static let points = [
        Segment(
            useWidth:  (1.00, 1.00, 1.00),
            xFactors:  (0.60, 0.40, 0.50),
            useHeight: (1.00, 1.00, 0.00),
            yFactors:  (0.05, 0.05, 0.00)
        ),
        Segment(
            useWidth:  (1.00, 1.00, 0.00),
            xFactors:  (0.05, 0.00, 0.00),
            useHeight: (1.00, 1.00, 1.00),
            yFactors:  (0.20 + adjustment, 0.30 + adjustment, 0.25 + adjustment)
        ),
        Segment(
            useWidth:  (1.00, 1.00, 0.00),
            xFactors:  (0.00, 0.05, 0.00),
            useHeight: (1.00, 1.00, 1.00),
            yFactors:  (0.70 - adjustment, 0.80 - adjustment, 0.75 - adjustment)
        ),
        Segment(
            useWidth:  (1.00, 1.00, 1.00),
            xFactors:  (0.40, 0.60, 0.50),
            useHeight: (1.00, 1.00, 1.00),
            yFactors:  (0.95, 0.95, 1.00)
        ),
        Segment(
            useWidth:  (1.00, 1.00, 1.00),
            xFactors:  (0.95, 1.00, 1.00),
            useHeight: (1.00, 1.00, 1.00),
            yFactors:  (0.80 - adjustment, 0.70 - adjustment, 0.75 - adjustment)
        ),
        Segment(
            useWidth:  (1.00, 1.00, 1.00),
            xFactors:  (1.00, 0.95, 1.00),
            useHeight: (1.00, 1.00, 1.00),
            yFactors:  (0.30 + adjustment, 0.20 + adjustment, 0.25 + adjustment)
        )
    ]
}

2.2 在 Badge.swift 中,给徽章添加一个路径形状,然后调用 fill() 修饰符把形状转换成一个视图。

我们可以使用路径组合直线、曲线和其他绘图单元来形成更复杂的形状,比如这里徽章的六边形背景。

Badge.swift

import SwiftUI

struct Badge: View {
    var body: some View {
        //
        Path { path in

        }
        .fill(Color.black)
        //
    }
}

struct Badge_Previews: PreviewProvider {
    static var previews: some View {
        Badge()
    }
}

2.3 给路径添加起始点。

move(to:) 方法把绘制光标移动到一个形状的边上,就像钢笔或铅笔悬停在该位置,等待开始绘制。

Badge.swift


import SwiftUI

struct Badge: View {
    var body: some View {
        Path { path in
            //
            var width: CGFloat = 100.0
            let height = width
            path.move(to: CGPoint(x: width * 0.95, y: height * 0.20))
            //
        }
        .fill(Color.black)
    }
}

struct Badge_Previews: PreviewProvider {
    static var previews: some View {
        Badge()
    }
}

2.4 绘制形状数据中的每一个点,创建一个大致的六边形形状。

addLine(to:) 拿到一点并绘制出来。连续调用 addLine(to:) 方法,在每两个点之间画一条线。

Badge.swift

import SwiftUI

struct Badge: View {
    var body: some View {
        Path { path in
            var width: CGFloat = 100.0
            let height = width
            path.move(to: CGPoint(x: width * 0.95, y: height * 0.20))

            //
            HexagonParameters.points.forEach {
                path.addLine(
                    to: .init(
                        x: width * $0.useWidth.0 * $0.xFactors.0,
                        y: height * $0.useHeight.0 * $0.yFactors.0
                    )
                )
            }
            //
        }
        .fill(Color.black)
    }
}

struct Badge_Previews: PreviewProvider {
    static var previews: some View {
        Badge()
    }
}

现在我们的六角形看起来不对劲,但这是正常的。在接下来的几个步骤中,我们会让六边形看起来更像本开文头所示的徽章形状。

2.5 使用 addQuadCurve(to:control:) 方法来给徽章的角绘制贝塞尔曲线。

Badge.swift

import SwiftUI

struct Badge: View {
    var body: some View {
        Path { path in
            var width: CGFloat = 100.0
            let height = width
            //
            path.move(
                to: CGPoint(
                    x: width * 0.95,
                    y: height * (0.20 + HexagonParameters.adjustment)
                )
            )
            //

            HexagonParameters.points.forEach {
                path.addLine(
                    to: .init(
                        x: width * $0.useWidth.0 * $0.xFactors.0,
                        y: height * $0.useHeight.0 * $0.yFactors.0
                    )
                )

                //
                path.addQuadCurve(
                    to: .init(
                        x: width * $0.useWidth.1 * $0.xFactors.1,
                        y: height * $0.useHeight.1 * $0.yFactors.1
                    ),
                    control: .init(
                        x: width * $0.useWidth.2 * $0.xFactors.2,
                        y: height * $0.useHeight.2 * $0.yFactors.2
                    )
                )
                //
            }
        }
        .fill(Color.black)
    }
}

struct Badge_Previews: PreviewProvider {
    static var previews: some View {
        Badge()
    }
}

2.6 把徽章的形状包装在一个 GeometryReader 中,这样徽章就不会用硬编码的大小(100)而是使用其所包含视图的大小。

当包含徽章的视图不是正方形时,使用最小的几何二维可以保留徽章的长宽比。

Badge.swift

import SwiftUI

struct Badge: View {
    var body: some View {
        //
        GeometryReader { geometry in
            Path { path in
                var width: CGFloat = min(geometry.size.width, geometry.size.height)
                let height = width
                path.move(
                    to: CGPoint(
                        x: width * 0.95,
                        y: height * (0.20 + HexagonParameters.adjustment)
                    )
                )

                HexagonParameters.points.forEach {
                    path.addLine(
                        to: .init(
                            x: width * $0.useWidth.0 * $0.xFactors.0,
                            y: height * $0.useHeight.0 * $0.yFactors.0
                        )
                    )

                    path.addQuadCurve(
                        to: .init(
                            x: width * $0.useWidth.1 * $0.xFactors.1,
                            y: height * $0.useHeight.1 * $0.yFactors.1
                        ),
                        control: .init(
                            x: width * $0.useWidth.2 * $0.xFactors.2,
                            y: height * $0.useHeight.2 * $0.yFactors.2
                        )
                    )
                }
            }
            .fill(Color.black)
        }
        //
    }
}

struct Badge_Previews: PreviewProvider {
    static var previews: some View {
        Badge()
    }
}

2.7 使用 xScalexOffset 调整变量将徽章置于其几何体中心。

Badge.swift

import SwiftUI

struct Badge: View {
    var body: some View {
        GeometryReader { geometry in
            Path { path in
                var width: CGFloat = min(geometry.size.width, geometry.size.height)
                let height = width
                //
                let xScale: CGFloat = 0.832
                let xOffset = (width * (1.0 - xScale)) / 2.0
                width *= xScale
                //
                path.move(
                    to: CGPoint(
                        //
                        x: xOffset + width * 0.95,
                        //
                        y: height * (0.20 + HexagonParameters.adjustment)
                    )
                )

                HexagonParameters.points.forEach {
                    path.addLine(
                        to: .init(
                            //
                            x: xOffset + width * $0.useWidth.0 * $0.xFactors.0,
                            //
                            y: height * $0.useHeight.0 * $0.yFactors.0
                        )
                    )

                    path.addQuadCurve(
                        to: .init(
                            //
                            x: xOffset + width * $0.useWidth.1 * $0.xFactors.1,
                            //
                            y: height * $0.useHeight.1 * $0.yFactors.1
                        ),
                        control: .init(
                            //
                            x: xOffset + width * $0.useWidth.2 * $0.xFactors.2,
                            //
                            y: height * $0.useHeight.2 * $0.yFactors.2
                        )
                    )
                }
            }
            .fill(Color.black)
        }
    }
}

struct Badge_Previews: PreviewProvider {
    static var previews: some View {
        Badge()
    }
}

2.8 参照设计,把徽章背景的纯黑色改成渐变色。

Badge.swift

import SwiftUI

struct Badge: View {
    var body: some View {
        GeometryReader { geometry in
            Path { path in
                var width: CGFloat = min(geometry.size.width, geometry.size.height)
                let height = width
                let xScale: CGFloat = 0.832
                let xOffset = (width * (1.0 - xScale)) / 2.0
                width *= xScale
                path.move(
                    to: CGPoint(
                        x: xOffset + width * 0.95,
                        y: height * (0.20 + HexagonParameters.adjustment)
                    )
                )

                HexagonParameters.points.forEach {
                    path.addLine(
                        to: .init(
                            x: xOffset + width * $0.useWidth.0 * $0.xFactors.0,
                            y: height * $0.useHeight.0 * $0.yFactors.0
                        )
                    )

                    path.addQuadCurve(
                        to: .init(
                            x: xOffset + width * $0.useWidth.1 * $0.xFactors.1,
                            y: height * $0.useHeight.1 * $0.yFactors.1
                        ),
                        control: .init(
                            x: xOffset + width * $0.useWidth.2 * $0.xFactors.2,
                            y: height * $0.useHeight.2 * $0.yFactors.2
                        )
                    )
                }
            }
            //
            .fill(LinearGradient(
                gradient: .init(colors: [Self.gradientStart, Self.gradientEnd]),
                startPoint: .init(x: 0.5, y: 0),
                endPoint: .init(x: 0.5, y: 0.6)
            ))
            //
        }
    }
    //
    static let gradientStart = Color(red: 239.0 / 255, green: 120.0 / 255, blue: 221.0 / 255)
    static let gradientEnd = Color(red: 239.0 / 255, green: 172.0 / 255, blue: 120.0 / 255)
    //
}

struct Badge_Previews: PreviewProvider {
    static var previews: some View {
        Badge()
    }
}

2.9 把 aspectRatio(_:contentMode:) 修饰符添加到渐变的填充上。

即使徽章的父项不是正方形,也可以通过保持1:1的宽高比,让徽章处于视图中心的位置。

Badge.swift

import SwiftUI

struct Badge: View {
    var body: some View {
        GeometryReader { geometry in
            Path { path in
                var width: CGFloat = min(geometry.size.width, geometry.size.height)
                let height = width
                let xScale: CGFloat = 0.832
                let xOffset = (width * (1.0 - xScale)) / 2.0
                width *= xScale
                path.move(
                    to: CGPoint(
                        x: xOffset + width * 0.95,
                        y: height * (0.20 + HexagonParameters.adjustment)
                    )
                )

                HexagonParameters.points.forEach {
                    path.addLine(
                        to: .init(
                            x: xOffset + width * $0.useWidth.0 * $0.xFactors.0,
                            y: height * $0.useHeight.0 * $0.yFactors.0
                        )
                    )

                    path.addQuadCurve(
                        to: .init(
                            x: xOffset + width * $0.useWidth.1 * $0.xFactors.1,
                            y: height * $0.useHeight.1 * $0.yFactors.1
                        ),
                        control: .init(
                            x: xOffset + width * $0.useWidth.2 * $0.xFactors.2,
                            y: height * $0.useHeight.2 * $0.yFactors.2
                        )
                    )
                }
            }
            .fill(LinearGradient(
                gradient: .init(colors: [Self.gradientStart, Self.gradientEnd]),
                startPoint: .init(x: 0.5, y: 0),
                endPoint: .init(x: 0.5, y: 0.6)
            ))
            //
            .aspectRatio(1, contentMode: .fit)
            //
        }
    }
    static let gradientStart = Color(red: 239.0 / 255, green: 120.0 / 255, blue: 221.0 / 255)
    static let gradientEnd = Color(red: 239.0 / 255, green: 172.0 / 255, blue: 120.0 / 255)
}

struct Badge_Previews: PreviewProvider {
    static var previews: some View {
        Badge()
    }
}

3. 绘制徽章符号

地标徽章的中心有一个自定义标志,它由 Landmarks app icon 中的山峰转变而来。

山峰由两个形状组成:一个代表峰顶的雪盖,另一个代表沿途的植被。我们使用两个三角形的形状绘制它们,然后由一个小间隙分开。

3.1 创建一个名为 BadgeBackground.swift 的新文件,将徽章视图的主体封装为新文件中的 BadgeBackground 视图,作为为其他视图创建 Badge 视图的一部分。

BadgeBackground.swift

import SwiftUI

struct BadgeBackground: View {
    var body: some View {
        GeometryReader { geometry in
            Path { path in
                var width: CGFloat = min(geometry.size.width, geometry.size.height)
                let height = width
                let xScale: CGFloat = 0.832
                let xOffset = (width * (1.0 - xScale)) / 2.0
                width *= xScale
                path.move(
                    to: CGPoint(
                        x: xOffset + width * 0.95,
                        y: height * (0.20 + HexagonParameters.adjustment)
                    )
                )

                HexagonParameters.points.forEach {
                    path.addLine(
                        to: .init(
                            x: xOffset + width * $0.useWidth.0 * $0.xFactors.0,
                            y: height * $0.useHeight.0 * $0.yFactors.0
                        )
                    )

                    path.addQuadCurve(
                        to: .init(
                            x: xOffset + width * $0.useWidth.1 * $0.xFactors.1,
                            y: height * $0.useHeight.1 * $0.yFactors.1
                        ),
                        control: .init(
                            x: xOffset + width * $0.useWidth.2 * $0.xFactors.2,
                            y: height * $0.useHeight.2 * $0.yFactors.2
                        )
                    )
                }
            }
            .fill(LinearGradient(
                gradient: .init(colors: [Self.gradientStart, Self.gradientEnd]),
                startPoint: .init(x: 0.5, y: 0),
                endPoint: .init(x: 0.5, y: 0.6)
            ))
            .aspectRatio(1, contentMode: .fit)
        }
    }
    static let gradientStart = Color(red: 239.0 / 255, green: 120.0 / 255, blue: 221.0 / 255)
    static let gradientEnd = Color(red: 239.0 / 255, green: 172.0 / 255, blue: 120.0 / 255)
}

struct BadgeBackground_Previews: PreviewProvider {
    static var previews: some View {
        BadgeBackground()
    }
}

3.2 将 BadgeBackground 放置在徽章的正文中来恢复徽章。

Badge.swift

import SwiftUI

struct Badge: View {
    var body: some View {
        BadgeBackground()
    }
}

struct Badge_Previews: PreviewProvider {
    static var previews: some View {
        Badge()
    }
}

3.3 给设计中旋转样式的山峰形状创建一个新的自定义视图 BadgeSymbol

BadgeSymbol.swift

import SwiftUI

struct BadgeSymbol: View {
    var body: some View {
        Text("Badge Symbol")
    }
}

struct BadgeSymbol_Previews: PreviewProvider {
    static var previews: some View {
        BadgeSymbol()
    }
}

3.4 使用路径 API 绘制符号的顶部。

试着调整一下与 spacingtopWidthtopHeight 常量关联的系数,体验它们是如何影响整体形状的。

BadgeSymbol.swift

import SwiftUI

struct BadgeSymbol: View {
    var body: some View {
        //
        GeometryReader { geometry in
            Path { path in
                let width = min(geometry.size.width, geometry.size.height)
                let height = width * 0.75
                let spacing = width * 0.030
                let middle = width / 2
                let topWidth = 0.226 * width
                let topHeight = 0.488 * height

                path.addLines([
                    CGPoint(x: middle, y: spacing),
                    CGPoint(x: middle - topWidth, y: topHeight - spacing),
                    CGPoint(x: middle, y: topHeight / 2 + spacing),
                    CGPoint(x: middle + topWidth, y: topHeight - spacing),
                    CGPoint(x: middle, y: spacing)
                ])
            }
        }
        //
    }
}

struct BadgeSymbol_Previews: PreviewProvider {
    static var previews: some View {
        BadgeSymbol()
    }
}

3.5 绘制符号的底部。

使用 move(to:) 修饰符在同一路径中的多个形状之间插入间隙。

BadgeSymbol.swift

import SwiftUI

struct BadgeSymbol: View {
    var body: some View {
        GeometryReader { geometry in
            Path { path in
                let width = min(geometry.size.width, geometry.size.height)
                let height = width * 0.75
                let spacing = width * 0.030
                let middle = width / 2
                let topWidth = 0.226 * width
                let topHeight = 0.488 * height

                path.addLines([
                    CGPoint(x: middle, y: spacing),
                    CGPoint(x: middle - topWidth, y: topHeight - spacing),
                    CGPoint(x: middle, y: topHeight / 2 + spacing),
                    CGPoint(x: middle + topWidth, y: topHeight - spacing),
                    CGPoint(x: middle, y: spacing)
                ])

                //
                path.move(to: CGPoint(x: middle, y: topHeight / 2 + spacing * 3))
                path.addLines([
                    CGPoint(x: middle - topWidth, y: topHeight + spacing),
                    CGPoint(x: spacing, y: height - spacing),
                    CGPoint(x: width - spacing, y: height - spacing),
                    CGPoint(x: middle + topWidth, y: topHeight + spacing),
                    CGPoint(x: middle, y: topHeight / 2 + spacing * 3)
                ])
                //
            }
        }
    }
}

struct BadgeSymbol_Previews: PreviewProvider {
    static var previews: some View {
        BadgeSymbol()
    }
}

3.6 按照设计,给符号填充颜色。

BadgeSymbol.swift

import SwiftUI

struct BadgeSymbol: View {
    //
    static let symbolColor = Color(red: 79.0 / 255, green: 79.0 / 255, blue: 191.0 / 255)
    //

    var body: some View {
        GeometryReader { geometry in
            Path { path in
                let width = min(geometry.size.width, geometry.size.height)
                let height = width * 0.75
                let spacing = width * 0.030
                let middle = width / 2
                let topWidth = 0.226 * width
                let topHeight = 0.488 * height

                path.addLines([
                    CGPoint(x: middle, y: spacing),
                    CGPoint(x: middle - topWidth, y: topHeight - spacing),
                    CGPoint(x: middle, y: topHeight / 2 + spacing),
                    CGPoint(x: middle + topWidth, y: topHeight - spacing),
                    CGPoint(x: middle, y: spacing)
                ])

                path.move(to: CGPoint(x: middle, y: topHeight / 2 + spacing * 3))
                path.addLines([
                    CGPoint(x: middle - topWidth, y: topHeight + spacing),
                    CGPoint(x: spacing, y: height - spacing),
                    CGPoint(x: width - spacing, y: height - spacing),
                    CGPoint(x: middle + topWidth, y: topHeight + spacing),
                    CGPoint(x: middle, y: topHeight / 2 + spacing * 3)
                ])
            }
            //
            .fill(Self.symbolColor)
            //
        }
    }
}

struct BadgeSymbol_Previews: PreviewProvider {
    static var previews: some View {
        BadgeSymbol()
    }
}

4. 组合徽章的前景和背景

设计中要求在徽章的背景上旋转并重复多次山峰的形状。

我们来定义一个新的旋转类型,并利用 ForEach 视图让山峰形状的多个副本保持相同的设置 。

4.1 创建一个新的 RotatedBadgeSymbol 视图来封装旋转的符号。

试着在预览中调整角度来测试旋转的效果。

RotatedBadgeSymbol.swift

import SwiftUI

struct RotatedBadgeSymbol: View {
    let angle: Angle

    var body: some View {
        BadgeSymbol()
            .padding(-60)
            .rotationEffect(angle, anchor: .bottom)
    }
}

struct RotatedBadgeSymbol_Previews: PreviewProvider {
    static var previews: some View {
        RotatedBadgeSymbol(angle: .init(degrees: 5))
    }
}

4.2 在 Badge.swift 中,用一个 ZStack 把徽章的 symbol 叠加在徽章的背景上。

Badge.swift

import SwiftUI

struct Badge: View {
    //
    var badgeSymbols: some View {
        RotatedBadgeSymbol(angle: .init(degrees: 0))
            .opacity(0.5)
    }
    //

    var body: some View {
        //
        ZStack {
            BadgeBackground()

            self.badgeSymbols
        }
        //
    }
}

struct Badge_Previews: PreviewProvider {
    static var previews: some View {
        Badge()
    }
}

现在徽章符号与预期的设计相比,它与背景的比例太大。

4.3 通过读取周围的几何图形并缩放符号来修改徽章符号的大小。

Badge.swift

import SwiftUI

struct Badge: View {
    var badgeSymbols: some View {
        RotatedBadgeSymbol(angle: .init(degrees: 0))
            .opacity(0.5)
    }

    var body: some View {
        ZStack {
            BadgeBackground()

            //
            GeometryReader { geometry in
                self.badgeSymbols
                    .scaleEffect(1.0 / 4.0, anchor: .top)
                    .position(x: geometry.size.width / 2.0, y: (3.0 / 4.0) * geometry.size.height)
            }
            //
        }
    }
}

struct Badge_Previews: PreviewProvider {
    static var previews: some View {
        Badge()
    }
}

4.4 添加 ForEach 视图来旋转并显示徽章符号的副本。

完整的 360° 旋转分为八个部分,通过重复山峰符号来创建一个类似太阳的样式。

Badge.swift

import SwiftUI

struct Badge: View {
    //
    static let rotationCount = 8
    //

    var badgeSymbols: some View {
        //
        ForEach(0..<Badge.rotationCount) { i in
            RotatedBadgeSymbol(
                angle: .degrees(Double(i) / Double(Badge.rotationCount)) * 360.0
            )
        }
        .opacity(0.5)
        //
    }

    var body: some View {
        ZStack {
            BadgeBackground()

            GeometryReader { geometry in
                self.badgeSymbols
                    .scaleEffect(1.0 / 4.0, anchor: .top)
                    .position(x: geometry.size.width / 2.0, y: (3.0 / 4.0) * geometry.size.height)
            }
        }
        //
        .scaledToFit()
        //
    }
}

struct Badge_Previews: PreviewProvider {
    static var previews: some View {
        Badge()
    }
}