需求来源
红包雨需求源于常见产品的奖励活动需求。而一般这类活动页面常使用h5实现。但为了更好的产品体验,给用户提供更好的交互,已经减少资源的加载时间,于是便应产品的需求进行端的开发。实现的方案难度并不复杂,主要是接管整个页面的点击,捕捉当前动画状态已及动画的控制。
此外, 代码已分为两部分集成至CocoaPod。
若希望能自定义程度更高,可 Pod 'RedPackRainView'
, 里面只包含红包雨的基本生命周期与回调,附: GitHub地址。
优酷视屏中为集成化更高的红包雨组件。暂时未公开,而放在公司的私有源中。
gif 图为框架的基本功能, 集成化完整特效见: 完整特效 (因为是截屏,背景音乐估计听不到)
实现思路
实现的思路十分简明,主要分为四部分。
首先接管点击事件。界面上的除了红包外,还有其他展示的界面元素。因此需要我们自己来决定是否响应view的点击事件。需要特别说明的是,组件在实现上并没有持有一个监控列表来辨别区分红包,炸弹与其他界面,而是简单的通过tag来标记,这里使用的时候可能要注意。响应事件后我们可以通过layer.presentation
获取红包 layer
的transform
状态。
第二部则为创建红包雨的定时器与动画特效,这里可以通过动画时间偏移timeOffset
实现。
紧接着便是完成功能上剩下的需求,实现添加动画暂停 与 回溯动画的功能。
最后一步再配上素材网提供的五毛钱背景音乐,一个红包雨界面就完成了。
处理点击事件
接管页面整个点击事件
// RedpackRainView
// MARK: 初始化设置
public override init(frame: CGRect) {
super.init(frame: frame)
let tap = UITapGestureRecognizer()
tap.addTarget(self, action: #selector(self.clicked))
addGestureRecognizer(tap)
}
第一步,为红包雨界面添加点击手势。并在在clicked方法中接管红包雨页面的所有点击事件。
点击测试与点击穿透
点击事件的接管与响应主要依赖于系统的 点击事件传递 hitTest()
方法。我们先来看看苹果对点击传递的说明。
hitTest function
Description:
Returns the farthest descendant of the receiver in the layer hierarchy (including itself) that contains the specified point
Parameters:
thePoint
A point in the coordinate system of the receiver's superlayer.
Returns: return The layer that contains
thePoint
ornil
if the point lies outside the receiver’s bounds rectangle.
函数将进行layer的点击测试,如果点击点在 layer 范围内, 则会返回最末层的叶子节点的子 layer,否则则返回nil。因此我们可以自己判断组件是否被点击,代码如下:
/// 简单使用tag标记, -999: 红包, -1000: 炸弹, -1001: 点击不可穿透的 view
public let notPenetrateTag = -1001
public let redPackCompomentTag = -999
public let bombCompomentTag = -1000
/// 点击事件
@objc func clicked(tapgesture: UITapGestureRecognizer) {
// 1.定位点击点
let touchPoint = tapgesture.location(in: self)
let views = self.subviews
// 2.倒序遍历, 从最上层view开始找起
for viewTuple in views.enumerated().reversed() {
// 3.判断界面内的红包的点击事件
let hitTestSuccess = (viewTuple.element.layer.presentation()?
.hitTest(touchPoint) != nil)
if hitTestSuccess {
// 4.通过 tag 判断这是个红包或是什么
switch(viewTuple.element.tag) {
/// 点到的是红包,马上结束点击事件
case redPackCompomentTag:
redPackClickedCount += 1
// 组件支持 handle 和 delegate 两种方式
clickHandle?(self, viewTuple.element)
delegate?.redpackDidClicked?(rainView: self, redpack: viewTuple.element)
return
/// 如果是炸弹,逻辑类似
case bombCompomentTag:
bombClickedCount += 1
bombClickHandle?(self, viewTuple.element)
delegate?.bombDidClicked?(rainView: self, bomb: viewTuple.element)
return
/// 其他view
/// 没开启点击穿透 或 这是个不可穿透的对象,则阻断点击,进行 return
/// 开启点击穿透后, 默认穿透所有非红包或炸弹的view
default:
if !clickPenetrateEnable ||
viewTuple.element.tag == notPenetrateTag {
return
}
}
}
}
}
实现上十分简单, 主要是:
1.先定位点击点。
2.再 倒序遍历
红包雨界面中的所有子界面,让 view 从上层向下
处理点击事件。
3.使用 hitTest 方法判断点击事件。
4.简单的通过 tag 区分是红包,炸弹还是可以阻挡点击的view。如果这个view在阻挡列表中,则退出函数,不处理点击事件。点击被阻挡。
我们继续来看看苹果对点击传递的说明。
概括来说:
点击事件会从点击界面开始响应。如果自己不能响应,则逐级向上传递。直到遇到可以处理的处理者为止。如果最后都没有响应者,则无视此次点击事件。
每当我们发起一个点击,它会通过 UIWindow, 调用 hitTest: withEvent:
一直向子控件询问是否为被点击的对象。直到再无子控件为止,这时会返回控件自身。最后找到并返回被点击的控件。需要注意的是子视图之间层级是有前后之分的,应该先遍历上层的自视图再逐级向下。因此,遍历子视图时是 倒序
的。具体流程,就引用 CoderMJLee 的简图了。
动画定位
再点击事件后, 最大的问题便是。点击特效的处理与定位。因为使用的是系统特效。我们需要获取点击后的当前view像frame
,transform
的一些位置, 旋转信息。但是如果你简单的获取 frame 或 transform 值,取到的只能是原view的值,而非当前动画的效果。那么,我们要如何获取动画中view 的当前属性信息呢。其实Apple早就为我们想好了这一块的功能。使用 presentation 函数即可。他的说明如下:
presentation function
Description:
Returns a copy of the presentation layer object that represents the state of the layer as it currently appears onscreen.
The layer object returned by this method provides a close approximation of the layer that is currently being displayed onscreen. While an animation is in progress, you can retrieve this object and use it to get the current values for those animations.
The sublayers, mask, and superlayer properties of the returned layer return the corresponding objects from the presentation tree (not the model tree). This pattern also applies to any read-only layer methods. For example, the hitTest(_:) method of the returned object queries the layer objects in the presentation tree.
Returns: return a copy of the current presentation layer object.
presentation
会返回当前 layer 在屏幕绘制拷贝对象, 改对象的属性及位置会和当前动画特效显示的一样。这个方法返回的图层对象在当前屏幕上显示的图层的近似值。 当动画正在进行时,您可以检索此对象并使用它来获取这些动画的当前值。
因此,除了定位外最重要的是还能获得动画 layer 的当前 transform
变换属性。
/// 返回被点击红包的当前状态的红包副本
func copyRedPack(clickView: UIView) -> UIView? {
guard let layer = clickView.layer.presentation() else {
return nil
}
...
newRedPack.transform = layer.affineTransform()
newRedPack.frame = layer.frame
}
序列动画常驻
红包雨动画中容易遇到的另一个问题就是, 首次播放序列动画的时候容易卡顿。因此需要作为属性初始化并持有常驻于内存。
/// 使用属性加载缓存图片
private lazy var smakeImgList: [UIImage] = {
var images: [UIImage] = []
for i in 1...12 {
let bundle = Bundle.init(for: self.classForCoder)
if let img = img(String(format:"%03d", i)) {
images.append(img)
}
}
return images
}()
时间魔法 - 动画的偏移
那么我们是要如何实现上图中的时间暂停与倒流特效呢。答案便是使用Apple 贴心为我们提供的动画偏移属性 timeOffset
。
时间暂停
// 暂停动画
private func pauseLayer(layer: CALayer) {
// notice: 不要随便调整代码顺序!
// 时间回溯
let off = layer.beginTime * CFTimeInterval(layer.speed)
layer.timeOffset = 0.0
layer.beginTime = 0.0
let pausedTime = layer.convertTime(CACurrentMediaTime(), to: nil)
layer.speed = 0.0
layer.timeOffset = pausedTime - off
}
这里的代码可能不太好理解,因此先解释几个使用了的属性 beginTime
, speed,
, timeOffset
beginTime
指定了动画开始之前的的延迟时间。这里的延迟从动画添加到可见图层的那一刻开始测量,默认是0(就是说动画会立刻执行)。
speed
是一个时间的倍数,默认1.0,减少它会减慢图层/动画的时间,增加它会加快速度。如果2.0的速度,那么对于一个duration
为1的动画,实际上在0.5秒的时候就已经完成了。
timeOffset
和beginTime
类似,但是和增加beginTime
导致的延迟动画不同,增加timeOffset
只是让动画快进到某一点,例如,对于一个持续1秒的动画来说,设置timeOffset
为0.5意味着动画将从一半的地方开始。
因此, 先使用 off
变量 记录当前的开始延时偏移 layer.beginTime * CFTimeInterval(layer.speed)
。
然后把动画开始延时设为0。最后减去延期时间的偏移量就是当前动画状态的真正偏移量。具体可参考apple提供的以下对 timeOffset
的说明:
Additional offset in active local time. i.e. to convert from parent
time tp to active local time t: t = (tp - begin) * speed + offset.
One use of this is to "pause" a layer by setting `speed' to zero and
`offset' to a suitable value. Defaults to 0.
恢复动画与动画倒流
恢复动画与动画倒流特效原理类似,主要还是依赖于动画偏移timeOffset
以及 convertTime()
方法的本地时间的使用。
CoreAnimation有一个全局时间的概念,也就是所谓的马赫时间(“马赫”实际上是iOS和Mac OS系统内核的命名)。马赫时间在设备上所有进程都是全局的--但是在不同设备上并不是全局的--不过这已经足够对动画的参考点提供便利了,你可以使用
CACurrentMediaTime
函数来访问马赫时间:CFTimeInterval time = CACurrentMediaTime();</pre>
这个函数返回的值其实无关紧要(它返回了设备自从上次启动后的秒数,并不是你所关心的),它真实的作用在于对动画的时间测量提供了一个相对值。注意当设备休眠的时候马赫时间会暂停,也就是所有的
CAAnimations
(基于马赫时间)同样也会暂停。因此马赫时间对长时间测量并不有用。比如用
CACurrentMediaTime
去更新一个实时闹钟并不明智。(可以用Date()
代替)。每个
CALayer
和CAAnimation
实例都有自己本地时间的概念,是根据父图层/动画层级关系中的beginTime
,timeOffset
和speed
属性计算。就和转换不同图层之间坐标关系一样,CALayer
同样也提供了方法来转换不同图层之间的本地时间。如下:func convertTime(_ t: CFTimeInterval, to l: CALayer?) -> CFTimeInterval func convertTime(_ t: CFTimeInterval, from l: CALayer?) -> CFTimeInterval
当用来同步不同图层之间有不同的
speed
,timeOffset
和beginTime
的动画,这些方法会很有用。
// 恢复动画
private func resumeLayer(layer: CALayer) {
let pausedTime = layer.timeOffset
layer.speed = 1.0
layer.timeOffset = 0.0
layer.beginTime = 0.0
let timeSincePause = layer.convertTime(CACurrentMediaTime(), to: nil) - pausedTime
layer.beginTime = timeSincePause;
}
/// 时间倒流,回溯红包雨
private var isInTimeBack = false
private var backTimeCount = 0.0
private var backTimetimer: Timer?
public func timeBackRain() {
// 暂停红包雨
stopRain()
// 重置回溯参数
stopTimeBack()
// 标记当前状态 (为了下面判断 0.3 秒的延时)
isInTimeBack = true
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.3) {
if self.isInTimeBack {
self.backTimetimer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { (_) in
// 回溯红包
for i in 0..<self.redPackList.count {
let imgView = self.redPackList[i]
self.timeBackLayer(layer: imgView.layer)
}
// 回溯炸弹
for i in 0..<self.bombList.count {
let imgView = self.bombList[i]
self.timeBackLayer(layer: imgView.layer)
}
// 动画慢慢加速
self.backTimeCount += 0.001
}
}
}
}
// 回溯动画的时间偏移量
private func timeBackLayer(layer: CALayer) {
layer.timeOffset = layer.timeOffset - self.backTimeCount
}
清除界面外的红包
// 清屏, 把视野外的view去掉
private func clearViewsOutSideScreen() {
// 红包
for repack in redPackList {
removeCompoment(compoment: repack)
if let index = redPackList.index(of: repack),
repack.superview == nil {
redPackList.remove(at: index)
}
}
// 炸弹
...
}
附加五毛钱特效 - 点击音效
点击音效的实现也十分简单,使用系统 AudioToolbox 库即可。他会把资源文件作为铃声放入内存中。使用时通过id标记播放即可。唯一需要注意的是使用完铃声后注意销毁。
准备音效
// 点击音效
let redpackSoundUrl = "hit.mp3"
var redpackSoundId: SystemSoundID = 0
let bombSoundUrl = "boom.mp3"
var bombSoundId: SystemSoundID = 0
/// 准备音效
func prepareSound() {
let url = Bundle.init(for: self.classForCoder).url(forResource: redpackSoundUrl, withExtension: nil)
AudioServicesCreateSystemSoundID(url! as CFURL, &redpackSoundId)
let url2 = Bundle.init(for: self.classForCoder).url(forResource: bombSoundUrl, withExtension: nil)
AudioServicesCreateSystemSoundID(url2! as CFURL, &bombSoundId)
}
播放音效
// 红包点击音效
func playHitSound() {
AudioServicesPlaySystemSound(redpackSoundId)
}
// 炸弹点击音效
func playBombSound() {
AudioServicesPlaySystemSound(bombSoundId)
}
回收音效
deinit {
// 回收
AudioServicesDisposeSystemSoundID(redpackSoundId)
AudioServicesDisposeSystemSoundID(bombSoundId)
}
最后使用完音效后记得销毁回收,不然会一直把音效放在内存中。