SwiftUI如何在动画完成时得到通知

本文详细介绍了在SwiftUI中如何在动画完成时得到通知。通过分析SwiftUI动画的局限性和自定义动画的方法,特别是AnimatableModifier协议的应用,提出了一种通过监听animatableData属性变化来实现动画完成通知的策略。文中提供了两种实现方式,一种是通过比较animatableData的值变化,另一种是结合Combine的timeout功能。此外,文章还提供了相关自定义动画的链接供读者进一步学习。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

在这里插入图片描述



功能需求

在App开发中,少不了动画的点缀。有时候我们希望在动画完成时得到通知,这在UIKit编程中是家常便饭的事,但在SwiftUI中又该怎么做呢?

Alt

如上,在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协议

单从该协议的名称我们就可以大概了解它的含义:

  1. 是可动画的
  2. 作为一个修改器去使用

上面已经说过,遵守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中实现旋转倒计时动画

SwiftUI轻松实现翻牌(Flip)动画


好了,至此我们实现了SwiftUI中动画完成时通知的功能,那么聪明的你,学会了吗? 😃


结束语

Hi,我是大熊猫侯佩,一名非自由App开发者,希望我的文章可以解决你的痛点、难点问题。

如果还有疑问就在下面告诉本猫吧 😉

感谢观赏,再会。

评论 14
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

大熊猫侯佩

赏点钱让我买杯可乐好吗 ;)

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值