II.8 显式动画

如何你想某事正确,自己动手做吧。——Charles-Guillaume Étienne

前一章介绍了隐式动画的概念。隐式动画是iOS上创建用户界面的一种直接的方式,它们是UIKit自身动画机制的基础,但它们并不是一个完整的通用的动画方案。这一章,我们将讲解显式动画,这使我们得以对特殊属性指定自定义动画或者创建非线性动画,如沿弧移动。

属性动画

我们将讲解的第一种显式动画类型是属性动画。属性动画针对于图层的一个单一属性,并指定动画属性的目标值的区间。属性动画分为两类:基础关键帧

基础动画

基础动画是随时间发生的,是值改变的最简单的方法,它是CABasicAnimation设计的模型。

CABasicAnimationCAPropertyAnimation这一抽象类的具体子类,它是CAAnimation的子类,而CAAnimationCore Animation提供的所有动画类型的抽象基类。身为抽象类,CAAnimation自身并不会明确实现功能。它提供了一个时间函数(正如第10章“缓动”所讲解的),一个委托(用于获得动画状态反馈),以及一个removedOnCompletion标志用于指示是否在结束后自动释放(这默认为YES,这会防止内存溢出)。CAAnimation也实现包括CAAction(允许任何CAAnimation子类被作为图层动作支持)以及CAMediaTiming(将在第9章“图层时间”中细说)等一系列协议。

CAPropertyAnimation作用于单一属性,由动画的keyPath值指定。CAAnimation通常用于某一CALayer,同样keyPath是与该图层相关的。事实上这是一个关键路径(一系列由点限定的键,它们指向一个有层次的直接结构对象)而并非仅是一个属性名,keyPath十分有趣,它意味着动画不仅可以应用于图层自身,还可以应用于成员对象的属性,甚至虚拟属性(稍后细说)。

CABasicAnimationCAPropertyAnimation扩展了三个额外的属性:

  • id fromValue
  • id toValue
  • id byValue

它们的意思如同字面所示:fromValue表示动画开始时的属性值;toValue表示动画结束时的属性值;byValue表示动画期间改变的属性值。

通过这三个属性,你可以用不同的方式改变某个值。它们的类型是id(而不是具体类),这是因为属性动画可以用于包括数值、向量、变形矩阵以及颜色和图像在内的不同的属性类型。

id类型的属性可以包含任意派生的NSObject,但通常你会想要不是继承自NSObject的属性类型,这意味着你将需要将值包装成一个对象(被称作boxing)或者转型为一个对象(被称作toll-free bredging),这使得任意Core Foundation类型表现的如同Objective-C类,即使它们本身并不是。有时如何将想要的数据类型转换成适配id的值是并不明显的,但表格8.1列举了一些普遍的情况。

表格8.1 用CAPropertyAnimation`封装原始值

类型 对象类型 代码例子
CGFloat NSNumber id obj = @(float);
CGPoint NSValue id obj = [NSValue valueWithCGPoint: point);
CGSize NSValue id obj = [NSValue valueWithCGSize: size);
CGRect NSValue id obj = [NSValue valueWithCGRect: rect);
CATransform3D NSValue id obj = [NSValue valueWithCATransform3D: transform);
CGImageRef id id obj = (__bridge id)imageRef;
CGColorRef id id obj = (__bridge id)colorRef;

fromValuetoValue,和byValue属性可以被用于不同的组合,但你不能同时指定三个值,这样会引起冲突。例如,如果你指定fromValue2toValue4,而byValue为3,Core Animation并不知道最终值应该为4(由toValue指定)还是5formValue+byValue)。关于这些属性值到底如何使用在CABasicAnimation的头文件中有良好的文档,所以在这我们并不重复它们。通常,你只需要指定toValuebyValue;其它值会根据上下文推断出来。

让我们试一下:我们将会修改第7章“隐式动画”的颜色渐变动画,我们将使用一个明确的CABasicAnimation而非一个隐式动画。表8.1展示了代码。

表8.1 用CABasicAnimation设置图层背景颜色

import UIKit

class ViewController: UIViewController {

    @IBOutlet weak var layerView: UIView!
    var colorLayer: CALayer!

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()

        // 判断横屏
        let screenSize = UIScreen.mainScreen().applicationFrame.size
        if (screenSize.width > screenSize.height) {
            // 创建子图层
            self.colorLayer = CALayer()
            self.colorLayer.frame = CGRectMake(50, 50, 100, 100)
            self.colorLayer.backgroundColor = UIColor.blueColor().CGColor

            // 添加到视图中
            self.layerView.layer.addSublayer(self.colorLayer)
        }
    }

    @IBAction func changeColor(sender: AnyObject) {
        // 创建随机颜色
        let red: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
        let green: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
        let blue: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
        let color = UIColor(red: red, green: green, blue: blue, alpha: 1.0).CGColor

        // 创建基本动画
        let animation = CABasicAnimation()
        animation.keyPath = "backgroundColor"
        animation.toValue = color

        // 对图层应用动画
        self.colorLayer.addAnimation(animation, forKey: nil)
    }
}

当我们运行程序时,它并不像想象中运行。点击按钮后图层会动画成一个新颜色,但它马上又跳回原值。

其原因是动画并没有修改的模型,而修改了它的展示(见第7章)。一但动画结束,它会从图层移除,图层变回它模型属性定义的样子。我们从未修改过backgroundColor属性,所以图层返回它的初值。

当我们之前使用隐式动画时,底层动作是使用一个CABasicAnimation实现的,恰如我们之前用过那个一样。(你可能回忆起在第7章,我们输出了-actionForLayer:forKey:委托方法的日志记录,然后发现动作类型是CABasicAnimation。)然而,在那里,我们通过设置属性来触发动画。现在我们用直接使用的画,但我们并没有设置属性(因此引发了闪回问题)。

将我们的动画注册为图层动作(然后简单地通过改变值来触发动画)是目前为止最简单的保持属性值并同步动画状态的方法,但假使我们因为某些原因我们不能使用这个方法(通常因为我们需要动画的图层是一个UIView的主图层),我们有两个选择来更新属性值:动画开始前或结束后。

相比之下在动画开始前更新属性会更简单点,但这意味着我们无法利用隐式的fromValue的优势,因此我们需要手动设置动画的fromValue来匹配图层的当前值。

考虑到这点,在创建动画的代码中,如果我们在将它添加到图层时添加如下两行代码,将可以避免闪回:

animation.fromValue = self.colorLayer.backgroundColor
self.colorLayer.backgroundColor = color

这起作用了,但它并不可靠。我们应该从展示层(如果存在)而非模型层派生出fromValue,除非进程中早有动画。而且,因为这个图层并不是主图层,在设置属性前我们应该用CATransaction来禁用隐式动画,否则默认的图层行为会影响我们的显式动画。(事实上,显式动画通常会覆盖隐式动画,但这一行为并没有记录在文档中,所以为了安全起见不要随意使用。)

如果我们做了这些改变,我们以以下这些代码结束:

let layer = (self.colorLayer.presentationLayer() != nil) ? self.colorLayer.presentationLayer() as? CALayer : self.colorLayer
animation.fromValue = layer.backgroundColor
CATransaction.begin()
CATransaction.setDisableActions(true)
self.colorLayer.backgroundColor = color
CATransaction.commit()

这样需要给每个动画加上许多代码。幸运的是,我们可以从CABasicAnimation对象本身自动派生这些信息,所以我们可以创建一个可复用的方法。表8.2展示了我们第一个例子的改版,它包括一个方法来应用一个CABasicAnimation而无需重复这些冗长的代码。

表8.2 一个用于修复动画闪回的可复用方法
func applyBasicAnimation(animation: CABasicAnimation, toLayer layer: CALayer) {
    // 设置起始值(如果可能使用展示层)
    animation.fromValue = ((layer.presentationLayer() != nil) ? layer.presentationLayer() as? CALayer : layer)?.valueForKeyPath(animation.keyPath)

    // 提前更新属性
    // 注意:这一方法只在toValue != nil时可用
    CATransaction.begin()
    CATransaction.setDisableActions(true)
    layer.setValue(animation.toValue, forKeyPath: animation.keyPath)
    CATransaction.commit()

    // 给图层应用动画
    layer.addAnimation(animation, forKey: nil)
}

@IBAction func changeColor(sender: AnyObject) {
    // 创建随机颜色
    let red: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
    let green: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
    let blue: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
    let color = UIColor(red: red, green: green, blue: blue, alpha: 1.0).CGColor

    // 创建基本动画
    let animation = CABasicAnimation()
    animation.keyPath = "backgroundColor"
    animation.toValue = color

    // 添加无闪回的动画
    self.applyBasicAnimation(animation, toLayer: self.colorLayer)
}

这一简单实现只处理有toValue而非byValue的动画,但它是面向通用解决方案的好的开始。你可以将其封装成CALayer的类别(category)方法来使其更加方便及可复用。

这一切看起来好像是用了很复杂的方法解决这一简单的问题,但这一选择其实有复杂的考虑。如果在我们动画开始前不更新目标属性,我们在其完全结束前都不能更新它,否则我们将会在进程里取消这一CABasicAnimation。这意味着我们需要在动画结束后的恰当时刻更新属性,但应该在其从图层移除前,否则属性会闪回初始值。我们如何判定这一时间点?

CAAniamtionDelegate

在第7章中,当我们使用隐式动画时,我们可以用CATransaction闭包来检测动画结束。然而使用显式动画时,这一方法并不适用,这是因为这一动画与事务无关。

为了发现显式动画何时结束,我们需要使用动画的delegate属性,它遵循CAAnimationDelegate协议。

CAAnimationDelegate是一个自助协议,所以你不会在任何头文件中找到CAAnimationDelegate这一协议的定义,但你可以在Apple的开发者文档的CAAnimation中发现相应的支持方法。在这里,我们使用-animationDidStop:finished:方法来在动画结束后立即更新我们的图层backgroundColor

我们需要在更新属性时开始新事务并禁用图层动作;否则,动画将发生两次——一次出于我们的显式CABasicAnimation,再一次因为该属性的隐式动画。见表8.3所示的完整实现。

表8.3 实现背景颜色值一次动画

import UIKit

class ViewController: UIViewController {

    @IBOutlet weak var layerView: UIView!
    var colorLayer: CALayer!

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()

        // 判断横屏
        let screenSize = UIScreen.mainScreen().applicationFrame.size
        if (screenSize.width > screenSize.height) {
            // 创建子图层
            self.colorLayer = CALayer()
            self.colorLayer.frame = CGRectMake(50, 50, 100, 100)
            self.colorLayer.backgroundColor = UIColor.blueColor().CGColor

            // 添加到视图中
            self.layerView.layer.addSublayer(self.colorLayer)
        }
    }

    @IBAction func changeColor(sender: AnyObject) {
        // 创建随机颜色
        let red: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
        let green: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
        let blue: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
        let color = UIColor(red: red, green: green, blue: blue, alpha: 1.0).CGColor

        // 创建基本动画
        let animation = CABasicAnimation()
        animation.keyPath = "backgroundColor"
        animation.toValue = color
        animation.delegate = self

        // 给图层应用动画
        self.colorLayer.addAnimation(animation, forKey: nil)
    }


    override func animationDidStop(anim: CAAnimation!, finished flag: Bool) {
        // 设置backgroundColor属性来匹配动画toValue
        CATransaction.begin()
        CATransaction.setDisableActions(true)
        self.colorLayer.backgroundColor = (anim as? CABasicAnimation)?.toValue as! CGColorRef
        CATransaction.commit()
    }
}

使用CAAnimation的委托方法而非闭包带来的问题是你很容易陷入追踪许多动画和图层的麻烦中。当在视图控制器中创建动画时,你通常会使用控制器本身作为动画委托(正如我们在表8.3中所做的一样),但因为所有的动画都会调用同一个委托方法,你需要寻找图层的相应的图层。

考虑第3章“图层几何”中的时钟;我们最初通过简单的非动画地更新指针角度来实现时钟。如果我们让指针像现实一样动画到相应位置看起来会更好。

我们不能使用隐式动画来移动指针,因为指针是用UIView实例展示的,而隐式动画对它们的主图层是禁用的。我们可以轻松地使用UIView动画方法来实现动画,但如果使用显式动画,我们将得以控制动画时间(将在第10章细说)。使用CABasicAnimation移动指针动画十分复杂,因为我们需要在-animaitonDidStop:finished:方法中检测指针的相应动画(这样我们才能设置它终止的位置)。

动画本身作为委托方法的一个参数传递。你可能认为你可以在控制器中将动画存储成属性然后与委托方法中的参数进行比较,但这并没有用,因为委托返回的动画是原始的一份不可变的拷贝,而非同一对象。

当我们使用-addAnimation:forKey:来向我们的图层添加动画时,有一个我们至今总设为nilkey参数。这个键(key)是一个用于动画的唯一标识的NSString,可用于在图层在-animationForKey:方法中使用。当前附加到图层的所以动画的键可以使用animationKeys取得。如果我们对每个动画使用一个独一无二的键,我们可以遍历每一个动画图层的动画键并与传递给我们委托方法的动画对象调用-animationForKey:的结果相比较。但这仍不是一个优雅的解决方案。

幸运的是,存在更为简单的方法。像所有NSObject子类一样,CAAnimation遵循KVC(Key-Value Coding键值码)自助协议,这使得我们可以使用名字通过-setValue:forKey:-valueForKey:方法来设置或取得属性。但CAAnimation有一个不一般的特性:它表现的如同NSDictionary,允许你直接设置键值对,即使他们并不匹配任何你在用的动画类的已声明属性。

这意味着你可以给一个动画加上额外的数据标签供自己使用。这里,我们将给动画加上时钟指针的UIView,这样我们可以容易的判断每一个动画相应的视图。接下来我们在委托中使用这一信息来更新正确的指针(如表8.4)。

表8.4 使用KVC来给动画添加额外的数据标签

import UIKit

class ViewController: UIViewController {

    @IBOutlet weak var hourHand: UIImageView!
    @IBOutlet weak var minuteHand: UIImageView!
    @IBOutlet weak var secondHand: UIImageView!
    var timer: NSTimer!

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()

        // 判断横屏
        let screenSize = UIScreen.mainScreen().applicationFrame.size
        if (screenSize.width > screenSize.height) {
            // 调整锚点
            self.secondHand.layer.anchorPoint = CGPointMake(0.5, 0.9)
            self.minuteHand.layer.anchorPoint = CGPointMake(0.5, 0.9)
            self.hourHand.layer.anchorPoint = CGPointMake(0.5, 0.9)

            // 启动计时器
            self.timer = NSTimer.scheduledTimerWithTimeInterval(1.0, target: self, selector: "tick", userInfo: nil, repeats: true)

            // 设置初始指针位置
            self.updateHandsAnimated(false)
        }
    }

    func tick() {
        self.updateHandsAnimated(true)
    }

    func updateHandsAnimated(animated: Bool) {
        // 将时间转换成小时、分钟和秒
        let calendar = NSCalendar(calendarIdentifier: NSCalendarIdentifierChinese)!

        let units = NSCalendarUnit.CalendarUnitHour | NSCalendarUnit.CalendarUnitMinute | NSCalendarUnit.CalendarUnitSecond

        let components = calendar.components(units, fromDate: NSDate())

        // 计算时针角度
        let hoursAngle: CGFloat = (CGFloat(components.hour) / 12.0) * CGFloat(M_PI * 2.0)

        // 计算分针角度
        let minsAngle: CGFloat = (CGFloat(components.minute) / 60.0) * CGFloat(M_PI * 2.0)

        // 计算秒针角度
        let secsAngle: CGFloat = (CGFloat(components.second) / 60.0) * CGFloat(M_PI * 2.0)

        // 旋转指针
        self.setAngle(hoursAngle, forHand: hourHand, animated: animated)
        self.setAngle(minsAngle, forHand: minuteHand, animated: animated)
        self.setAngle(secsAngle, forHand: secondHand, animated: animated)

    }

    func setAngle(angle: CGFloat, forHand handView: UIView, animated: Bool) {
        // 产生形变
        let transform = CATransform3DMakeRotation(angle, 0, 0, 1)

        if (animated) {
            // 创建形变动画
            let animation = CABasicAnimation()
            animation.keyPath = "transform"
            animation.toValue = NSValue(CATransform3D: transform)
            animation.duration = 0.5
            animation.delegate = self
            animation.setValue(handView, forKey: "handView")
            handView.layer.addAnimation(animation, forKey: nil)
        } else {
            // 直接设置形变
            handView.layer.transform = transform
        }
    }

    override func animationDidStop(anim: CAAnimation!, finished flag: Bool) {
        // 为指针视图设置最终位置
        let handView: UIImageView = anim.valueForKey("handView") as! UIImageView
        handView.layer.transform = (anim as! CABasicAnimation).toValue.CATransform3DValue
    }
}

我们成功地在每一图层结束动画时辨别出它们,并且将它们的值更新成正确的形变值。到这一步,一切都很棒。

不幸的是,即使做了这些步骤,我们仍有另一个问题。表8.4在模拟器上运作正常,但如果我们在一台iOS设备上运行它,我们会看见我们的时钟指针在调用-animationDidStop:finished:委托方法前会轻微地跳回其初始值。同样的事情发生在表8.3的图层颜色上。

问题在于尽管回调函数是在动画结束后调用的,但并不能保证它在属性重置为先前状态前被调用。这是一个好例子用来说明你总应该在真机上测试动画代码,而不仅是在模拟器上。

我们可以通过使用一个叫fillMode的属性来解决这个问题,这个将在下一章中讲解,但在本章中我们在应用动画之设置要动画的属性为最终值,这比尝试在动画结束后更新它更简单。

关键帧动画

CABasicAnimaiton有趣之处在于它向我们展示了大多数iOS上的隐式动画的底层机制。相比于用隐式动画实现同样动画效果,显式地向一个图层添加CABasicAnimation常常需要更多的工作才能换来一点小小的收益(无论是有组织图层的隐式动画或视图或主图层的UIView动画)。

然而CAKeyframeAnimation是更为强大的,且在UIKit中没有暴露相应等同接口的。它像CABasicAnimation一样是CAPropertyAnimation的子类。它同样作用于单一属性,但不同于CABasicAnimation它并未限定为唯一的开始或结束值,它可以赋予一系列动画区间的系列值。

术语关键帧来源于传统动画,表示的是首席绘画师只绘制显著发生的帧(关键帧),而技巧差点的艺术家绘制之前的帧(这可以容易地从关键帧中推出)。同样的原则适用于CAKeyframeAnimation:,你提供显著帧,然后Core Animation使用一个叫“插值”的过程填充空隙。

我们可以用我们先前的颜色图层演示这个。我们将设置一组颜色然后用一个关键帧动画采用一条命令回放它们(如表8.5)。

表8.5 使用CAKeyframeAnimation应用一系列颜色
@IBAction func changeColor(sender: AnyObject) {
    // 创建关键帧动画
    let animation = CAKeyframeAnimation()
    animation.keyPath = "backgroundColor"
    animation.duration = 2.0
    animation.values = [
        UIColor.blueColor().CGColor,
        UIColor.redColor().CGColor,
        UIColor.greenColor().CGColor,
        UIColor.blueColor().CGColor
    ]

    // 给图层应用动画
    self.colorLayer.addAnimation(animation, forKey: nil)
}

注意我们指定序列的开始和结束都是蓝色。这是必须的,因为CAKeyframeAnimation并没有一个选项来自动使用当前值作为第一帧(因此我们在CABasicAnimation中将fromValue设为nil)。动画在开始后会立即跳到第一个关键帧值,然后在结束后立即退回其初始属性值,为了形成一个平滑的动画,我们需要开始和结束的关键帧都匹配当前的属性值。

当然,也可以创建开始与结束不同值的动画。那样的话,我们需要在触发动画前手动更新属性值来切尔西最后的关键帧,正如我们先前说过的那样。

我们已经使用duration属性将动画时长从默认的0.25秒增加到2秒,这样动画不至于快得看不清。如果你运行这一动画,你会看见图层依次变换这些颜色,但效果看起来有点奇怪。原因是动画以一个恒定速度运行。在颜色之间过渡时并不会减速,这使得结果有点不真实感。为了使动画看起来更自然,我们需要调整缓动(easing),这将会在第10章讲解。

对于颜色改变的动画来说一系列值显得有意义,但通常对于描述动作来说就十分怪异了。CAKeyframeAnimation有一个可选方法来指定动画,就是使用CGPathpath属性允许你用一种自然的方式定义运行序列,通过使用Core Graphics函数来绘制你的动画。

让我们用一个沿简单曲线移动的飞船图像的动画来演示这点。为了创建路径,我们将使用一个三次贝塞尔曲线(cubic Bézier curve),这是一个用一个起点、一个终点以及两个额外的控制点来描述形状的特定曲线类型。可以使用纯粹基于C的Core Graphics的命令来创建这一路径,但使用UIKit提供的高层的UIBezierPath类会更简单。

尽管对于动画来说并不是必须的,我们将用CAShapeLayer来在屏幕上绘制这一曲线。这使其很容易视觉化的看出我们的动画走向。在我们画完CGPath之后,我们用它创建一个CAKeyframeAnimation,然后将之用于我们的飞船。表8.6展示了相应代码,图8.1展示了相应结果。

表8.6 沿三次贝塞尔曲线运动的图层动画

import UIKit

class ViewController: UIViewController {

    @IBOutlet weak var containerView: UIView!
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()

        // 判断横屏
        let screenSize = UIScreen.mainScreen().applicationFrame.size
        if (screenSize.width > screenSize.height) {
            // 创建路径
            let bezierPath = UIBezierPath()
            bezierPath.moveToPoint(CGPointMake(0, 150))
            bezierPath.addCurveToPoint(CGPointMake(300, 150), controlPoint1: CGPointMake(75, 0), controlPoint2: CGPointMake(225, 300))

            // 使用CAShapeLayer绘制路径
            let pathLayer = CAShapeLayer()
            pathLayer.path = bezierPath.CGPath
            pathLayer.fillColor = UIColor.clearColor().CGColor
            pathLayer.strokeColor = UIColor.redColor().CGColor
            pathLayer.lineWidth = 3.0
            self.containerView.layer.addSublayer(pathLayer)

            // 增加飞船
            let shipLayer = CALayer()
            shipLayer.frame = CGRectMake(0, 0, 64, 64)
            shipLayer.position = CGPointMake(0, 150)
            // 译者用之前的雪人图像代替飞船,读者理解方法就好
            shipLayer.contents = UIImage(named: "Snowman")?.CGImage
            self.containerView.layer.addSublayer(shipLayer)

            // 创建关键帧动画
            let animation = CAKeyframeAnimation()
            animation.keyPath = "position"
            animation.duration = 4.0
            animation.path = bezierPath.CGPath
            shipLayer.addAnimation(animation, forKey: nil)
        }
    }

}
图8.1 沿贝塞尔曲线移动的图像图层

如果你运行这一案例,你会注意到动画看起来有一点不真实,因为它移动时总指向同一个方向而不是随曲线切线改变。你可以调整它的affineTransform在其移动时改变朝向,但同步其它动画将会十分麻烦。

幸运的是,Apple预料到了这一情况,给CAKeyframeAnimation增加了一个叫rotationMode的属性。将rotationMode设置为固定值kCAAnimationRotateAuto(如表8.7),图层将会在动画时自动随切线旋转(如图8.2)。

表8.7 使用rotationMode自动对齐图层和曲线
override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()

    // 判断横屏
    let screenSize = UIScreen.mainScreen().applicationFrame.size
    if (screenSize.width > screenSize.height) {
        // 创建路径
        let bezierPath = UIBezierPath()
        bezierPath.moveToPoint(CGPointMake(0, 150))
        bezierPath.addCurveToPoint(CGPointMake(300, 150), controlPoint1: CGPointMake(75, 0), controlPoint2: CGPointMake(225, 300))

        // 使用CAShapeLayer绘制路径
        let pathLayer = CAShapeLayer()
        pathLayer.path = bezierPath.CGPath
        pathLayer.fillColor = UIColor.clearColor().CGColor
        pathLayer.strokeColor = UIColor.redColor().CGColor
        pathLayer.lineWidth = 3.0
        self.containerView.layer.addSublayer(pathLayer)

        // 增加飞船
        let shipLayer = CALayer()
        shipLayer.frame = CGRectMake(0, 0, 64, 64)
        shipLayer.position = CGPointMake(0, 150)
        // 译者用之前的雪人图像代替飞船,读者理解方法就好
        shipLayer.contents = UIImage(named: "Snowman")?.CGImage
        self.containerView.layer.addSublayer(shipLayer)

        // 创建关键帧动画
        let animation = CAKeyframeAnimation()
        animation.keyPath = "position"
        animation.duration = 4.0
        animation.path = bezierPath.CGPath
        animation.rotationMode = kCAAnimationRotateAuto
        shipLayer.addAnimation(animation, forKey: nil)
    }
}
图8.2 匹配曲线切线旋转的图层

虚拟属性

我们先前说过事实上属性动画作用于关键路径而非,这意味我们可以对子属性甚至虚拟属性添加动画。但什么是虚拟属性?

想象一个旋转的动画:如果我们想要添加一个旋转对象动画,我们不得不使用transform,因为CALayer并没有任何显式的角度/朝向属性。我们可以如表8.8所示这样做。

表8.8 动画于transform属性来旋转图层

import UIKit

class ViewController: UIViewController {

    @IBOutlet weak var containerView: UIView!

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()

        // 判断横屏
        let screenSize = UIScreen.mainScreen().applicationFrame.size
        if (screenSize.width > screenSize.height) {
            // 增加飞船
            let shipLayer = CALayer()
            shipLayer.frame = CGRectMake(0, 0, 64, 64)
            shipLayer.position = CGPointMake(0, 150)
            // 译者用之前的雪人图像代替飞船,读者理解方法就好
            shipLayer.contents = UIImage(named: "Snowman")?.CGImage
            self.containerView.layer.addSublayer(shipLayer)

            // 飞船旋转动画
            let animation = CABasicAnimation()
            animation.keyPath = "transform"
            animation.duration = 2.0
            animation.toValue = NSValue(CATransform3D: CATransform3DMakeRotation(CGFloat(M_PI), 0, 0, 1))
            shipLayer.addAnimation(animation, forKey: nil)
        }
    }
}

这生效了,但这看起来更像是运气好而非是设计的。如果我们将旋转角度从M_PI(180度)变为2 * M_PI(360度),然后运行动画,我们会发现飞船压根不动。这是因为矩阵展示中360度等同于0度,所以就动画而言,数值并没有改变。

现在尝试再次使用M_PI,但是赋值给byValue而非toValue属性,这表明旋转应该是相对于当前值而言的。你可能以为这会和设置toValue得到一样的效果,因为0 + 90度==90度,但实际上图像会拉伸而非旋转,因为变形矩阵不能像角度值一样相加。

如果我们想独立于飞船角度移动或缩放飞船会怎么样?因为它们都需要我们修改transform属性,我们需要重新计算每个时刻下的这些动画的结合效果,然后从这些组合变形数据中创建一个复杂的关键帧动画,即使我们真正想要做的只是对我们独立图层的一些概念上分享的属性添加动画效果。

幸运的是,有一个解决方案:为了旋转图层,我们可以将我们的动画添加到transform.rotation关键路径上,而非直接添加到transform属性自身上(如表8.9)。

表8.9 给虚拟的transform.rotation属性添加动画

import UIKit

class ViewController: UIViewController {

    @IBOutlet weak var containerView: UIView!

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()

        // 判断横屏
        let screenSize = UIScreen.mainScreen().applicationFrame.size
        if (screenSize.width > screenSize.height) {
            // 增加飞船
            let shipLayer = CALayer()
            shipLayer.frame = CGRectMake(0, 0, 128, 128)
            shipLayer.position = CGPointMake(150, 150)
            // 译者用之前的雪人图像代替飞船,读者理解方法就好
            shipLayer.contents = UIImage(named: "Snowman")?.CGImage
            self.containerView.layer.addSublayer(shipLayer)

            // 飞船旋转动画
            let animation = CABasicAnimation()
            animation.keyPath = "transform.rotation"
            animation.duration = 2.0
            animation.byValue = CGFloat(M_PI * 2)
            shipLayer.addAnimation(animation, forKey: nil)
        }
    }
}

这个方法效果十分。使用transform.rotation而非transform的好处如下:

  • 它允许我们在一步中不用关键帧旋转多于180度。
  • 它允许我们使用相对值而非绝对值旋转(通过设置byValue而非toValue)。
  • 它允许我们用一个简单的数字值指定角度而不用构建一个CATransform3D
  • 它不会与transform.positiontransform.scale冲突(这些也是使用关键路径的独立动画)。

关于transform.rotation属性的奇怪事情是它并不是真实存在的。由于CATransform3D并非对象,所以这个属性不能存在;它是结构体所以不能有类似KVC(键值码)的属性。transfrom.rotation实际上是一个虚拟属性,它是CALayer提供的用来简化动画变形进程的。

你不能直接设置如同transform.rotationtransform.scale等属性;它们只用于动画。当你给这些属性添加动画时,Core Animation通过使用一个叫CAValueFuntion的类来自动更新你改变的必须的transform属性。

CAValueFuntion被用于转换我们赋值给虚拟的transform.rotation属性的简单浮点数为真正用于移动图层所需要的CATransform3D矩阵值。你可以通过设置给定的CAPropertyAnimationvalueFuntion属性改变值函数。你指定的函数会覆写默认的。

CAValueFunction看起来像是一个用于给不能自然相加或添加的属性动画(例如变形矩阵)的有用的机制,但因为CAValueFunction的实现细节是私有的,现在并不能直接继承它来创建一个新的值函数。你只能使用Apple早已提供的可用常量函数(现在全部关联变形矩阵的虚拟属性,因此有点少,因为默认的这些属性的动作早已使用合适的值函数)。

动画组

尽管CABasicAnimationCAKeyframeAnimation只针对独立的属性,多个这种动画可以用CAAnimationGroup组合在一起。CAAnimationGroup是另一个CAAnimation的具体子类,它增加了一个animations数组属性,用来组合其它动画。让我们在表8.6中组合关键帧动画和另一个改变图层背景色的动画(如表8.10)。表8.3展示了结果。

向图层添加动画组和单独添加多个动画并没有本质上的区别,所以你现在可能还不是很清楚何时以及为什么使用这个类。它为集体设置动画时长,或者通过一条指令添加或移除多个动画提供了一些便利,但对于第9章讲解的层次时间显得并没有什么用。

表8.10 将关键帧动画和基本动画组合起来

import UIKit

class ViewController: UIViewController {

    @IBOutlet weak var containerView: UIView!

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()

        // 判断横屏
        let screenSize = UIScreen.mainScreen().applicationFrame.size
        if (screenSize.width > screenSize.height) {
            // 创建路径
            let bezierPath = UIBezierPath()
            bezierPath.moveToPoint(CGPointMake(0, 150))
            bezierPath.addCurveToPoint(CGPointMake(300, 150), controlPoint1: CGPointMake(75, 0), controlPoint2: CGPointMake(225, 300))

            // 使用CAShapeLayer绘制路径
            let pathLayer = CAShapeLayer()
            pathLayer.path = bezierPath.CGPath
            pathLayer.fillColor = UIColor.clearColor().CGColor
            pathLayer.strokeColor = UIColor.redColor().CGColor
            pathLayer.lineWidth = 3.0
            self.containerView.layer.addSublayer(pathLayer)

            // 添加有色图层
            let colorLayer = CALayer()
            colorLayer.frame = CGRectMake(0, 0, 64, 64)
            colorLayer.position = CGPointMake(0, 150)
            colorLayer.backgroundColor = UIColor.greenColor().CGColor
            self.containerView.layer.addSublayer(colorLayer)

            // 创建位置动画
            let animation1 = CAKeyframeAnimation()
            animation1.keyPath = "position"
            animation1.path = bezierPath.CGPath
            animation1.rotationMode = kCAAnimationRotateAuto

            // 创建颜色动画
            let animation2 = CABasicAnimation()
            animation2.keyPath = "backgroundColor"
            animation2.toValue = UIColor.redColor().CGColor

            // 创建组动画
            let groupAnimation = CAAnimationGroup()
            groupAnimation.animations = [animation1, animation2]
            groupAnimation.duration = 4.0

            // 给颜色涂层添加这一动画
            colorLayer.addAnimation(groupAnimation, forKey: nil)
        }
    }
}
图8.3 一个关键帧路径和基本颜色属性动画组

过渡

对于iOS应用来说,使用属性动画来改变布局是十分困难的。例如,你可能需要转化某些文本或图像,或一次性替换掉整个网格或表格。属性动画只作用于图层的可动画属性,所以如果你需要改变一个不可动画的属性(例如图像)或从层次中增删图层,属性动画就不起作用了。

这时过渡的作用就体现了。过渡动画并不像属性动画一样尝试在两个值之间平滑的插值;相反的是它被设计为一系列分治策略——用一个动画掩饰内容改变。过渡影响整个图层而非某一指定属性。过渡会留一个旧图层样式的快照,然后一次将之动画过渡到新的样式。

我们使用CATransition来创建过渡,它是CAAnimation的另一个子类。它继承了CAAnimation除了时间函数外的一切,CATransition有一个type和一个subtype用来指定过渡效果。type属性是一个NSString,它可以被设置为下列常量之一:

kCATransitionFade
kCATransitionMoveIn
kCATransitionPush
kCATransitionReveal

你现在被限制为这四个基本的CATransition类型,但还有一些方法让你可以实现额外的过渡效果,这将在本章后面讲解。

默认的过渡类型是kCATransitionFade,这会在你修改属性或内容后创建先前图层样式和新样式的平滑交替效果。

我们在第7章的自定义行为案例中曾使用过kCATransitionPush类型;这会从侧面滑入新的图层样式,从另一面推出旧的样式。

kCATransitionMoveInkCATransitionReveal类似于kCATransitionPush;它们都实现了一个方向滑动动画,但有些许不同;kCATransitionMoveIn从先前样式的上移入新的图层样式,但并不像推过渡一样推出旧样式,kCATransitionReveal移出旧样式来露出新样式而不是移入新样式。

后三个标准类型有自然地内在指向的。默认情况下,它们从左侧滑入,但你可以使用subtype属性控制它们的方向,它接受如下常量:

kCATransitionFromRight
kCATransitionFromLeft
kCATransitionFromTop
kCATransitionFromBottom

表8.11展示了一个使用CATransition添加不可动画属性的动画的简单例子。在这我们改变了UIImageimage属性,它正常并不能通过隐式动画或CAPropertyAnimation添加动画,这是因为Core Animation并不知道如何在图像之间插值。通过对图层应用交替渐变过渡,我们可以无视内容类型创建平滑的动画改变(如图8.4)。试试改变过渡的type常量来看看其它可用的效果。

表8.11 使用CATransition给UIImageView添加动画

import UIKit
import Foundation

class ViewController: UIViewController {

    @IBOutlet weak var imageView: UIImageView!
    var images: NSArray!
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()

        // 判断横屏
        let screenSize = UIScreen.mainScreen().applicationFrame.size
        if (screenSize.width > screenSize.height) {
            // 设置图像
            self.images = NSArray(objects:
                UIImage(named: "Snowman")!,
                UIImage(named: "Cone")!,
                UIImage(named: "Igloo")!
            )
        }
    }

    @IBAction func switchImage(sender: AnyObject) {
        // 设置交替渐变过渡
        let transition = CATransition()
        transition.type = kCATransitionFade

        // 给imageView的主图层添加过渡
        self.imageView.layer.addAnimation(transition, forKey: nil)

        // 循环至下一图像
        let currentImage = self.imageView.image
        var index = self.images.indexOfObject(currentImage!)
        index = (index + 1) % self.images.count
        self.imageView.image = self.images[index] as? UIImage
    }
    
}

正如你从代码中所见,过渡可以用和属性或动画组一样使用-addAnimation:forKey:的方式添加到图层上。然而,不同于属性动画,同一时刻只有一个CATransition用于一个给定图层上。因此,无论你给键指定了什么值,过渡实际上都会附上一个transition键,用常量kCATransition表示。

图8.4 使用`CATransition`在图像中平滑过渡

隐式过渡

CATransition可以平滑的实现图层的任何改变,这使得在其它情况下难以添加动画的属性有了一个理想的候补方法。Apple当然意识了这点,CATransition被用作设置CALayer contents属性的默认行为。这在有其它隐式动画行为的视图主图层上是禁用的,但对于你自己创建的图层,这意味着图层contents图像的改变会自动添加交替渐变动画。

在第7章我们使用CATranstion作为图层行为来给我们的图层背景色添加动画。backgroundColor属性可以用一个普通的CAPropertyAnimation添加动画,但这并不意味着你不能用一个CATransition来代替。

图层树改变动画

CATransition并不对指定图层属性进行操作,这意味着你可以用它进行图层改变的动画而不需要明确知道什么改变了。例如,你可以在不知道哪些行被添加或移除的情况下,用一个交替渐变平滑的覆写一个复杂的UITableView的重载动画,或者改变两个不同的UIViewController实例间的事务而无需知道任何有关它们内在视图层次的信息。

这两种情况都有别于目前我们所做过的其它例子,因为它们动画执行不仅与图层属性改变有关而且和实际的图层树改变有关——我们需要在动画过程中从图层层次结构中确切地添加或移除图层。

这一技巧是用于保证附加了CATransition的图层不会自己在事务期间从图层树中移除,因为随后CATransition会随之一切移除。通常来说,你只需要将事务附加到受它们影响的图层的父图层上。

在表8.12中我们将展示如何在UITabBarController的选项卡之间实现交替渐变事务。这里我们简单地使用了默认的Tabbed Application项目模板,使用UITabBarControllerDelegate中的-tabBarController:didSelectViewController:方法来施加动画事务。我们将事务附加到UITabBarController的视图图层上,因为它们在选项卡相互交换时不会被替换。

表8.12 给UITabBarController添加动画
import UIKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate, UITabBarControllerDelegate {

    var window: UIWindow?
    var tabBarController: UITabBarController?


    func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool {
        self.window = UIWindow(frame: UIScreen.mainScreen().bounds)
        let viewController1 = FirstViewController()
        let viewController2 = SecondViewController()
        self.tabBarController = UITabBarController()
        self.tabBarController?.viewControllers = [viewController1, viewController2]
        self.tabBarController?.delegate = self
        self.window?.rootViewController = self.tabBarController
        self.window?.makeKeyAndVisible()
        return true
    }

    func tabBarController(tabBarController: UITabBarController, didSelectViewController viewController: UIViewController) {
        // 设置交替渐变事务
        let transition = CATransition()
        transition.type = kCATransitionFade

        // 向选项卡控制器视图添加事务
        self.tabBarController?.view.layer.addAnimation(transition, forKey: nil)
    }

}

自定义事务

我们说过事务是一个强大的方式来给那些难以平滑改变的属性添加动画的。但列举CATransition看起来有点限制。

更奇怪的是Apple通过UIView +transitionFromView:duration:options:completion:+transitionWithView:duration:options:animations:方法来揭露Core Animation事务,但可用选项与通过CATransition type属性可访问的常量完全不同。可用于UIView事务方法options参数的指定常量如下:

UIViewAnimationOptionTransitionFlipFromLeft
UIViewAnimationOptionTransitionFlipFromRight
UIViewAnimationOptionTransitionCurlUp
UIViewAnimationOptionTransitionCurlDown
UIViewAnimationOptionTransitionCrossDissolve 
UIViewAnimationOptionTransitionFlipFromTop 
UIViewAnimationOptionTransitionFlipFromBottom

除了UIViewAnimationOptionTransitionCrossDissolve,其它事务都不符合CATransition类型。你可以修改我们之前的事务例子来测试这些可选事务(如表8.13)。

表8.13 使用UIKit方法的可选事务实现

import UIKit
import Foundation

class ViewController: UIViewController {

    @IBOutlet weak var imageView: UIImageView!
    var images: NSArray!

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()

        // 判断横屏
        let screenSize = UIScreen.mainScreen().applicationFrame.size
        if (screenSize.width > screenSize.height) {
            // 设置图像
            self.images = NSArray(objects:
                UIImage(named: "Snowman")!,
                UIImage(named: "Cone")!,
                UIImage(named: "Igloo")!
            )
        }
    }

    @IBAction func switchImage(sender: AnyObject) {
        UIView.transitionWithView(self.imageView, duration: 1.0, options: UIViewAnimationOptions.TransitionFlipFromLeft, animations: {
            // 循环至下一图像
            let currentImage = self.imageView.image
            var index = self.images.indexOfObject(currentImage!)
            index = (index + 1) % self.images.count
            self.imageView.image = self.images[index] as? UIImage
        }, completion: nil)
    }
    
}

从iOS 5(从这时候开始引入了Core Image框架)开始的某些文档似乎在暗示,可能可以使用CIFilterCATransitionfilter属性的组合来创建额外的事务类型。然而,直到iOS 6,还是不可以用。尝试对CATransition使用Core Image过滤器并没有任何效果。(但在Mac OS上这个是支持的,这也导致了文档的矛盾。)

因此,你不得不选择使用CATransition或者UIView的事务方法,这取决于你想实现的效果。希望iOS将来的版本可以支持Core Image事务过滤器,这样可以通过CATransition使得所有的Core Image事务动画可用(甚至可以创建新的动画)。

然而,这并不意味着不可以在iOS中实现自定义的事务动画效果。它只意味着你们需要多做一些工作。正如先前提及的,事务动画的基本原则是你先取得当前图层状态的快照,然后在你改变场景之后的图层时对这一快照添加动画。如果我们知道如何对取得图层的快照,我们可以使用正常的属性动画来实现动画,这样我们根本就不需要使用CATransiton或者UIKit的事务方法。

最终我们不难发现,获得图层快照会相对容易一些。CALayer有一个叫-renderInContext:的方法可以把当前内容绘入Core Graphics上下文中,这样就可以捕获一张当前内容的图像,这个图像可以用来在另一个视图中显示。如果我们将这一快照视图置于原始视图之前,它会遮盖住我们对真正视图内容的所有改变,这允许我们重新实现一个简单事务的效果。

表8.14演示了这一想法的基本实现:我们获得当前视图状态的快照,然后改变原始视图背景色的同时旋转并渐隐快照。图8.5展示了我们进行中的自定义事务。

为了让一切简单化,我们使用UIView-animateWithDuration:completion:方法来实现动画效果。尽管我们也可以使用CABasicAnimation实现同样的效果,但我们就不得不为图层变形和透明度属性设置分离的动画,并且我们需要实现CAAnimationDelegate在动画完成后从屏幕中移除coverView

表8.14 使用renderInContext:创建自定义事务

import UIKit

class ViewController: UIViewController {


    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()

        // 判断横屏
        let screenSize = UIScreen.mainScreen().applicationFrame.size
        if (screenSize.width > screenSize.height) {
        }
    }

    @IBAction func performTransition(sender: AnyObject) {
        // 保持当前视图快照
        UIGraphicsBeginImageContextWithOptions(self.view.bounds.size, true, 0.0)
        self.view.layer.renderInContext(UIGraphicsGetCurrentContext())
        let coverImage = UIGraphicsGetImageFromCurrentImageContext()

        // 将快照视图插入到当前视图前
        let coverView = UIImageView(image: coverImage)
        coverView.frame = self.view.bounds
        self.view.addSubview(coverView)


        // 更新视图(我们将简单地随机图层背景色)
        let red: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
        let green: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
        let blue: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
        self.view.backgroundColor = UIColor(red: red, green: green, blue: blue, alpha: 1.0)

        // 添加动画效果(任何你喜欢的都行)
        UIView.animateWithDuration(1.0, animations: {
            // 缩放、旋转并渐隐视图
            var transform = CGAffineTransformMakeScale(0.01, 0.01)
            transform = CGAffineTransformRotate(transform, CGFloat(M_PI_2))
            coverView.transform = transform
            coverView.alpha = 0.0
        }, completion: {
            finished in
            // 完成后移除覆盖视图
            coverView.removeFromSuperview()
        })
    }
    
}
图8.5 使用renderInContext:实现自定义事务

有一点需要注意:-renderInContext:方法会捕获图层的主图像和子图层,但并不会正确处理这些子图层所应用的变形,而且不会处理视频或OpenGL内容。CATransition并不受此限制,它大概用了一个私有方法来捕获快照。

取消进行中的动画

正如这章前面所说,你可以使用-addAnimation:forKey:方法中的key参数来将应用到图层上的动画撤回。你可以使用如下方法:

SWIFT
func animationForKey(_ key: String) -> CAAnimation?

OBJECTIVE-C
- (CAAnimation * nullable)animationForKey:(NSString * nonnull)key

系统并不支持修改进行中的动画,所以这一参数的主要目的是用来检查动画属性或者检测图层上是否有某一特定动画。

要终止指定动画,你可以使用如下方法将之从图层上移除:

SWIFT
func removeAnimationForKey(_ key: String!)

OBJECTIVE-C
- (void)removeAnimationForKey:(NSString *)key

或者用这个方法移除所有动画:

SWIFT
func removeAllAnimations()

OBJECTIVE-C
- (void)removeAllAnimations

一旦动画被移除,图层样式会更新来匹配当前模型值。动画会在结束后自动移除,除非你将动画的removedOnCompletion属性设置为NO。如果你设置动画自动移除,你要记得在不再需要它时手动移除它;否则,它会在图层自身最终销毁前一直存储在内存中。

让我们再次扩展旋转飞船案例,添加按钮来控制动画的开始和结束。这一次,我们为我们的动画键提供一个非nil值,这样我们就可以稍后移除它。-animationDidStop:finished:~方法中的flag参数表示动画是自然结束的还是被中止的,我们将在控制台中输出记录。如何我们用结束按钮中止动画,控制台会输出NO,但如果让其完成动画,会输出YES`。

看表8.15中更新后的例子代码。图8.6展示了结果。

表8.15 开始、结束动画

import UIKit

class ViewController: UIViewController {

    @IBOutlet weak var containerView: UIView!
    var shipLayer: CALayer!

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()

        // 判断横屏
        let screenSize = UIScreen.mainScreen().applicationFrame.size
        if (screenSize.width > screenSize.height) {
            // 增加飞船
            self.shipLayer = CALayer()
            self.shipLayer.frame = CGRectMake(0, 0, 128, 128)
            self.shipLayer.position = CGPointMake(150, 150)
            // 译者用之前的雪人图像代替飞船,读者理解方法就好
            self.shipLayer.contents = UIImage(named: "Snowman")?.CGImage
            self.containerView.layer.addSublayer(shipLayer)
        }
    }

    @IBAction func start(sender: AnyObject) {
        // 飞船旋转动画
        let animation = CABasicAnimation()
        animation.keyPath = "transform.rotation"
        animation.duration = 2.0
        animation.byValue = CGFloat(M_PI * 2)
        animation.delegate = self
        self.shipLayer.addAnimation(animation, forKey: "rotateAnimation")
    }

    @IBAction func stop(sender: AnyObject) {
        self.shipLayer.removeAnimationForKey("rotateAnimation")
    }

    override func animationDidStop(anim: CAAnimation!, finished flag: Bool) {
        // 输出动画停止
        NSLog("The animation stopped (finished: %@)", flag ? "YES": "NO");
    }
    
}
图8.6 用开始和结束按钮控制的旋转动画

译者注:不难发现,原图像仍然存在,译者会在解决问题后再次更新此版本。

总结

这一章中,我们讲解了属性动画(这允许你控制独立图层属性动画),动画组(这允许将多个属性动画组合成单一单元),事务(影响整个图层并可用于对图层内容的任何改变添加动画,包括子图层的添加和移除)。

在第9章中,我们将学习CAMediaTiming协议并解释Core Animation如何处理时间流逝。

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

推荐阅读更多精彩内容