|
|
51CTO旗下网站
|
|
移动端

在 iOS 中实现谷歌灭霸彩蛋

最近上映的复仇者联盟4据说没有片尾彩蛋,不过谷歌帮我们做了。只要在谷歌搜索灭霸,在结果的右侧点击手套,你将化身为灭霸,其中一半的搜索结果会化为灰烬消失...那么这么酷的动画在iOS中可以实现吗?答案是肯定的。

作者:potato04来源:掘金|2019-05-16 13:37

【大咖·来了 第7期】10月24日晚8点观看《智能导购对话机器人实践》

示例代码下载

最近上映的复仇者联盟4据说没有片尾彩蛋,不过谷歌帮我们做了。只要在谷歌搜索灭霸,在结果的右侧点击手套,你将化身为灭霸,其中一半的搜索结果会化为灰烬消失...那么这么酷的动画在iOS中可以实现吗?答案是肯定的。整个动画主要包含以下几部分:响指动画、沙化消失以及背景音效和复原动画,让我们分别来看看如何实现。

在 iOS 中实现谷歌灭霸彩蛋

图1 左为沙化动画,右为复原动画

响指动画

Google的方法是利用了48帧合成的一张Sprite图进行动画的:

在 iOS 中实现谷歌灭霸彩蛋

图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:

  1. class AnimatableSpriteLayer: CALayer { 
  2.     private var animationValues = [CGFloat]() 
  3.     convenience init(spriteSheetImage: UIImage, spriteFrameSize: CGSize ) { 
  4.         self.init() 
  5.         //1 
  6.         masksToBounds = true 
  7.         contentsGravity = CALayerContentsGravity.left 
  8.         contents = spriteSheetImage.cgImage 
  9.         bounds.size = spriteFrameSize 
  10.         //2 
  11.         let frameCount = Int(spriteSheetImage.size.width / spriteFrameSize.width) 
  12.         for frameIndex in 0..            animationValues.append(CGFloat(frameIndex) / CGFloat(frameCount)) 
  13.         } 
  14.     } 
  15.  
  16.     func play() { 
  17.         let spriteKeyframeAnimation = CAKeyframeAnimation(keyPath: "contentsRect.origin.x"
  18.         spriteKeyframeAnimation.values = animationValues 
  19.         spriteKeyframeAnimation.duration = 2.0 
  20.         spriteKeyframeAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.linear) 
  21.         //3 
  22.         spriteKeyframeAnimation.calculationMode = CAAnimationCalculationMode.discrete 
  23.         add(spriteKeyframeAnimation, forKey: "spriteKeyframeAnimation"
  24.     } 
  • //1: masksToBounds = true和contentsGravity = CALayerContentsGravity.left是为了当前只显示Sprite图的画面
  • //2: 根据Sprite图大小和每幅画面的大小计算出画面数量,预先计算出每幅画面的contentsRect.origin.x偏移量
  • //3: 这里是关键,指定关键帧动画的calculationMode为discrete确保关键帧动画依次使用values中指定的关键帧值进行变化,而不是默认情况下采用线性插值进行过渡,来个对比图可能比较容易理解: 

在 iOS 中实现谷歌灭霸彩蛋在 iOS 中实现谷歌灭霸彩蛋

图3 左边为离散模式,右边为默认的线性模式

沙化消失

这个效果是整个动画较难的部分,Google的实现很巧妙,它将需要沙化消失内容的html通过html2canvas渲染成canvas,然后将其转换为图片后的每一个像素点随机地分配到32块canvas中,对每块画布进行随机地移动和旋转即达到了沙化消失的效果。

像素处理

新建自定义视图 DustEffectView,这个视图的作用是用来接收图片并将其进行沙化消失。首先创建函数createDustImages,它将一张图片的像素随机分配到32张等待动画的图片上:

  1. class DustEffectView: UIView { 
  2.     private func createDustImages(image: UIImage) -> [UIImage] { 
  3.         var result = [UIImage]() 
  4.         guard let inputCGImage = image.cgImage else { 
  5.             return result 
  6.         } 
  7.         //1 
  8.         let colorSpace = CGColorSpaceCreateDeviceRGB() 
  9.         let width = inputCGImage.width 
  10.         let height = inputCGImage.height 
  11.         let bytesPerPixel = 4 
  12.         let bitsPerComponent = 8 
  13.         let bytesPerRow = bytesPerPixel * width 
  14.         let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Little.rawValue 
  15.  
  16.         guard let context = CGContext(data: nil, width: width, height: height, bitsPerComponent: bitsPerComponent, bytesPerRow: bytesPerRow, space: colorSpace, bitmapInfo: bitmapInfo) else { 
  17.             return result 
  18.         } 
  19.         context.draw(inputCGImage, in: CGRect(x: 0, y: 0, width: width, height: height)) 
  20.         guard let buffer = context.data else { 
  21.             return result 
  22.         } 
  23.         let pixelBuffer = buffer.bindMemory(to: UInt32.self, capacity: width * height) 
  24.         //2 
  25.         let imagesCount = 32 
  26.         var framePixels = Array(repeating: Array(repeating: UInt32(0), count: width * height), count: imagesCount) 
  27.         for column in 0..            for row in 0..                let offset = row * width + column 
  28.                 //3 
  29.                 for _ in 0...1 {  
  30.                     let factor = Double.random(in: 0..<1) + 2 * (Double(column)/Double(width)) 
  31.                     let index = Int(floor(Double(imagesCount) * ( factor / 3))) 
  32.                     framePixels[index][offset] = pixelBuffer[offset] 
  33.                 } 
  34.             } 
  35.         } 
  36.         //4 
  37.         for frame in framePixels { 
  38.             let data = UnsafeMutablePointer(mutating: frame) 
  39.             guard let context = CGContext(data: data, width: width, height: height, bitsPerComponent: bitsPerComponent, bytesPerRow: bytesPerRow, space: colorSpace, bitmapInfo: bitmapInfo) else { 
  40.                 continue 
  41.             } 
  42.             result.append(UIImage(cgImage: context.makeImage()!, scale: image.scale, orientation: image.imageOrientation)) 
  43.         } 
  44.         return result 
  45.     } 
  • //1: 根据指定格式创建位图上下文,然后将输入的图片绘制上去之后获取其像素数据
  • //2: 创建像素二维数组,遍历输入图片每个像素,将其随机分配到数组32个元素之一的相同位置。随机方法有点特别,原始图片左边的像素只会分配到前几张图片,而原始图片右边的像素只会分配到后几张。

在 iOS 中实现谷歌灭霸彩蛋

图4 上部分为原始图片,下部分为像素分配后的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中的元素是这么运动的:

在 iOS 中实现谷歌灭霸彩蛋

图5 CSS中transform多值动画

rotate和translate决定了最终的位置和运动轨迹,至于第二个rotate作用,只是叠加rotate的值作为最终的旋转弧度,这里刚好为0也就是不旋转。那么在iOS中该如何实现相似的运动轨迹呢?可以利用UIBezierPath, CAKeyframeAnimation的属性path可以指定这个UIBezierPath为动画的运动轨迹。确定起点和实际终点作为贝塞尔曲线的起始点和终止点,那么如何确定控制点?好像可以将“预想”的终点(下图中的(0,-1))作为控制点。

在 iOS 中实现谷歌灭霸彩蛋
图6 将“预想”的终点作为控制点的贝塞尔曲线,看起来和CSS中的运动轨迹差不多

扩展问题

通过文章中描述的方式生成的贝塞尔曲线是否与CSS中的动画轨迹完全一致呢?

现在可以给视图添加动画了:

  1. let layer = CALayer() 
  2.     layer.frame = bounds 
  3.     layer.contents = image.cgImage 
  4.     self.layer.addSublayer(layer) 
  5.     let centerX = Double(layer.position.x) 
  6.     let centerY = Double(layer.position.y) 
  7.     let radian1 = Double.pi / 12 * Double.random(in: -0.5..<0.5) 
  8.     let radian2 = Double.pi / 12 * Double.random(in: -0.5..<0.5) 
  9.     let random = Double.pi * 2 * Double.random(in: -0.5..<0.5) 
  10.     let transX = 60 * cos(random) 
  11.     let transY = 30 * sin(random) 
  12.     //1:  
  13.     // x' = x*cos(rad) - y*sin(rad) 
  14.     // y' = y*cos(rad) + x*sin(rad) 
  15.     let realTransX = transX * cos(radian1) - transY * sin(radian1) 
  16.     let realTransY = transY * cos(radian1) + transX * sin(radian1) 
  17.     let realEndPoint = CGPoint(x: centerX + realTransX, y: centerY + realTransY) 
  18.     let controlPoint = CGPoint(x: centerX + transX, y: centerY + transY) 
  19.     //2: 
  20.     let movePath = UIBezierPath() 
  21.     movePath.move(to: layer.position) 
  22.     movePath.addQuadCurve(to: realEndPoint, controlPoint: controlPoint) 
  23.     let moveAnimation = CAKeyframeAnimation(keyPath: "position"
  24.     moveAnimation.path = movePath.cgPath 
  25.     moveAnimation.calculationMode = .paced 
  26.     //3:                  
  27.     let rotateAnimation = CABasicAnimation(keyPath: "transform.rotation"
  28.     rotateAnimation.toValue = radian1 + radian2 
  29.     let fadeOutAnimation = CABasicAnimation(keyPath: "opacity"
  30.     fadeOutAnimation.toValue = 0.0 
  31.     let animationGroup = CAAnimationGroup() 
  32.     animationGroup.animations = [moveAnimation, rotateAnimation, fadeOutAnimation] 
  33.     animationGroup.duration = 1 
  34.     //4: 
  35.     animationGroup.beginTime = CACurrentMediaTime() + 1.35 * Double(i) / Double(imagesCount) 
  36.     animationGroup.isRemovedOnCompletion = false 
  37.     animationGroup.fillMode = .forwards 
  38.     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。

【编辑推荐】

  1. 白天不懂夜的黑!Android Q拿什么打赢iOS 13
  2. iOS13下个月发布,能让iPhone6再战一年吗
  3. iOS 12.3发布 支持启用「隔空播放 2」的电视
  4. 自由女神像AR应用上架iOS 在家游览自由岛
  5. 耗时 2 年,Android 还是没找到比 iOS 更好用的手势操作
【责任编辑:未丽燕 TEL:(010)68476606】

点赞 0
分享:
大家都在看
猜你喜欢

订阅专栏+更多

这就是5G

这就是5G

5G那些事儿
共15章 | armmay

103人订阅学习

16招轻松掌握PPT技巧

16招轻松掌握PPT技巧

GET职场加薪技能
共16章 | 晒书包

364人订阅学习

20个局域网建设改造案例

20个局域网建设改造案例

网络搭建技巧
共20章 | 捷哥CCIE

751人订阅学习

读 书 +更多

Absolute C++中文版(原书第2版)  

本书是讲解C++语言程序设计的优秀教程。全书围绕C++语言来组织,开始章节介绍编程的普通感念,接下来详细介绍C++中的继承、多态、异常处理...

订阅51CTO邮刊

点击这里查看样刊

订阅51CTO邮刊

51CTO服务号

51CTO官微