在 iOS 中实现Google灭霸彩蛋【有趣】

作者:potato04
原文地址:https://juejin.im/post/5cc652adf265da03540316e3

示例代码下载

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

沙化动画

复原动画

响指动画

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

响指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 = truecontentsGravity = CALayerContentsGravity.left是为了当前只显示Sprite图的第一幅画面
//2: 根据Sprite图大小和每幅画面的大小计算出画面数量,预先计算出每幅画面的contentsRect.origin.x偏移量
//3: 这里是关键,指定关键帧动画的calculationModediscrete确保关键帧动画依次使用values中指定的关键帧值进行变化,而不是默认情况下采用线性插值进行过渡,来个对比图可能比较容易理解:

离散模式

默认线性模式

沙化消失

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

像素处理

新建自定义视图 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个元素之一的相同位置。随机方法有点特别,原始图片左边的像素只会分配到前几张图片,而原始图片右边的像素只会分配到后几张。

上部分为原始图片,下部分为像素分配后的32张图片依次显示效果

//3: 这里循环2次将像素分配两次,可能 Google 觉得只分配一遍会造成像素比较稀疏。个人认为在移动端,只要一遍就好了。
//4: 创建32张图片并返回

添加动画

Google的实现是给canvascsstransform属性设置为rotate(deg) translate(px, px) rotate(deg),值都是随机生成的。如果你对CSS的动画不熟悉,那你会觉得在iOS中只要添加三个CABasicAnimation然后将它们添加到AnimationGroup就好了嘛,实际上并没有那么简单... 因为CSStransform中后一个变换函数是基于前一个变换后的新transform坐标系。假如某张图片的动画样式是这样的:rotate(90deg) translate(0px, 100px) rotate(-90deg) 直觉告诉我应该是旋转着向下移动100px,然而在CSS中的元素是这么运动的:

CSS中transform多值动画

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

将“预想”的终点作为控制点的贝塞尔曲线,看起来和CSS中的运动轨迹差不多

扩展问题
通过文章中描述的方式生成的贝塞尔曲线是否与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。

参考资料

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

推荐阅读更多精彩内容

  • 我总以为每个人之间都能成为一段故事,年幼无知的故事,一个个同学之间的故事,而比同学之间长的是同桌的故事,同学成为...
    林小林阿阅读 295评论 0 0
  • 1.骑着车子快到小区门口了,门卫阿姨帮我开门。虽然是一件很小的事情,但有人真诚的帮助你,心里还是暖暖的。 2.下班...
    安静的吃饼干阅读 177评论 0 0
  • 随着“中国有嘻哈”的结束,有网友调侃说,现在每天嚷嚷着“我吃火锅,你吃火锅底料”的那群人,和上半年想要去成都的街头...
    A亮闪闪的五角星阅读 246评论 0 1
  • 每个人在出生的时候就要有所谓的担当,小时候,父母的担子比较重,要教育子女,又要照顾男女双方的父母,过年时还得弄红包...
    王玉笙阅读 132评论 0 0
  • 书名:《故事思维》 作者:安妮特•西蒙斯 金句:在传记中,正邪之争是由来已久的套路。在这些情节里,情感往往是第一位...
    精力满满阅读 336评论 0 0