本文由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
看見有“attributes”屬性的時候,就准備動手開始設置。悲劇的是,這個屬性是只讀的。因為 CIFilter 玩的是 KVC(Key-Value-Coding Compliance)。要設置屬性,得用繼承自 NSObject 的 setValue: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屬性一樣)。
步驟2:將屬性應用到 Filter 上
對於我們的 CIRippleTransition 例子來說,文檔裡面有 inputImage,inputTargetImage 和 inputShadingImage 這三個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
等一下,好像有什麼不對勁,我們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,但是用於繪制顯示內容時,我還是更傾向於使用 CADisplayLink,NSTimer在這裡不受待見,在自定義動畫時,無法確定繪制一幀的時間,而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值,這樣,通過計算 transitionStartTime 和 duration 的值就可以知道過渡動畫的進度。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了。
但是(又是一個但是),filter動畫還有一個小問題:要是動畫用的image和容器(如imageView)不是一樣大小,要注意,你的imageview才不會主動關心自己的contentMode。你也可能看到image露底走光,恭喜你!我們碰到了最後一個陷阱。
陷阱3
我們來聊一聊圖片露底走光的原因,先讓我們看看是什麼效果:
一開始我並沒有找到解決的辦法,後來看到 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。
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
還有一個壞消息,要是你的圖片和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
另一個問題是當你想創建高分辨率的圖片時,動畫跳幀嚴重,大概只有5 FPS。我這裡能找到的方法,只有用代碼縮小圖片,要麼就用Photoshop或Preview軟件縮小原圖。如果你有其他的方法,請告訴我。
陷阱7
最後一個陷阱,相信我,真的是最後一個了!也很簡短。
相信你已經知道,在動畫開始時要把你的圖片“像素化”,為了使圖片盡量“簡潔”,得為 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 ,按照一樣的方法,我已經搞定了這樣的動畫(呵呵):
結論
CIFilter一開始用起來不怎麼容易。用它做動畫對我來說更難。上周才開始用的我也不算什麼高手。如有意見請隨時評論。我是樂意學習新東西的。如你所見,這個過程中真是問題多多,通過這個帖子,希望能幫你節省寶貴的時間。這些問題都挺難纏的,文檔太復雜,視頻又太長,項目經理又催你馬上搞定。討厭在Stack Overflow上翻答案的你一定會喜歡上我這個帖子。願你在你的filter動畫之路上,好運連連,直到永遠!