用SwiftUI打造一个精美App

SwiftUI中的一切都是视图。

文章来源:Building Custom Views with SwiftUI

更多SwiftUI文章:

手把手教你用SwiftUI写程序

SwiftUI布局基础

struct ContentView: View {
    var body: some View {
        Text("Hello, world!")
//            .edgesIgnoringSafeArea(.all)
    }
}
复制代码

这段代码包含三个视图:

1.png

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

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

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

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

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

2.png

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

布局步骤

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

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

var body: some View {
        Text("Avocado Toast")
            .padding(10)
            .background(Color.green)
    }
复制代码

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

1.gif

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

struct ContentView: View {
    var body: some View {
        Image("20x20_avoado")
    }
}
复制代码

做法:

.frame(width: 30, height: 30)
复制代码

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

3.png

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

例三:

// 子视图必须平等竞争一个空间
HStack {
            Text("Delicious")
            Image("20x20_avocado")
            Text("Avocado Toast")
        }
        .lineLimit(1)
复制代码

2.1.gif

  • 设置文字底基线对齐

    4.png

  • 设置图片的底基线

    5.png

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

6.png

  • 自定义对齐方式

    extension VerticalAlignment {
        private enum MidStarAndTitle : AlignmentID {
            // 告诉SwiftUI如何计算默认值
            static func defaultValue(in d: ViewDimensions) -> CGFloat {
                return d[.bottom]
            }
        }
        static let midStarAndTitle = VerticalAlignment(MidStarAndTitle.self)
    }
    复制代码
  • 设置文字的基线

    7.png

SwiftUI绘图

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

8.png

  • 实现渐变色

    9.png

    • 角渐变色

      10.png

    • 使用角渐变色填充圆

      11.png

    • 使用渐变色填充圆环

      12.png

  • 实现复杂图形绘制

    3.gif

    完整代码可参见:官方Demo

    总体步骤主要包括:

    1. 创建单个楔形的数据模型
    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. 绘制单个子图形
    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组装所有的楔形
    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按钮渐变消失,我们可以这样来实现:

    @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不知道要如何展示动画呢?

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

    Shape(sides: 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需要监听哪些属性的变化。

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

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

      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防止视图的新值和旧值相同时,更新子视图

      struct Wedge: Equatable { 
      	// ···
      }
      复制代码
    • 使用PassthroughSubject通知SwiftUI值发生变化

      • PassthroughSubject

        • 使用PassthroughSubject通知绑定的属性的视图,属性发生变化,需要重新绘制。
        let objectWillChange = PassthroughSubject<Void, Never>()
        
        private(set) var wedgeIDs = [Int]() {
                willSet {
                    objectWillChange.send()
                }
            }
        复制代码
        • contentView中监听了Ring模型
        @EnvironmentObject var ring: Ring
        复制代码

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

      • CurrentValueSubject

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

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

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

    参考:

    stackoverflow.com/questions/6…

    swiftui-lab.com/swiftui-ani…

猜你喜欢

转载自juejin.im/post/6982748666394050590