第18章:路径和自定义形状¶
在本章中,你将熟练地创建自定义形状,用它来裁剪照片。你将点击卡片上的一张照片,这样就可以启用Frames
按钮。然后你可以从模式视图中的形状列表中选择一个形状,并将照片夹在该形状上。
除了创建形状外,你还会学到一些令人兴奋的高级协议用法,以及如何创建不属于同一类型的对象阵列。
启动项目¶
初始项目从刺猬转向长颈鹿。
在ViewState
中,有一个新发布的属性,叫做selectedElement
,用来保存当前选中的元素。在CardDetailView
中,点击一个元素会更新selectedElement
,CardElementView
会在所选元素周围显示一个边界。
在CardBottomToolbar
中,当selectedElement
为nil
时,Frames
按钮是禁用的,但当你点击一个元素时,就会启用。点击背景色会取消对元素的选择并再次禁用Frames
。
目前,当你点击Frames时,会弹出一个EmptyView
的模态。你将用一个FramePicker
视图来取代这个模态视图,你将能够选择一个形状。
➤ 构建并运行该项目以查看变化。
Shapes
¶
本节将学习的技能:预定义形状
➤ 在模型组中,创建一个名为Shapes.swift
的新SwiftUI
视图文件。此文件将保存您的所有自定义形状。
➤ 将body
替换为:
var body: some View {
VStack {
Rectangle()
RoundedRectangle(cornerRadius: 25.0)
Circle()
Capsule()
Ellipse()
}
.padding()
}
这是五个内置的形状,它们尽可能地填充空间。
➤ 预览视图。
这些形状符合Shape
协议,它继承了View
。使用Shape
协议,你可以使用路径定义任何你想要的形状。
Paths
¶
本节将学习的技能:路径;直线;弧线;二次曲线
这是你要先画的三角形形状。你将创建一条由线组成的路径,从点到点。
路径是简单的抽象的,直到你给它们一个轮廓描边或填充。除非您另有指定,否则SwiftUI
默认会用主色填充路径。
➤ 在Shapes.swift
的末尾,用此代码添加一个新形状:
struct Triangle: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
return path
}
}
Shape
有一个必要的方法,返回一个Path
。path(in:)
接收一个CGRect
,其中包含绘制路径的画布尺寸。
Lines
¶
➤ 创建一个三角形,其坐标与上图中的相同。在return path
之前,将其添加到path(in:)
:
//1
path.move(to: CGPoint(x: 20, y: 30))
// 2
path.addLine(to: CGPoint(x: 130, y: 70))
path.addLine(to: CGPoint(x: 60, y: 140))
// 3
path.closeSubpath()
翻阅代码:
- 你通过移动到一个点来创建一个新的子路径。路径可以包含多个子路径。
- 从上一个点开始添加直线。你也可以把这两个点放在一个数组中,然后使用
addLines(_:)
。 - 完成后关闭子路径,创建多边形。
➤ 将Shapes
改为:
struct Shapes: View {
let currentShape = Triangle()
var body: some View {
currentShape
.background(Color.yellow)
}
}
➤ 预览Shapes
.
形状尽可能多地填充空间。填充的路径是使用path(in:)
的固定数字。但是三角形
本身是在填充整个黄色区域。你的代码只有在给currentShape
添加.frame(width: 150, height: 150)
时才会复制上图中的三角形。
如果你想让三角形保持它的形状,但大小与可用的大小相同,你必须使用相对坐标,而不是绝对值。
➤ 在Triangle
中,将path(in:)
替换为。
func path(in rect: CGRect) -> Path {
let width = rect.width
let height = rect.height
var path = Path()
path.addLines([
CGPoint(x: width 0.13, y: height 0.2),
CGPoint(x: width 0.87, y: height 0.47),
CGPoint(x: width 0.4, y: height 0.93)
])
path.closeSubpath()
return path
}
这里你用addLines(_:)
和一个点的数组来组成三角形。你用取决于宽度和高度的相对坐标来取代硬编码的坐标。你可以通过用硬编码的坐标除以原始框架的大小来计算这些坐标。例如,20.0/150.0
大约为0.13
。
➤ 在Shapes
中,将body
的内容改为。
currentShape
.aspectRatio(1, contentMode: .fit)
.background(Color.yellow)
你保持正方形的长宽比,而三角形现在会调整到可用空间的大小。
➤ 在Shapes_Previews
中,向Shapes
添加此修改器:
.previewLayout(.sizeThatFits)
现在,预览将只显示Shapes
中容纳三角形的部分。
Arcs
¶
另一个有用的路径组件是弧线。
➤ 在Shapes.swift
的底部,添加此代码以创建一个新的形状:
struct Cone: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
// path code goes here
return path
}
}
这里你创建了一个新的形状,你将在其中描述一个圆锥体。为了画出这个圆锥体,你要画一个弧线和两条直线。
➤ 在return path
之前将弧线添加到path(in:)
let radius = min(rect.midX, rect.midY)
path.addArc(
center: CGPoint(x: rect.midX, y: rect.midY),
radius: radius,
startAngle: Angle(degrees: 0),
endAngle: Angle(degrees: 180),
clockwise: true)
在这里,你设置中心点在给定的矩形的中间,半径设置为宽度或高度中的较小者。
➤ 在Shapes
中,用currentShape
代替:
let currentShape = Cone()
➤ 预览圆锥体的顶部。
忘掉你认为你知道的关于顺时针方向的一切。在iOS中,角度总是从右手边的0开始,而顺时针方向是相反的。因此,当你在clockwise
设置为true
的情况下,从0º
的起始角度到180º
的结束角度,你从右手边开始,逆时针绕圈。
这是有历史原因的。在macOS
中,原点--也就是坐标(0,0)--在左下角,就像标准的笛卡尔坐标系统一样。当iOS
问世时,苹果在Y
轴上翻转了iOS
的绘图坐标系统,这样(0,0)就在左上角。然而,许多绘图代码都是基于旧的macOS
绘图坐标系统。
➤ 在Cone
的path(in:)
中,添加两条直线,在return
前完成圆锥体:
path.addLine(to: CGPoint(x: rect.midX, y: rect.height))
path.addLine(to: CGPoint(x: rect.midX + radius, y: rect.midY))
path.closeSubpath()
你从弧线离开的地方开始第一行,在可用空间的中间底部结束。第二条线在右手边的中间位置结束。
Curves
¶
除了直线和弧线,你还可以在路径上添加其他各种标准元素,如矩形和椭圆。通过曲线,您可以创建任何您想要的自定义形状。
➤ 在Shapes.swift
的末尾,添加此代码以创建一个新的形状:
struct Lens: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
// path code goes here
return path
}
}
镜头的形状将由两条二次曲线组成,就像一个两端都有一个点的椭圆。
如果你使用过矢量绘图软件,你会使用控制点来绘制曲线。要在代码中创建一条二次曲线,你要设置一个起点、一个终点和一个控制点,定义曲线的走向。
图中的两个中点是经过计算的,定义了曲率。可能需要一些练习来计算出曲线的控制点。
➤ 在path(in:)
中,在回车前添加这段代码:
path.move(to: CGPoint(x: 0, y: rect.midY))
path.addQuadCurve(
to: CGPoint(x: rect.width, y: rect.midY),
control: CGPoint(x: rect.midX, y: 0))
path.addQuadCurve(
to: CGPoint(x: 0, y: rect.midY),
control: CGPoint(x: rect.midX, y: rect.height))
path.closeSubpath()
这里的第一条曲线与上图中的相同,第二条曲线与之对应。
➤ 在Shapes
中,替换currentShape
以使用此形状:
let currentShape = Lens()
➤ 预览形状。
笔触和填充¶
本节将学习的技能:笔画;笔画样式;填充
SwiftUI
目前正在用固体填充物来填充路径。你可以指定填充的颜色,或者,你也可以指定一个笔画,它可以勾勒出形状。
在Shapes
的body
中,将此添加到currentShape
:
.stroke(lineWidth: 5)
你只能在符合Shape
的对象上使用stroke(_:)
,所以你必须把修改器直接放在currentShape
之后。
笔画样式¶
当你定义一个笔画时,你可以不给它一个lineWidth
,而是给它一个StrokeStyle
实例。
例如:
currentShape
.stroke(style: StrokeStyle(dash: [30, 10]))
通过笔画样式,你可以定义轮廓的样子--是否为虚线,虚线的形成方式以及线条末端的样子。
为了形成破折号,你要创建一个数组,定义填充部分的水平点数量,然后是空部分的水平点数量。
上面的例子描述了一个虚线,你有一个5点的垂直线,然后是10点的空间,接着是一个1点的垂直线,然后是5点的空间。
这第二个例子添加了一个破折号阶段,将破折号的起点向右移动15点,这样破折号就从一点线开始。
Swift
到目前为止,你还没有做太多的动画,因为你会在后面的第21章"愉悦的用户体验--最后的修饰"中涉及到这一点,但是这些虚线参数是可以动画的,所以你可以很容易地实现"行军蚁"的marquee
外观。
你可以选择用lineCap
参数来改变线条末端的外观:
lineCap: .square
与.butt
类似,只是两端突出一点。
➤ 在Shapes
中,将.stroke(lineWidth:)
替换为:
.stroke(
Color.primary,
style: StrokeStyle(lineWidth: 10, lineJoin: .round))
.padding()
在这里,你给笔画一个轮廓颜色,并使用lineJoin
参数,镜头形状的两个部分现在在每一边都很好地圆了起来:
剪辑形状模式¶
你现在已经创建了一些形状,可以随意尝试更多的形状。本章的挑战将推荐几个形状供你尝试。
除了显示形状视图外,你还可以用一个形状来剪辑另一个视图。你要在一个模态中列出你的所有形状,这样用户就可以选择一张照片并将其夹在所选的形状中。
➤ 在卡片模态视图组中,创建一个名为FramePicker.swift
的新SwiftUI
视图文件。这将与StickerPicker.swift
非常相似,但会将您的自定义形状加载到一个网格中,而不是贴纸。
首先,你将为模态设置一个所有形状的数组,以便迭代通过。
最初,你可能认为你可以像这样在Shapes
中定义数组:
static let shapes: [Shape] = [Circle(), Rectangle()]
然而,这将给你带来一个编译错误:
Protocol 'Shape' can only be used as a generic constraint because it has Self or associated type requirements.
那么,你怎样才能解决这个问题呢?继续阅读!
关联类型¶
本节中你将学习的技能:具有关联类型的协议;类型清除
Swift Dive:
具有关联类型的协议¶
带关联类型的协议(PATs
)是Swift
的高级黑魔法,如果你没有做过很多泛型的编程,那么这个主题将需要一些时间来学习和吸收。苹果的API
到处都在使用它们,所以有一个概述是很有用的。
Shape
继承于View
,View
是这样定义的。
public protocol View {
associatedtype Body : View
@ViewBuilder var body: Self.Body { get }
}
associatedType
使一个协议通用。当你创建一个符合View
的结构时,要求你有一个body
属性,并告诉View
真正的类型来替代。比如说:
struct ContentView: View {
var body: some View {
EmptyView()
}
}
在这个例子中,body
是EmptyView
的类型。
早些时候,你创建了协议CardElement
。这没有使用相关的类型,所以你能够设置一个CardElement
类型的数组。这就是你定义CardElement
的方式:
protocol CardElement {
var id: UUID { get }
var transform: Transform { get set }
}
所有在CardElement
中的属性类型都是存在的类型。这意味着它们本身就是类型,而不是通用的。然而,你可能需要id
是一个UUID
或Int
或String
。在这种情况下,你可以用ID
的泛型来定义CardElement
。
protocol CardElement {
associatedtype ID
var id: ID { get }
var transform: Transform { get set }
}
当你创建一个符合CardElement
的结构时,你要告诉它ID
到底是什么类型。比如说:
struct NewElement: CardElement {
let id = Int.random(in: 0...1000)
var transform = Transform()
}
在这种情况下,其他的CardElement id
是UUID
类型的,而这个id
是Int
类型的。
一旦一个协议有一个相关的类型,因为它现在是一个泛型,协议就不再是一个存在的类型。该协议被限制使用另一种类型,而编译器没有任何关于它实际上可能是什么类型的信息。由于这个原因,你不能建立一个包含有相关类型的协议的数组,例如View
或Shape
。
回到本节开头的代码,它不能被编译:
static let shapes: [Shape] = [Circle(), Rectangle()]
尽管Circle
和Rectangle
都符合Shape
,但它们是具有不同关联类型的Shape
,因此,你不能把它们都放在同一个Shape
数组中。
类型擦除¶
你可以通过将View
类型转换为AnyView
来将不同的View
放在一个数组中:
// does not compile
let views: [View] = [Text("Hi"), Image("giraffe")]
// does compile
let views: [AnyView] = [
AnyView(Text("Hi")),
AnyView(Image("giraffe"))
]
AnyView
是一个类型消除的视图。它接收任何类型的视图并传回一个存在的、非通用类型的AnyView
。
不幸的是,没有一个内置的AnyShape
用于你的Shape
数组,但当你知道对Shape
的要求是什么时,制作一个AnyShape
是很容易的。
➤ 创建一个名为AnyShape.swift
的新Swift
文件。
➤ 将代码替换为:
import SwiftUI
struct AnyShape: Shape {
func path(in rect: CGRect) -> Path {
}
}
AnyShape
符合Shape
的要求path(in:)
。你会得到一个编译错误,直到你从该方法返回一个路径。
为了将你的自定义形状转换为AnyShape
,你将使用一个初始化器,它接收一个通用的Shape
。这个初始化器将创建一个闭包,使用这个形状来创建一个路径。你将把这个闭合作为一个属性来存储,当视图调用path
时,你将执行这个闭合。
如果你需要查看闭合,请看第9章"保存历史数据"。
➤ 添加一个属性来保存闭合:
private let path: (CGRect) -> Path
你将在需要时执行自定义形状的path(in:)
。path(in:)
接收一个CGRect
并返回一个Path
。
➤ 添加初始化器:
// 1
init<CustomShape: Shape>(_ shape: CustomShape) {
// 2
self.path = { rect in
// 3
shape.path(in: rect)
}
}
当你创建结构时,你会吸收自定义的形状。来解释一下这个代码:
- 因为
CustomShape
是一个通用类型--在角括号中--你告诉初始化器,CustomShape
是某种Shape
。 - 你定义了一个闭包来接收
CGRect
,其中有{ rect in }
。 - 当你执行闭包时,它使用提供的
rect
调用形状的path(in:)
。
你仍然得到一个编译错误,因为path(in:)
需要一个返回。
➤ 在path(in:)
中加入这段代码:
path(rect)
你调用你的path
闭包,提供当前的rect
作为参数。该方法现在返回自定义形状的路径作为Path
。
你的代码现在可以编译了,AnyShape
已经准备好将任何自定义形状转换为自己。
一个类型的擦除数组¶
➤ 在Shapes.swift
中,为Shapes
添加一个新的扩展:
extension Shapes {
static let shapes: [AnyShape] = [
AnyShape(Circle()), AnyShape(Rectangle()),
AnyShape(Cone()), AnyShape(Lens())
]
}
这里面有一个所有你定义的形状的类型删除列表。当你创建更多的形状时,将它们添加到这个数组中。
形状选择模式¶
现在,你在一个数组中拥有所有的形状,你可以创建一个选择模式,就像你为你的贴纸做的那样。
➤ 打开FramePicker.swift
并将FramePicker
替换为:
struct FramePicker: View {
@Environment(\.presentationMode) var presentationMode
// 1
@Binding var frame: AnyShape?
private let columns = [
GridItem(.adaptive(minimum: 120), spacing: 10)
]
private let style = StrokeStyle(
lineWidth: 5,
lineJoin: .round)
var body: some View {
ScrollView {
LazyVGrid(columns: columns) {
// 2
ForEach(0..<Shapes.shapes.count, id: \.self) { index in
Shapes.shapes[index]
// 3
.stroke(Color.primary, style: style)
// 4
.background(
Shapes.shapes[index].fill(Color.secondary))
.frame(width: 100, height: 120)
.padding()
// 5
.onTapGesture {
frame = Shapes.shapes[index]
presentationMode.wrappedValue.dismiss()
}
}
}
}
.padding(5)
}
}
这与你为StickerPicker
写的代码几乎完全相同。例外的情况是:
- 你传入了一个框架,这个框架将容纳所选的形状。
- 你按索引遍历形状数组。
- 用主色勾勒出形状的轮廓。
- 你需要填充这个形状,这样你就有了一个触摸区域。如果你不填满形状,轻拍将只对笔画起作用。
- 当你敲击形状时,你会更新
frame
,并解除模态。
➤ 将预览改为:
struct FramePicker_Previews: PreviewProvider {
static var previews: some View {
FramePicker(frame: .constant(nil))
}
}
➤ 预览FramePicker
以查看网格中的所有形状:
在卡片上添加框架选择器模式¶
➤ 打开CardDetailView.swift
并添加一个新属性:
@State private var frame: AnyShape?
这就是你要传递给FramePicker
的框架。
➤ 找到.sheet(item:)
,并在switch
语句中添加一个新的案例:
case .framePicker:
FramePicker(frame: $frame)
.onDisappear {
if let frame = frame {
card.update(
viewState.selectedElement,
frame: frame)
}
frame = nil
}
这里你调用模态,然后用框架来更新卡片元素。由于你还没有写update(_:frame:)
,你会得到一个编译错误。
将框架添加到卡片元素中¶
➤ 打开CardElement.swift
,为ImageElement
添加一个新的属性:
var frame: AnyShape?
这将保持元素的框架。你只需要把它添加到ImageElement
中,因为这个框架只会剪辑图片。
➤ 打开Card.swift
,将新的更新方法添加到Card
:
mutating func update(_ element: CardElement?, frame: AnyShape) {
if let element = element as? ImageElement,
let index = element.index(in: elements) {
var newElement = element
newElement.frame = frame
elements[index] = newElement
}
}
这里你传入元素和框架。因为element
是不可变的,而你需要更新它的frame
,你创建一个新的可变副本,并用这个新实例更新elements
。
现在要做的就是剪辑图像元素。
➤ 打开CardElementView.swift
,找到ImageElementView
。
你要添加的修改器是.clipShape(_:)
,但你只想在元素的框架不是nil
时添加它。令人惊讶的是,在SwiftUI
中添加一个有条件的修改器并不容易,但当现有的代码相当简单时,下面是一个解决方案。
有条件地添加一个修改器¶
➤ 在ImageElementView
中,将body
重命名为bodyMain
。
➤ 给ImageElementView
添加一个新的属性:
var body: some View {
if let frame = element.frame {
bodyMain
.clipShape(frame)
} else {
bodyMain
}
}
你重新创建body
并在条件的两部分使用bodyMain
。如果有一个框架,添加修改器。
➤ 建立并运行应用程序,并选择绿卡。点选长颈鹿,选择框架。选择一个框架,长颈鹿的照片就会被剪成这个形状。
挑战¶
挑战1:创建新形状¶
练习创建新的形状,并把它们放在框架选择器模式中。这里有一些建议:
后面两个是具有边数属性的多边形
形状,请试一试,并看看挑战文件夹中的代码。
挑战2:剪辑选择边界¶
目前,当你点击一个图像时,它周围会有一个矩形的边框。当图像有框架时,边框应该是框架的形状,而不是矩形的。为了实现这一点,你要用叠加中的描边框来代替边框。
- 在
CardElementView.swift
中,在CardElementView
中,删除ImageElementView
的边框修改器。把它放在ImageElementView
的body
里面的条件的每个部分。 - 从
CardElementView
传递selected
到ImageElementView
。 - 当图像有框架时,用描边框架的叠加来替换边框修改器。
- 当你敲击框架外的空间,但在原始的未剪辑的图像内,
SwiftUI
仍然认为你在敲击图像。在叠加之后,添加修改器.contentShape(frame)
。这将把点击区域夹在框架上。
通过运行应用程序或实时预览SingleCardView
来检查你的变化。
关键点¶
Shape
协议提供了一种简单的方法来绘制一个二维形状。有一些内置的形状,比如Rectangle
和Circle
,但你可以通过提供Path
来创建自定义的形状。Path
是2D
形状的轮廓,由线条和曲线组成。- 一个
Shape
默认以主色填充。你可以用fill(_:style:)
修改器来覆盖它,用一种颜色或梯度填充。你可以用stroke(_:lineWidth:)
修改器来勾勒形状的颜色或渐变,而不是填充形状。 - 使用
clipShape(_:style:)
修改器,你可以将任何视图夹在一个给定的形状上。 - 协议中的关联类型使协议通用,使代码可重复使用。一旦一个协议有了关联类型,编译器就不能确定该协议是什么类型,直到一个结构、类或枚举采用它,并为协议提供使用的类型。
- 使用类型擦除,你可以隐藏一个对象的类型。这对于将不同的形状组合成一个数组或通过使用
AnyView
从一个方法中返回任何种类的视图是很有用的。