最近上映的復仇者聯盟4
據說沒有片尾彩蛋,不過谷歌幫我們做了。只要在谷歌搜尋滅霸
,在結果的右側點選無限手套,你將化身為滅霸,其中一半的搜尋結果會化為灰燼消失...那麼這麼酷的動畫在iOS
中可以實現嗎?答案是肯定的。整個動畫主要包含以下幾部分:響指動畫、沙化消失以及背景音效和復原動畫,讓我們分別來看看如何實現。
圖1 左為沙化動畫,右為復原動畫
響指動畫
Google的方法是利用了48幀合成的一張Sprite
圖進行動畫的:
圖2 響指Sprite圖片
原始圖片中48幅全部排成一行,這裡為了顯示效果截成2行
iOS 中通過這張圖片來實現動畫並不難。CALayer
有一個屬性contentsRect
,通過它可以控制內容顯示的區域,而且是Animateable
的。它的型別是CGRect
,預設值為(x:0.0, y:0.0, width:1.0, height:1.0),它的單位不是常見的Point
,而是單位座標空間,所以預設值顯示100%的內容區域。新建Sprite
播放檢視層AnimatableSpriteLayer
:
class AnimatableSpriteLayer: CALayer {
private var animationValues = [CGFloat]()
convenience init(spriteSheetImage: UIImage, spriteFrameSize: CGSize ) {
self.init()
//1
masksToBounds = true
contentsGravity = CALayerContentsGravity.left
contents = spriteSheetImage.cgImage
bounds.size = spriteFrameSize
//2
let frameCount = Int(spriteSheetImage.size.width / spriteFrameSize.width)
for frameIndex in 0..<frameCount {
animationValues.append(CGFloat(frameIndex) / CGFloat(frameCount))
}
}
func play() {
let spriteKeyframeAnimation = CAKeyframeAnimation(keyPath: "contentsRect.origin.x")
spriteKeyframeAnimation.values = animationValues
spriteKeyframeAnimation.duration = 2.0
spriteKeyframeAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.linear)
//3
spriteKeyframeAnimation.calculationMode = CAAnimationCalculationMode.discrete
add(spriteKeyframeAnimation, forKey: "spriteKeyframeAnimation")
}
}
複製程式碼
//1
: masksToBounds = true
和contentsGravity = CALayerContentsGravity.left
是為了當前只顯示Sprite圖的第一幅畫面
//2
: 根據Sprite圖大小和每幅畫面的大小計算出畫面數量,預先計算出每幅畫面的contentsRect.origin.x
偏移量
//3
: 這裡是關鍵,指定關鍵幀動畫的calculationMode
為discrete
確保關鍵幀動畫依次使用values
中指定的關鍵幀值進行變化,而不是預設情況下采用線性插值進行過渡,來個對比圖可能比較容易理解:
圖3 左邊為離散模式,右邊為預設的線性模式
沙化消失
這個效果是整個動畫較難的部分,Google
的實現很巧妙,它將需要沙化消失內容的html
通過html2canvas
渲染成canvas
,然後將其轉換為圖片後的每一個畫素點隨機地分配到32
塊canvas
中,最後對每塊畫布進行隨機地移動和旋轉即達到了沙化消失的效果。
畫素處理
新建自定義檢視 DustEffectView
,這個檢視的作用是用來接收圖片並將其進行沙化消失。首先建立函式createDustImages
,它將一張圖片的畫素隨機分配到32張等待動畫的圖片上:
class DustEffectView: UIView {
private func createDustImages(image: UIImage) -> [UIImage] {
var result = [UIImage]()
guard let inputCGImage = image.cgImage else {
return result
}
//1
let colorSpace = CGColorSpaceCreateDeviceRGB()
let width = inputCGImage.width
let height = inputCGImage.height
let bytesPerPixel = 4
let bitsPerComponent = 8
let bytesPerRow = bytesPerPixel * width
let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Little.rawValue
guard let context = CGContext(data: nil, width: width, height: height, bitsPerComponent: bitsPerComponent, bytesPerRow: bytesPerRow, space: colorSpace, bitmapInfo: bitmapInfo) else {
return result
}
context.draw(inputCGImage, in: CGRect(x: 0, y: 0, width: width, height: height))
guard let buffer = context.data else {
return result
}
let pixelBuffer = buffer.bindMemory(to: UInt32.self, capacity: width * height)
//2
let imagesCount = 32
var framePixels = Array(repeating: Array(repeating: UInt32(0), count: width * height), count: imagesCount)
for column in 0..<width {
for row in 0..<height {
let offset = row * width + column
//3
for _ in 0...1 {
let factor = Double.random(in: 0..<1) + 2 * (Double(column)/Double(width))
let index = Int(floor(Double(imagesCount) * ( factor / 3)))
framePixels[index][offset] = pixelBuffer[offset]
}
}
}
//4
for frame in framePixels {
let data = UnsafeMutablePointer(mutating: frame)
guard let context = CGContext(data: data, width: width, height: height, bitsPerComponent: bitsPerComponent, bytesPerRow: bytesPerRow, space: colorSpace, bitmapInfo: bitmapInfo) else {
continue
}
result.append(UIImage(cgImage: context.makeImage()!, scale: image.scale, orientation: image.imageOrientation))
}
return result
}
}
複製程式碼
//1:
根據指定格式建立點陣圖上下文,然後將輸入的圖片繪製上去之後獲取其畫素資料
//2:
建立畫素二維陣列,遍歷輸入圖片每個畫素,將其隨機分配到陣列32個元素之一的相同位置。隨機方法有點特別,原始圖片左邊的畫素只會分配到前幾張圖片,而原始圖片右邊的畫素只會分配到後幾張。
//3:
這裡迴圈2次將畫素分配兩次,可能 Google 覺得只分配一遍會造成畫素比較稀疏。個人認為在移動端,只要一遍就好了。
//4:
建立32張圖片並返回
新增動畫
Google的實現是給canvas
中css
的transform
屬性設定為rotate(deg) translate(px, px) rotate(deg)
,值都是隨機生成的。如果你對CSS
的動畫不熟悉,那你會覺得在iOS
中只要新增三個CABasicAnimation
然後將它們新增到AnimationGroup
就好了嘛,實際上並沒有那麼簡單... 因為CSS
的transform
中後一個變換函式是基於前一個變換後的新transform
座標系。假如某張圖片的動畫樣式是這樣的:rotate(90deg) translate(0px, 100px) rotate(-90deg)
直覺告訴我應該是旋轉著向下移動100px,然而在CSS
中的元素是這麼運動的:
第一個rotate
和translate
決定了最終的位置和運動軌跡,至於第二個rotate
作用,只是疊加第一個rotate
的值作為最終的旋轉弧度,這裡剛好為0也就是不旋轉。那麼在iOS中該如何實現相似的運動軌跡呢?可以利用UIBezierPath
, CAKeyframeAnimation
的屬性path可以指定這個UIBezierPath
為動畫的運動軌跡。確定起點和實際終點作為貝塞爾曲線的起始點和終止點,那麼如何確定控制點?好像可以將“預想”的終點(下圖中的(0,-1))作為控制點。
擴充套件問題
通過文章中描述的方式生成的貝塞爾曲線是否與CSS中的動畫軌跡完全一致呢?
現在可以給檢視新增動畫了:
let layer = CALayer()
layer.frame = bounds
layer.contents = image.cgImage
self.layer.addSublayer(layer)
let centerX = Double(layer.position.x)
let centerY = Double(layer.position.y)
let radian1 = Double.pi / 12 * Double.random(in: -0.5..<0.5)
let radian2 = Double.pi / 12 * Double.random(in: -0.5..<0.5)
let random = Double.pi * 2 * Double.random(in: -0.5..<0.5)
let transX = 60 * cos(random)
let transY = 30 * sin(random)
//1:
// x' = x*cos(rad) - y*sin(rad)
// y' = y*cos(rad) + x*sin(rad)
let realTransX = transX * cos(radian1) - transY * sin(radian1)
let realTransY = transY * cos(radian1) + transX * sin(radian1)
let realEndPoint = CGPoint(x: centerX + realTransX, y: centerY + realTransY)
let controlPoint = CGPoint(x: centerX + transX, y: centerY + transY)
//2:
let movePath = UIBezierPath()
movePath.move(to: layer.position)
movePath.addQuadCurve(to: realEndPoint, controlPoint: controlPoint)
let moveAnimation = CAKeyframeAnimation(keyPath: "position")
moveAnimation.path = movePath.cgPath
moveAnimation.calculationMode = .paced
//3:
let rotateAnimation = CABasicAnimation(keyPath: "transform.rotation")
rotateAnimation.toValue = radian1 + radian2
let fadeOutAnimation = CABasicAnimation(keyPath: "opacity")
fadeOutAnimation.toValue = 0.0
let animationGroup = CAAnimationGroup()
animationGroup.animations = [moveAnimation, rotateAnimation, fadeOutAnimation]
animationGroup.duration = 1
//4:
animationGroup.beginTime = CACurrentMediaTime() + 1.35 * Double(i) / Double(imagesCount)
animationGroup.isRemovedOnCompletion = false
animationGroup.fillMode = .forwards
layer.add(animationGroup, forKey: nil)
複製程式碼
//1:
實際的偏移量旋轉了radian1
弧度,這個可以通過公式x' = x*cos(rad) - y*sin(rad), y' = y*cos(rad) + x*sin(rad)
算出
//2:
建立UIBezierPath
並關聯到CAKeyframeAnimation
中
//3:
兩個弧度疊加作為最終的旋轉弧度
//4:
設定CAAnimationGroup
的開始時間,讓每層Layer
的動畫延遲開始
結尾
到這裡,谷歌滅霸彩蛋中較複雜的技術點均已實現。如果您感興趣,完整的程式碼(包含音效和復原動畫)可以通過文章開頭的連結進行檢視,可以嘗試將沙化圖片的數量從32提高至更多,效果會越好,記憶體也會消耗更多 :-D。
參考資料