• Cocoa利用TexturePacker创建的纹理图集实现角色的帧动画 by 大熊猫侯佩 什么是TexturePacker TexturePacker是一个非常棒的纹理集制作工具,广泛应用在2D游戏的制作中。它可以支持多种开发平台,比如Unity,...

    Cocoa利用TexturePacker创建的纹理图集实现角色的帧动画

    by 大熊猫侯佩


    什么是TexturePacker

    TexturePacker是一个非常棒的纹理集制作工具,广泛应用在2D游戏的制作中。它可以支持多种开发平台,比如Unity,Cocos2D-x,Cocos2D,SpriteKit等等。利用这些游戏开发平台使用它来制作帧动画那是小菜一碟。不过这里要说的是如何在Cocoa环境下利用TexturePacker来制作角色的帧动画。

    这里写图片描述

    这里写图片描述

    TexturePacker有windows、linux和mac三种版本,可以到官网下载:
    https://www.codeandweb.com/texturepacker

    什么是角色的帧动画

    常见于游戏开发中,比如主人公的行走,奔跑和跳跃等动画效果

    这里写图片描述

    辅以各种方向和位置的位移,可以实现在地图中四处行走的效果。

    正常来说我们需要为角色的每个方向创建一套纹理,上图只是制作了角色面朝左侧的纹理。如果加上其他特殊的动作,比如躺倒,跳跃,弯腰等等效果,要制作的纹理就更多了。不过别担心,我们这里讲解只涉及到角色4个方向,即上、下、左、右的纹理。

    使用场景

    神马!Cocoa里面还需要这种动画?

    额…怎么说呢…正常来说确实不多见,不过在一些特别的场合利用角色动画可以给你的App带来意想不到的惊艳效果。比如想象一下开发一款任务管理类型的App,但这种App太多了,如何让它与众不同一点?我们尝试这样一个idea:用打怪升级的方式来记事,用游戏冒险的方式来完成任务,只不过这次游戏的主角不再是虚拟的人物,而是真真切切的大活人—–就是你自己!

    我们需要在设计时就让App外观给人的感受定一个基调:卡通英雄迎接变态挑战!那么可以想象到的一个场景就是:用户完成了一个任务后,一个卡通骑士从屏幕里冲出来欢呼雀跃!

    注意我们的App的基调是冒险升级,所以UI里到处应该可见各种角色的各种玩耍动画,这里如果简单的使用UIKit的视图或层动画来纯手工完成这些活,对于这么多的工作量,会出人命的!-_-b

    利用前面提到的TexturePacker,结合新的或已有的纹理素材,我们可以在Cocoa中快速完成我们所需要的动画效果。

    有人会说,可以用SpriteKit来完成这一效果哦,再不济也可以SpriteKit与Cocoa混搭。这是可以的,但需要有SpriteKit基础,而且涉及到两者的整合,尤其是UI整合的问题,有机会我们可以在以后的偏向游戏开发的内容中探讨。

    概览:我需要准备神马?

    简单的说你只需要TexturePacker、一些素材、Xcode、再加上一些特定的库就哦了!当然你需要有iOS开发的基础,我会用Swift语言(4.0)来介绍,虽然那些特定的库是用Objective-C开发的,但这对我们的使用不会有太大影响。

    我的开发环境是OS X 10.12.6 + Xcode 9.2 + TexturePacker 4.6.1
    大家可以参考一下。

    零.准备纹理素材

    如果没有现成的纹理集,我们需要依次为角色建立不同动作的纹理,然后加以整合。本猫从第三方的图片集中使用图片处理工具截取了16张图片纹理,对应角色行走的4个方向,每个方向4帧。

    这里写图片描述

    注意这些图片的命名方式,我们写代码的时候会涉及到。

    一.TexturePacker出场:制作纹理图集

    打开TexturePacker,将上面16张图片拖拽到左侧工具栏:

    这里写图片描述

    可以看到TexturePacker中间已经显示了纹理图集的预览图,右侧中间部分可以设置一些通用属性,如果觉得不满意,右下角还可以选择打开高级设置。不过这里我们啥也不用调整,使用默认设置即可。

    现在我们注意一下右侧顶部:

    这里写图片描述

    在这里有3个关键的配置点,依次为:

    1. 导出的Framework配置格式
    2. 导出的数据文件路径
    3. 导出的纹理文件路径

    对于后两个配置,大家可以指定保存数据文件和纹理文件的位置。有些人可能好奇这两个文件分别表示什么?其中数据文件用来描述纹理图集中各个单个纹理,比如第1张纹理在图集的什么位置,是否旋转,叫什么名称等信息;而纹理文件就是实际导出的各个纹理的集合了,这里是之前16张纹理的整合。

    现在我们面临一个关键的问题,我们要导出何种格式的配置文件?这就是第一个配置点的用途,点击打开可选择的格式列表:

    这里写图片描述

    可以发现:哇!好多格式可以选择啊!这只是冰山一角,你可以向下拖动选择更多的格式。这里我们只关注两种格式:UIKit(Plist)和xml格式。

    xml是一种通用格式,用过的人都知道它是神马。Cocoa是可以支持读取xml文件的,所以如果用它也是可以的。不过这里我们使用导出iOS或者OS X上非常常用的Plist格式,这是因为Cocoa对其支持更好。

    选中第一个UIKit(Plist)格式,点击Convert按钮。这时并没有真正导出任何东西哦。回到TexturePacker主界面,点击上方工具栏中的Publish sprite sheet按钮,选择一个保存名称w(这不是误敲,我选择的导出名称就是w.如果你之前选择过了则会自动跳过),点击确定后会自动完成发布,也就是导出纹理。如果不出意外你会看到一列绿钩,然后点击Ok按钮就可以。

    这里写图片描述

    回到导出纹理文件的目录中,你会发现多了2个文件:w.png和w.plist

    TexturePacker的使命暂时告一段落了,接下来轮到Xcode隆重登场了!

    二.创建一个新项目,导入纹理图集和动画库

    打开Xcode,创建一个单视图工程,作为一个熟练的iOS开发者,你一定知道怎么做。在新工程左侧的资源导航视图中新建一个group,名字就叫:Support Files.将之前创建的w.png和w.plist文件拖入该group。

    这里写图片描述

    再创建一个group,名称为API。将4对Objective-C文件(共8个,.h和.m各4个)拖入该组。这8个文件分别为:

    • CAWSpriteReader.h和CAWSpriteReader.m
    • CAWSpriteData.h和CAWSpriteData.m
    • CAWSpriteCoreLayer.h和CAWSpriteCoreLayer.m
    • CAWSpriteLayer.h和CAWSpriteLayer.m

    它们可以在github中下载到:

    https://github.com/CodeAndWeb/UIKit-TexturePacker/tree/master/demo/CAWTexturePackerSprites

    不过后面使用中需要稍微做些修改和扩展。别看它们有8个感觉好多,不过别怕,我们实际只会用到2个,就是加粗显示的那2个,其中1个还是轻度使用。我们只会稍微多的使用CAWSpriteLayer这个类,另外4个是对它们的“后台”支持,你基本可以不用关心。

    这里写图片描述

    当你拖入Objective-C文件到Swift项目中时,Xcode会为你自动创建一个桥接文件,打开它,将其修改成如下内容:

    #import "CAWSpriteReader.h"
    #import "CAWSpriteLayer.h"

    三.调整UI界面

    打开main.storyboard,大致按如下步骤调整界面:

    1. 拖入一个UIView,占据View的上边大部分空间,将其背景色设置为灰色;
    2. 拖入4个按钮,向PS4游戏手柄方向键那样布局,放在View的下半部分,分别设置好其title对应的名称: up,down,left和right;
    3. 在ViewController类中创建1个outlet和4个action,分别对应于灰色的View和4个按钮,然后从IB中绑定它们:
    @IBOutlet weak var sandBoxView:UIView!
    
    @IBAction func up(){}
    @IBAction func down(){}
    @IBAction func left(){}
    @IBAction func right(){}

    最终的界面类似下图:

    这里写图片描述

    没必要再为每个UI元素设定自动布局了,因为我们决定只在iPhone6上运行。

    四.正式开始前的一点小调整

    首先这8个文件(4个类)是用Objective-C写的,比较早了。所以导入项目后会有若干语法错误和警告。总的来说都是比较容易修复的问题,大家可以自行尝试修复,可以只修复错误而忽略警告。

    如果Objective-C语言不太熟的,可以使用我最终修改后的版本。

    另外在正式写代码之前,我们有必要对那8个文件中的CAWSpriteReader.m文件代码做些小调整。打开CAWSpriteReader.m文件,定位到 + (NSDictionary )spritesWithContentOfFile:(NSString )filename方法,注释掉方法开头这段代码:

        // check if we need to load the @2x file
        if ([[UIScreen mainScreen] respondsToSelector:@selector(displayLinkWithTarget:selector:)] &&
            ([UIScreen mainScreen].scale == 2.0))
        {
            file = [NSString stringWithFormat:@"%@@2x", file];
        }
    

    因为我们不准备做一套2x大小的纹理,因为你还可能要再做一套3x大小的,以讲解为目的意义不大,所以这段代码不要也罢。如果不懂啥意思的可以自行忽略。

    五.写一个简单的测试

    是时候写一些代码了 ^0^

    当你看到如上的界面,你大概已经了解要写一个怎样的测试:就是通过点击方向键控制游戏主角在沙盒(sandBox)中行走,同时显示动画,这是必须的!

    打开ViewController类,在viewDidLoad中添加如下代码:

    let spritesData = CAWSpriteReader.sprites(withContentOfFile: "w.plist")!
    let texture = UIImage(named: "w.png")!
    print("sprites count is \(spritesData.count)")

    运行App,你应该在调试console中看到

    sprites count is 16

    这句话,否则一定之前的哪个步骤有问题,请回到前面检查。

    因为你的纹理图集中有16个纹理,所以这里spritesData中也会有16个对应的项目。你猜的没错,CAWSpriteReader就是用来读取纹理图集的配置文件并将其内容保存为内存对象供后续使用的类。

    随后再添加如下几句代码:

    sprite = CAWSpriteLayer(spriteData: spritesData, andImage: texture)
    
    sandBoxView.layer.addSublayer(sprite)
    sprite.position = sandBoxView.center
    
    sprite.showFrame("w正1")
    

    你会发现编译不过去,提示sprite变量未定义,你一定知道怎么办。在ViewController中添加一个实例变量:

    var sprite:CAWSpriteLayer!

    现在运行App,当当当…

    这里写图片描述

    哇!主角闪亮登场…很有成就感的样子。不过除了他还不会动之外,可能还有几个问题:

    • 他为什么那么小?
    • 上面这些代码都是啥意思?

    别急,下面本猫会解释代码并给出解决办法

    六.熟悉CAWSpriteLayer类

    CAWSpriteLayer类派生自CALayer类。它其实只是一个包装类,真正在后面干活的是CAWSpriteCoreLayer这个类,CAWSpriteCoreLayer也派生自CALayer。

    正常情况下对于第三方的库或类,我们只要简单看一下其接口声明,不用太过关心它的内部实现,基本把它们当做黑盒来用。不过在某些情况下我们需要稍微了解一下实现,比如你觉得类缺少某些功能,需要自己添加的情形。在后面我们会尝试对CAWSpriteLayer做一些扩展,到时候我们会再详细说明。

    简单浏览一下CAWSpriteLayer的接口你大致可以知道怎么用这个类了,我再解释一下上面添加的代码:

    //创建一个CAWSpriteLayer类,同时关联纹理配置信息和纹理图片
    sprite = CAWSpriteLayer(spriteData: spritesData, andImage: texture)
    //将sprite加入到沙盒场景中        
    sandBoxView.layer.addSublayer(sprite)
    //设置sprite的位置为居中显示
    sprite.position = sandBoxView.center
    //显示角色正面的第一个静态纹理    
    sprite.showFrame("w正1")
    

    最后一句很重要,如果没有它,屏幕上就会啥也没有。

    为什么sprite在场景中显示会那么小?这时因为我是在iPhone 6p上运行的,这意味着如果要正常显示,得提供@3x大小的纹理素材,否则相对来说就会“缩小”3倍显示。对这个概念不太了解的童鞋可以自行搜索一下。

    如果有@3x的素材那就会十分完美,不过咱不是没有嘛!还好我们只是以讲解为目的,所以丑就丑点,只要能让它放大3倍,哪怕分辨率变差,变模糊也是可以接受的。

    前面说过CAWSpriteLayer派生自CALayer,所以我们直接将CALayer放大就可以了,添加如下代码:

    sprite.transform = CATransform3DScale(sprite.transform, 3.0, 3.0, 1.0)

    运行App,我们感觉变得稍微好了一点:

    这里写图片描述

    不过,还有一个大问题:它呆呆的站在那里,丝毫不会动!

    解决起来很容易,超乎你的想象!!!

    七.让动画跑起来

    让主角动起来很容易,只需一句!紧接上面的代码添加如下一行:

    sprite.playAnimation("w正%d", withRate: 6, andRepeat: Int32.max)

    运行App,这就是原地踏步的赶脚:

    这里写图片描述

    是不是超简单,它背后的原理是使用CALayer上的动画,可以通过CAWSpriteCoreLayer类源代码来查看。

    注意这里的rate表示的是每一帧显示的秒数。比如这里被设置为6,向下纹理共有4帧,所以每帧显示4/6 = 0.67秒,总共显示 4/6 * 4 = 2.67秒。如果总共有6帧则每帧显示1秒,共显示6秒。所以这里可以通过调整rate的大小来决定纹理集动画显示的时间,越大动画显示的越快,越小动画显示的越慢。

    八.让主角走起来

    显然你不想让主角原地踏步,你想让它走动起来。实现起来也不难,只要动画配合位移就可以了。我们先来实现向上方向的移动

    首先在ViewController类中创建一个Direction枚举:

    enum Direction {
        case none
        case down
        case up
        case left
        case right
    }

    然后创建一个currentDirection实例方法:

    var currentDirection:Direction = .none

    接着在up方法里添加如下代码:

    @IBAction func up(){
        if currentDirection != .up{
            currentDirection = .up
            sprite.playAnimation("w背%d", withRate: 6, andRepeat: Int32.max)
        }
        sprite.position.y -= 10
    }

    每次按下up按钮,我们将主角向上移动10个点。

    运行App,感觉一下效果:

    这里写图片描述

    哇!我们之前的努力没有白费,值得拍手庆祝一下!既然向上的放心搞定了,其它放心也没什么难度了,依次补全其它3个方法:

    @IBAction func down(){
        if currentDirection != .down{
            currentDirection = .down
            sprite.playAnimation("w正%d", withRate: 6, andRepeat: Int32.max)
        }
        sprite.position.y += 10
    }
    
    @IBAction func left(){
        if currentDirection != .left{
            currentDirection = .left
            sprite.playAnimation("w左%d", withRate: 6, andRepeat: Int32.max)
        }
        sprite.position.x -= 10
    }
    
    @IBAction func right(){
        if currentDirection != .right{
            currentDirection = .right
            sprite.playAnimation("w右%d", withRate: 6, andRepeat: Int32.max)
        }
        sprite.position.x += 10
    }

    现在我们的主角可以向四个方向随意行走了,并且还伴随动画,爱死它了!!!

    九.设置边界

    现在主角走着走着就看不见人影了,所以有必要给沙盒设置一个边界。理论上很容易,只要确定好每个边界上的x和y值就可以了,不过我们需要同时考虑到sprite本身的大小!但遗憾的是直接通过:

    sprite.bounds.size

    取出的值是(0,0),所以我们得尝试用其他办法来取得主角的大小。这就得像前面所说的那样深入第三方类去一窥究竟了。

    我们发现在CAWSpriteCoreLayer里包含一个spriteData对象,其中包含了所有纹理的信息,当然包括尺寸了。我们采用同样的策略:

    CAWSpriteCoreLayer干活,CAWSpriteLayer享受

    首先在CAWSpriteCoreLayer类里添加如下方法:


    - (CGSize)sizeForFrame:(NSString *)frameName{
    CAWSpriteData *data = [spriteData objectForKey:frameName];
    CGSize size = CGSizeMake(data.spriteWidth, data.spriteHeight);
    return size;
    }

    然后修改它的接口:

    - (CGSize)sizeForFrame:(NSString *)frameName;

    同样在CAWSpriteLayer类里添加同名方法:

    - (CGSize)sizeForFrame:(NSString *)frameName{
        return [animationLayer sizeForFrame:frameName];
    }

    最后修改其接口:

    - (CGSize)sizeForFrame:(NSString *)frameName;

    OK,回到ViewController类中,创建一个spriteSize方法:

    func spriteSize(for toward:Direction)->CGSize{
        let spriteSize:CGSize
        switch toward{
        case .down:
            spriteSize = sprite.size(forFrame: "w正0")
        case .up:
            spriteSize = sprite.size(forFrame: "w背0")
        case .left:
            spriteSize = sprite.size(forFrame: "w左0")
        case .right:
            spriteSize = sprite.size(forFrame: "w右0")
        default:
            fatalError()
        }
        return spriteSize
    }

    这里我们取每个方向第一个帧的纹理作为基准,返回它的大小。

    现在我们可以写边界检查方法了,新建boundaryTest方法:

    func boundaryTest(toward:Direction){
    
        let spriteSize = self.spriteSize(for: toward)
    
        if sprite.position.x <= spriteSize.width / 2{
            sprite.position.x = spriteSize.width / 2
        }
    
        if sprite.position.x >= sandBoxView.bounds.width - spriteSize.width * 1.5{
            sprite.position.x = sandBoxView.bounds.width - spriteSize.width * 1.5
        }
    
        if sprite.position.y <= spriteSize.height / 2{
            sprite.position.y = spriteSize.height / 2
        }
    
        if sprite.position.y >= sandBoxView.bounds.height - spriteSize.height * 1.5{
            sprite.position.y = sandBoxView.bounds.height - spriteSize.height * 1.5
        }
    }

    然后在up,down,left,right四个方法的最后添加一句:

    boundaryTest(toward: currentDirection)

    运行App,欧耶!终于不能突破边框啦!Perfect!!!

    十.静若处子,动若脱兔

    继续在沙盒里游走一番,享受一下我们的战斗成果.你会发现当主角保持静止状态时仍然会显示一个行走的动画.有时候这很好,但有时原地踏步也会显得很怪异.

    我们希望当主角移动的时候显示行走动画,当他停下来的时候动画也停下来.

    因为CAWSpriteLayer类实际上是一个CALayer,所以我们想办法使用层上的动画来达到这一目的.同样我们先尝试实现一个方向,然后拓展到所有方向,就先拿向上的方向up来说吧,基本逻辑是这样:

    1. 因为播放层动画不希望被打断,所以up方法不能重入.这是靠实例变量wasEntered来保证;
    2. 只有当转向到up方向时才需要重新播放动画,否则只需要恢复动画;
    3. 创建层动画指定向下的位移,计算动画播放需要经历的时间,将动画添加到sprite上去;
    4. 在层动画完成时暂停主角帧动画的播放;
    5. 最终进行边界检查.

    OK,我们首先注释掉之前viewDidLoad中的动画播放代码:

    //sprite.playAnimation("w正%d", withRate: 6, andRepeat: Int32.max)

    同时新建一个实例方法:

    var wasEntered = false

    然后我们修改up方法为如下内容:

    @IBAction func up(){
        guard wasEntered == false else {return}
    
        wasEntered = true
    
        if currentDirection != .up{
            currentDirection = .up
            sprite.playAnimation("w背%d", withRate: 6, andRepeat: Int32.max)
        }else{
            sprite.resume()
        }
    
        sprite.position.y -= 20
        let moveAnim = CABasicAnimation(keyPath: "position.y")
        moveAnim.fromValue = sprite.position.y + 20
        moveAnim.toValue = sprite.position.y
        moveAnim.duration = 4.0/6.0
    
        sprite.add(moveAnim, forKey: nil)
    
        DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 4.0/6.0){
            self.sprite.pause()
            self.wasEntered = false
        }
        boundaryTest(toward: currentDirection)
    }

    运行一下App:

    这里写图片描述

    是不是满足我们的期望呢? ^0^

    等一下,如果你看到最后,会发现主角在碰到上方边界时有一个回退现象,好像有些唐突,我们马上就来修复它.

    十一.修复边界”回退”

    这种情况出现的原因是我们先位移再判断边界,当检查到超出边界强制退回,此时已经晚了.解决的办法就是主动调整位移的长度,做到”先下手为强”!

    这里写图片描述

    可以参考上图,该图示意的是主角向上或向右移动的情况;当主角处在y=5的位置时,此时向上移动10个点将会超出边界0,达到y=-5(向右侧移动同理).所以此时不可以移动10个点,只能移动 当前位置(5) - 边界(0) = 5个点.其他方向道理是一样的,我们很快可以写一个新的方法来计算实际的位移:

    func adjustDistance(_ distance:CGFloat,for toward:Direction)->CGFloat{
        let maxPoint:CGFloat
        let spriteSize = self.spriteSize(for: toward)
    
        switch toward {
        case .up:
            maxPoint = spriteSize.height / 2
            return min(sprite.position.y - maxPoint, distance)
        case .down:
            maxPoint = sandBoxView.bounds.height - spriteSize.height * 1.5
            return min(maxPoint - sprite.position.y, distance)
        case .left:
            maxPoint = spriteSize.width / 2
            return min(sprite.position.x - maxPoint, distance)
        case .right:
            maxPoint = sandBoxView.bounds.width - spriteSize.width * 1.5
            return min(maxPoint - sprite.position.x, distance)
        default:
            fatalError()
        }
    }

    adjustDistance包含2个参数,第一个是尝试移动的距离,第二个是移动的方向.该方法返回调整后移动的距离.

    回到我们新实现的up方法,将其中下面两句代码:

    sprite.position.y -= 20
    moveAnim.fromValue = sprite.position.y + 20

    分别替换为:

    sprite.position.y -= adjustedDistance
    moveAnim.fromValue = sprite.position.y + adjustedDistance

    别忘了在前面加上adjustedDistance变量的定义:

    let adjustedDistance = adjustDistance(20, for: .up)

    再次运行App,看一下效果吧:

    这里写图片描述

    这下主角遇到边界也不会回退了,我们的目的达到了。下面我们就来尝试将新的up方法拓展到所有的方向吧。

    十二.拓展还是重构?

    但是先等一下!!!你确定要把up里的内容重复3遍,其中的内容到底有多少要改动呢?我们来看一下:实际要改动的地方只有和方向有关的位移,也就两、三句代码而已。并且如果你只是重复拷贝代码,还会带来一个非常严重的问题:你的位移距离以及动画时长会同时存在于4个地方,如果你将来觉得不妥要修改,那可麻烦了,你要同时修改所有这些地方,而且稍有不慎忘了或改错了哪个地方,那么调试起来可有你受的哦。

    所以为了不以后遭罪,为了不违反DRY原则,我们当然选择重构代码!

    为了避免同一方向反复重新播放动画,我们首先创建一个新的实例变量:

    var lastDirection:Direction = .none

    我们看一下新的方法需要哪些参数:

    • 角色需要移动的方向
    • 角色需要移动的位移距离
    • 同一方向帧的数量
    • 每一帧显示的时间

    有了这些参数再结合我们上面新实现up方法的内容,我们就可以灵活可变的实现角色移动功能了,在ViewController类中新建如下moveSprite实例方法:

    func moveSprite(toward:Direction,point:CGFloat,framesCount:Int,rate:Int){
        guard wasEntered == false else {return}
        wasEntered = true
    
        let moveAnim:CABasicAnimation
        let duration = TimeInterval(CGFloat(framesCount)/CGFloat(rate))
        let frameName:String
        let adjustedPoint = adjustDistance(point, for: toward)
    
        switch toward {
        case .down:
            frameName = "w正%d"
            sprite.position.y += adjustedPoint
            moveAnim = CABasicAnimation(keyPath: "position.y")
            moveAnim.fromValue = sprite.position.y - adjustedPoint
            moveAnim.toValue = sprite.position.y
        case .up:
            frameName = "w背%d"
            sprite.position.y -= adjustedPoint
            moveAnim = CABasicAnimation(keyPath: "position.y")
            moveAnim.fromValue = sprite.position.y + adjustedPoint
            moveAnim.toValue = sprite.position.y
        case .left:
            frameName = "w左%d"
            sprite.position.x -= adjustedPoint
            moveAnim = CABasicAnimation(keyPath: "position.x")
            moveAnim.fromValue = sprite.position.x + adjustedPoint
            moveAnim.toValue = sprite.position.x
        case .right:
            frameName = "w右%d"
            sprite.position.x += adjustedPoint
            moveAnim = CABasicAnimation(keyPath: "position.x")
            moveAnim.fromValue = sprite.position.x - adjustedPoint
            moveAnim.toValue = sprite.position.x
        default:
            fatalError()
        }
    
        if toward == lastDirection{
            sprite.resume()
        }else{
            lastDirection = toward
            sprite.playAnimation(frameName, withRate: Float(rate), andRepeat: Int32.max)
        }
    
        moveAnim.duration = duration
        sprite.add(moveAnim, forKey: nil)
    
        DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + duration){
            self.sprite.pause()
            self.wasEntered = false
        }
    }

    貌似有点长,不过带来的好处是显而易见的,我们消除了重复代码错误的万恶之源!并且我们新的四个方向处理方法变了异乎寻常的简单了,将up,down,left和right方法修改为如下内容:

    @IBAction func up(){
        moveSprite(toward: .up, point: 60, framesCount: 4, rate: 6)
    }
    
    @IBAction func down(){
        moveSprite(toward: .down, point: 60, framesCount: 4, rate: 6)
    }
    
    @IBAction func left(){
        moveSprite(toward: .left, point: 60, framesCount: 4, rate: 6)
    }
    
    @IBAction func right(){
        moveSprite(toward: .right, point: 60, framesCount: 4, rate: 6)
    }

    我们可以删除原先的currentDirection变量,因为已经用不着了。

    好了,运行一下App,欣赏一下我们的劳动果实吧 ^_^

    我们的文章到此即将告一段落了,不过如果你还意犹未尽,可以看一下如何按需求扩展第三方的类,以达到我们的特定的需求。如果你感觉有点累,想要去happy一下,跳过它直接看结尾也没有问题哦。

    十三.番外篇:扩展第三方类

    细心的朋友可能会发现,我们前面计算主角的大小用的总是同一方向第一帧纹理的大小,如果纹理大小有出入的话,会产生较大的偏差,最好的方法是取当前动画帧纹理的大小。不过这有些难度,所以我们退之求其次,计算所有帧的平均大小吧。

    这次我们不修改原有的第三方类,因为我们上面已经熟悉了类的内部功能,所以我们直接用Swift写一个类的扩展吧(Objective-C的语法…)。

    在项目API组中新建一个Swift文件,名为CAWSpriteLayer+ext.swift。

    打开该文件,将其替换为如下内容:

    import UIKit
    
    extension CAWSpriteLayer{
        func avgSizeForFrameBase(_ frameNameBase:String)->CGSize{
            //待实现
        }
    }

    可以看到我们在CAWSpriteLayer类的扩展里新建了方法,该方法唯一的参数为同一方向的纹理名称前缀,即如果是向上,则会传入 “w背” 实参,它会将所有”w背”前缀的纹理大小都加入计算。

    我们前面已经了解到,CAWSpriteLayer类中含有一个animationLayer.spriteData变量,其中有我们想要每一帧名称、大小等等所需要的所有信息。

    我们现在来实现avgSizeForFrameBase方法,将其中的注释一行替换为如下内容:

    let dict = animationLayer.spriteData as! [String:CAWSpriteData]
    let baseNames = Array(dict.keys)
    let frameNames = baseNames.filter {$0.hasPrefix(frameNameBase)}
    
    var totalWidth:CGFloat = 0
    var totalHeight:CGFloat = 0
    let count = CGFloat(frameNames.count)
    for frameName in frameNames{
        let spriteData = dict[frameName]!
        totalWidth += CGFloat(spriteData.spriteWidth)
        totalHeight += CGFloat(spriteData.spriteHeight)
    }
    
    return CGSize(width: totalWidth/count, height: totalHeight/count)

    回到spriteSize方法,将其中的:

    spriteSize = sprite.size(forFrame: "w正0")

    之类的方法,换为新的平均值方法:

    spriteSize = sprite.avgSizeForFrameBase("w正")

    其他方向类似。

    好啦!我们已经成功的按我们的需求扩展了第三方的类!!!

    十四.结尾

    经历了前面这么多的内容,大家看的一定很累,这是自然的。(虽然本猫写的也很累…),希望大家可以略微学到一丢丢新知识,希望大家可以把它应用到实际App开发中去 ^_^

    现在!抛开电脑,到了happy的时候了!冲个热水澡,来杯冰镇可乐+至尊大汉堡套餐?之类的美味吧!!!

    感谢观赏,再会!

    PS:全部代码可以到我的github中下载:

    https://gitee.com/hopy/iOS-JingJin/tree/master/TPSupportsTest

    展开全文
  • iOS Layer动画Swift)基本Layer动画(Basic Layer Animation)这里使用到的是CABasicAnimation,创建基本动画使用keyPath,支持的keyPath的请参考支持的键路径的完整列表动画的效果很基本,使用到了fromValue、to...

    本文内容来自raywenderlich的Intermediate iOS Animation,记录下学习的内容。

    iOS Layer动画(Swift)

    概念

    隐式动画

    参考隐式动画

    隐式动画是指不指定任何动画类型,例如当改变layerbackgroundColor属性,就会有动画的效果,如下,改变layer的backgroundColor

    self.colorLayer.backgroundColor = [UIColor colorWithRed:red green:green blue:blue alpha:1.0].CGColor; 

    就会有动画的效果,有个渐变的过程

    当改变一个属性,Core Animation是如何判断动画类型和持续时间的呢?
    实际上动画执行的时间取决于当前事务的设置,动画类型取决于图层行为
    事务实际上是Core Animation用来包含一系列属性动画集合的机制,任何用指定事务去改变可以做动画的图层属性都不会立刻发生变化,而是当事务一旦提交的时候开始用一个动画过渡到新值。
    事务是通过CATransaction类来做管理

    • 用类方法+begin+commit分别来入栈或者出栈
    • +setAnimationDuration:方法设置当前事务的动画时间,+animationDuration方法来获取动画时间(默认0.25秒)
    • +setCompletionBlock:动画结束的时候提供一个完成的动作
    • +setDisableActions:对所有属性打开或者关闭隐式动画

    Core Animation在每个run loop周期中自动开始一次新的事务(run loop是iOS负责收集用户输入,处理未完成的定时器或者网络事件,最终重新绘制屏幕的东西),即使你不显式地使用[CATransaction begin]开始一次事务,在一个特定run loop循环中的任何属性的变化都会被收集起来,然后做一次0.25秒的动画。

    把改变属性时CALayer自动应用的动画称作行为,当CALayer的属性被修改时候,它会调用-actionForKey:方法,传递属性的名称。剩下的操作都在CALayer的头文件中有详细的说明,实质上是如下几步:

    • 图层首先检测它是否有委托,并且是否实现CALayerDelegate协议指定的-actionForLayer:forKey方法。如果有,直接调用并返回结果。
    • 如果没有委托,或者委托没有实现-actionForLayer:forKey方法,图层接着检查包含属性名称对应行为映射的actions字典。
    • 如果actions字典没有包含对应的属性,那么图层接着在它的style字典接着搜索属性名。
    • 最后,如果在style里面也找不到对应的行为,那么图层将会直接调用定义了每个属性的标准行为的-defaultActionForKey:方法。

    所以一轮完整的搜索结束之后,-actionForKey:要么返回空(这种情况下将不会有动画发生),要么是CAAction协议对应的对象,最后CALayer拿这个结果去对先前和当前的值做动画。
    于是这就解释了UIKit是如何禁用隐式动画的:每个UIView对它关联的图层都扮演了一个委托,并且提供了-actionForLayer:forKey的实现方法。当不在一个动画块的实现中,UIView对所有图层行为返回nil,但是在动画block范围之内,它就返回了一个非空值

    显式动画

    属性动画(CAPropertyAnimation)作用于图层的某个单一属性,并指定了它的一个目标值,或者一连串将要做动画的值。属性动画分为两种:基础关键帧CAPropertyAnimation通过指定动画的keyPath作用于一个单一属性

    CAPropertyAnimation的父类是CAAnimationCAAnimation同时也是Core Animation所有动画类型的抽象基类。CAAnimation同时实现了一些协议,包括CAAction(允许CAAnimation的子类可以提供图层行为),以及CAMediaTimingCAAnimation提供了:

    • 一个计时函数
    • 一个委托(用于反馈动画状态)
    • 一个removedOnCompletion,用于标识动画是否该在结束后自动释放(默认YES,为了防止内存泄露)

    基础动画(CABasicAnimation)是CAPropertyAnimation的一个子类,并添加了如下的属性:

    • fromValue-动画开始之前属性的值
    • toValue-代表了动画结束之后的值
    • byValue-代表了动画执行过程中改变的相对值

    图层时间

    参考图层时间

    CAMediaTiming协议定义了在一段动画内用来控制逝去时间的属性的集合,CALayerCAAnimation都实现了这个协议

    • duration-动画的时间
    • repeatCount-动画重复的次数,设为INFINITY表示无限循环
    • repeatDuration-动画重复的时间,设为INFINITY表示无限循环
    • autoreverses-在动画完成后是否倒回回放

    相对时间

    • beginTime-动画开始之前的的延迟时间
    • speed-一个时间的倍数,默认1.0,减少它会减慢图层/动画的时间,增加它会加快速度。如果2.0的速度,那么对于一个duration为1的动画,实际上在0.5秒的时候就已经完成了
    • timeOffset-增加timeOffset只是让动画快进到某一点,例如,对于一个持续1秒的动画来说,设置timeOffset为0.5意味着动画将从一半的地方开始

    CACurrentMediaTime()方法获取当前的绝对时间

    基本Layer动画(Basic Layer Animation)

    这里使用到的是CABasicAnimation,创建基本动画使用keyPath,支持的keyPath的请参考支持的键路径的完整列表

    动画的效果很基本,使用到了fromValuetoValueduration属性,创建好CABasicAnimation后,加入到对应的layer就行,如heading.layer.addAnimation(flyRight, forKey: nil),需要注意的是这里的flyRight是复制的,并不是引用的(This object is copied by the render tree, not referenced. Therefore, subsequent modifications to the object are not propagated into the render tree),所以修改flyRight将对原来的动画并没有影响。

    这里动画的效果如下:
    基本动画效果

    代码如下:
    上部的Label和TextField主要改变了position.x

      override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
    
        if !didInitialLayout {
          presentationAnimations()
          didInitialLayout = true
        }
    
        animateInfo()
    
      }
    
      func presentationAnimations() {
    
        let flyRight = CABasicAnimation(keyPath: "position.x")
        flyRight.fromValue = -view.bounds.width / 2
        flyRight.toValue = view.bounds.size.width / 2
        flyRight.duration = 0.5
        heading.layer.addAnimation(flyRight, forKey: nil)
        username.layer.addAnimation(flyRight, forKey: nil)
        password.layer.addAnimation(flyRight, forKey: nil)
    
      }
    

    下面从右至左的Infolabel,改变了position.xopacity,有fadeIn的效果

      func animateInfo() {
        //add text info
        info.frame = CGRect(x: 0.0, y: loginButton.center.y + 30.0,
                                    width: view.frame.size.width, height: 30)
        info.backgroundColor = UIColor.clearColor()
        info.font = UIFont(name: "HelveticaNeue", size: 12.0)
        info.textAlignment = .Center
        info.textColor = UIColor.whiteColor()
        info.text = "Tap on a field and enter username and password"
        view.insertSubview(info, belowSubview: loginButton)
    
        let flyLeft = CABasicAnimation(keyPath: "position.x")
        flyLeft.fromValue = self.view.bounds.width + info.layer.position.x
        flyLeft.toValue = self.view.bounds.width / 2
        flyLeft.duration = 2.0
        info.layer.addAnimation(flyLeft, forKey: nil)
    
        let fadeIn = CABasicAnimation(keyPath: "opacity")
        fadeIn.fromValue = 0.0
        fadeIn.toValue = 1.0
        fadeIn.duration = 2.0
        info.layer.addAnimation(fadeIn, forKey: nil)
    
      }
    

    Core Animation 模型(Core Animation Models)

    Core Animation 维护了两个平行 layer 层次结构: model layer tree(模型层树) 和 presentation layer tree(表示层树)。前者中的 layers 反映了我们能直接看到的 layers 的状态,而后者的 layers 则是动画正在表现的值的近似。

    layer的层次

    如下,所示点击Log in按钮时,button的颜色由green变成ochre brown。

    let startColor = UIColor(red: 0.63, green: 0.84, blue: 0.35, alpha: 1.0)
    let tintColor = UIColor(red: 0.85, green: 0.83, blue: 0.45, alpha: 1.0)
    
    let tint = CABasicAnimation(keyPath: "backgroundColor")
    tint.fromValue = startColor.CGColor
    tint.toValue = tintColor.CGColor
    tint.duration = 1.0
    
    loginButton.layer.addAnimation(tint, forKey: nil)
    

    效果如下,会发现在变成ochre brown后,会突然回到原来的颜色:

    点击按钮效果

    原因是在我们的代码中并没有更新model layer,这个tint动画发送到
    Core Animation Server 在屏幕上渲染,当动画完成后,就从屏幕上移除。在屏幕上显示的就是原来的model layer tree。所以,正确的做法是添加上如下代码:

    loginButton.layer.backgroundColor = tintColor.CGColor
    

    补充

    参考显式动画

    有两种方式更新属性值:在动画开始之前或者动画结束之后
    动画开始之前
    注意要禁用隐式动画

    CALayer *layer = self.colorLayer.presentationLayer ?: self.colorLayer;
     animation.fromValue = (__bridge id)layer.backgroundColor;
    [CATransaction begin];
    [CATransaction setDisableActions:YES];
    self.colorLayer.backgroundColor = color.CGColor;
    [CATransaction commit];

    动画开始之后
    CAAnimationDelegate代理的-animationDidStop:finished:方法,在动画执行结束之后调用

    - (void)animationDidStop:(CABasicAnimation *)anim finished:(BOOL)flag
    {
        //set the backgroundColor property to match animation toValue
        [CATransaction begin];
        [CATransaction setDisableActions:YES];
        self.colorLayer.backgroundColor = (__bridge CGColorRef)anim.toValue;
        [CATransaction commit];
    }

    动画时间(Animation Timing)

    通过设置动画的timingFunction,使动画更美观,看起来更自然,CAMediaTimingFunction常用的值有:

    • kCAMediaTimingFunctionLinear-线性的计时函数,线性步调对于那些立即加速并且保持匀速到达终点的场景会有意义
    • kCAMediaTimingFunctionEaseIn-慢慢加速,然后突然停止
    • kCAMediaTimingFunctionEaseOut-全速开始,慢慢减速停止
    • kCAMediaTimingFunctionEaseInEaseOut-慢慢加速然后再慢慢减速

    如下设置timingFunction

    flyRight.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut)
    

    beginTime可以设置动画延时执行,CACurrentMediaTime()获取动画当前的时间

    flyRight.beginTime = CACurrentMediaTime() + 0.33
    

    fillMode

    fillMode解释

    fillMode的作用就是决定当前对象过了非active时间段的行为. 比如动画开始之前,动画结束之后。如果是一个动画CAAnimation,则需要将其removedOnCompletion设置为NO,要不然fillMode不起作用. 下面来讲各个fillMode的意义
    kCAFillModeRemoved 这个是默认值,也就是说当动画开始前和动画结束后,动画对layer都没有影响,动画结束后,layer会恢复到之前的状态
    kCAFillModeForwards 当动画结束后,layer会一直保持着动画最后的状态
    kCAFillModeBackwards 这个和kCAFillModeForwards是相对的,就是在动画开始前,你只要将动画加入了一个layer,layer便立即进入动画的初始状态并等待动画开始.你可以这样设定测试代码,将一个动画加入一个layer的时候延迟5秒执行.然后就会发现在动画没有开始的时候,只要动画被加入了layer,layer便处于动画初始状态
    kCAFillModeBoth 理解了上面两个,这个就很好理解了,这个其实就是上面两个的合成.动画加入后开始之前,layer便处于动画初始状态,动画结束后layer保持动画最后的状态.

    示例动画效果如下,动画会有延时执行的效果:

    延时动画效果

    代码如下:

      func presentationAnimations() {
    
        let flyRight = CABasicAnimation(keyPath: "position.x")
        flyRight.fromValue = -view.bounds.size.width/2
        flyRight.toValue = view.bounds.size.width/2
        flyRight.duration = 0.5
    
        flyRight.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut)
        //flyRight.speed = 0.5
    
        flyRight.fillMode = kCAFillModeBackwards
    
        heading.layer.addAnimation(flyRight, forKey: nil)
    
        flyRight.beginTime = CACurrentMediaTime() + 0.33
        username.layer.addAnimation(flyRight, forKey: nil)
    
        flyRight.beginTime = CACurrentMediaTime() + 0.5
        password.layer.addAnimation(flyRight, forKey: nil)
    
        animateInfo()
      }
    

    其它有关时间的属性,参考谈谈iOS Animation

    timeOffset
    这个timeOffset可能是这几个属性中比较难理解的一个,官方的文档也没有讲的很清楚. local time也分成两种一种是active local time 一种是basic local time.
    timeOffset则是active local time的偏移量.
    你将一个动画看作一个环,timeOffset改变的其实是动画在环内的起点,比如一个duration为5秒的动画,将timeOffset设置为2(或者7,模5为2),那么动画的运行则是从原来的2秒开始到5秒,接着再0秒到2秒,完成一次动画.

    speed
    speed属性用于设置当前对象的时间流相对于父级对象时间流的流逝速度,比如一个动画beginTime是0,但是speed是2,那么这个动画的1秒处相当于父级对象时间流中的2秒处. speed越大则说明时间流逝速度越快,那动画也就越快.比如一个speed为2的layer其所有的父辈的speed都是1,它有一个subLayer,speed也为2,那么一个8秒的动画在这个运行于这个subLayer只需2秒(8 / (2 * 2)).所以speed有叠加的效果.

    动画群组(Animation Groups)

    CAAnimationGroup可以将多个动画一起执行,CAAnimationGroup继承自CAAnimation,它一个animations数组的属性,用来组合别的动画

    动画群组

    组合动画效果如下,集合了缩放、旋转和Opacity:

    组合动画

    代码如下:

    func animateLoginButton() {
        let groupAnimation = CAAnimationGroup()
        groupAnimation.duration = 0.5
        groupAnimation.beginTime = CACurrentMediaTime() + 0.5
        groupAnimation.fillMode = kCAFillModeBackwards
        groupAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseIn)
    
        let scaleDown = CABasicAnimation(keyPath: "transform.scale")
        scaleDown.fromValue = 3.5
        scaleDown.toValue = 1.0
    
        let rotate = CABasicAnimation(keyPath: "transform.rotation")
        rotate.fromValue = CGFloat(M_PI_4)
        rotate.toValue = 0.0
    
        let fadeIn = CABasicAnimation(keyPath: "opacity")
        fadeIn.fromValue = 0.0
        fadeIn.toValue = 1.0
    
        groupAnimation.animations = [scaleDown, rotate, fadeIn]
    
        loginButton.layer.addAnimation(groupAnimation, forKey: nil)
    
    }
    

    动画代理(Animation Delegate)

    使用CAAnimation的代理方法

    • animationDidStart(anim: CAAnimation!)
    • animationDidStop(anim: CAAnimation!,finished flag: Bool)

    这里的关键在于如何区别不同的CAAnimation,可以通过KVC来为CAAnimation设置不同的key和value,如下:

    flyRight.setValue("form", forKey: "name")
    flyRight.setValue(heading.layer, forKey: "layer")
    

    然后在代理方法中获取namelayer

    name = anim.valueForKey("name") as? String
    

    本例子的效果如下,在顶部的Label和TextField完成动画后,会有一个bounce的弹性效果,背景的cloud会移动:

    动画代理效果

    代码如下:
    给flyRight设置代理,并设置Value

      func presentationAnimations() {
    
        let flyRight = CABasicAnimation(keyPath: "position.x")
        flyRight.fromValue = -view.bounds.size.width/2
        flyRight.toValue = view.bounds.size.width/2
        flyRight.duration = 0.5
    
        flyRight.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut)
        //flyRight.speed = 1.5
    
        flyRight.fillMode = kCAFillModeBackwards
        flyRight.delegate = self
        flyRight.setValue("form", forKey: "name")
        flyRight.setValue(heading.layer, forKey: "layer")
    
        heading.layer.addAnimation(flyRight, forKey: nil)
    
        flyRight.setValue(username.layer, forKey: "layer")
        flyRight.beginTime = CACurrentMediaTime() + 0.33
        username.layer.addAnimation(flyRight, forKey: nil)
    
        flyRight.setValue(password.layer, forKey: "layer")
        flyRight.beginTime = CACurrentMediaTime() + 0.5
        password.layer.addAnimation(flyRight, forKey: nil)
    
        animateInfo()
        animateLoginButton()
    
        //云 动画
        animateCloud(cloud1.layer)
        animateCloud(cloud2.layer)
        animateCloud(cloud3.layer)
        animateCloud(cloud4.layer)
      }
    

    cloud的动画是改变position.x,对不同位置有不同时间

    func animateCloud(cloudLayer: CALayer) {
        let cloudSpeed = 30.0 / Double(view.frame.size.width)
        let duration = NSTimeInterval(view.frame.size.width - cloudLayer.frame.origin.x) * cloudSpeed
    
        let cloudMove = CABasicAnimation(keyPath: "position.x")
        cloudMove.duration = duration
        cloudMove.fromValue = cloudLayer.frame.origin.x
        cloudMove.toValue = view.frame.size.width + cloudLayer.frame.size.width
        cloudMove.delegate = self
        cloudMove.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear)
    
        cloudMove.setValue("cloud", forKey: "name")
        cloudMove.setValue(cloudLayer, forKey: "layer")
    
        cloudLayer.addAnimation(cloudMove, forKey: nil)
    
    }
    

    最后,在animationDidStop代理方法中,判断不同的动画:

    override func animationDidStop(anim: CAAnimation, finished flag: Bool) {
    
        guard let name = anim.valueForKey("name") as? String,
            let layer = anim.valueForKey("layer") as? CALayer else {
                return
        }
    
        if name == "form" {
            let  bounce = CABasicAnimation(keyPath: "transform.scale")
            bounce.fromValue = 1.2
            bounce.toValue = 1.0
            bounce.duration = 0.5
            layer.addAnimation(bounce, forKey: nil)
        } else if name == "cloud" {
    
            layer.frame.origin.x = -layer.frame.size.width
            delay(seconds: 0.1, completion: {
                self.animateCloud(layer)
            })
    
        }
    
    
    }
    

    参考文档

    展开全文
  • 动画的创建首先创建一个组动画,也就是大小变化和透明度变化的动画。// 大小变化 let scaleAnimation = CAKeyframeAnimation(keyPath: "transform.scale") scaleAnimation.keyTimes = [0, 0.5, 1] ...

    一.组动画的创建

    首先创建一个组动画,也就是大小变化和透明度变化的动画。

    // 大小变化
            let scaleAnimation = CAKeyframeAnimation(keyPath: "transform.scale")
    
            scaleAnimation.keyTimes = [0, 0.5, 1]
            scaleAnimation.values = [1, 0.4, 1]
            scaleAnimation.duration = duration
    
            // 透明度变化
            let opacityAnimaton = CAKeyframeAnimation(keyPath: "opacity")
            //该属性是一个数组,用以指定每个子路径的时间。
            opacityAnimaton.keyTimes = [0, 0.5, 1]
            //values属性指明整个动画过程中的关键帧点,需要注意的是,起点必须作为values的第一个值。
            opacityAnimaton.values = [1, 0.3, 1]
            opacityAnimaton.duration = duration
    
            // 组动画
            let animation = CAAnimationGroup()
            //将大小变化和透明度变化的动画加入到组动画
            animation.animations = [scaleAnimation, opacityAnimaton]
            //动画的过渡效果
            animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear)
            //动画的持续时间
            animation.duration = duration
            //设置重复次数,HUGE可看做无穷大,起到循环动画的效果
            animation.repeatCount = HUGE
            //运行一次是否移除动画
            animation.removedOnCompletion = false

    需要注意的是上面的animation.timingFunction是动画的过渡效果,里面有这几种,你可以根据自己的需求选择
    1. kCAMediaTimingFunctionLinear//线性
    2. kCAMediaTimingFunctionEaseIn//淡入
    3. kCAMediaTimingFunctionEaseOut//淡出
    4. kCAMediaTimingFunctionEaseInEaseOut//淡入淡出
    5. kCAMediaTimingFunctionDefault//默认

    二.创建圆形图层

      // Draw circles
            for var i = 0; i < 8; i++ {
                let circle = creatCircle(angle: CGFloat(M_PI_4 * Double(i)),
                    size: circleSize,
                    origin: CGPoint(x: x, y: y + 50),
                    containerSize: size,
                    color: color)
                animation.beginTime = beginTime + beginTimes[i]
                circle.addAnimation(animation, forKey: "animation")
                layer.addSublayer(circle)
            }

    这里面是创建了八个小圆,将组动画赋予这八个圆,并添加在这个视图的Layer上,creatCircle 创图层的这个方法如下

     func creatCircle(# angle: CGFloat, size: CGFloat, origin: CGPoint, containerSize: CGSize, color: UIColor) -> CALayer {
            let radius = containerSize.width/2
            let circle = createLayerWith(size: CGSize(width: size, height: size), color: color)
            let frame = CGRect(
                x: origin.x + radius * (cos(angle) + 1) - size / 2,
                y: origin.y + radius * (sin(angle) + 1) - size / 2,
                width: size,
                height: size)
            circle.frame = frame
    
            return circle
    
        }

    上面anglesizeorigincontainerSizecolor,都是传入的参数,-> CALayer 这个在Swift中代表的是返回类型,这里的返回类型就是CALayer,不像在OC中返回类型写在方法的前面- (CALayer *) ...
    这里func creatCircle(# angle: CGFloat,里面有#,代表的就是在函数(或者方法)的参数名前添加”#”,可以使该参数拥有相同的本地参数名和外部参数名。(注:在方法中,第二个及后续的参数,默认是具有和内部参数一致的外部参数名的,只有第一个参数没有,可以使用”#”符号来强制给第一个参数添加与本地参数名一致的外部参数名,但是不推荐。)

    三.创建CAShapeLayer

    也就是上面的createLayerWith这个方法,如下

     func createLayerWith(# size: CGSize, color: UIColor) -> CALayer {
            //创建CAShapeLayer,如果对CAShapeLayer比较陌生,简单介绍下CAShapeLayer
            let layer: CAShapeLayer = CAShapeLayer()
            //创建贝塞尔曲线路径(CAShapeLayer就依靠这个路径渲染)
            var path: UIBezierPath = UIBezierPath()
            //addArcWithCenter,顾名思义就是根据中心点画圆(OC语法的命名优越感又体现出来了0.0),这几个参数
            /**
            center: CGPoint 中心点
            radius: CGFloat 半径
            startAngle: CGFloat 起始的弧度
            endAngle: CGFloat 结束的弧度
            clockwise: Bool 绘画方向 true:顺时针 false:逆时针
            */
            path.addArcWithCenter(CGPoint(x: size.width / 2, y: size.height / 2),
                    radius: size.width / 2,
                    startAngle: 0,
                    endAngle: CGFloat(2 * M_PI),
                    clockwise: false);
            //线宽,如果画圆填充的话也可以不设置
            layer.lineWidth = 2
            //填充颜色,这里也就是圆的颜色
            layer.fillColor = color.CGColor
            //图层背景色
            layer.backgroundColor = nil
            //把贝塞尔曲线路径设为layer的渲染路径
            layer.path = path.CGPath
    
            return layer;
        }

    CAShapeLayer是一个通过矢量图形而不是bitmap来绘制的图层子类。你指定诸如颜色和线宽等属性,用CGPath来定义想要绘制的图形,最后CAShapeLayer就自动渲染出来了。当然,你也可以用Core Graphics直接向原始的CALyer的内容中绘制一个路径,相比直下,使用CAShapeLayer有以下一些优点:

    1. 渲染快速。CAShapeLayer使用了硬件加速,绘制同一图形会比用Core Graphics快很多。
    2. 高效使用内存。一个CAShapeLayer不需要像普通CALayer一样创建一个寄宿图形(CALyercontents属性,如果要给contents赋值就是layer.contents = (__bridge id)image.CGImage,所以占用内存大),所以无论有多大,都不会占用太多的内存。
    3. 不会被图层边界剪裁掉。一个CAShapeLayer可以在边界之外绘制。你的图层路径不会像在使用Core Graphics的普通CALayer一样被剪裁掉。
    4. 不会出现像素化。当你给CAShapeLayer做3D变换时,它不像一个有寄宿图的普通图层一样变得像素化。

    到此,运行我们就可以看到效果了,如下
    这里写图片描述
    代码可在#这里#下载。

    展开全文
  • iOS 动画大多是用UIView, ...前几天用需要做动画, 用Swift 扩展了核心动画的库, 用起来舒服多了. 不自吹了先看代码: view.layer.animate(forKey: "cornerRadius") { $0.cornerRadius .value(from: 0, ...

    iOS 动画大多是用UIView, 复杂一些的需要用到核心动画,但完全不同风格的使用方式, 和复杂的调用流程实在让萌新头疼。

    前几天用需要做动画, 用Swift 扩展了核心动画的库, 用起来舒服多了.

    不自吹了先看代码:

    view.layer.animate(forKey: "cornerRadius") {
        $0.cornerRadius
            .value(from: 0, to: cornerValue, duration: animDuration)
        $0.size
            .value(from: startRect.size, to: endRect.size, duration: animDuration)
        $0.position
            .value(from: startRect.center, to: endRect.center, duration: animDuration)
        $0.shadowOpacity
            .value(from: 0, to: 0.8, duration: animDuration)
        $0.shadowColor
            .value(from: .blackClean, to: color, duration: animDuration)
        $0.timingFunction(.easeOut).onStoped {
            [weak self] (finished:Bool) in
            if finished { self?.updateAnimations() }
        }
    }
    复制代码

    上面的代码中将一个视图的圆角, 尺寸, 位置, 阴影和阴影颜色都进行了动画, 并统一设置变化模式为easeOut, 当动画整体结束时调用另一个方法

                shareLayer.animate(forKey: "state") {
                    $0.strokeStart
                        .value(from: 0, to: 1, duration: 1).delay(0.5)
                    $0.strokeEnd
                        .value(from: 0, to: 1, duration: 1)
                    $0.timingFunction(.easeInOut)
                    $0.repeat(count: .greatestFiniteMagnitude)
                }
    复制代码

    形状 CAShareLayer (实际为圆)的 圆形进度条动画,效果如下

    那么,这些是如何实现的呢?

    首先,肯定是扩展CALayer,添加animate方法, 这里闭包传给使用者一个AnimationsMaker动画构造器 泛型给当前CALayer的实际类型(因为Layer 可能是 CATextLayer, CAShareLayer, CAGradientLayer ...等等 他们都继承自CALayer)

    这样我们就可以精确的给构造器添加可以动画的属性, 不能动画的属性则 . 不出来.

    extension CALayer {
        public func animate(forKey key:String? = nil, by makerFunc:(AnimationsMaker<Self>) -> Void) {
        }
    }
    复制代码

    想法是好的, 遗憾的是失败了.

    xcode提示 Self 只能用作返回值 或者协议中,难道就没办法解决了吗?

    答案是有的

    CALayer 继承自 CAMediaTiming 协议,那么我们只需要扩展这个协议, 并加上必须继承自CALayer 的条件, 效果和直接扩展CALayer一样.

    extension CAMediaTiming where Self : CALayer {
        public func animate(forKey key:String? = nil, by makerFunc:(AnimationsMaker<Self>) -> Void) {
        }
    }
    复制代码

    OK 效果完美, 圆满成功, 但如果一个class 没实现xxx协议怎么办? 这一招还有效么?

    答案是有的

    写一个空协议, 扩展目标class 实现此协议, 再扩展空协议, 条件是必须继承自此class , 然后添加方法。

    一不小心跑题了,下一步要创建动画构造器

    open class AnimationsMaker<Layer> : AnimationBasic<CAAnimationGroup, CGFloat> where Layer : CALayer {
        
        public let layer:Layer
        
        public init(layer:Layer) {
            self.layer = layer
            super.init(CAAnimationGroup())
        }
        
        internal var animations:[CAAnimation] = []
        open func append(_ animation:CAAnimation) {
            animations.append(animation)
        }
        
        internal var _duration:CFTimeInterval?
        
        /* The basic duration of the object. Defaults to 0. */
        @discardableResult
        open func duration(_ value:CFTimeInterval) -> Self {
            _duration = value
            return self
        }
    }
    复制代码

    目的很明显, 就是建立一个核心动画的组, 以方便于将后面一堆属性动画合并成一个

    下面开始完善之前的方法

    extension CAMediaTiming where Self : CALayer {
        
        /// CALayer 创建动画构造器
        public func animate(forKey key:String? = nil, by makerFunc:(AnimationsMaker<Self>) -> Void) {
            
            // 移除同 key 的未执行完的动画
            if let idefiniter = key {
                removeAnimation(forKey: idefiniter)
            }
            // 创建动画构造器 并 开始构造动画
            let maker = AnimationsMaker<Self>(layer: self)
            makerFunc(maker)
            
            // 如果只有一个属性做了动画, 则忽略动画组
            if maker.animations.count == 1 {
                return add(maker.animations.first!, forKey: key)
            }
            
            // 创建动画组
            let group = maker.caAnimation
            group.animations = maker.animations
            // 如果未设定动画时间, 则采用所有动画中最长的时间做动画时间
            group.duration = maker._duration ?? maker.animations.reduce(0) { max($0, $1.duration + $1.beginTime) }
        
            // 开始执行动画
            add(group, forKey: key)
        }
    }
    复制代码

    接下来自然是给 动画构造器 添加CALayer各种可动画的属性

    extension AnimationsMaker {
    
        /// 对 cornerRadius 属性进行动画 默认 0
        public var cornerRadius:AnimationMaker<Layer, CGFloat> {
            return AnimationMaker<Layer, CGFloat>(maker:self, keyPath:"cornerRadius")
        }
        
        /// 对 bounds 属性进行动画.
        public var bounds:AnimationMaker<Layer, CGRect> {
            return AnimationMaker<Layer, CGRect>(maker:self, keyPath:"bounds")
        }
        
        /// 对 size 属性进行动画
        public var size:AnimationMaker<Layer, CGSize> {
            return AnimationMaker<Layer, CGSize>(maker:self, keyPath:"bounds.size")
        }
    
        /// 以下若干属性略
        ......
    }
    复制代码

    这里的AnimationMaker 和 前面的 AnimationsMaker 很像,但其意义是单一属性的动画构造器

    CABasicAnimation 里面的fromValuetoValue 的属性都是Any?

    原因是对layer的不同属性进行动画时, 给的值类型也是不确定的, 比如size属性 是CGSize, position属性是CGPoint, zPosition属性是CGFloat等, 因此它也只能是Any?

    但这不符合Swift 安全语言的目标, 因为我们使用时可能不小心传递了一个错误的类型给它而不被编译器发现, 增加了DEBUG的时间, 不利于生产效率

    因此, 在定义 AnimationMaker(单一属性动画)时,应使用泛型约束变化的值和动画属性值的类型相同,并且为了方便自身构造的CAAnimation 加到动画组中, 将AnimationsMaker也传递进去

    public class AnimationMaker<Layer, Value> where Layer : CALayer {
        public unowned let maker:AnimationsMaker<Layer>
        public let keyPath:String
        public init(maker:AnimationsMaker<Layer>, keyPath:String) {
            self.maker = maker
            self.keyPath = keyPath
        }
        /// 指定弹簧系数的 弹性动画
        @available(iOS 9.0, *)
        func animate(duration:TimeInterval, damping:CGFloat, from begin:Any?, to over:Any?) -> Animation<CASpringAnimation, Value> {
            let anim = CASpringAnimation(keyPath: keyPath)
            anim.damping    = damping
            anim.fromValue  = begin
            anim.toValue    = over
            anim.duration   = duration
            maker.append(anim)
            return Animation<CASpringAnimation, Value>(anim)
        }
        
        /// 指定起始和结束值的 基础动画
        func animate(duration:TimeInterval, from begin:Any?, to over:Any?) -> Animation<CABasicAnimation, Value> {
            let anim = CABasicAnimation(keyPath: keyPath)
            anim.fromValue  = begin
            anim.toValue    = over
            anim.duration   = duration
            maker.append(anim)
            return Animation<CABasicAnimation, Value>(anim)
        }
        
        /// 指定关键值的帧动画
        func animate(duration:TimeInterval, values:[Value]) -> Animation<CAKeyframeAnimation, Value> {
            let anim = CAKeyframeAnimation(keyPath: keyPath)
            anim.values     = values
            anim.duration   = duration
            maker.append(anim)
            return Animation<CAKeyframeAnimation, Value>(anim)
        }
        
        /// 指定引导线的帧动画
        func animate(duration:TimeInterval, path:CGPath) -> Animation<CAKeyframeAnimation, Value> {
            let anim = CAKeyframeAnimation(keyPath: keyPath)
            anim.path       = path
            anim.duration   = duration
            maker.append(anim)
            return Animation<CAKeyframeAnimation, Value>(anim)
        }
    }
    复制代码

    为了避免可能存在的循环引用内存泄露, 这里将父动画组maker 设为不增加引用计数的 unowned (相当于OCassign)

    虽然实际上没循环引用, 但因为都是临时变量, 没必要增加引用计数, 可以加快运行效率

    AnimationMaker里只给了动画必要的基础属性, 一些额外属性可以通过链式语法额外设置, 所以返回了一个包装CAAnimationAnimation 对象, 同样传递值类型的泛型

    public final class Animation<T, Value> : AnimationBasic<T, Value> where T : CAAnimation {
        
        /* The basic duration of the object. Defaults to 0. */
        @discardableResult
        public func duration(_ value:CFTimeInterval) -> Self {
            caAnimation.duration = value
            return self
        }
        
    }
    复制代码

    因为CAAnimation动画 和CAAnimationGroup动画组都共有一些属性, 所以写了一个 基类 AnimationBasic 而动画组的时间额外处理, 默认不给的时候使用所有动画中最大的那个时间, 否则使用强制指定的时间,参考前面的AnimationsMaker 定义

    open class AnimationBasic<T, Value> where T : CAAnimation {
        
        open let caAnimation:T
        
        public init(_ caAnimation:T) {
            self.caAnimation = caAnimation
        }
        
        /* The begin time of the object, in relation to its parent object, if
         * applicable. Defaults to 0. */
        @discardableResult
        public func delay(_ value:TimeInterval) -> Self {
            caAnimation.beginTime = value
            return self
        }
        
        /* A timing function defining the pacing of the animation. Defaults to
         * nil indicating linear pacing. */
        @discardableResult
        open func timingFunction(_ value:CAMediaTimingFunction) -> Self {
            caAnimation.timingFunction = value
            return self
        }
        
        /* When true, the animation is removed from the render tree once its
         * active duration has passed. Defaults to YES. */
        @discardableResult
        open func removedOnCompletion(_ value:Bool) -> Self {
            caAnimation.isRemovedOnCompletion = value
            return self
        }
        
        @discardableResult
        open func onStoped(_ completion: @escaping @convention(block) (Bool) -> Void) -> Self {
            if let delegate = caAnimation.delegate as? AnimationDelegate {
                delegate.onStoped = completion
            } else {
                caAnimation.delegate = AnimationDelegate(completion)
            }
            return self
        }
        
        @discardableResult
        open func onDidStart(_ started: @escaping @convention(block) () -> Void) -> Self {
            if let delegate = caAnimation.delegate as? AnimationDelegate {
                delegate.onDidStart = started
            } else {
                caAnimation.delegate = AnimationDelegate(started)
            }
            return self
        }
            
        /* The rate of the layer. Used to scale parent time to local time, e.g.
         * if rate is 2, local time progresses twice as fast as parent time.
         * Defaults to 1. */
        @discardableResult
        open func speed(_ value:Float) -> Self {
            caAnimation.speed = value
            return self
        }
        
        /* 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. */
        @discardableResult
        open func time(offset:CFTimeInterval) -> Self {
            caAnimation.timeOffset = offset
            return self
        }
        
        /* The repeat count of the object. May be fractional. Defaults to 0. */
        @discardableResult
        open func `repeat`(count:Float) -> Self {
            caAnimation.repeatCount = count
            return self
        }
        
        /* The repeat duration of the object. Defaults to 0. */
        @discardableResult
        open func `repeat`(duration:CFTimeInterval) -> Self {
            caAnimation.repeatDuration = duration
            return self
        }
        
        /* When true, the object plays backwards after playing forwards. Defaults
         * to NO. */
        @discardableResult
        open func autoreverses(_ value:Bool) -> Self {
            caAnimation.autoreverses = value
            return self
        }
        
        /* Defines how the timed object behaves outside its active duration.
         * Local time may be clamped to either end of the active duration, or
         * the element may be removed from the presentation. The legal values
         * are `backwards', `forwards', `both' and `removed'. Defaults to
         * `removed'. */
        @discardableResult
        open func fill(mode:AnimationFillMode) -> Self {
            caAnimation.fillMode = mode.rawValue
            return self
        }
    }
    复制代码

    接下来开始锦上添花 给单一属性动画 添加快速创建

    extension AnimationMaker {
        
        /// 创建 指定变化值的帧动画 并执行 duration 秒的弹性动画
        @discardableResult
        public func values(_ values:[Value], duration:TimeInterval) -> Animation<CAKeyframeAnimation, Value> {
            return animate(duration: duration, values: values)
        }
        
        /// 创建从 begin 到 over 并执行 duration 秒的弹性动画
        @available(iOS 9.0, *)
        @discardableResult
        public func value(from begin:Value, to over:Value, damping:CGFloat, duration:TimeInterval) -> Animation<CASpringAnimation, Value> {
            return animate(duration: duration, damping:damping, from: begin, to: over)
        }
    
        /// 创建从 begin 到 over 并执行 duration 秒的动画
        @discardableResult
        public func value(from begin:Value, to over:Value, duration:TimeInterval) -> Animation<CABasicAnimation, Value> {
            return animate(duration: duration, from: begin, to: over)
        }
        
        /// 创建从 当前已动画到的值 更新到 over 并执行 duration 秒的动画
        @discardableResult
        public func value(to over:Value, duration:TimeInterval) -> Animation<CABasicAnimation, Value> {
            let begin = maker.layer.presentation()?.value(forKeyPath: keyPath) ?? maker.layer.value(forKeyPath: keyPath)
            return animate(duration: duration, from: begin, to: over)
        }
    }
    复制代码

    给不同的核心动画添加其独有属性

    extension Animation where T : CABasicAnimation {
    
        @discardableResult
        public func from(_ value:Value) -> Self {
            caAnimation.fromValue = value
            return self
        }
        
        @discardableResult
        public func to(_ value:Value) -> Self {
            caAnimation.toValue = value
            return self
        }
        
        /* - `byValue' non-nil. Interpolates between the layer's current value
         * of the property in the render tree and that plus `byValue'. */
        @discardableResult
        public func by(_ value:Value) -> Self {
            caAnimation.byValue = value
            return self
        }
    }
    复制代码
    @available(iOSApplicationExtension 9.0, *)
    extension Animation where T : CASpringAnimation {
        /* The mass of the object attached to the end of the spring. Must be greater
         than 0. Defaults to one. */
        /// 质量 默认1 必须>0 越重回弹越大
        @available(iOS 9.0, *)
        @discardableResult
        public func mass(_ value:CGFloat) -> Self {
            caAnimation.mass = value
            return self
        }
        
        /* The spring stiffness coefficient. Must be greater than 0.
         * Defaults to 100. */
        /// 弹簧钢度系数 默认100 必须>0 越小回弹越大
        @available(iOS 9.0, *)
        @discardableResult
        public func stiffness(_ value:CGFloat) -> Self {
            caAnimation.stiffness = value
            return self
        }
        
        /* The damping coefficient. Must be greater than or equal to 0.
         * Defaults to 10. */
        /// 阻尼 默认10 必须>=0
        @available(iOS 9.0, *)
        @discardableResult
        public func damping(_ value:CGFloat) -> Self {
            caAnimation.damping = value
            return self
        }
        
        /* The initial velocity of the object attached to the spring. Defaults
         * to zero, which represents an unmoving object. Negative values
         * represent the object moving away from the spring attachment point,
         * positive values represent the object moving towards the spring
         * attachment point. */
        /// 初速度 默认 0, 正数表示正方向的初速度, 负数表示反方向的初速度
        @available(iOS 9.0, *)
        @discardableResult
        public func initialVelocity(_ value:CGFloat) -> Self {
            caAnimation.initialVelocity = value
            return self
        }
    
    }
    复制代码

    还有一些略

    最后, 给一些特殊属性, 可以点出子属性的做一些扩展添加

    extension AnimationMaker where Value == CGSize {
        
        /// 对 size 的 width 属性进行动画
        public var width:AnimationMaker<Layer, CGFloat> {
            return AnimationMaker<Layer, CGFloat>(maker:maker, keyPath:"\(keyPath).width")
        }
        
        /// 对 size 的 height 属性进行动画
        public var height:AnimationMaker<Layer, CGFloat> {
            return AnimationMaker<Layer, CGFloat>(maker:maker, keyPath:"\(keyPath).height")
        }
    }
    复制代码
    extension AnimationMaker where Value == CATransform3D {
        
        /// 对 transform 的 translation 属性进行动画
        public var translation:UnknowMaker<Layer, CGAffineTransform> {
            return UnknowMaker<Layer, CGAffineTransform>(maker:maker, keyPath:"\(keyPath).translation")
        }
        
        /// 对 transform 的 rotation 属性进行动画
        public var rotation:UnknowMaker<Layer, CGAffineTransform> {
            return UnknowMaker<Layer, CGAffineTransform>(maker:maker, keyPath:"\(keyPath).rotation")
        }
        
    }
    复制代码
    extension UnknowMaker where Value == CGAffineTransform {
        /// 对 transform 的 x 属性进行动画
        public var x:AnimationMaker<Layer, CGFloat> {
            return AnimationMaker<Layer, CGFloat>(maker:maker, keyPath:"\(keyPath).x")
        }
        
        /// 对 transform 的 y 属性进行动画
        public var y:AnimationMaker<Layer, CGFloat> {
            return AnimationMaker<Layer, CGFloat>(maker:maker, keyPath:"\(keyPath).y")
        }
        
        /// 对 transform 的 z 属性进行动画
        public var z:AnimationMaker<Layer, CGFloat> {
            return AnimationMaker<Layer, CGFloat>(maker:maker, keyPath:"\(keyPath).z")
        }
    }
    复制代码

    暂时没有深入了解 transform 更多属性的动画, 因此只写了几个已知的基础属性, 为了避免中间使用异常, 所以弄了个 UnknowMaker 对此熟悉的大佬可以帮忙补充。

    最后扩展了2个常用范例

    private let kShakeAnimation:String = "shakeAnimation"
    private let kShockAnimation:String = "shockAnimation"
    
    
    extension CALayer {
        
        /// 摇晃动画
        public func animateShake(count:Float = 3) {
            let distance:CGFloat = 0.08        // 摇晃幅度
            animate(forKey: kShakeAnimation) {
                $0.transform.rotation.z
                    .value(from: distance, to: -distance, duration: 0.1).by(0.003)
                    .autoreverses(true).repeat(count: count).timingFunction(.easeInOut)
            }
        }
        
        /// 震荡动画
        public func animateShock(count:Float = 2) {
            let distance:CGFloat = 10        // 震荡幅度
            animate(forKey: kShockAnimation) {
                $0.transform.translation.x
                    .values([0, -distance, distance, 0], duration: 0.15)
                    .autoreverses(true).repeat(count: count).timingFunction(.easeInOut)
            }
    
        }
        
    }
    复制代码

    最后为了方便使用, 减少编译时间, 将项目写成了一个库, iOS 和 Mac 都可以用, 因为Swift 4 仍然没有稳定ABI的库, 建议将库拖入项目 使用

    记的不仅仅是Linked Frameworks 自定义的framework 都要加入 Embedded Binaries

    源码 Github下载地址

    如果好用请给我个Start, 本文为作者原创, 如需转载, 请注明出处和原文链接。

    展开全文
  • Gifu 在内部维护一个 animator,用它跟踪和显示不同的。其优势是内存占用少,同一时间只会向内存中加载三(当前和预加载的两),释放之前展示过的内容,从而大幅减少内存占用。
  • 一、问题出现 在公司项目中,需要显示一些网络 GIF 图片,使用的是 Kingfisher 第三方图片...一个 1MB 大小但数有 150 的 GIF 图片,采用 Kingfisher 加载到内存中需要占用至少 300 MB 以上的内存,多加载几张...

    一、问题出现

    在公司项目中,需要显示一些网络 GIF 图片,使用的是 Kingfisher 第三方图片缓存库进行加载图片,一般情况下挺好的,但有时候会出现内存暴增,一开始以为是没有对图片缓存进行释放导致,后来测试发现是因为某个 GIF 帧数过高导致的,一个 1MB 大小但帧数有 150 帧的 GIF 图片,采用 Kingfisher 加载到内存中需要占用至少 300 MB 以上的内存,多加载几张这样的 GIF 内存直接爆炸,所以需要进行 GIF 图片加载进行优化。

    二、问题思考

    为什么会导致这样的内存暴增呢?

    因为 Kingfisher 在加载 GIF 图的时候,会把 GIF 图的所有帧图片数据都加载到内存进行显示,导致内存暴增。

    降低内存消耗,提高 CPU 消耗

    去网上找第三方 GIF 图加载优化库,发现了SwiftGifYLGIFImage-Swift 这两个框架,我看了一下 YLGIFImage-Swift 框架里面的实现,是通过动态加载动画帧的形式来优化的。

    动态加载帧原理:

    1. 一开始不加载所有图片帧,只加载少量的帧图片
    2. 在动画执行过程中利用定时器不断进行加载帧图片
    3. 释放已执行完动画的帧图片内存
    4. 内存消耗降低,这样的代价就是会导致 CPU 的使用提高

    因为项目代码使用到的是 Swift3.2,YLGIFImage-Swift 第三方库更新比较慢,所以对该框架手动进行了一些调整和优化。

    三、源代码解析和优化

    String+MD5.swift 文件如下: 【需要桥接 OC 头文件 <CommonCrypto/CommonDigest.h>

    // String+MD5.swift
    import Foundation
    extension String {
        /// 字符串 MD5 加密
        var encodeMD5: String? {
            guard let str = cString(using: String.Encoding.utf8) else { return nil }
            let strLen = CC_LONG(lengthOfBytes(using: String.Encoding.utf8))
            let digestLen = Int(CC_MD5_DIGEST_LENGTH)
            let result = UnsafeMutablePointer<CUnsignedChar>.allocate(capacity: digestLen)
            // MD5 加密
            CC_MD5(str, strLen, result)
            // 把结果打印输出成 16 进制字符串
            let hash = NSMutableString()
            for i in 0..<digestLen {
                hash.appendFormat("%02x", result[I])
            }
            result.deallocate(capacity: digestLen)
            return String(format: hash as String)
        }
    }
    复制代码

    GIFImage.swift 文件如下:

    // GIFImage.swift
    import UIKit
    import ImageIO
    import MobileCoreServices
    
    class GIFImage {
        /// 内部读取图片帧队列
        fileprivate lazy var readFrameQueue: DispatchQueue = DispatchQueue(label: "image.gif.readFrameQueue", qos: .background)
        /// 图片资源数据
        fileprivate var cgImageSource: CGImageSource?
        /// 总动画时长
        var totalDuration: TimeInterval = 0.0
        /// 每一帧对应的动画时长
        var frameDurations: [Int: TimeInterval] = [:]
        /// 每一帧对应的图片
        var frameImages: [Int: UIImage] = [:]
        /// 总图片数
        var frameTotalCount: Int = 0
        /// 兼容之前的 UIImage 使用
        var image: UIImage?
    
        /// 全局配置
        struct GlobalSetting {
            /// 配置预加载帧的数量
            static var prefetchNumber: Int = 10
            static var minFrameDuration: TimeInterval = 0.01
        }
    
        /// 兼容 UIImage named 调用
        convenience init?(named name: String!) {
            guard let path = Bundle.main.path(forResource: name, ofType: ".gif") else { return nil }
            guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return nil }
            self.init(data: data)
        }
    
        /// 兼容 UIImage contentsOfFile 调用
        convenience init?(contentsOfFile path: String) {
            guard let url = URL(string: path) else { return nil }
            guard let data = try? Data(contentsOf: url) else { return nil }
            self.init(data: data)
        }
        
        /// 兼容 UIImage contentsOf 调用
        convenience init?(contentsOf url: URL) {
            guard let data = try? Data(contentsOf: url) else { return nil }
            self.init(data: data)
        }
    
        /// 兼容 UIImage data 调用
        convenience init?(data: Data) {
            self.init(data: data, scale: 1.0)
        }
        
        /// 根据二进制数据初始化【核心初始化方法】
        init?(data: Data, scale: CGFloat) {
            guard let cgImageSource = CGImageSourceCreateWithData(data as CFData, nil) else { return }
            self.cgImageSource = cgImageSource
            if GIFImage.isCGImageSourceContainAnimatedGIF(cgImageSource: cgImageSource) {
                initGIFSource(cgImageSource: cgImageSource)
            } else {
                image = UIImage(data: data, scale: scale)
            }
        }
        
        /// 判断图片数据源包含 GIF 信息
        fileprivate class func isCGImageSourceContainAnimatedGIF(cgImageSource: CGImageSource) -> Bool {
            guard let type = CGImageSourceGetType(cgImageSource) else { return false }
            let isGIF = UTTypeConformsTo(type, kUTTypeGIF)
            let imgCount = CGImageSourceGetCount(cgImageSource)
            return isGIF && imgCount > 1
        }
        
        /// 获取图片数据源的第 index 帧图片的动画时间
        fileprivate class func getCGImageSourceGifFrameDelay(imageSource: CGImageSource, index: Int) -> TimeInterval {
            var delay = 0.0
            guard let imgProperties: NSDictionary = CGImageSourceCopyPropertiesAtIndex(imageSource, index, nil) else { return delay }
            // 获取该帧图片的属性字典
            if let property = imgProperties[kCGImagePropertyGIFDictionary as String] as? NSDictionary {
                // 获取该帧图片的动画时长
                if let unclampedDelayTime = property[kCGImagePropertyGIFUnclampedDelayTime as String] as? NSNumber {
                    delay = unclampedDelayTime.doubleValue
                    if delay <= 0, let delayTime = property[kCGImagePropertyGIFDelayTime as String] as? NSNumber {
                        delay = delayTime.doubleValue
                    }
                }
            }
            return delay
        }
        
        /// 根据图片数据源初始化,设置动画总时长、总帧数等属性
        fileprivate func initGIFSource(cgImageSource: CGImageSource) {
            let numOfFrames = CGImageSourceGetCount(cgImageSource)
            frameTotalCount = numOfFrames
            for index in 0..<numOfFrames {
                // 获取每一帧的动画时长
                let frameDuration = GIFImage.getCGImageSourceGifFrameDelay(imageSource: cgImageSource, index: index)
                self.frameDurations[index] = max(GlobalSetting.minFrameDuration, frameDuration)
                self.totalDuration += frameDuration
                // 一开始初始化预加载一定数量的图片,而不是全部图片
                if index < GlobalSetting.prefetchNumber {
                    if let cgimage = CGImageSourceCreateImageAtIndex(cgImageSource, index, nil) {
                        let image: UIImage = UIImage(cgImage: cgimage)
                        if index == 0 {
                            self.image = image
                        }
                        self.frameImages[index] = image
                    }
                }
            }
        }
    
        /// 获取某一帧图片
        func getFrame(index: Int) -> UIImage? {
            guard index < frameTotalCount else { return nil }
            // 取当前帧图片
            let currentImage = self.frameImages[index] ?? self.image
            // 如果总帧数大于预加载数,需要加载后面未加载的帧图片
            if frameTotalCount > GlobalSetting.prefetchNumber {
                // 清除当前帧图片缓存数据,空出内存
                if index != 0 {
                    self.frameImages[index] = nil
                }
                // 加载后面帧图片到内存
                for i in 1...GlobalSetting.prefetchNumber {
                    let idx = (i + index) % frameTotalCount
                    if self.frameImages[idx] == nil {
                        // 默认加载第一张帧图片为占位,防止多次加载
                        self.frameImages[idx] = self.frameImages[0]
                        self.readFrameQueue.async { [weak self] in
                            guard let strongSelf = self, let cgImageSource = strongSelf.cgImageSource else { return }
                            guard let cgImage = CGImageSourceCreateImageAtIndex(cgImageSource, idx, nil) else { return }
                            strongSelf.frameImages[idx] = UIImage(cgImage: cgImage)
                        }
                    }
                }
            }
            return currentImage
        }
    }
    复制代码

    BasicGIFImageView.swift 文件如下:

    // BasicGIFImageView.swift
    import UIKit
    import QuartzCore
    
    class BasicGIFImageView: UIImageView {
        /// 后台下载图片队列
        fileprivate lazy var downloadImageQueue: DispatchQueue = DispatchQueue(label: "image.gif.downloadImageQueue", qos: .background)
        /// 累加器,用于计算一个定时循环中的可用动画时间
        fileprivate var accumulator: TimeInterval = 0.0
        /// 当前正在显示的图片帧索引
        fileprivate var currentFrameIndex: Int = 0
        /// 当前正在显示的图片
        fileprivate var currentFrame: UIImage?
        /// 动画图片存储属性
        fileprivate var animatedImage: GIFImage?
        /// 定时器
        fileprivate var displayLink: CADisplayLink!
        /// 当前将要显示的 GIF 图片资源路径
        fileprivate var gifUrl: URL?
      
        /// 重载初始化,初始化定时器
        required init?(coder aDecoder: NSCoder) {
            super.init(coder: aDecoder)
            setupDisplayLink()
        }
        
        override init(frame: CGRect) {
            super.init(frame: frame)
            setupDisplayLink()
        }
        
        override init(image: UIImage?) {
            super.init(image: image)
            setupDisplayLink()
        }
        
        override init(image: UIImage?, highlightedImage: UIImage!) {
            super.init(image: image, highlightedImage: highlightedImage)
            setupDisplayLink()
        }
        
        /// 当设置该属性时,将不显示 GIF 动效
        override var image: UIImage? {
            get {
                if let animatedImage = self.animatedImage {
                    return animatedImage.getFrame(index: 0)
                } else {
                    return super.image
                }
            }
            set {
                if image === newValue {
                    return
                }
                super.image = newValue
                self.gifImage = nil
            }
        }
        
        /// 设置 GIF 图片
        var gifImage: GIFImage? {
            get {
                return self.animatedImage
            }
            set {
                if animatedImage === newValue {
                    return
                }
                self.stopAnimating()
                self.currentFrameIndex = 0
                self.accumulator = 0.0
                if let newAnimatedImage = newValue {
                    self.animatedImage = newAnimatedImage
                    if let currentImage = newAnimatedImage.getFrame(index: 0) {
                        super.image = currentImage
                        self.currentFrame = currentImage
                    }
                    self.startAnimating()
                } else {
                    self.animatedImage = nil
                }
                self.layer.setNeedsDisplay()
            }
            
        }
        
        /// 当显示 GIF 时,不处理高亮状态
        override var isHighlighted: Bool {
            get {
                return super.isHighlighted
            }
            set {
                if self.animatedImage == nil {
                    super.isHighlighted = newValue
                }
            }
        }
        
        /// 获取是否正在动画
        override var isAnimating: Bool {
            if self.animatedImage != nil {
                return !self.displayLink.isPaused
            } else {
                return super.isAnimating
            }
        }
        
        /// 开启定时器
        override func startAnimating() {
            if self.animatedImage != nil {
                self.displayLink.isPaused = false
            } else {
                super.startAnimating()
            }
        }
        
        /// 暂停定时器
        override func stopAnimating() {
            if self.animatedImage != nil {
                self.displayLink.isPaused = true
            } else {
                super.stopAnimating()
            }
        }
        
        /// 当前显示内容为 GIF 当前帧图片
        override func display(_ layer: CALayer) {
            if self.animatedImage != nil {
                if let frame = self.currentFrame {
                    layer.contents = frame.cgImage
                }
            }
        }
        
        /// 初始化定时器
        fileprivate func setupDisplayLink() {
            displayLink = CADisplayLink(target: self, selector: #selector(BasicGIFImageView.changeKeyFrame))
            self.displayLink.add(to: RunLoop.main, forMode: .commonModes)
            self.displayLink.isPaused = true
        }
        
        /// 动态改变图片动画帧
        @objc fileprivate func changeKeyFrame() {
            if let animatedImage = self.animatedImage {
                guard self.currentFrameIndex < animatedImage.frameTotalCount else { return }
                self.accumulator += min(1.0, displayLink.duration)
                var frameDuration = animatedImage.frameDurations[self.currentFrameIndex] ?? displayLink.duration
                while self.accumulator >= frameDuration {
                    self.accumulator -= frameDuration
                    self.currentFrameIndex += 1
                    if self.currentFrameIndex >= animatedImage.frameTotalCount {
                        self.currentFrameIndex = 0
                    }
                    if let currentImage = animatedImage.getFrame(index: self.currentFrameIndex) {
                        self.currentFrame = currentImage
                    }
                    self.layer.setNeedsDisplay()
                    if let newFrameDuration = animatedImage.frameDurations[self.currentFrameIndex] {
                        frameDuration = min(displayLink.duration, newFrameDuration)
                    }
                }
            } else {
                self.stopAnimating()
            }
        }
        
        /// 显示本地 GIF 图片
        func showLocalGIF(name: String?) {
            guard let name = name else { return }
            self.gifImage = GIFImage(named: name)
        }
        
        /// 根据 urlStr 显示网络 GIF 图片
        func showNetworkGIF(urlStr: String?) {
            guard let urlStr = urlStr else { return }
            guard let url = URL(string: urlStr) else { return }
            showNetworkGIF(url: url)
        }
        
        /// 根据 url 显示网络 GIF 图片
        func showNetworkGIF(url: URL) {
            guard let fileName = url.absoluteString.encodeMD5, let directoryPath = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true).first else { return }
            let filePath = (directoryPath as NSString).appendingPathComponent("\(fileName).gif") as String
            let fileUrl = URL(fileURLWithPath: filePath)
            self.gifUrl = fileUrl
            // 后台下载网络图片或者加载本地缓存图片
            self.downloadImageQueue.async { [weak self] in
                if FileManager.default.fileExists(atPath: filePath) { // 本地缓存
                    let gifImage = GIFImage(contentsOf: fileUrl)
                    DispatchQueue.main.async { [weak self] in
                        if let strongSelf = self, strongSelf.gifUrl == fileUrl {
                            strongSelf.gifImage = gifImage
                        }
                    }
                } else { // 网络加载
                    let task = URLSession.shared.dataTask(with: url, completionHandler: { (data, _, _) in
                        guard let data = data else { return }
                        do {
                            try data.write(to: fileUrl, options: .atomic)
                        } catch {
                            debugPrint(error)
                        }
                        let gifImage = GIFImage(data: data)
                        DispatchQueue.main.async { [weak self] in
                            if let strongSelf = self, strongSelf.gifUrl == fileUrl {
                                strongSelf.gifImage = gifImage
                            }
                        }
                    })
                    task.resume()
                }
            }
        }
    }
    复制代码

    使用如下:

    // ViewController.swift
    import UIKit
    class ViewController: UIViewController {
        @IBOutlet weak var networkImageView: BasicGIFImageView!
        @IBOutlet weak var localImageView: BasicGIFImageView!
     
        override func viewDidLoad() {
            super.viewDidLoad()
            // 加载网络 GIF 图片
            let testUrlStr = "https://images.ifanr.cn/wp-content/uploads/2018/05/2018-05-09-17_22_48.gif"
            networkImageView.showNetworkGIF(urlStr: testUrlStr)
            // 加载本地 GIF 图片
            localImageView.showLocalGIF(name: "test")
        }
    }
    复制代码

    Demo 源代码在这:GIFImageLoadDemo

    有什么问题可以在下方评论区提出,写得不好可以提出你的意见,我会合理采纳的,O(∩_∩)O哈哈~,求关注求赞

    转载于:https://juejin.im/post/5b126ea5e51d4506c60e1f31

    展开全文
  • 章节目录如下: 第 1 章 游戏的设计 … 9 ...1.5 计算屏幕的刷新时间 …18 1.6 进入和退出游戏 …19 1.7 用定时器进行游戏的刷新…22 1.8 每刷新…23 1.9 游戏暂停…24 1.10 记录游戏时间 …25...
  • 偶然间在网页上看到一个过山车动画觉得很炫,就想用swift纯代码实现了一个类似的效果,因为没有设计天赋,所以就完全高仿的人家的效果-.-下面上效果图: 给大家介绍一下项目中主要会用到的类: CAShapeLayer ...
  • Core Animation基于一个假设,说屏幕上的任何东西都可以(或者可能)做动画动画并不需要你在Core Animation中手动打开,相反需要明确地关闭,否则他会一直存在。 当你改变CALayer的一个可做动画的属性,它并不...
  • 袋子内部动画就是上面波纹水平移动,金币总量垂直移动(transform:translateX||translateY) 钱袋子摇晃就是Z轴上偏移 金币飞出主要是用transition-origin 代码 html &amp;lt;div id=&quot;container&...
  • GitHub上Swift开源项目!

    2017-02-04 19:10:16
    swift-open-project这里汇集了目前为止最为流行的Swift开源项目,选取SwiftGuide中的开源部分,这里将每周对项目进行总结,如果大家有推荐的可以联系thinkloki@gmail.com,或者issues,欢迎Star、Fork。感谢...
  • 而且Android和iOS两端不好统一效果,如果用gif图片来实现的话,在图片大小和动画帧数之间很难权衡。而且会导致内存吃紧。为了解决这样的问题,今天来介绍两款实现复杂动画的开源库:Lottie和SVGA。 Lottie Lot...
  • Swift学习资料@SwiftGuide很赞 的Swift学习资料leetcode一个练习、评估自己水平的代码平台,跟ACM有点类似完整App@Swift 30 Projects- 最新 Swift 3.0 的30个小App,更注重代码规范和架构设计(故胤道长)V2ex-Swift- ...
  • swift kingfisher原理使用

    2018-08-30 10:03:55
    目录  一、使用方式: ...今年到新公司,发现公司用的kingfisher这个第三方库,和SDWebImage很类似,不过是swift版本的,就了解了下。 源码地址: https://github.com/onevcat/Kingfisher 我看的是swif...
  • swift 框架大全

    2017-06-26 11:27:09
    版本:Swift github排名 https://github.com/trending,github搜索:https://github.com/search 主要工作说明:  1. 将Swift从Objective-C混合的md文件中抽出(正在进行…) 使用方法:根据目录关键字...
  • GCD精讲(Swift 3&4)

    2017-12-10 12:26:12
    前言我们首先来看一张图:...把过多的任务放在主线程,会导致主线程卡顿,进而用户看到的就是App响应慢,列表滚动的时候掉。把任务分散到多个线程执行有很多种技术,在iOS/MacOS App开发中,最简单直观的就是GCD(又
  • swift 学习资料大全

    2017-07-15 18:22:50
    版本:Swift github排名 https://github.com/trending,github搜索:https://github.com/search 主要工作说明:  1. 将Swift从Objective-C混合的md文件中抽出(正在进行…) 使用方法:根据目录关键字...
  • 职业前景苹果在WWDC14压轴发布全新编程Swift语言,在不到1个月的时间里,Swift就挤进流行语言前列。Swift编程语言,由于其快速、动态、优雅的优势,必将取代Objective-C成为iOS开发主流编程语言,相信在不久,Swift...
  • iOS 常用动画第三方

    2017-02-08 22:10:32
    动画 Core Animation笔记,基本的使用方法 - Core Animation笔记,基本的使用方法:1.基本动画,2.多步动画,3.沿路径的动画,4.时间函数,5.动画组。 awesome-ios-animation - iOS Animation 主流炫酷动画...
1 2 3 4 5 ... 20
收藏数 800
精华内容 320