功能需求
在App开发中,少不了动画的点缀。有时候我们希望在动画完成时得到通知,这在UIKit编程中是家常便饭的事,但在SwiftUI中又该怎么做呢?
如上,在SwiftUI编写的运行示例中,左侧图片演示了单一、复合以及重复动画在完成时如何得到通知;右侧的图片则演示了重复动画在每个单次重复完成时如何得到通知。
那么上面功能在SwiftUI中到底怎么实现呢?
Let‘s go for it!!! 😉
功能分析
1. SwiftUI动画的局限性
在SwiftUI中,你可以在任意View可动画属性改变时,应用动画效果。
比如缩放、透明度以及旋转角度属性改变时:
myView_1
.scaleEffect(isAnimStart ? 2.0 : 1.0)
myView_2
.rotationEffect(isAnimStart ? .radians(Double.pi/2.0) : .radians(0.0))
myView_3
.opacity(isAnimStart ? 0.3 : 1.0)
你可以任意组合系统提供的各种动画、也可以加速或减速动画播放,甚至可以设置动画播放的次数。
但直至目前为止,在Swift5.3, SwiftUI2.0中(Xcode 12.2)中,你仍然没有直接的办法在动画结束时得到通知。
所以我们怎么实现它呢?
答案是: 自定义动画!
2. SwiftUI的自定义动画
SwiftUI使用Animatable协议来实现自定义动画,遵守该协议你必须实现一个名为animatableData的属性,该属性必须遵守VectorArithmetic协议。
很多Swift中的类型都默认遵守VectorArithmetic协议,比如Double,CGFloat等类型。
还有一些比如:CGSize、CGPoint以及CGRect等类型,虽然都不直接遵守 VectorArithmetic,但它们都可以被动画,因为它们都遵守Animatable。
你也可以用GeometryEffect或AnimatableModifier两个协议来完成自定义动画,它们都默认遵守Animatable协议,并且它们还遵守修改器(ViewModifier)协议,这意味着你可以在任意View上像调用修改器那样使用它们。
看了上面这些协议,大家是不是有点头昏脑涨的感觉呢?
这很正常,但是我必须说明的是,尽管做一件事有N种方法,但有时只要一种最简单的方法就够了。
正所谓黑猫、白猫、花猫以及加菲猫(可以吗?)都能抓老鼠,我们只需任何一只就可以搞定鼠害了。
所以,这里我们就选择一个最适合、最容易实现动画完成时通知的方法: 遵守AnimatableModifier协议.
3. AnimatableModifier协议
单从该协议的名称我们就可以大概了解它的含义:
- 是可动画的
- 作为一个修改器去使用
上面已经说过,遵守Animatable协议必须实现一个名为animatableData的属性,这里就是唯一值得关注的地方: 我们用它来实现动画完成通知!
由于AnimatableModifier同样遵守Animatable协议,所以我们只需要在animatableData属性的set方法中监听动画的改变即可。
说到现在,animatableData属性到底是干嘛用的呢!?
很简单,就是它让SwiftUI知道在View动画的任意时刻View该如何显示!
比如,下面就是一个最简单遵守AnimatableModifier的一个实现:
struct OpacityModifier: AnimatableModifier {
var opacityVal: CGFloat
var animatableData: CGFloat {
get {return opacityVal}
set {
opacityVal = newValue
}
}
func body(content: Content) -> some View {
content
.opacity(Double(opacityVal))
}
}
大家可以看到,其中最核心的一点就是content在每次opacityVal发生改变时都会被重新创建!然而opacityVal被什么改变呢?
答案是: SwiftUI会在动画进行时连续修改animatableData属性,而animatableData在set方法中改变了opacityVal。
可以这样应用OpacityModifier修改器:
myView
.modifier(OpacityModifier(opacityVal: isAnimStart ? 1.0 : 0.3))
我们可以用隐式或者显式动画来驱动isAnimStart的变化,不管如何OpacityModifier会根据动画的类型(easeInOut或是spring),用不同的增量(或减量)来连续修改animatableData属性,从而导致myView被动画化!
这,就是AnimatableModifier的核心工作方式!
明白了这些,我们终于可以回过头去填博文开头挖的坑,那就是实现SwiftUI的动画完成通知了。
代码实现
1. 第一种不“太聪明”的实现
为了能够识别动画的开始和结束,我么必须时刻关注animatableData的set方法里newValue值的变化。
拿上面的例子来说,第一次newValue代表着本次动画的初始值(即from),而opacityVal则表示动画的结束时的值(即to)。
所以算法的核心是: 当每一个后续的newValue值等于from或to值时,我们就完成了一次动画!当动画重复时,我们就完成了每个单次的动画。
这里,我们可以在动画重复的每一次结束时(或开始时)得到通知。
但先等等,实际没有那么简单!
因为像easeIn、easeOut或者easeInOut之类的动画,在动画的开始或结束时,View的改变会逐渐保持一段时间的稳定,这意味着newValue会多次等于from或to的值。我们必须区分这种情况,这里采用的方法是忽略动画开头n(代码中是10)次比较,代码如下:
struct RepeatOpacityModifier: AnimatableModifier {
static var fromVal: Double? = nil
static var ignoreCount = 0
var opacityVal: Double
@Binding var animCount: Int
private let p = PassthroughSubject<Void,Never>()
var animatableData: Double {
get {opacityVal}
set {
if let fromVal = Self.fromVal {
// 忽略前10次比较
if Self.ignoreCount >= 10{
// 当newValue等于to或from值时,我们知道完成了一次动画
if newValue == opacityVal || newValue == fromVal {
// 在set方法中不能直接在异步调用中修改animCount
// 因为异步block会捕获可修改的self,这是不被允许的!
// 所以我们要用间接的方法修改animCount的值。
p.send()
Self.ignoreCount = 0
}
}else{
Self.ignoreCount += 1
}
}else{
Self.fromVal = newValue
}
if self.animCount >= TotalCount {
// 整个动画完成(所有重复都完成)
Self.ignoreCount = 0
Self.fromVal = nil
}
opacityVal = newValue
}
}
func body(content: Content) -> some View {
content
.opacity(opacityVal)
.onReceive(p){
DispatchQueue.main.async {
// 某一次动画完成
self.animCount += 1
}
}
}
}
像上面注释里说明的那样,之所以要在发布器p的触发中修改animCount,是因为Swift编译器不允许在可逃脱闭包中引用可修改的self,大家实际编译一下就知道是什么意思了。
但上面这个SwiftUI的动画完成的通知算法,似乎有些傻,因为我们需要生硬的比较animCount是否达到总完成次数,才能判断整个动画是否完成。
有没有更简单的方法呢?
当然有,那就是:Combine!
2. Combine闪亮登场
仔细思考一下,动画在完成后会停止读写animatableData属性,如果在一段(足够短的)时间内我们发现animatableData不再被写入,我们就知道全部动画完成了!
是不是很简单!?
Combine恰好有一个timeout修改器帮我们做这件事,我们不用费事多写哪怕一行相关的代码。
timeout修改器的作用是,当其监听的发布器指定时间内没有发送消息时,则会自动结束,否则会一直监听。
所以聪明的你已经知道该怎么做了: 只要在animatableData属性的set方法中不停发送消息,并且监听该消息:一旦消息停止发送,我们就知道动画结束了!
下面是代码:
struct OpacityModifier2: AnimatableModifier {
var opacityVal: CGFloat
static var first = true
static var subs = Set<AnyCancellable>()
let completion: ()->()
let p: PassthroughSubject<Void,Never> = .init()
var animatableData: CGFloat {
get {return opacityVal}
set {
if Self.first {
// 只在第一次进入set方法中监听发布者p
Self.first = false
// 同样原因,不能在可逃脱闭包中引用可变self,这里需要先将其放到局部变量中。
let comp = self.completion
p.timeout(.seconds(0.1), scheduler: DispatchQueue.main)
.sink(receiveCompletion: {_ in
// 在结束监听时,调用用户传入的回调,此时意味着动画完成了。
comp()
Self.first = true
}){}
.store(in: &Self.subs)
}
p.send()
opacityVal = newValue
}
}
func body(content: Content) -> some View {
content
.opacity(Double(opacityVal))
}
}
我们可以在视图上这样应用OpacityModifier2修改器:
myView
.modifier(OpacityModifier2(opacityVal: isAnimStart ? 1.0 : 0.15){
// 动画完成回调,做你想做的吧 ;)
})
.animation(.spring())
我们还可以对同一个View应用多个自定义动画修改器,而且可以对它们分别使用不同的动画。
比如我还写了一个ScaleModifier修改器,用来改变视图的大小,我这样与OpacityModifier2一起使用:
myView
.modifier(ScaleModifier(scaleVal: isAnimStart ? 1.5 : 1.0){
// 动画Scale完成
})
.animation(.linear(duration: 3.0))
.modifier(OpacityModifier2(opacityVal: isAnimStart ? 1.0 : 0.15){
// 动画Opacity完成
})
.animation(.spring())
注意上面Scale和Opacity分别应用了两种不同动画。
其实监听animatableData还有很多种算法,这里只是抛砖引玉,大家可以找到最适合自己的算法。
最后要说明的是,拿SwiftUI中的可动画协议Animatable来实现动画完成的监听,实在是大材小用。它的主要目的是用来实现强大的自定义动画,以后有机会可以多写几篇来介绍。
PS: 关于自定义动画的进一步介绍请观赏下面的链接:
SwiftUI在任意视图上实现酷炫的溶解(Dissolve)动画
好了,至此我们实现了SwiftUI中动画完成时通知的功能,那么聪明的你,学会了吗? 😃
结束语
Hi,我是大熊猫侯佩,一名非自由App开发者,希望我的文章可以解决你的痛点、难点问题。
如果还有疑问就在下面告诉本猫吧 😉
感谢观赏,再会。