0%

用SwiftUI实现一个精美的App

SwiftUI中的一切都是视图。

文章来源:Building Custom Views with SwiftUI

SwiftUI布局基础

1
2
3
4
5
6
struct ContentView: View {
var body: some View {
Text("Hello, world!")
// .edgesIgnoringSafeArea(.all)
}
}

这段代码包含三个视图:

01:44

  • 视图等级底部的文本(图中Hello World)

  • 内容视图(和文本的布局一致,即图中Hello World四周白线内)

  • 根视图(屏幕Size - 安全区域)

    如果想将根视图扩展到安全区,可以使用edgesIgnoringSafeArea(.all)修饰器

当然,文本和文本的内容视图,我们通常当做同一个来操作

2:18

在SwiftUI中,不能给子视图强制规定一个尺寸,而是应该有父视图决定

布局步骤

  • 父视图提供给子视图一个Size
  • 子视图决定自身的Size(子视图也许不能完全使用父视图的Size)
  • 父视图将子视图放在其坐标系中
  • SwiftUI会让视图坐标像最接近的像素值取整

例一:查看一段代码的布局:

1
2
3
4
5
var body: some View {
Text("Avocado Toast")
.padding(10)
.background(Color.green)
}

在设置backgroundpadding修饰器时,会在Text视图和根视图中间插入对应的背景视图和边距视图

1

例二:图片的原尺寸为20x20,我们希望1.5倍尺寸展示图片

1
2
3
4
5
struct ContentView: View {
var body: some View {
Image("20x20_avoado")
}
}

做法:

1
.frame(width: 30, height: 30)

效果:图片尺寸不会发生变化,但是在图片周围会插入一个30x30尺寸的Frame视图

9:39

在SwiftUI中frame并不是一个重要的布局元素,它其实只是一个View。

例三:

1
2
3
4
5
6
7
// 子视图必须平等竞争一个空间
HStack {
Text("Delicious")
Image("20x20_avocado")
Text("Avocado Toast")
}
.lineLimit(1)

  • 设置文字底基线对齐

    19:51

  • 设置图片的底基线

    20:40

例四:让不同容器中的视图对齐

21:14

  • 自定义对齐方式

    1
    2
    3
    4
    5
    6
    7
    8
    9
    extension VerticalAlignment {
    private enum MidStarAndTitle : AlignmentID {
    // 告诉SwiftUI如何计算默认值
    static func defaultValue(in d: ViewDimensions) -> CGFloat {
    return d[.bottom]
    }
    }
    static let midStarAndTitle = VerticalAlignment(MidStarAndTitle.self)
    }
  • 设置文字的基线

    22:55

SwiftUI绘图

SwiftUI默认提供了多种样式的图形,比如圆形,胶囊和椭圆

26:08

  • 实现渐变色

    27:00

    • 角渐变色

      27:06

    • 使用角渐变色填充圆

      27:26

    • 使用渐变色填充圆环

      27:39

  • 实现复杂图形绘制

    完整代码可参见:官方Demo

    总体步骤主要包括:

    1. 创建单个楔形的数据模型
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class Ring: ObservableObject {
    /// A single wedge within a chart ring.
    struct Wedge: Equatable {
    /// 弧度值(所有楔形的弧度值之合最大为2π,即360°)
    var width: Double
    /// 横轴深度比例 [0,1]. (用来计算楔形的长度)
    var depth: Double
    /// 颜色值
    var hue: Double
    }
    }
    1. 绘制单个子图形
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    struct WedgeShape: Shape {
    func path(in rect: CGRect) -> Path {
    // WedgeGeometry是用来计算绘制信息的类,详细代码见Demo。
    let points = WedgeGeometry(wedge, in: rect)

    var path = Path()
    path.addArc(center: points.center, radius: points.innerRadius,
    startAngle: .radians(wedge.start), endAngle: .radians(wedge.end),
    clockwise: false)
    path.addLine(to: points[.bottomTrailing])
    path.addArc(center: points.center, radius: points.outerRadius,
    startAngle: .radians(wedge.end), endAngle: .radians(wedge.start),
    clockwise: true)
    path.closeSubpath()
    return path
    }
    // ···
    }
    1. 用ZStack组装所有的楔形
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    let wedges = ZStack {
    ForEach(ring.wedgeIDs, id: \.self) { wedgeID in
    WedgeView(wedge: self.ring.wedges[wedgeID]!)

    // use a custom transition for insertions and deletions.
    .transition(.scaleAndFade)

    // remove wedges when they're tapped.
    .onTapGesture {
    withAnimation(.spring()) {
    self.ring.removeWedge(id: wedgeID)
    }
    }
    }

    // 如果不加这个Spacer(),会使Mac程序,在没添加任何楔形时,APP尺寸为0。
    Spacer()
    }

    为了更好的理解这个工程,你还需要一些知识:

    如何使用Animatable自定义复杂的动画

    假如我们想实现一个简单的SwiftUI动画,比如点击Button按钮渐变消失,我们可以这样来实现:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    @State private var hidden = false

    var body: some View {
    Button("Tap Me") {
    self.hidden = true
    }
    .opacity(hidden ? 0 : 1)
    .animation(.easeInOut(duration: 2))
    }

    在这个例子中,我们使用@State修饰变量hidden,当hidden的值发生变化时,SwiftUI会自动为我们处理渐变动画。而SwiftUI能够为我们自动执行动画的前提是:SwiftUI已经知道要如果展示该动画效果,那什么情况下,SwiftUI不知道要如何展示动画呢?

    比如:我们通过下面代码绘制出了多角形。

    1
    Shape(sides: 3)

    该方法支持传入不同的值,生成不同的多边形。如果我们希望从三边形变成四边型,那么可以写如下代码:

    1
    2
    3
    Shape(sides: isSquare ? 4 : 3)
    .stroke(Color.blue, lineWidth: 3)
    .animation(.easeInOut(duration: duration))

    但是运行代码后发现,这段代码并无动画过渡效果。这是因为在执行动画的过程中,SwiftUI会从起始状态到终止状态分成不同的阶段来绘制,像opacity从0-1,可能会分成0,0.1,0.2,0.3,···,0.9,1.0,SwiftUI依次进行绘制,从而展示过渡状态。

    同理,从三角形变到四边形,SwiftUI也需要绘制中间状态,但SwiftUI并不知道该如何绘制3.5边形。这时就需要我们自己来告诉SwiftUI该如何绘制了。

    • animatableDataAnimatable协议中,唯一需要实现的方法,通过这个方法来告诉SwiftUI需要监听哪些属性的变化。

      1
      2
      3
      4
      5
      // 代表需要监听的值为Float类型
      var animatableData: Float {
      get { //··· }
      set { //··· }
      }
    • 然而并不是所有类型的属性都能够被SwiftUI所监听,只有遵循VectorArithmetic协议的对象AnimatablePair, CGFloat, Double, EmptyAnimatableData and Float才能被SwiftUI监听。

    • 要实现Demo中楔形图片变换的效果,需要监听的值有startenddepthhue这四个值。animatableData属性的返回值也应该包含这四个值。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      extension Ring.Wedge: Animatable {
      typealias AnimatableData = AnimatablePair<AnimatablePair<Double, Double>, AnimatablePair<Double, Double>>

      var animatableData: AnimatableData {
      get {
      .init(.init(start, end), .init(depth, hue))
      }
      set {
      start = newValue.first.first
      end = newValue.first.second
      depth = newValue.second.first
      hue = newValue.second.second
      }
      }
      }
    • 接下来在这四个属性发生变化时,SwiftUI会通过函数func path(in rect: CGRect) -> Path {}重新绘制,这样就可以展示动画的过渡效果了

    一些琐碎的知识点

    • 使用drawingGroup()提高复杂UI的渲染效率

      以往每创建一个楔形,都是一个单独的View,当楔形数量非常多时,再加上每个View都在执行动画,非常耗费性能。在SwiftUI中,可以通过drawingGroup()将相同类型的View通过Metal绘制在一张画布上,从而减少渲染耗费的性能,避免卡顿。

    • 使用Equatable防止视图的新值和旧值相同时,更新子视图

      1
      2
      3
      struct Wedge: Equatable { 
      // ···
      }
    • 使用PassthroughSubject通知SwiftUI值发生变化

      • PassthroughSubject

        • 使用PassthroughSubject通知绑定的属性的视图,属性发生变化,需要重新绘制。
        1
        2
        3
        4
        5
        6
        7
        let objectWillChange = PassthroughSubject<Void, Never>()

        private(set) var wedgeIDs = [Int]() {
        willSet {
        objectWillChange.send()
        }
        }
        • contentView中监听了Ring模型
        1
        @EnvironmentObject var ring: Ring

        因此在Ring模型的wedgeIDs发生变化时,会发出通知告知contentView使用其绘制的地方,需要重新绘制

      • CurrentValueSubject

        我们常用的@Published属性包装器,实际上就是一种CurrentValueSubject

      简单来说:PassthroughSubject用于表示事件。CurrentValueSubject用于表示状态。用现实世界的案例进行类比。

      PassthroughSubject = 门铃按钮,当有人按门时,只有在你在家时才会收到通知。CurrentValueSubject = 电灯开关,当你在外面时,有人打开了您家中的灯。你回到家,你知道有人打开了它们。

参考:

https://stackoverflow.com/questions/60482737/what-is-passthroughsubject-currentvaluesubject

https://swiftui-lab.com/swiftui-animations-part1/