跳转至

第18章:路径和自定义形状

在本章中,你将熟练地创建自定义形状,用它来裁剪照片。你将点击卡片上的一张照片,这样就可以启用Frames按钮。然后你可以从模式视图中的形状列表中选择一个形状,并将照片夹在该形状上。

除了创建形状外,你还会学到一些令人兴奋的高级协议用法,以及如何创建不属于同一类型的对象阵列。

启动项目

初始项目从刺猬转向长颈鹿。

ViewState中,有一个新发布的属性,叫做selectedElement,用来保存当前选中的元素。在CardDetailView中,点击一个元素会更新selectedElementCardElementView会在所选元素周围显示一个边界。

CardBottomToolbar中,当selectedElementnil时,Frames按钮是禁用的,但当你点击一个元素时,就会启用。点击背景色会取消对元素的选择并再次禁用Frames

目前,当你点击Frames时,会弹出一个EmptyView的模态。你将用一个FramePicker视图来取代这个模态视图,你将能够选择一个形状。

➤ 构建并运行该项目以查看变化。

A selected element enables the Frames button

Shapes

本节将学习的技能:预定义形状

➤ 在模型组中,创建一个名为Shapes.swift的新SwiftUI视图文件。此文件将保存您的所有自定义形状。

➤ 将body替换为:

var body: some View {
  VStack {
    Rectangle()
    RoundedRectangle(cornerRadius: 25.0)
    Circle()
    Capsule()
    Ellipse()
  }
  .padding()
}

这是五个内置的形状,它们尽可能地填充空间。

➤ 预览视图。

Five predefined shapes

这些形状符合Shape协议,它继承了View。使用Shape协议,你可以使用路径定义任何你想要的形状。

Paths

本节将学习的技能:路径;直线;弧线;二次曲线

这是你要先画的三角形形状。你将创建一条由线组成的路径,从点到点。

Triangle

路径是简单的抽象的,直到你给它们一个轮廓描边或填充。除非您另有指定,否则SwiftUI默认会用主色填充路径。

➤ 在Shapes.swift的末尾,用此代码添加一个新形状:

struct Triangle: Shape {
  func path(in rect: CGRect) -> Path {
    var path = Path()
    return path
  }
}

Shape有一个必要的方法,返回一个Pathpath(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()

翻阅代码:

  1. 你通过移动到一个点来创建一个新的子路径。路径可以包含多个子路径。
  2. 从上一个点开始添加直线。你也可以把这两个点放在一个数组中,然后使用addLines(_:)
  3. 完成后关闭子路径,创建多边形。

➤ 将Shapes改为:

struct Shapes: View {
  let currentShape = Triangle() 

  var body: some View {
    currentShape
      .background(Color.yellow)
  }
}

➤ 预览Shapes.

Triangle Shape

形状尽可能多地填充空间。填充的路径是使用path(in:)的固定数字。但是三角形本身是在填充整个黄色区域。你的代码只有在给currentShape添加.frame(width: 150, height: 150)时才会复制上图中的三角形。

Fixed Triangle

如果你想让三角形保持它的形状,但大小与可用的大小相同,你必须使用相对坐标,而不是绝对值。

➤ 在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)

你保持正方形的长宽比,而三角形现在会调整到可用空间的大小。

Resizable Triangle

➤ 在Shapes_Previews中,向Shapes添加此修改器:

.previewLayout(.sizeThatFits)

现在,预览将只显示Shapes中容纳三角形的部分。

Resized preview

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()

➤ 预览圆锥体的顶部。

The arc

忘掉你认为你知道的关于顺时针方向的一切。在iOS中,角度总是从右手边的0开始,而顺时针方向是相反的。因此,当你在clockwise设置为true的情况下,从的起始角度到180º的结束角度,你从右手边开始,逆时针绕圈。

Describe an arc

这是有历史原因的。在macOS中,原点--也就是坐标(0,0)--在左下角,就像标准的笛卡尔坐标系统一样。当iOS问世时,苹果在Y轴上翻转了iOS的绘图坐标系统,这样(0,0)就在左上角。然而,许多绘图代码都是基于旧的macOS绘图坐标系统。

➤ 在Conepath(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()

你从弧线离开的地方开始第一行,在可用空间的中间底部结束。第二条线在右手边的中间位置结束。

The completed cone

Curves

除了直线和弧线,你还可以在路径上添加其他各种标准元素,如矩形和椭圆。通过曲线,您可以创建任何您想要的自定义形状。

➤ 在Shapes.swift的末尾,添加此代码以创建一个新的形状:

struct Lens: Shape {
  func path(in rect: CGRect) -> Path {
    var path = Path()
    // path code goes here
    return path
  }
}

镜头的形状将由两条二次曲线组成,就像一个两端都有一个点的椭圆。

如果你使用过矢量绘图软件,你会使用控制点来绘制曲线。要在代码中创建一条二次曲线,你要设置一个起点、一个终点和一个控制点,定义曲线的走向。

Quadratic curve

图中的两个中点是经过计算的,定义了曲率。可能需要一些练习来计算出曲线的控制点。

➤ 在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()

➤ 预览形状。

Lens shape

笔触和填充

本节将学习的技能:笔画;笔画样式;填充

SwiftUI目前正在用固体填充物来填充路径。你可以指定填充的颜色,或者,你也可以指定一个笔画,它可以勾勒出形状。

Stroke and fill

Shapesbody中,将此添加到currentShape

.stroke(lineWidth: 5)

你只能在符合Shape的对象上使用stroke(_:),所以你必须把修改器直接放在currentShape之后。

Stroke

笔画样式

当你定义一个笔画时,你可以不给它一个lineWidth,而是给它一个StrokeStyle实例。

例如:

currentShape
  .stroke(style: StrokeStyle(dash: [30, 10]))

StrokeStyle with dash

通过笔画样式,你可以定义轮廓的样子--是否为虚线,虚线的形成方式以及线条末端的样子。

为了形成破折号,你要创建一个数组,定义填充部分的水平点数量,然后是空部分的水平点数量。

img

上面的例子描述了一个虚线,你有一个5点的垂直线,然后是10点的空间,接着是一个1点的垂直线,然后是5点的空间。

img

这第二个例子添加了一个破折号阶段,将破折号的起点向右移动15点,这样破折号就从一点线开始。

Swift

到目前为止,你还没有做太多的动画,因为你会在后面的第21章"愉悦的用户体验--最后的修饰"中涉及到这一点,但是这些虚线参数是可以动画的,所以你可以很容易地实现"行军蚁"的marquee外观。

你可以选择用lineCap参数来改变线条末端的外观:

Line caps

lineCap: .square.butt类似,只是两端突出一点。

➤ 在Shapes中,将.stroke(lineWidth:)替换为:

.stroke(
  Color.primary, 
  style: StrokeStyle(lineWidth: 10, lineJoin: .round))
.padding()

在这里,你给笔画一个轮廓颜色,并使用lineJoin参数,镜头形状的两个部分现在在每一边都很好地圆了起来:

Line join

剪辑形状模式

你现在已经创建了一些形状,可以随意尝试更多的形状。本章的挑战将推荐几个形状供你尝试。

除了显示形状视图外,你还可以用一个形状来剪辑另一个视图。你要在一个模态中列出你的所有形状,这样用户就可以选择一张照片并将其夹在所选的形状中。

➤ 在卡片模态视图组中,创建一个名为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继承于ViewView是这样定义的。

public protocol View {
    associatedtype Body : View
    @ViewBuilder var body: Self.Body { get }
}

associatedType使一个协议通用。当你创建一个符合View的结构时,要求你有一个body属性,并告诉View真正的类型来替代。比如说:

struct ContentView: View {
  var body: some View {
    EmptyView()
  }
}

在这个例子中,bodyEmptyView的类型。

早些时候,你创建了协议CardElement。这没有使用相关的类型,所以你能够设置一个CardElement类型的数组。这就是你定义CardElement的方式:

protocol CardElement {
  var id: UUID { get }
  var transform: Transform { get set }
}

所有在CardElement中的属性类型都是存在的类型。这意味着它们本身就是类型,而不是通用的。然而,你可能需要id是一个UUIDIntString。在这种情况下,你可以用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 idUUID类型的,而这个idInt类型的。

一旦一个协议有一个相关的类型,因为它现在是一个泛型,协议就不再是一个存在的类型。该协议被限制使用另一种类型,而编译器没有任何关于它实际上可能是什么类型的信息。由于这个原因,你不能建立一个包含有相关类型的协议的数组,例如ViewShape

回到本节开头的代码,它不能被编译:

static let shapes: [Shape] = [Circle(), Rectangle()]

尽管CircleRectangle都符合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)
  }
}

当你创建结构时,你会吸收自定义的形状。来解释一下这个代码:

  1. 因为CustomShape是一个通用类型--在角括号中--你告诉初始化器,CustomShape是某种Shape
  2. 你定义了一个闭包来接收CGRect,其中有{ rect in }
  3. 当你执行闭包时,它使用提供的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写的代码几乎完全相同。例外的情况是:

  1. 你传入了一个框架,这个框架将容纳所选的形状。
  2. 你按索引遍历形状数组。
  3. 用主色勾勒出形状的轮廓。
  4. 你需要填充这个形状,这样你就有了一个触摸区域。如果你不填满形状,轻拍将只对笔画起作用。
  5. 当你敲击形状时,你会更新frame,并解除模态。

➤ 将预览改为:

struct FramePicker_Previews: PreviewProvider {
  static var previews: some View {
    FramePicker(frame: .constant(nil))
  }
}

➤ 预览FramePicker以查看网格中的所有形状:

Shapes Listing

在卡片上添加框架选择器模式

➤ 打开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。如果有一个框架,添加修改器。

➤ 建立并运行应用程序,并选择绿卡。点选长颈鹿,选择框架。选择一个框架,长颈鹿的照片就会被剪成这个形状。

Clipped giraffe

挑战

挑战1:创建新形状

练习创建新的形状,并把它们放在框架选择器模式中。这里有一些建议:

Try these shapes

后面两个是具有边数属性的多边形形状,请试一试,并看看挑战文件夹中的代码。

挑战2:剪辑选择边界

目前,当你点击一个图像时,它周围会有一个矩形的边框。当图像有框架时,边框应该是框架的形状,而不是矩形的。为了实现这一点,你要用叠加中的描边框来代替边框。

  1. CardElementView.swift中,在CardElementView中,删除ImageElementView的边框修改器。把它放在ImageElementViewbody里面的条件的每个部分。
  2. CardElementView传递selectedImageElementView
  3. 当图像有框架时,用描边框架的叠加来替换边框修改器。
  4. 当你敲击框架外的空间,但在原始的未剪辑的图像内,SwiftUI仍然认为你在敲击图像。在叠加之后,添加修改器.contentShape(frame)。这将把点击区域夹在框架上。

通过运行应用程序或实时预览SingleCardView来检查你的变化。

A selected giraffe

关键点

  • Shape协议提供了一种简单的方法来绘制一个二维形状。有一些内置的形状,比如RectangleCircle,但你可以通过提供Path来创建自定义的形状。
  • Path2D形状的轮廓,由线条和曲线组成。
  • 一个Shape默认以主色填充。你可以用fill(_:style:)修改器来覆盖它,用一种颜色或梯度填充。你可以用stroke(_:lineWidth:)修改器来勾勒形状的颜色或渐变,而不是填充形状。
  • 使用clipShape(_:style:)修改器,你可以将任何视图夹在一个给定的形状上。
  • 协议中的关联类型使协议通用,使代码可重复使用。一旦一个协议有了关联类型,编译器就不能确定该协议是什么类型,直到一个结构、类或枚举采用它,并为协议提供使用的类型。
  • 使用类型擦除,你可以隐藏一个对象的类型。这对于将不同的形状组合成一个数组或通过使用AnyView从一个方法中返回任何种类的视图是很有用的。