[译]高级 SwiftUI 动画 — Part 3:AnimatableModifier

我们已经看到 Animatable 协议是如何帮助我们实现路径变换矩阵的动画化的。在本系列的最后一部分,我们将更进一步。AnimatableModifier 是三者中最强大的。有了它,我们可以不受限制地完成任务。

这个名字说明了一切: AnimatableModifier。它是一个 ViewModifier,符合 Animatable。如果你不知道 AnimatableanimatableData 是如何工作的,请先到本系列的第一部分去看看。

iOS 15补充:
现在,视图协议可以准守 Animatable 协议,而且 AnimatableModifier 已被弃用。不过,本文的大部分内容也适用于 Animatable 视图。在文章的最后,我将向你展示如何编写一个 Animatable 视图。它与 AnimatableModifier 基本相同,但更为简单!

好吧,让我们暂停一下,想想拥有一个可动画的修饰符意味着什么......你可能认为这好得不像真的。我真的可以通过一个动画多次修改我的视图吗?答案很简单:是的,你可以。

本文的完整示例代码可在以下位置找到:

https://gist.github.com/swiftui-lab/e5901123101ffad6d39020cc7a810798

示例8 需要的图片资源。从这里下载:

https://swiftui-lab.com/?smd_process_download=1&download_id=916

AnimatableModifier不能实现动画! 为什么?

如果你打算在生产代码中使用 AnimatableModifier,请确保阅读本文最后一节: 与版本共舞。

如果你自己尝试使用了这个协议,有可能你一开始就会碰壁。我当然也是一样。在我的第一次尝试中,我写了一个非常简单的可动画的修饰符,但是,视图并没有动画化。我又试了几次,都没有效果。由于我们处于早期的测试阶段,我认为这个功能就是不存在的,于是就完全放弃了它。幸运的是,我后来坚持了下来。让我强调这个词:"幸运地"。事实证明,我的第一个修饰符是完美的,但可动画的修饰符在容器内是不工作的。恰好我第二次尝试时,我的视图不在容器内。如果我不是那么幸运,你就不会看到这第三篇文章了。

例如,以下 modifier 可以成功实现动画:

MyView().modifier(MyAnimatableModifier(value: flag ? 1 : 0))

但是相同的代码,在 VStack 中就没有动画了:

VStack {
    MyView().modifier(MyAnimatableModifier(value: flag ? 1 : 0))
}

这个问题在官方解决之前,经过尝试,可以在 VStack 中改成下面的代码,就可以实现动画:

VStack {
    Color.clear.overlay(MyView().modifier(MyAnimatableModifier(value: flag ? 1 : 0))).frame(width: 100, height: 100)
}

我们基本上是用一个透明的视图来占据我们的实际视图的空间,这个视图将被放置在它上面,使用.overlay()。唯一不方便的是,我们需要知道实际视图有多大,这样我们就可以在它后面设置透明视图的大小。这有时会很棘手,但我们必须要有一些技巧。我们将在下面的例子中看到。

## 动画文本

首先需要制作一些文字动画。对于这个例子,我们将创建一个进度加载指示器,它将是一个带有标签的 Label:

可能很多人都认为应该使用动画路径实现。但是那样的话,内部标签就无法设置动画,然而使用 AnimatableModifier 可以实现。

完整的代码作为 示例10 在文章开始的链接中。关键代码如下:

struct PercentageIndicator: AnimatableModifier {
    var pct: CGFloat = 0
    
    var animatableData: CGFloat {
        get { pct }
        set { pct = newValue }
    }
    
    func body(content: Content) -> some View {
        content
            .overlay(ArcShape(pct: pct).foregroundColor(.red))
            .overlay(LabelView(pct: pct))
    }
    
    struct ArcShape: Shape {
        let pct: CGFloat
        
        func path(in rect: CGRect) -> Path {

            var p = Path()

            p.addArc(center: CGPoint(x: rect.width / 2.0, y:rect.height / 2.0),
                     radius: rect.height / 2.0 + 5.0,
                     startAngle: .degrees(0),
                     endAngle: .degrees(360.0 * Double(pct)), clockwise: false)

            return p.strokedPath(.init(lineWidth: 10, dash: [6, 3], dashPhase: 10))
        }
    }
    
    struct LabelView: View {
        let pct: CGFloat
        
        var body: some View {
            Text("\(Int(pct * 100)) %")
                .font(.largeTitle)
                .fontWeight(.bold)
                .foregroundColor(.white)
        }
    }
}

正如你在例子中所看到的,我们没有让ArcShape成为可动画的。这是没有必要的,因为修饰符已经在用不同的 pct 值多次创建形状。

## 渐变动画

如果你曾经尝试对渐变进行动画处理,你可能发现有一些限制。例如,你可以对起点和终点制作动画,但你不能对渐变的颜色制作动画。在这里,我们也可以从AnimatableModifier中获益。

很容易就可以实现这个功能,在这个基础上可以实现更多复杂的动画。如果需要插入中间颜色,我们只需要计算 RGB 值的平均值。另外需要注意,modifier 假设输入颜色数组都包含相同数量的颜色。

完整的代码作为 示例11 在文章开始的链接中。关键代码如下:

struct AnimatableGradient: AnimatableModifier {
    let from: [UIColor]
    let to: [UIColor]
    var pct: CGFloat = 0
    
    var animatableData: CGFloat {
        get { pct }
        set { pct = newValue }
    }
    
    func body(content: Content) -> some View {
        var gColors = [Color]()
        
        for i in 0..<from.count {
            gColors.append(colorMixer(c1: from[i], c2: to[i], pct: pct))
        }
        
        return RoundedRectangle(cornerRadius: 15)
            .fill(LinearGradient(gradient: Gradient(colors: gColors),
                                 startPoint: UnitPoint(x: 0, y: 0),
                                 endPoint: UnitPoint(x: 1, y: 1)))
            .frame(width: 200, height: 200)
    }
    
    // This is a very basic implementation of a color interpolation
    // between two values.
    func colorMixer(c1: UIColor, c2: UIColor, pct: CGFloat) -> Color {
        guard let cc1 = c1.cgColor.components else { return Color(c1) }
        guard let cc2 = c2.cgColor.components else { return Color(c1) }
        
        let r = (cc1[0] + (cc2[0] - cc1[0]) * pct)
        let g = (cc1[1] + (cc2[1] - cc1[1]) * pct)
        let b = (cc1[2] + (cc2[2] - cc1[2]) * pct)

        return Color(red: Double(r), green: Double(g), blue: Double(b))
    }
}

更多文本动画

在我们的下一个例子中,我们将再次为文本制作动画。不过,在这种情况下,我们将逐步地做:一次一个字符。

平滑的渐进式缩放需要一些数学运算,但结果是值得的。完整的代码可在本页顶部链接的gist文件中的Example12中找到。

struct WaveTextModifier: AnimatableModifier {
    let text: String
    let waveWidth: Int
    var pct: Double
    var size: CGFloat
    
    var animatableData: Double {
        get { pct }
        set { pct = newValue }
    }
    
    func body(content: Content) -> some View {
        
        HStack(spacing: 0) {
            ForEach(Array(text.enumerated()), id: \.0) { (n, ch) in
                Text(String(ch))
                    .font(Font.custom("Menlo", size: self.size).bold())
                    .scaleEffect(self.effect(self.pct, n, self.text.count, Double(self.waveWidth)))
            }
        }
    }
    
    func effect(_ pct: Double, _ n: Int, _ total: Int, _ waveWidth: Double) -> CGFloat {
        let n = Double(n)
        let total = Double(total)
        
        return CGFloat(1 + valueInCurve(pct: pct, total: total, x: n/total, waveWidth: waveWidth))
    }
    
    func valueInCurve(pct: Double, total: Double, x: Double, waveWidth: Double) -> Double {
        let chunk = waveWidth / total
        let m = 1 / chunk
        let offset = (chunk - (1 / total)) * pct
        let lowerLimit = (pct - chunk) + offset
        let upperLimit = (pct) + offset
        guard x >= lowerLimit && x < upperLimit else { return 0 }
        
        let angle = ((x - pct - offset) * m)*360-90
        
        return (sin(angle.rad) + 1) / 2
    }
}

extension Double {
    var rad: Double { return self * .pi / 180 }
    var deg: Double { return self * 180 / .pi }
}

## 发挥你的创意

在我们对AnimatableModifier有所了解之前,下面这个例子可能看起来不可能实现。我们的下一个挑战是创建一个计数器。

这个练习的诀窍是为每个数字使用5个文本视图,用.spring()动画上下移动它们。我们还需要使用一个.clipShape()修饰符,来隐藏画在边界外的部分。为了更好地理解它的工作原理,你可以对.clipShape()进行注释,并大大降低动画的速度。

完整的代码可以在本页顶部链接的gist文件中以Example13的形式获得。

struct MovingCounterModifier: AnimatableModifier {
        @State private var height: CGFloat = 0

        var number: Double
        
        var animatableData: Double {
            get { number }
            set { number = newValue }
        }
        
        func body(content: Content) -> some View {
            let n = self.number + 1
            
            let tOffset: CGFloat = getOffsetForTensDigit(n)
            let uOffset: CGFloat = getOffsetForUnitDigit(n)

            let u = [n - 2, n - 1, n + 0, n + 1, n + 2].map { getUnitDigit($0) }
            let x = getTensDigit(n)
            var t = [abs(x - 2), abs(x - 1), abs(x + 0), abs(x + 1), abs(x + 2)]
            t = t.map { getUnitDigit(Double($0)) }
            
            let font = Font.custom("Menlo", size: 34).bold()
            
            return HStack(alignment: .top, spacing: 0) {
                VStack {
                    Text("\(t[0])").font(font)
                    Text("\(t[1])").font(font)
                    Text("\(t[2])").font(font)
                    Text("\(t[3])").font(font)
                    Text("\(t[4])").font(font)
                }.foregroundColor(.green).modifier(ShiftEffect(pct: tOffset))
                
                VStack {
                    Text("\(u[0])").font(font)
                    Text("\(u[1])").font(font)
                    Text("\(u[2])").font(font)
                    Text("\(u[3])").font(font)
                    Text("\(u[4])").font(font)
                }.foregroundColor(.green).modifier(ShiftEffect(pct: uOffset))
            }
            .clipShape(ClipShape())
            .overlay(CounterBorder(height: $height))
            .background(CounterBackground(height: $height))
        }
        
        func getUnitDigit(_ number: Double) -> Int {
            return abs(Int(number) - ((Int(number) / 10) * 10))
        }
        
        func getTensDigit(_ number: Double) -> Int {
            return abs(Int(number) / 10)
        }
        
        func getOffsetForUnitDigit(_ number: Double) -> CGFloat {
            return 1 - CGFloat(number - Double(Int(number)))
        }
        
        func getOffsetForTensDigit(_ number: Double) -> CGFloat {
            if getUnitDigit(number) == 0 {
                return 1 - CGFloat(number - Double(Int(number)))
            } else {
                return 0
            }
        }
    }

## 文本颜色动画

如果你曾经尝试对.foregroundColor()进行动画处理,你可能已经注意到它工作得很好,除了当视图是文本类型时。我不知道这是个错误,还是功能缺失。尽管如此,如果你需要对文本的颜色进行动画处理,你可以用下面这样的AnimatableModifier来实现。完整的代码可以在本页顶部链接的gist文件中的Example14中找到。

struct AnimatableColorText: View {
    let from: UIColor
    let to: UIColor
    let pct: CGFloat
    let text: () -> Text
    
    var body: some View {
        let textView = text()
        
        return textView.foregroundColor(Color.clear)
            .overlay(Color.clear.modifier(AnimatableColorTextModifier(from: from, to: to, pct: pct, text: textView)))
    }
    
    struct AnimatableColorTextModifier: AnimatableModifier {
        let from: UIColor
        let to: UIColor
        var pct: CGFloat
        let text: Text
        
        var animatableData: CGFloat {
            get { pct }
            set { pct = newValue }
        }

        func body(content: Content) -> some View {
            return text.foregroundColor(colorMixer(c1: from, c2: to, pct: pct))
        }
        
        // This is a very basic implementation of a color interpolation
        // between two values.
        func colorMixer(c1: UIColor, c2: UIColor, pct: CGFloat) -> Color {
            guard let cc1 = c1.cgColor.components else { return Color(c1) }
            guard let cc2 = c2.cgColor.components else { return Color(c1) }
            
            let r = (cc1[0] + (cc2[0] - cc1[0]) * pct)
            let g = (cc1[1] + (cc2[1] - cc1[1]) * pct)
            let b = (cc1[2] + (cc2[2] - cc1[2]) * pct)

            return Color(red: Double(r), green: Double(g), blue: Double(b))
        }
    }
}

## 与版本共舞

我们已经看到AnimatableModifier非常强大......但是,也有一点小毛病。最大的问题是,在Xcode和iOS/MacOS版本的某些组合下,应用程序会在启动时直接崩溃。更糟糕的是,这通常发生在部署应用程序时,但在正常开发期间用Xcode编译和运行时不会发生。你可能花了很多时间来开发和调试,认为一切都很好,但在部署时,你会得到这样的结果。

dyld: Symbol not found: _$s7SwiftUI18AnimatableModifierPAAE13_makeViewList8modifier6inputs4bodyAA01_fG7OutputsVAA11_GraphValueVyxG_AA01_fG6InputsVAiA01_L0V_ANtctFZ
  Referenced from: /Applications/MyApp.app/Contents/MacOS/MyApp
  Expected in: /System/Library/Frameworks/SwiftUI.framework/Versions/A/SwiftUI

例如,如果用Xcode 11.3部署应用程序,并在macOS 10.15.0上执行,它将无法启动,并出现 "没有找到符号 "的错误。然而,在10.15.1上运行相同的可执行文件却能正常工作。

相反,如果我们用Xcode 11.1进行部署,它在所有的macOS版本(至少我试过的版本)都能正常工作。

类似的情况也发生在iOS上。一个使用AnimatableModifier的应用程序,在Xcode 11.2中部署后,在iOS 13.2.2中无法启动,但在iOS 13.2.3中可以正常工作。

目前,我将继续使用Xcode 11.1用于我的需要AnimatableModifier的macOS项目。在未来,我可能会使用较新版本的Xcode,但将应用程序的要求提高到macOS 10.15.1(除非这个问题得到解决,我非常怀疑)。

Animatable View (iOS 15补充)

自 iOS15 和 macOS12 起,View 协议可以采用 Animatable 协议。这样就无需使用 AnimatableModifier 了。如果你知道如何使用 AnimatableModifier,通过这个快速示例,你就会发现更新代码是多么容易:

struct ExampleView: View {
    @State var animate = false
    
    var body: some View {
        CustomView(xoffset: animate ? 100 : -100)
            .task {
                withAnimation(.spring.repeatForever(autoreverses: true)) {
                    animate.toggle()
                }
            }
        
    }
}

struct CustomView: View {
    var xoffset: CGFloat
    
    var body: some View {
        Rectangle()
            .fill(.green.gradient)
            .frame(width: 30, height: 30)
            .offset(computedOffset())
    }
    
    func computedOffset() -> CGSize {
        return CGSize(width: xoffset, height: sin(xoffset/100 * .pi) * 100)
    }
}

该动画将使矩形从左到右直线移动。虽然垂直偏移量的计算公式为 sin(xoffset/100 * .pi) * 100,但在 x = -100.0 时,y 为 0.0,而在 x = 100.0 时,y 为 0.0。就 SwiftUI 而言,由于动画开始和结束时 y 均为 0.0,因此纵轴上没有任何动画。

如果我们希望在动画的每一帧中都重新计算视图主体,那么我们可以通过添加 AnimatableanimatableData 属性来采用 Animatable 协议。现在,在制作动画时,矩形将遵循正弦波路径:

struct CustomView: View, Animatable {
    var xoffset: CGFloat
    
    var animatableData: CGFloat {
        get { xoffset }
        set { xoffset = newValue }
    }
    
    var body: some View {
        Rectangle()
            .fill(.green.gradient)
            .frame(width: 30, height: 30)
            .offset(computedOffset())
    }
    
    func computedOffset() -> CGSize {
        return CGSize(width: xoffset, height: sin(xoffset/100 * .pi) * 100)
    }
}

小结

我们已经看到了Animatable协议有多简单,它有多大的作用。把你的创造力用于工作,结果将是惊人的。

译自 The SwiftUI LabAdvanced SwiftUI Animations – Part 3: AnimatableModifier

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,744评论 6 502
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,505评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,105评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,242评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,269评论 6 389
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,215评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,096评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,939评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,354评论 1 311
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,573评论 2 333
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,745评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,448评论 5 344
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,048评论 3 327
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,683评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,838评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,776评论 2 369
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,652评论 2 354

推荐阅读更多精彩内容