分享

CIFilter 酷炫动画指南

 最初九月雪 2016-01-08

240391-1305140QQ147.jpg

本文由CocoaChina译者 leno (社区ID)翻译

作者:Hector Matos

原文:BE COOL WITH CIFILTER ANIMATIONS 
(如需转载,请保持文中内容和所有链接的完整)


缘起

这个礼拜,一项艰巨的动画任务摆在了我面前,即便是我这种重度动画玩家,也感觉到有些无所适从。

这个动画让我紧皱眉头, 看到它的时候,你会感叹:曾经有一份 OpenGL 教程摆在我面前,我没有珍惜。。。如果能回到过去,趁还有大把时间的时候,一定会做个爱学习的好孩子。

对了,我要做的就是水波纹动画,让一个图片上泛起涟漪,不管怎样,很酷就是了。

和我想的一样,team里没有会OpenGL的。更糟的是,我告诉他们给我足够的时间我可以搞定。真是搬起石头砸自己的脚。

接下来,做了几个伸展运动后,打开电脑,开始google:

下面就是开始查到的 信息

//Solution #1
[UIView beginAnimations:nil context:NULL];
[UIView setAnimationDuration:1.0];
[UIView setAnimationTransition:(UIViewAnimationTransition)110 forView:view cache:NO];
[UIView commitAnimations];

//Solution #2
CATransition *animation = [CATransition animation];
[animation setDelegate:self];
[animation setDuration:2.0f];
[animation setTimingFunction:UIViewAnimationCurveEaseInOut];
[animation setType:@'rippleEffect'];
[myView.layer addAnimation:animation forKey:NULL];

照着做以后,当时我就惊呆了。谁曾想过私有API竟然不起作用(没错,这里说的是反话,呵呵)?另外,在iOS上想搞出个什么效果,只有这几行代码肯定是不行的。就算猜对了,用私有API也会被Apple残忍的干掉。

尝试了几次搜索后,我基本上放弃了。Google上的其他结果要么是三年前的,要么太难了。就在这时,奇迹发生了,我碰巧看到一个类:CIFilter,它看起来很复杂,文档又少的可怜。通过这个链接,找到了我梦寐以求的东西:CIRippleTransition。查看了 Core Image Programming Guide 这个文档后,我已经可以断定:以我能力很难理解该做什么。能帮助我理解的只有这11句话:

1. 创建Core Image对象(CIImage)就可以用过渡效果。

2. 设置定时器。

3. 创建CIContext对象。

4. 创建CIFilter对象,作用于图片上。

5. 在OS X上,需要设置filter的默认值。

6. 设置filter参数。

7. 设置要处理的源图片和目标图片。

8. 计算时间。

9. 应用滤镜。

10. 绘制结果。

11. 重复8-10步骤,直到过渡动画结束。

现在知道该怎么做了吧(才怪!)。好吧,帮人帮到底,简单说来,只有三个步骤:

1. 熟悉某个滤镜需要的各项属性

2. 将这些属性应用到你的滤镜中

3. 使用 CADisplayLink 来设置定时器,使用定时器更新你的图片显示。

和内功心法一样,在了解了事物的本质后,这些步骤都非常简单。但是,前进的路上仍然困难重重。另外,以上步骤不止可以用于 CIFilterTransitions ,还能使用 CIFilter 创建更多的动画效果。就个人而言,我感觉这些步骤就等于是代码创建的gif 。闲话少说,让我们按部就班地来创建 CIFilter 动画、过渡动画效果吧。记得是在iOS 9上,因为 CIRippleTransition 只支持iOS 9版本,当然,对于其他filter效果来说,低版本也是可以的。

OK,休息一下,喝个小酒,唱歌小曲,我们继续。

方案

步骤1:了解Filter属性

CIFilter 实例有一个 dictionary 类型的属性叫做 attributes。创建 CIFilter 实例的时候,要养成打印各项属性的好习惯。要是不打出类型,这个链接 也帮不了你。打开它吧,你会看到 CIFilter 所支持的所有属性值,创建filter离不开它们。

  • 陷阱1

QQ截图20151203092754.png

看见有“attributes”属性的时候,就准备动手开始设置。悲剧的是,这个属性是只读的。因为 CIFilter 玩的是 KVC(Key-Value-Coding Compliance)。要设置属性,得用继承自 NSObjectsetValue:forKey: 方法。而且要记住:你只能设置那些没有默认值的属性,也就是说,如果你设置了一个该列表以外的值,程序就会崩掉。在CIRippleTransition 的例子中,这样设置就会让程序crash:

rippleTransitionFilter?.setValue(CIColor(UIColor: .redColor()), forKey: kCIInputColorKey)

原因为:

'...setValue:forUndefinedKey:]: this class is not key value coding-compliant for the key inputColor.'

备注:你一定见过这样的错误,在用Interface Builder设置 IBOutlets 链接的时候,如果storyboard上放了一个button,链接到File's Owner上后,如果代码中的这个属性没有了,也会报这个错,因为你把一个不存在的属性设置给一个对象了(就像你给Student对象设置不存在的salary属性一样)。

下载.jpg

步骤2:将属性应用到 Filter 上

对于我们的 CIRippleTransition 例子来说,文档里面有 inputImageinputTargetImageinputShadingImage 这三个key是我们需要的。根据文档的描述,inputImage 就是原始图片,inputTargetImage 就是我们要过渡到的图片。inputShadingImage 是这样描述的:作用于方形图片上的阴影区域。我还不需要阴影效果,过渡动画就够了,所以这里直接设置一个空的 CIImage 对象。另外,我们只需要作用在一个图片上就可以了,因此,inputImage inputTargetImage 设置成一个。这样,动画效果就等于从原图过渡到原图。上代码:

let coreImage = CIImage(image: UIImage(named: 'TheKraken'))
let rippleTransitionFilter = CIFilter(name: 'CIRippleTransition')
rippleTransitionFilter?.setValue(coreImage, forKey: kCIInputImageKey)
//If you want to transition to another image, you would supply a different image value here.
rippleTransitionFilter?.setValue(coreImage, forKey: kCIInputTargetImageKey)
rippleTransitionFilter?.setValue(CIImage(), forKey: kCIInputShadingImageKey)
  • 陷阱2

QQ截图20151203092754.png

等一下,好像有什么不对劲,我们UI一族可是一直用 UIImage 来加载图片的。这个 CIImage 怎么整。什么?CoreImage 库不支持 UIImage ?好吧,只能转换过去了。突然回想起 UIColor 不是有个属性叫 CGColor,Apple的API设计应该都差不多。兴奋地发现 UIImage 也有 CIImage 属性,哈哈,天助我也!试了一下。怎么?回回都是nil?特别是在image是通过 drawRect 方法创建的 CGImageRef 对象时,nil的频率更高。继续查资料,找到了原因:UIImage 可由两种image类型来支持:可能是 CGImageRef,也可能是 CIImage。在 UIImage 的头文件中,你会看到:

var CGImage: CGImage? { get } // returns underlying CGImageRef or nil if CIImage based

@available(iOS 5.0, *)
var CIImage: CIImage? { get } // returns underlying CIImage or nil if CGImageRef based

长话短说,如果UIImage是由CGImageRef构成的,调用 image.CIImage 自然不会有什么结果。不过,车到山前必有路,CIImage 还有一个初始化方法,可以接受UIImage 对象作为参数,用这个方法就可以创建 CIImage 对象了:

let coreImage = CIImage(image: UIImage(named: 'TheKraken'))

为啥Apple要用 CIImage,而不直接用 UIImage,文档里有详细的解释:

“虽然 CIImage 对象包含相关的图片数据,它本质上并不是一个图片对象。可以吧 CIImage 对象想象成图片的‘菜谱’,CIImage 中包含所有创建图片的必要信息,但是 CIImage 只在需要的时候才去真正的绘制图片。这种‘懒汉’方式使Core Image更高效”

步骤3:用 CADISPLAYLINK 设置定时器,更新图片

刚看到这个步骤的时候很疑惑为什么要这么做。又在线学习了几个例子,终于明白了,CIFilter 动画效果本质上就是更新filter->截取filter效果图->重绘多次的过程。这就意味着使用 显示刷新率 是个明智的选择。

说起定时刷新,你肯定能想到 NSTimer,但是用于绘制显示内容时,我还是更倾向于使用 CADisplayLinkNSTimer在这里不受待见,在自定义动画时,无法确定绘制一帧的时间,而NSTimer的周期是固定的。CADisplayLink 就聪明的多,它会在屏幕刷新重绘内容时调用方法。

//For the sake of bookkeeping, I am retaining a reference to our filter since we need to adjust it in our timer function
private lazy var filter: CIFilter? = {

    let coreImage = CIImage(image: UIImage(named: 'TheKraken'))
    let rippleTransitionFilter = CIFilter(name: 'CIRippleTransition')
    rippleTransitionFilter?.setValue(coreImage, forKey: kCIInputImageKey)
    rippleTransitionFilter?.setValue(coreImage, forKey: kCIInputTargetImageKey)
    rippleTransitionFilter?.setValue(CIImage(), forKey: kCIInputShadingImageKey)
    
    return rippleTransitionFilter
}()

//Setting up a duration value and our transition's startTime so we can leverage it in our calculations later.
private var duration = 2.0
private var transitionStartTime = CACurrentMediaTime()

@IBOutlet private var imageView: UIImageView!

func rippleImage(duration: Double) {
    guard let filter = filter else {
        return
    }
    
    //Don't forget to keep track of your duration for calculations later.
    self.duration = duration
    
    //Update our start time since we immediately fire off our display link after this line.
    transitionStartTime = CACurrentMediaTime()
    
    let displayLink = CADisplayLink(target: self, selector: 'timerFired:')
    displayLink?.addToRunLoop(NSRunLoop.mainRunLoop(), forMode: NSDefaultRunLoopMode)
}

如你所见,设置好display link后,在屏幕刷新时,会调用 timerFired: 方法。在我们的timerFired方法中,我们将更新inputTimeKey值,这样,通过计算 transitionStartTimeduration 的值就可以知道过渡动画的进度。inputTimeKey 在文档里面是这样描述的:过渡动画的时间参数,这个值驱动过渡动画从开始(0时间)到结束(1时间)。了解了这些,我们就能把动画开回家了。

func timerFired(displayLink: CADisplayLink) {
    guard let filter = filter else {
        //If the filter is nil, invalidate our display link.
        displayLink.invalidate()
        return
    }
    //Grab the difference of the current time and our transitionStartTime and see the percentage of that against our duration. Using min(), we guarantee that our percentage doesn't go over 1.0.
    let progress = max((CACurrentMediaTime() - transitionStartTime) / duration, 1.0)
    filter.setValue(progress, forKey: kCIInputTimeKey)
    //After we set a value on our filter, the filter applies that value to the image and filters it accordingly so we get a new outputImage immediately after the setValue finishes running.
    imageView.image = UIImage(CIImage: filter.outputImage)
    if progress == 1.0 {
        imageView.image = UIImage(CIImage: originalCoreImage)
        displayLink.invalidate()
    }
}

这里只是简单的将 inputTime 的值从0.0设置到1.0。每当显示刷新时,我们的方法都会被调用,然后,拿到系统当前时间,再算出动画进度的百分比,设置给inputTime就OK了。用 setValue:forKey: 后,filter的 outputImage 就会更新,用更新后的image显示到imageView上,就有了动画效果。inputTime 到1.0时,displayLink的任务就完成了,我们用invalidate方法禁用它来停止动画。大功告成!现在你就可以举一反三地在过渡动画中使用所有的filter了。

下载 (1).jpg

但是(又是一个但是),filter动画还有一个小问题:要是动画用的image和容器(如imageView)不是一样大小,要注意,你的imageview才不会主动关心自己的contentMode。你也可能看到image露底走光,恭喜你!我们碰到了最后一个陷阱。

下载 (2).jpg

  • 陷阱3

我们来聊一聊图片露底走光的原因,先让我们看看是什么效果:

下载.png

一开始我并没有找到解决的办法,后来看到 WWDC 2015's What's New In Core Image video ,才豁然开朗。CIImage 有两个实例方法叫做 imageByClampingToExtent() imageByCroppingToRect(),和 CIImage 的extent属性搭配使用,可以解决这个问题。

首先,使用 CIFilter 时,通过调用 imageByClampingToExtent() 方法,会返回一个 CIImage 实例,这个可以复制每个边界上最后一像素,从而无限延伸图片。要是每个filter使用的图片在通过setValue:forKey方法传入之前都这么干的话,就不会有这个难看的问题。在我们的例子中,每次使用 kCIInputImageKey kCIInputTargetImageKey 设置图片前,我都会调用一下这个方法。

private lazy var filter: CIFilter? = {
    let coreImage = CIImage(image: UIImage(named: 'TheKraken'))?.imageByClampingToExtent()
    
    let rippleTransitionFilter = CIFilter(name: 'CIRippleTransition')
    rippleTransitionFilter?.setValue(coreImage, forKey: kCIInputImageKey)
    rippleTransitionFilter?.setValue(coreImage, forKey: kCIInputTargetImageKey)
    rippleTransitionFilter?.setValue(CIImage(), forKey: kCIInputShadingImageKey)
    
    return rippleTransitionFilter
}()
  • 陷阱4

可惜,每次在timer函数中的这一行,都会crash。

QQ截图20151203092754.png

imageView.image = UIImage(CIImage: filter.outputImage)

别忘了,我们之前做过图片延展,现在的图片可是“无限大”的。表面上,是一个奇怪的autolayout问题(这里和它真没关系),实际上,是UIImageView不知道怎么把一个“无限大”的图片装进来。既然这样,我们就来剪一刀,还好,CIImage imageByCroppingToRect() 方法,这样就可以剪到刚刚好了。实际上,CIImage不会真正剪切源图片,imageByCroppingToRect 方法只是告诉它绘制用的图片范围,rect的值应该和 CIImage extent 属性保持一致。最终,我们把代码修改成这个样子:

private lazy var originalImageExtent: CGRect? = {
    return CIImage(image: UIImage(named: 'TheKraken'))?.extent
}()

func timerFired(displayLink: CADisplayLink) {
    //Make sure our extent and filter aren't nil
    guard let filter = filter, extent = originalImageExtent else {
        //If the filter is nil, invalidate our display link.
        displayLink.invalidate()
        return
    }
    
    //...
    //Let's use our fancy new copy function here.
    imageView.image = UIImage(CIImage: filter.outputImage.imageByCroppingToRect(extent))
    //...
}

到此,你就有了一个完(shi)美(bai)的CIImage动画。

  • 陷阱5

QQ截图20151203092754.png

还有一个坏消息,要是你的图片和image view不是一个尺寸,会有另外的问题:使用 CIImage: filter.outputImage 方法从 CIImage 中创建的UIImage,在绘制时,并不符合image view的比例或content mode。一个解决的方法是把 CIImage 先用 CIContext 变成 CGImage

let context = CIContext(options: nil) //You can also create a context from an OpenGL context bee-tee-dubs.
let cgImage = context.createCGImage(filter.outputImage, fromRect: originalInputCIImage.extent())
imageView.image = UIImage(CGImage: cgImage)
  • 陷阱6

QQ截图20151203092754.png

另一个问题是当你想创建高分辨率的图片时,动画跳帧严重,大概只有5 FPS。我这里能找到的方法,只有用代码缩小图片,要么就用Photoshop或Preview软件缩小原图。如果你有其他的方法,请告诉我。

  • 陷阱7

QQ截图20151203092754.png

最后一个陷阱,相信我,真的是最后一个了!也很简短。

相信你已经知道,在动画开始时要把你的图片“像素化”,为了使图片尽量“简洁”,得为 CIImage 指定缩放(scale)属性,这样就可以适配普通屏幕和retina屏幕。这点和使用**CoreGraphics**库绘制图片一样。很简单,上代码:

let transitionImage = filter.outputImage.imageByCroppingToRect(extent)
imageView.image = UIImage(CIImage: transitionImage, scale: UIScreen.mainScreen().scale, orientation: .Up)

这样,你的图片分辨率就正确了。

秘密步骤4:高兴点,因为你搞定了!

看到这里,可以给自己一个大大的微笑了。一通百通,你可以把在所有CIFilter上使用同样的策略。在过渡动画filter中,我们更新 kCIInputTimeKey 的值。而使用其他filter,可以按照同样的方式更新其他的key(比如 kCIInputCenterKey )。当然,filter得要支持你修改的key。用 CIBumpDistortion ,按照一样的方法,我已经搞定了这样的动画(呵呵):

下载 (3).jpg

结论

CIFilter一开始用起来不怎么容易。用它做动画对我来说更难。上周才开始用的我也不算什么高手。如有意见请随时评论。我是乐意学习新东西的。如你所见,这个过程中真是问题多多,通过这个帖子,希望能帮你节省宝贵的时间。这些问题都挺难缠的,文档太复杂,视频又太长,项目经理又催你马上搞定。讨厌在Stack Overflow上翻答案的你一定会喜欢上我这个帖子。愿你在你的filter动画之路上,好运连连,直到永远!

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多