由于 API 变动,此文章部分内容已失效,最新完整中文教程及代码请查看 https://github.com/WillieWangWei/SwiftUI-Tutorials
微信技术群
SwiftUI
代表未来构建 App 的方向,欢迎加群一起交流技术,解决问题。
加群现在需要申请了,可以先加我微信,备注 "SwiftUI",我会拉你进群。
绘制 Path 和 Shape
用户访问列表中的地标时应当获得徽章,为此,我们需要创建徽章。在本文中,我们将通过组合
paths
和shapes
来创建徽章,然后把它和另一个表示位置的shape
叠在一起。我们可以尝试使用
overlaid
符号来给不同类型的地标创建多个徽章,修改它的重复次数或更改各种角度和比例。下载项目文件并按照以下步骤操作,也可以打开已完成的项目自行浏览代码。
- 预计完成时间:25 分钟
- 项目文件:下载
1. 创建一个 Badge View
首先我们创建一个使用 SwiftUI
中矢量绘图 API 的徽章 view 。
1.1 选择 File
> New
> File
,从 iOS Templates
中选择 SwiftUI View
。单击 Next
,命名为 Badge
,单击 Create
。
1.2 在定义徽章的 shape
前,先让 Badge view 显示 Badge
。
Badge.swift
import SwiftUI
struct Badge: View {
var body: some View {
Text("Badge")
}
}
#if DEBUG
struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}
#endif
2. 绘制 Badge 的背景
用 SwiftUI
中的图形 API 绘制自定义的徽章 shape
。
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
中,给徽章添加一个 Path shape
,然后调用 fill()
方法把 shape
转换成一个 view 。
我们可以使用 path
组合直线、曲线和其他绘图单元来形成更复杂的形状,如这里徽章的六边形背景。
Badge.swift
import SwiftUI
struct Badge: View {
var body: some View {
Path { path in
}
.fill(Color.black)
}
}
#if DEBUG
struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}
#endif
2.3 给 path
添加起始点。
move(to:)
方法把绘制光标移动到一个 shape
的边上,就像钢笔或铅笔悬停在该位置,等待开始绘制。
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)
}
}
#if DEBUG
struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}
#endif
2.4 绘制 shape
数据中的每一个点,创建一个大致的六边形 shape
。
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)
}
}
#if DEBUG
struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}
#endif
现在我们的六角形看起来不对劲,但这是正常的。在接下来的几个步骤中,我们会让六边形看起来更像本开文头所示的徽章形状。
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)
}
}
#if DEBUG
struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}
#endif
把徽章的 path
包装在一个 GeometryReader
中,这样徽章就不会用硬编码的大小(100)而是使用其所包含 view 的大小。
当徽章所包含的 view 不是正方形时,使用几何体最小的的两个维度可以在保证宽高比。
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)
}
}
}
#if DEBUG
struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}
#endif
2.7 使用 xScale
和 xOffset
调整变量将徽章置于其几何体中心。
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)
}
}
}
#if DEBUG
struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}
#endif
参照设计,把徽章背景的纯黑色改成渐变色。
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)
}
#if DEBUG
struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}
#endif
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)
}
#if DEBUG
struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}
#endif
3. 绘制 Badge Symbol
地标徽章的中心有一个自定义徽章,它由 Landmarks
app icon 中的山峰转变而来。
山峰由两个形状组成:一个代表峰顶的雪盖,另一个代表沿途的植被。我们使用两个三角形的 shape
绘制它们,然后由一个小间隙分开。
3.1 创建一个名为 BadgeBackground.swift
的新文件,将徽章 view 的主体封装为新文件中的 BadgeBackground
view,作为为其他视图创建 Badge
view 的一部分。
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)
}
#if DEBUG
struct BadgeBackground_Previews: PreviewProvider {
static var previews: some View {
BadgeBackground()
}
}
#endif
3.2 将 BadgeBackground
放置在徽章的正文中来恢复徽章。
Badge.swift
import SwiftUI
struct Badge: View {
var body: some View {
BadgeBackground()
}
}
#if DEBUG
struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}
#endif
3.3 给设计中旋转样式的山峰 shape
创建一个新的自定义 view BadgeSymbol
。
BadgeSymbol.swift
import SwiftUI
struct BadgeSymbol: View {
var body: some View {
Text("Badge Symbol")
}
}
#if DEBUG
struct BadgeSymbol_Previews: PreviewProvider {
static var previews: some View {
BadgeSymbol()
}
}
# #endif
3.4 使用 path
API 绘制 symbol
的顶部。
尝试调整一下与 spacing
、 topWidth
和 topHeight
常量关联的系数,了解它们是如何影响整体 shape
的。
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)
])
}
}
}
}
#if DEBUG
struct BadgeSymbol_Previews: PreviewProvider {
static var previews: some View {
BadgeSymbol()
}
}
#endif
3.5 绘制 symbol
的底部。
使用 move(to:)
方法在同一 path
中的多个 shapes
之间插入间隙。
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)
])
}
}
}
}
#if DEBUG
struct BadgeSymbol_Previews: PreviewProvider {
static var previews: some View {
BadgeSymbol()
}
}
#endif
3.6 按照设计,给 symbol
填充颜色。
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)
}
}
}
#if DEBUG
struct BadgeSymbol_Previews: PreviewProvider {
static var previews: some View {
BadgeSymbol()
}
}
#endif
4. 组合 Badge 的前景和背景
设计中要求在徽章的背景上旋转并重复多次山峰的 shape
。
我们来定义一个新的旋转类型,并利用 ForEach
view 让山峰 shape
的多个副本保持相同的设置 。
4.1 创建一个新的 RotatedBadgeSymbol
view 来封装旋转的 symbol
。
在预览中调整角度来测试旋转的效果。
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
}
}
}
#if DEBUG
struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}
#endif
现在徽章 symbol
与预期的设计相比,它与背景的比例太大。
4.3 通过读取周围的几何图形并缩放 symbol
来修改徽章 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()
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)
}
}
}
}
#if DEBUG
struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}
#endif
4.4 添加 ForEach
view 来旋转并显示徽章 symbol
的副本。
完整的 360° 旋转分为八个部分,通过重复山峰 symbol
来创建一个类似太阳的样式。
Badge.swift
import SwiftUI
struct Badge: View {
static let rotationCount = 8
var badgeSymbols: some View {
ForEach(0..