精通 SwiftUI - iOS 16 版

第 8 章
实现路径与形状来绘制线条与圆饼图

对于有经验的开发者,你可能已使用过 Core Graphics API 来绘制形状与物件。这是一个非常强大的框架,可于建立向量图。在 SwiftUI 中,它也提供几个向量绘图 API,可供开发者绘制线条与形状。

在本章中,你将学习如何使用 Path 与内建的 Shape(如 CircleRoundedRectangle ), 来绘制线条、圆弧、圆饼图与环圈图。下列是我将要介绍的主题:

  • 了解 Path 以及如何利用它来绘制线条。
  • 什么是 Shape 协议?如何遵守这个协议来绘制出自订的形状?
  • 如何绘制圆饼图( pie chart)?
  • 如何以开口圆环( open circle)来建立一个进度指示器?
  • 如何绘制环圈图( donut chart)?

图8.1 列出了我们在后面的小节中所要建立的一些形状与图表。

图 8.1. 示例形状与图形
图 8.1. 示例形状与图形

了解 Path

在 SwiftUI 中,你可使用 Path 绘制线条与形状。如果你参考 Apple 的文件 (https://developer.apple.com/documentation/swiftui/path) , Path 是一个包含 2D 形状轮廓的结构,基本上,线条与形状是以路径逐步描绘。以图 8.2 为例,这是我们要在屏幕上绘制的矩形。

图 8.2 具有坐标的矩形
图 8.2 具有坐标的矩形

请叙说你要如何逐步绘制正方形呢?你可能会提供下列的描述:

  1. 移动至点( 20, 20)。
  2. 从( 20, 20 )画一条线至( 300, 20 )。
  3. 从( 300, 20 画一条线至 (300, 200 )。
  4. 从( 300, 200 ) 画一条线至( 20, 200 )。
  5. 以绿色填满整个区域。

这就是所谓的 Path。如果将上面的步骤写成代码,代码如下所示:

Path() { path in
    path.move(to: CGPoint(x: 20, y: 20))
    path.addLine(to: CGPoint(x: 300, y: 20))
    path.addLine(to: CGPoint(x: 300, y: 200))
    path.addLine(to: CGPoint(x: 20, y: 200))
}
.fill(.green)

这里初始化一个 Path,并在闭包中提供详细的说明。你可以调用 move(to:) 方法移动至一个特定的坐标。要从目前的点画一条线到特定的点,则可以调用 addLine(to:) 方法。默认上,iOS 会以默认的前景色(即黑色)来填满路径,若填满其他颜色,则可以使用 .fill 修饰器,并设定为其颜色。

你可以使用 “App”模板建立一个新项目来测试代码。将项目命名为 SwiftUIShape(或你喜欢的任何名称),然后在 body 输入上列的代码片段,预览画布即会显示出一个绿色矩形,如图 8.3 所示。

图 8.3. 使用路径绘制一个矩形
图 8.3. 使用路径绘制一个矩形

使用Stroke 绘制边框

你不需要以颜色填满整个区域,如果你只想绘制线条的话,则可以使用 .stroke 修饰器,并指定线条的宽度与颜色,如图 8.4 所示。

图 8.4 使用 Stroke 绘制线条
图 8.4 使用 Stroke 绘制线条

因为我们没有指定将线条绘制到原点的步骤,所以显示为一个开放路径。要封闭路径的话,你可以在 Path 闭包的结尾处调用 closeSubpath() 方法,此方法会自动将目前点与起点连接起来。

图 8.5 使用 closeSubpath() 封闭路径
图 8.5 使用 closeSubpath() 封闭路径

绘制曲线

Path 提供了多个内建的 API 来帮助你绘制不同的形状。你不只能够画出直线,还可以使用 addQuadCurveaddCurveaddArc 方法来绘制出曲线与圆弧。例如:你想要在矩形顶部绘制出一个圆顶,如图 8.6 所示。

图 8.6 具有矩形底座的圆顶
图 8.6 具有矩形底座的圆顶

代码可以这样编写:

Path() { path in
    path.move(to: CGPoint(x: 20, y: 60))
    path.addLine(to: CGPoint(x: 40, y: 60))
    path.addQuadCurve(to: CGPoint(x: 210, y: 60), control: CGPoint(x: 125, y: 0))
    path.addLine(to: CGPoint(x: 230, y: 60))
    path.addLine(to: CGPoint(x: 230, y: 100))
    path.addLine(to: CGPoint(x: 20, y: 100))
}
.fill(Color.purple)

addQuadCurve 方法可以让你通过定义一个控制点(control point )来绘制曲线。参考图 8.6,(40, 60) 与(210, 60) 就是所谓的“锚点”(anchor point ),(125, 0) 则是计算建立圆顶形状的控制点,我不打算在这里讨论有关绘制曲线的数学,你可尝试修改控制点的值来查看效果。简单而言,该控制点控制如何绘制曲线。如果你将控制点放在更靠近矩形顶部的位置(例如:125, 30),则会绘制出不圆的外观。

Fill 与 Stroke

如果要画出形状的边框,并同时以颜色填满形状,该怎么做呢? fillstroke 修饰器无法并行使用,不过你可以使用 ZStack 来达到相同的效果,代码如下所示:

ZStack {
    Path() { path in
        path.move(to: CGPoint(x: 20, y: 60))
        path.addLine(to: CGPoint(x: 40, y: 60))
        path.addQuadCurve(to: CGPoint(x: 210, y: 60), control: CGPoint(x: 125, y: 0))
        path.addLine(to: CGPoint(x: 230, y: 60))
        path.addLine(to: CGPoint(x: 230, y: 100))
        path.addLine(to: CGPoint(x: 20, y: 100))
    }
    .fill(Color.purple)

    Path() { path in
        path.move(to: CGPoint(x: 20, y: 60))
        path.addLine(to: CGPoint(x: 40, y: 60))
        path.addQuadCurve(to: CGPoint(x: 210, y: 60), control: CGPoint(x: 125, y: 0))
        path.addLine(to: CGPoint(x: 230, y: 60))
        path.addLine(to: CGPoint(x: 230, y: 100))
        path.addLine(to: CGPoint(x: 20, y: 100))
        path.closeSubpath()
    }
    .stroke(Color.black, lineWidth: 5)
}

我们使用相同的路径建立两个 Path 物件,然后使用 ZStack 来让一个 Path 物件叠在另一个 Path 物件上面。下面是使用 fill 填满紫色的圆顶矩形,并以黑色边框叠在上面,如图 8.7 所示。

图 8.7. 具有边框的圆顶矩形
图 8.7. 具有边框的圆顶矩形

绘制圆弧与圆饼图

SwiftUI 为开发者提供了一个方便的 API 来绘制圆弧,该 API 对于组合各种形状和物件(包含圆饼图)非常有用。要绘制圆弧,你可以撰写代码如下:

Path { path in
    path.move(to: CGPoint(x: 200, y: 200))
    path.addArc(center: .init(x: 200, y: 200), radius: 150, startAngle: .degrees(0), endAngle: .degrees(90), clockwise: true)
}
.fill(.green)

如果你将代码放入 body 中,则会在预览画布中看到一个填满绿色的圆弧,如图 8.8 所示。

图 8.8. 圆弧示例
图 8.8. 圆弧示例

在上列的代码中,我们先至起点 (200, 200),然后调用 addArc 来建立圆弧。addArc 方法接受几个参数:

  • center - 圆的中心点。
  • radius - 建立圆弧的圆半径。
  • startAngle - 圆弧的起点角度。
  • endAngle - 圆弧的终点角度。
  • clockwise - 画圆弧的方向。

如果只看“startAngle”与“endAngle”等两个参数的名称,应该会对其含义有点困惑, 图 8.9 可让你更加了解这些参数的含义。

图 8.9. 了解起点角度与终点角度
图 8.9. 了解起点角度与终点角度

使用 addArc 可轻松建立不同色扇形的圆饼图,你只需要以 ZStack 来重叠不同的扇形即可。组成其图的各个扇形都有不同 startAngle 值与 endAngle 值,下列是代码片段:

ZStack {
    Path { path in
        path.move(to: CGPoint(x: 187, y: 187))
        path.addArc(center: .init(x: 187, y: 187), radius: 150, startAngle: .degrees(0), endAngle: .degrees(190), clockwise: true)
    }
    .fill(.yellow)

    Path { path in
        path.move(to: CGPoint(x: 187, y: 187))
        path.addArc(center: .init(x: 187, y: 187), radius: 150, startAngle: .degrees(190), endAngle: .degrees(110), clockwise: true)
    }
    .fill(.teal)

    Path { path in
        path.move(to: CGPoint(x: 187, y: 187))
        path.addArc(center: .init(x: 187, y: 187), radius: 150, startAngle: .degrees(110), endAngle: .degrees(90), clockwise: true)
    }
    .fill(.blue)

    Path { path in
        path.move(to: CGPoint(x: 187, y: 187))
        path.addArc(center: .init(x: 187, y: 187), radius: 150, startAngle: .degrees(90), endAngle: .degrees(360), clockwise: true)
    }
    .fill(.purple)

}

这将渲染出一个具有四个扇形的圆饼图,如果你需要更多的扇形,则只要使用不同角度值来建立其他的路径物件即可。顺带一提,我使用的颜色是来自 iOS 所提供的标准颜色物件。你可以至下列的网址来了解完整的颜色物件:https://developer.apple.com/documentation/uikit/uicolor/standard_colors.

有时,你可能想从圆饼图切分出来,以突显特定的扇形。举例而言,要以紫色突显扇形时,你可以应用 offset 修饰器来改变扇形的位置:

Path { path in
    path.move(to: CGPoint(x: 187, y: 187))
    path.addArc(center: .init(x: 187, y: 187), radius: 150, startAngle: .degrees(90), endAngle: .degrees(360), clockwise: true)
}
.fill(.purple)
.offset(x: 20, y: 20)

或者,你可以叠加一个边框来进一步吸引人们目光。如果你要在突显的扇形上加入标签,则可以叠上一个 Text 视图,如下所示:

Path { path in
    path.move(to: CGPoint(x: 187, y: 187))
    path.addArc(center: .init(x: 187, y: 187), radius: 150, startAngle: .degrees(90), endAngle: .degrees(360), clockwise: true)
    path.closeSubpath()
}
.stroke(Color(red: 52/255, green: 52/255, blue: 122/255), lineWidth: 10)
.offset(x: 20, y: 20)
.overlay(
    Text("25%")
        .font(.system(.largeTitle, design: .rounded))
        .bold()
        .foregroundColor(.white)
        .offset(x: 80, y: -110)
)

该路径有与紫色扇形相同的起点角度与终点角度,但是它只仅绘制边框及加入一个文字视图,以使扇形突出,图 8.10 为最后的结果。

图 8.10. 突出扇形的分裂式圆饼图
图 8.10. 突出扇形的分裂式圆饼图

了解 Shape 协议

在我们深入了解 Shape 协议之前,我们先从一个简单的作业来开始。根据所学,使用 Path 绘制下列的形状,如图8.11 所示。

图 8.11. 你的作业-使用Path 来绘制形状
图 8.11. 你的作业-使用Path 来绘制形状

请先不要看解答,试着自己做看看。

好,要建立一个像这样的形状,你可使用 addLineaddQuadCurve 来建立一个 Path

Path() { path in
    path.move(to: CGPoint(x: 0, y: 0))
    path.addQuadCurve(to: CGPoint(x: 200, y: 0), control: CGPoint(x: 100, y: -20))
    path.addLine(to: CGPoint(x: 200, y: 40))
    path.addLine(to: CGPoint(x: 200, y: 40))
    path.addLine(to: CGPoint(x: 0, y: 40))
}
.fill(Color.green)

如果你阅读过 Path 的文件,则可能找到另一个名为 addRect 的函数,该函数可以让你以特定的宽度与高度来绘制矩形。因此,下面是替代的解决方案:

Path() { path in
    path.move(to: CGPoint(x: 0, y: 0))
    path.addQuadCurve(to: CGPoint(x: 200, y: 0), control: CGPoint(x: 100, y: -20))
    path.addRect(CGRect(x: 0, y: 0, width: 200, height: 40))
}
.fill(Color.green)

现在,我们来讨论一下 Shape 协议,这个协议非常简单,只有一个需求,当你使用它时,你必须实现下列函数:

func path(in rect: CGRect) -> Path

那么,我们何时需要使用 Shape 协议呢?试问你如何重新使用刚建立的 Path 呢?例如: 你想要建立一个圆顶(Dome)形状、大小弹性的按钮,该如何实现呢?

再看一下上列的代码,你以绝对坐标与尺寸来建立一个路径。为了建立相同但大小可变的形状,则可以建立一个结构来采用 Shape 协议,并实现 path(in:) 函数。当 path(in:) 函数被框架调用时,你将获得 rect 的大小,然后可在 rect 中绘制路径。

我们来了解如何建立圆顶形状,如此你便能更了解 Shape 协议。

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

        path.move(to: CGPoint(x: 0, y: 0))
        path.addQuadCurve(to: CGPoint(x: rect.size.width, y: 0), control: CGPoint(x: rect.size.width/2, y: -(rect.size.width * 0.1)))
        path.addRect(CGRect(x: 0, y: 0, width: rect.size.width, height: rect.size.height))

        return path
    }
}

使用该协议后,我们会获得用于绘制路径的矩形区城,我们从 rect 可以找到矩形区域的宽度与高度来计算控制点,并绘制矩形底座。

藉由这个形状,你就可以使用它来建立各种 SwiftUI 控制组件。举例而言,你可以建立一个具有圆顶形状的按钮,如下所示:

Button(action: {
    // 运行动作
}) {
    Text("Test")
        .font(.system(.title, design: .rounded))
        .bold()
        .foregroundColor(.white)
        .frame(width: 250, height: 50)
        .background(Dome().fill(Color.red))
}

我们将圆顶形状作为按钮的背景,其宽度与高度是基于指定的框架大小。

图 8.12 建立圆顶形状的按钮
图 8.12 建立圆顶形状的按钮

使用内建形状

在前面,我们使用 Shape 协议自订了一个形状。而 SwiftUI其实有几种内建形状,如圆形( Circle )、矩形( Rectangle )、圆角矩形( RoundedRectangle )与椭圆( Ellipse )等, 如果你不想要太花俏的话,这些形状已经足以建立一些常见的物件了。

图 8.13 停止按钮
图 8.13 停止按钮

举例而言,你要建立一个如图 8.13 所示的“停止”按钮,此按钮是由一个圆角矩形与一个圆形所组成,你可以撰写代码如下:

Circle()
    .foregroundColor(.green)
    .frame(width: 200, height: 200)
    .overlay(
        RoundedRectangle(cornerRadius: 5)
            .frame(width: 80, height: 80)
            .foregroundColor(.white)
    )

这里,我们初始化一个 Circle 视图,然后将一个 RoundedRectangle 视图叠在上面。

使用 Shape 建立进度指示器

通过内建形状的混搭,你可以为应用程序建立各种类型的向量式(vector-based )UI 控制组件。我再举另一个例子,图 8.14 为一个使用 Circle 建立的进度指示器。

图 8.14 进度指示器
图 8.14 进度指示器

这个进度指示器其实是由两个圆形所组成,下方是一个灰色圆环,而在灰色圆环的上方则是一个开口圆环,指示完成的进度。你可以在 ContentView 中撰写代码,如下所示:

struct ContentView: View {

    private var purpleGradient = LinearGradient(gradient: Gradient(colors: [ Color(red: 207/255, green: 150/255, blue: 207/255), Color(red: 107/255, green: 116/255, blue: 179/255) ]), startPoint: .trailing, endPoint: .leading)

    var body: some View {

        ZStack {
            Circle()
                .stroke(Color(.systemGray6), lineWidth: 20)
                .frame(width: 300, height: 300)

        }
    }
}

我们使用 stroke 修饰器来画出圆环的轮廓,若是你喜欢较粗(或较细)的线条,则可以调整 lineWidth 参数。而 purpleGradient 属性定义了紫色渐层,我们稍后在绘制开口圆环时会使用它。

图 8.15. 绘制灰色圆环
图 8.15. 绘制灰色圆环

现在,在 ZStack 中插入下列的代码,以建立开口圆环:

Circle()
    .trim(from: 0, to: 0.85)
    .stroke(purpleGradient, lineWidth: 20)
    .frame(width: 300, height: 300)
    .overlay {
        VStack {
            Text("85%")
                .font(.system(size: 80, weight: .bold, design: .rounded))
                .foregroundColor(.gray)
            Text("Complete")
            .font(.system(.body, design: .rounded))
            .bold()
            .foregroundColor(.gray)
        }
    }

建立一个开口圆环的技巧是加上一个 trim 修饰器。你可指定 from 值与 to 值,以指示要显示圆环的哪一个部分,在这个示例中,我们想要显示 85% 的进度,所以设定 from 的值为“0”、to 的值为“0.85”。

为了显示完成百分比( completion percentage),我们将一个文字视图叠在圆环的中间, 如图 8.16 所示。

图 8.16. 绘制进度视图
图 8.16. 绘制进度视图

绘制环圈图

最后要示范的是环圈图,如果你完全了解 trim 修饰器的用法,那么你可能已经知道我们将如何实现环圈图了。处理 trim 修饰器的值,我们可以将圆环切分成多段。

这是我们用来建立环圈图的技巧,代码如下所示:

ZStack {
    Circle()
        .trim(from: 0, to: 0.4)
        .stroke(Color(.systemBlue), lineWidth: 80)

    Circle()
        .trim(from: 0.4, to: 0.6)
        .stroke(Color(.systemTeal), lineWidth: 80)

    Circle()
        .trim(from: 0.6, to: 0.75)
        .stroke(Color(.systemPurple), lineWidth: 80)

    Circle()
        .trim(from: 0.75, to: 1)
        .stroke(Color(.systemYellow), lineWidth: 90)
        .overlay(
            Text("25%")
                .font(.system(.title, design: .rounded))
                .bold()
                .foregroundColor(.white)
                .offset(x: 80, y: -100)
        )
}
.frame(width: 250, height: 250)

第一段圆弧只显示圆环的 40%,第二段圆弧显示圆环的 20%,不过请注意 from 值是“0.4”,而不是“0”,这可以让第二段圆弧连接第一段圆弧。

对于最后一个圆弧,我故意把线宽设得大一点,以使该段圆弧突出,如图8.17 所示。如果你不喜欢这样的设计,则可以将 linewidth 值由“90”改为“80”。

图 8.17 绘制环圈图
图 8.17 绘制环圈图

本章小结

我希望你喜欢本章内容,并爱上示例项目。藉由框架所提供的绘图 API,你可以轻松为应用程序建立自订形状。Path 与 Shape 的运用还有很多,我仅介绍本章中的一些技巧,但请试着运用所学来施展一些魔法吧 !

在本章所准备的示例档中,有完整的项目可以下载: