SwiftUI之深入解析高级动画的时间轴TimelineView

Posted Serendipity·y

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了SwiftUI之深入解析高级动画的时间轴TimelineView相关的知识,希望对你有一定的参考价值。

一、前言

  • 本文中将详细探讨 TimelineView,将从最常见的用法开始。然而,我认为最大的潜力在于结合TimelineView和我们已经知道的现有动画。通过一点创造性,这种组合将让我们最终做出“关键帧类”的动画。

二、TimelineView 的组件

  • TimelineView 是一个容器视图,它根据相关的调度器确定的频率重新评估它的内容,TimelineView 接收一个调度器作为参数。如下所示,使用一个每半秒触发一次的调度器:
TimelineView(.periodic(from: .now, by: 0.5))  timeline in

    ViewToEvaluatePeriodically()


  • 另一个参数是接收 TimelineView 的内容闭包,上下文参数看起来像这样:
struct Context 
    let cadence: Cadence
    let date: Date

    enum Cadence: Comparable 
        case live
        case seconds
        case minutes
    

  • Cadence 是一个 enum,可以使用它来决定在视图中显示什么内容,值可以为 live、seconds、minutes 等。以此作为一个提示,避免显示与节奏无关的信息,典型的例子是避免在具有以秒或分钟为节奏的调度程序的时钟上显示毫秒。
  • 注意,Cadence 不是可以改变的东西,而是反映设备状态的东西,例如在 watchOS 上,手腕下降时节奏会变慢。

三、TimelineView 工作原理

  • 如下所示,有两个随机变化的表情符号,两者之间的唯一区别是,一个是在内容闭包中编写的,而另一个被放在一个单独的视图中,以提高可读性:
struct ManyFaces: View 
    static let emoji = ["😀", "😬", "😄", "🙂", "😗", "🤓", "😏", "😕", "😟", "😎", "😜", "😍", "🤪"]
    
    var body: some View 
        TimelineView(.periodic(from: .now, by: 0.2))  timeline in

            HStack(spacing: 120) 

                let randomEmoji = ManyFaces.emoji.randomElement() ?? ""
            
                Text(randomEmoji)
                    .font(.largeTitle)
                    .scaleEffect(4.0)
                
                SubView()
                
            
        
    
    
    struct SubView: View 
        var body: some View 
            let randomEmoji = ManyFaces.emoji.randomElement() ?? ""

            Text(randomEmoji)
                .font(.largeTitle)
                .scaleEffect(4.0)
        
    

  • 运行代码时效果如下:

  • 为什么左边的表情变了,而另一个表情一直是悲伤的表情?其实,SubView 没有接收到任何变化的参数,这意味着它没有依赖关系,SwiftUI 没有理由重新计算视图的 body。在去年的 WWDC Demystify SwiftUI 有一个很棒的演讲,那就是揭开 SwiftUI 的神秘面纱,它解释了视图标识、生存期和依赖关系,所有这些主题对于理解时间轴的行为是非常重要的。
  • 为了解决这个问题,可以改变 SubView 视图来添加一个参数,这个参数会随着时间轴的每次更新而改变。注意,我们不需要使用参数,它只是必须存在:
struct SubView: View 
    let date: Date // just by declaring it, the view will now be recomputed apropriately.
    
    var body: some View 

        let randomEmoji = ManyFaces.emoji.randomElement() ?? ""

        Text(randomEmoji)
            .font(.largeTitle)
            .scaleEffect(4.0)
    

  • 现在的 SubView 是这样创建的:
SubView(date: timeline.date)
  • 最后,表情符号都可以经历情感的旋风:

四、作用于 Timeline

  • 大多数关于 TimelineView 的例子(在撰写本文时)通常都是关于绘制时钟的,这是有意义的,毕竟时间轴提供的数据是一个日期。
  • 一个最简单的 TimelineView 时钟如下:
TimelineView(.periodic(from: .now, by: 1.0))  timeline in
            
    Text("\\(timeline.date)")


  • 钟表可能会变得更精致一些,例如使用带有形状的模拟时钟,或使用新的 Canvas 视图绘制时钟。然而,TimelineView 不仅仅用于时钟,在很多情况下,我们希望视图在每次时间线更新视图时都做一些事情,放置这些代码的最佳位置是 onChange(of:perform) 闭包。
  • 在下面的例子中,我们使用这种技术,每 3 秒更新模型:
struct ExampleView: View 
    var body: some View 
        TimelineView(.periodic(from: .now, by: 3.0))  timeline in
            QuipView(date: timeline.date)
        
    

    struct QuipView: View 
        @StateObject var quips = QuipDatabase()
        let date: Date
        
        var body: some View 
            Text("_\\(quips.sentence)_")
                .onChange(of: date)  _ in
                    quips.advance()
                
        
    


class QuipDatabase: ObservableObject 
    static var sentences = [
        "There are two types of people, those who can extrapolate from incomplete data",
        "After all is said and done, more is said than done.",
        "Haikus are easy. But sometimes they don't make sense. Refrigerator.",
        "Confidence is the feeling you have before you really understand the problem."
    ]
    
    @Published var sentence: String = QuipDatabase.sentences[0]
    
    var idx = 0
    
    func advance() 
        idx = (idx + 1) % QuipDatabase.sentences.count
        
        sentence = QuipDatabase.sentences[idx]
    

  • 需要注意的是,每次时间轴更新,QuipView 都会刷新两次,也就是说,当时间轴更新一次时,再更新一次,因为通过调用 quips.advance(),将影响 quips 的 @Published 值,更改并触发视图更新。

五、 TimelineView 与传统动画结合

  • 新的 TimelineView 带来了很多新的用处,将它与 Canvas 结合起来,这是一个很好的添加,但这就把为每一帧动画编写所有代码的任务推给了我们。使用已经知道并喜欢的动画来动画视图从一个时间轴更新到下一个,这最终将让完全在 SwiftUI 中创建类似关键帧的动画。
  • 如下所示的节拍器,放大音量播放视频,欣赏拍子的声音是如何与钟摆同步的,而且就像节拍器一样,每隔几拍就会有一个铃声响起:

  • 首先,来看看时间线是怎样的:
struct Metronome: View 
    let bpm: Double = 60 // beats per minute
    
    var body: some View 
        TimelineView(.periodic(from: .now, by: 60 / bpm))  timeline in
            MetronomeBack()
                .overlay(MetronomePendulum(bpm: bpm, date: timeline.date))
                .overlay(MetronomeFront(), alignment: .bottom)
        
    

  • 节拍器的速度通常用 bpm 表示,上面的示例使用了一个周期调度器,它每 60/bpm 秒重复一次,bpm = 60,因此调度器每 1 秒触发一次,也就是每分钟 60 次。
  • Metronome 视图由三个层组成:MetronomeBack、MetronomePendulum 和 MetronomeFront,它们是按这个顺序叠加的,唯一需要在每次时间轴更新时刷新的视图是 MetronomePendulum,它会从一边摆动到另一边,其它视图不会刷新,因为它们没有依赖项。
  • MetronomeBack 和 MetronomeFront 的代码非常简洁,它们使用了一个名为 rounded 梯形的自定义形状:
struct MetronomeBack: View 
    let c1 = Color(red: 0, green: 0.3, blue: 0.5, opacity: 1)
    let c2 = Color(red: 0, green: 0.46, blue: 0.73, opacity: 1)
    
    var body: some View 
        let gradient = LinearGradient(colors: [c1, c2],
                                      startPoint: .topLeading,
                                      endPoint: .bottomTrailing)
        
        RoundedTrapezoid(pct: 0.5, cornerSizes: [CGSize(width: 15, height: 15)])
            .foregroundStyle(gradient)
            .frame(width: 200, height: 350)
    


struct MetronomeFront: View 
    var body: some View 
        RoundedTrapezoid(pct: 0.85, cornerSizes: [.zero, CGSize(width: 10, height: 10)])
            .foregroundStyle(Color(red: 0, green: 0.46, blue: 0.73, opacity: 1))
            .frame(width: 180, height: 100).padding(10)
    

struct RoundedTrapezoid: Shape 
    let pct: CGFloat
    let cornerSizes: [CGSize]
    
    func path(in rect: CGRect) -> Path 
        return Path  path in
            let (cs1, cs2, cs3, cs4) = decodeCornerSize()
            
            // Start of path
            let start = CGPoint(x: rect.midX, y: 0)
            
            // width base and top
            let wb = rect.size.width
            let wt = wb * pct
            
            // angles
            let angle: CGFloat = atan(Double(rect.height / ((wb - wt) / 2.0)))
            
            // Control points
            let c1 = CGPoint(x: (wb - wt) / 2.0, y: 0)
            let c2 = CGPoint(x: c1.x + wt, y: 0)
            let c3 = CGPoint(x: wb, y: rect.maxY)
            let c4 = CGPoint(x: 0, y: rect.maxY)
            
            // Points a and b
            let pa2 = CGPoint(x: c2.x - cs2.width, y: 0)
            let pb2 = CGPoint(x: c2.x + CGFloat(cs2.height * tan((.pi/2) - angle)), y: cs2.height)
            
            let pb3 = CGPoint(x: c3.x - cs3.width, y: rect.height)
            let pa3 = CGPoint(x: c3.x - (cs3.height != 0 ? CGFloat(tan(angle) / cs3.height) : 0.0), y: rect.height - cs3.height)
            
            let pa4 = CGPoint(x: c4.x + cs4.width, y: rect.height)
            let pb4 = CGPoint(x: c4.x + (cs4.height != 0 ? CGFloat(tan(angle) / cs4.height) : 0.0), y: rect.height - cs4.height)
            
            let pb1 = CGPoint(x: c1.x + cs1.width, y: 0)
            let pa1 = CGPoint(x: c1.x - CGFloat(cs1.height * tan((.pi/2) - angle)), y: cs1.height)
            
            path.move(to: start)
            
            path.addLine(to: pa2)
            path.addQuadCurve(to: pb2, control: c2)
            
            path.addLine(to: pa3)
            path.addQuadCurve(to: pb3, control: c3)
            
            path.addLine(to: pa4)
            path.addQuadCurve(to: pb4, control: c4)
            
            path.addLine(to: pa1)
            path.addQuadCurve(to: pb1, control: c1)
            
            path.closeSubpath()
        
    
    
    func decodeCornerSize() -> (CGSize, CGSize, CGSize, CGSize) 
        if cornerSizes.count == 1 
            // If only one corner size is provided, use it for all corners
            return (cornerSizes[0], cornerSizes[0], cornerSizes[0], cornerSizes[0])
         else if cornerSizes.count == 2 
            // If only two corner sizes are provided, use one for the two top corners,
            // and the other for the two bottom corners
            return (cornerSizes[0], cornerSizes[0], cornerSizes[1], cornerSizes[1])
         else if cornerSizes.count == 4 
            // If four corners are provided, use one for each corner
            return (cornerSizes[0], cornerSizes[1], cornerSizes[2], cornerSizes[3])
         else 
            // In any other case, do not round corners
            return (.zero, .zero, .zero, .zero)
        
    

  • 在 MetronomePendulum 视图中:
struct MetronomePendulum: View 
    @State var pendulumOnLeft: Bool = false
    @State var bellCounter = 0 // sound bell every 4 beats

    let bpm: Double
    let date: Date
    
    var body: some View 
        Pendulum(angle: pendulumOnLeft ? -30 : 30)
            .animation(.easeInOut(duration: 60 / bpm), value: pendulumOnLeft)
            .onChange(of: date)  _ in beat() 
            .onAppear  beat() 
    
    
    func beat() 
        pendulumOnLeft.toggle() // triggers the animation
        bellCounter = (bellCounter + 1) % 4 // keeps count of beats, to sound bell every 4th
        
        // sound bell or beat?
        if bellCounter == 0 
            bellSound?.play()
         else 
            beatSound?.play()
        
    
        
    struct Pendulum: View 
        let angle: Double
        
        var body: some View 
            return Capsule()
                .fill(.red)
                .frame(width: 10, height: 320)
                .overlay(weight)
                .rotationEffect(Angle.degrees(angle), anchor: .bottom)
        
        
        var weight: some View 
            RoundedRectangle(cornerRadius: 10)
                .fill(.orange)
                .frame(width: 35, height: 35)
                .padding(.bottom, 200)
        
    

  • 视图需要跟踪在动画中的位置,可以叫做动画阶段,因为需要跟踪这些阶段,所以将使用 @State 变量:
    • pendulumOnLeft:保持钟摆摆动的轨迹;
    • bellCounter:它记录节拍的数量,以确定是否应该听到节拍或铃声。
  • 这个例子使用了 .animation(_:value:) 修饰符,此版本的修饰符,是在指定值改变时应用动画。注意,也可以使用显式动画,只需在 withAnimation 闭包中切换 pendulumOnLeft 变量,而不是调用 .animation()。
  • 为了让视图在动画阶段中前进,我们使用 onChange(of:perform) 修饰符监视日期的变化。除了在每次日期值改变时推进动画阶段外,我们还在 onAppear 闭包中这样做,否则一开始就会有停顿。
  • 最后是创建 NSSound 实例,为了避免例子过于复杂,创建两个全局变量:
let bellSound: NSSound? = 
    guard let url = Bundle.main.url(forResource: "bell", withExtension: "mp3") else  return nil 
    return NSSound(contentsOf: url, byReference: true)
()

let beatSound: NSSound? = 
    guard let url = Bundle.main.url(forResource: "beat", withExtension: "mp3") else  return nil 
    return NSSound(contentsOf: url, byReference: true)
()

六、时间调度器 TimelineScheduler