iOS 动画 - 窗景篇(二)

本文是系列文章的第二篇。

看过上一篇文章的同学,已经知道标题中的“景”指代 view,“窗”指代 view.mask,窗景篇就是在梳理 mask 及 mask 动画。如果你还不熟悉 iOS 的 mask,建议先看一下第一篇

相对于景来说,窗的变化更多样一些,所以本文我们重点来看一下窗的效果。

我们从3个维度来看:窗在动吗?窗在变吗?有几个窗?

很多动画就是这3个维度的单独体现,或者组合后的效果。我们先看一下各个维度的单独效果,然后再来看一下它们的组合效果。

一、窗动

前文中,我们用一个圆作为窗,先贴张图回忆一下:

我们大都做过基本的动画,因此可以想到,只要动画地改变圆 mask 的中心位置,就可以让窗动起来。

效果如下面的动图所示:

示意代码如下:

/// viewDidLoad
// 景
frontView.frame = UIScreen.main.bounds
view.addSubview(frontView)

// 圆窗
let mask = CircleView()
mask.frame = CGRect(x: 0, y: 0, width: 200, height: 200)
mask.center = CGPoint(x: 100, y: 100)
self.mask = mask
frontView.mask = mask

// 窗动
startAnimation()

/// startAnimation
// 动画地改变 mask 的中心
private func startAnimation() {
    mask.layer.removeAllAnimations()
    
    let anim = CAKeyframeAnimation(keyPath: "position")
    let bound = UIScreen.main.bounds
    anim.values = [CGPoint(x: 100, y: 250), CGPoint(x:bound.width - 100 , y: 250), CGPoint(x: bound.midX, y: 450), CGPoint(x: 100, y: 250)]
    anim.duration = 4
    anim.repeatCount = Float.infinity
    mask.layer.add(anim, forKey: nil)
}

让窗动起来非常简单,这简单的效果也可以成为其他效果的基础。

比如我们加入一个 pan(拖动) 手势,实现这样一个效果:

思路很简单:

  1. 初始时一片黑色,窗的大小为0
  2. pan 手势开始时,开始显示窗户
  3. pan 手势拖动时,移动窗
  4. pan 手势结束时,窗的大小恢复为0,回归一片黑色

示意代码如下:

// 在刚才窗动的代码基础上
// 添加 pan 手势来控制 mask 的 center

@objc func onPan(_ pan: UIPanGestureRecognizer) {
    switch pan.state {
    case .began:
        // 拖动开始,显示窗
        mask.frame = CGRect(x: 0, y: 0, width: 200, height: 200)
        mask.center = pan.location(in: pan.view)
    case .changed:
        // 拖动过程,移动窗
        mask.center = pan.location(in: pan.view)
    default:
        // 其他,隐藏窗
        mask.frame = CGRect.zero
    }
}

好了,“窗动”先看到这,接下来,我们看一下“窗变”这个维度。

二、窗变

我们还是用圆窗示例,这次使用前后两个 view,圆作为 frontView 的 mask;

还是看一下前文的一张图:

这次我们让圆窗动态的变大(缩放),缩放也是基本的动画,效果如下面的动图所示:

示意代码如下:

/// viewDidLoad
// back view
backView.frame = UIScreen.main.bounds
view.addSubview(backView)

// 景
frontView.frame = UIScreen.main.bounds
view.addSubview(frontView)

// 圆窗
let mask = CircleView()
mask.frame = CGRect(x: 0, y: 0, width: 200, height: 200)
mask.center = CGPoint(x: frontView.bounds.midX, y: frontView.bounds.midY)
self.mask = mask
frontView.mask = mask

// 窗变
startAnimation()

/// startAnimation
// 动画改变 mask 的大小
private func startAnimation() {
    mask.layer.removeAllAnimations()
    
    let scale: CGFloat = 5.0
    let anim = CABasicAnimation(keyPath: "transform.scale.xy")
    anim.fromValue = 1.0
    anim.toValue = scale
    anim.duration = 1
    mask.layer.add(anim, forKey: nil)
    
    // 真正改变 layer 的 transform,防止动画结束后恢复原状
    mask.layer.transform = CATransform3DMakeScale(scale, scale, 1)
}

我想你已经发现了,将这个效果和 iOS 转场机制结合起来,就是一种很常见的转场效果。

关于窗变,我们再举一个常见的例子:进度环效果。
先看一下效果,如下面的动图所示:

其实就是一个渐变的景,加一个圆环的窗,和前文我们看过的文字窗没有什么区别,如下图所示:

只不过是窗从无逐渐地变化成了完整的圆环;最适合这种变化的,是 stroke 动画。

stroke,也就是 CAShapeLayer 的 strokeStart 和 strokeEnd 属性,网上有成熟的教程

为了方便理解这个效果,本文只对 stroke 做个基本的介绍:

  1. 我们想画一个圆环,首先是设计圆环的起点和终点,如果从起点开始画线,画到终点,就能画出完整的圆环,我们可以把从起点(strokeStart)画(stroke)到终点(strokeEnd)叫做路径(path)
  2. 但我们现在只想画出圆环的一部分,比如从圆环 1/4(0.25) 处,画到 3/4(0.75) 处;那我们设置strokeStart = 0.25,strokeEnd = 0.75,这样的话,圆环(path)就只会显示 1/4 到 3/4 这部分了
  3. strokeStart、strokeEnd 属性,就是相对于完整的 path来说,我们要显示哪一段
  4. 我们想让圆环一开始不显示,那设置 strokeStart = 0,strokeEnd = 0 就可以了
  5. 我们想让圆环最后完整显示,设置 strokeStart = 0,strokeEnd = 1(也就是 100%) 就可以了
  6. 动画过程就是 strokeEnd 从0到1的变化。

示意代码如下:

/// ViewController
// 渐变景
frontView.frame = UIScreen.main.bounds
view.addSubview(frontView)

// 环窗
let mask = RingView()
mask.frame = CGRect(x: 0, y: 0, width: 200, height: 200)
mask.center = CGPoint(x: frontView.bounds.midX, y: frontView.bounds.midY)
self.mask = mask
frontView.mask = mask

// 窗变
// 动画地改变 mask 圆环的完成度(从圆环未开始,到圆环完全闭合)
startAnimation()


/// RingView(环 view)
// 为环 view 设置 progress 可以改变 它的 strokeEnd
var progress: CGFloat = 0 {
    didSet {
        if progress < 0 {
            progress = 0
        } else if progress > 1 {
            progress = 1
        } else {}
        
        (layer as! CAShapeLayer).strokeEnd = progress
    }
}

我们可以使用 CAShapeLayer 的 path 画出各式各样的窗,配合 strokeStart、strokeEnd, 会有很多有趣的 stroke 窗变动画。

接下来,我们看一下“多窗”这个维度,
由于单纯的多窗没有什么效果,这次我们直接和“窗动”或者“窗变”组合起来看。

三、多窗

由于 view 只有一个 mask 属性,所以我们所说的多窗,不是多个 mask,而是在 mask 上做文章。
比如,我们可以用一种粗糙但直观的方式,来实现这样一个效果:

实现思路如下:

  1. mask 有 6个 子view,相当于6扇小窗户
  2. 子 view 一个接一个的变透明,窗户依次打开

示意代码如下:

/// ViewController
// back view
backView.frame = UIScreen.main.bounds
view.addSubview(backView)

// 景
frontView.frame = UIScreen.main.bounds
view.addSubview(frontView)

// 多窗(百叶窗)
// mask 的 子 view,依次隐藏
let mask = ShutterView(frame: frontView.bounds, count: 8)
frontView.mask = mask

mask.startAnimation()


/// ShutterView(多窗 view)
func startAnimation() {
    layers.enumerated().forEach {
        let anim = CABasicAnimation(keyPath: "opacity")
        anim.duration = 0.5
        anim.beginTime = $0.element.convertTime(CACurrentMediaTime(), from: nil) + anim.duration * Double($0.offset)
        anim.fromValue = 1
        anim.toValue = 0
        // 由于 layer 的动画用 beginTime 做了延迟
        // 后面代码修改 opacity 的真实值后,layer 开始就会显示(opacity == 0)的状态
        // 所以我们使用 backwards,来保证动画执行前,layer 显示 fromValue(opacity == 1) 的状态
        anim.fillMode = CAMediaTimingFillMode.backwards
        $0.element.add(anim, forKey: nil)
        // 修改 opacity 的真实值,防止动画完成后恢复原样
        CATransaction.begin()
        CATransaction.setDisableActions(true)
        $0.element.opacity = 0
        CATransaction.commit()
    }
}

以上是“多窗”和“窗变”(透明度变化)的组合,

看到一组类似的小窗,有的同学可能就想到了 CAReplicatorLayer 这种专精于复制子 layer 的类,

那接下来,我们用 CAReplicatorLayer 当窗试试,来实现一个“多窗”和“窗动”的组合。

四、多窗(CAReplicatorLayer)

网上已经有 CAReplicatorLayer 的成熟教程,在此我们只做个简单的类比,让没接触过的同学有个印象。

CAReplicatorLayer 就好比 UITableView,你可以给它指定一个 subLayer 和 数量,它可以把 subLayer 复制到你指定的数量,就像 UITableView 根据你指定的 Cell 类创建并管理一组 Cell 一样。

UITableView 可以管理 Cell 的布局,可以让 Cell 一个接一个的排列,类似地,CAReplicatorLayer 也可以根据你的设置,让 一组 subLayer 按规则地排列。

CAReplicatorLayer 还可以根据设置,让一组 subLayer 有各种过渡效果,比如第一个 subLayer 背景色为白色,中间的subLayer 背景色递减,直到最后一个 subLayer 为黑色。本文的效果只涉及 subLayer 位置,因此不再讨论其他设置。

本例中,我们依然用渐变 view 作为景,让 CAReplicatorLayer 复制 3个子 layer(圆) 作为窗,来实现一个 loading 动画,效果如下面的动图所示:

有了前面的经验,大家很容易发现,这个动画就是 3个小圆窗,在渐变景上面不断交换各自的位置,如下图所示:

示意代码如下:

/// ViewController
// 渐变景
frontView.frame = UIScreen.main.bounds
view.addSubview(frontView)

// 多窗(3球窗,CAReplicatorLayer 窗)
let mask = TriangleLoadingView()
mask.frame = CGRect(x: 0, y: 0, width: 200, height: 200)
mask.center = CGPoint(x: frontView.bounds.midX, y: frontView.bounds.midY)
self.mask = mask
frontView.mask = mask

// 窗动(3球旋转)
mask.startAnimation()

/// TriangleLoadingView
// 创建3球窗
override init(frame: CGRect) {
    super.init(frame: frame)
    
    let layer = (self.layer as! CAReplicatorLayer)
    layer.backgroundColor = UIColor.clear.cgColor
    layer.instanceCount = 3
    // 3个小球
    // 每个以本 view 的 layer(CAReplicatorLayer)中心为原点,以 z 轴为旋转轴
    // 以上一个 cellLayer 的状态 为初始态,顺时针旋转 120°
    // 形成一个等边三角形
    layer.instanceTransform = CATransform3DMakeRotation(CGFloat.pi / 3 * 2, 0, 0, 1)
    layer.addSublayer(cellLayer)
}

// 定位小球
override func layoutSubviews() {
    super.layoutSubviews()
    
    // 第1个小球,在本 view 的顶部,水平居中
    cellLayer.position = CGPoint(x: bounds.midX, y: Constants.cellRadius)
}

// 执行动画(3球旋转)
func startAnimation() {
    cellLayer.removeAllAnimations()
    
    let anim = CABasicAnimation(keyPath: "position")
    let from = cellLayer.position
    anim.fromValue = from
    // 使用一点等边三角形的知识
    // r:等边三角形的外径(外接圆的半径)
    let r = bounds.midY -  Constants.cellRadius
    // 根据等边三角形的上顶点的坐标和外径,求右下顶点的坐标
    let radian = CGFloat.pi / 6
    anim.toValue = CGPoint(x: from.x + r * cos(radian), y: from.y + r + r * sin(radian))
    anim.duration = 1
    anim.repeatCount = Float.infinity
    cellLayer.add(anim, forKey: nil)
    
    // 注:我们实现了圆窗从上顶点到右下顶点的移动
    // CAReplicatorLayer 就可以根据我们之前设置的 instanceTransform,自动帮我们完成其他两种顶点间的移动
}

看到 CAReplicatorLayer,有的同学就想到了 CAEmitterLayer,也就是实现粒子效果的 layer,
粒子也能当窗吗?

当然能,一切 view(layer)都可以当做窗,接下来我们来看一个 CAEmitterLayer 实现的 “多窗”、“窗动”、“窗变” 3个维度的组合。

五、粒子窗

CAEmitterLayer 的知识我们也不展开了,直接看一个效果,如下面动图所示:

实现思路很简单:

  1. 用一张图片作为景
  2. 用一个从底部向上发射(窗动)不断变大(窗变)的心形粒子(多窗)的 view 作为窗

关于CAEmitterLayer的使用 网上有成熟的教程

示意代码如下:

/// ViewController
// back view
backView.frame = UIScreen.main.bounds
view.addSubview(backView)

// 景
frontView.frame = UIScreen.main.bounds
view.addSubview(frontView)

// 粒子窗
let mask = EmitterView()
mask.frame = frontView.bounds
frontView.mask = mask

/// EmitterView
/// 配置粒子窗
private func configLayer() {
    // 心形粒子
    let cell = CAEmitterCell()
    // 样式
    cell.contents = UIImage(named: "love")?.cgImage
    cell.scale = 0.5
    cell.scaleSpeed = 2
    // 产生粒子的速率
    cell.birthRate = 20
    // 存活时长
    cell.lifetime = 3
    // 方向
    cell.emissionLongitude = CGFloat(Float.pi / 2)
    cell.emissionRange = CGFloat.pi / 3
    // 速度
    cell.velocity = -250
    cell.velocityRange = 50

    // 发射器
    let emitterLayer = (layer as! CAEmitterLayer)
    emitterLayer.emitterPosition = CGPoint(x: UIScreen.main.bounds.midX, y: UIScreen.main.bounds.height)
    emitterLayer.birthRate = 1
    emitterLayer.emitterSize = CGSize(width: UIScreen.main.bounds.width, height: 0)
    emitterLayer.emitterShape = CAEmitterLayerEmitterShape.point
    emitterLayer.emitterCells = [cell]
}

尾声

这一篇,我们以窗为例,从“窗动”、“窗变”、“多窗” 3个维度入手,梳理了一些 mask 动画的例子。
窗的思路已经打开,那么更为简单的景,我们就不再单独开篇。

在下一篇文章里,我们将一起看一个初看复杂、其实简单的效果。文章的重点并不是讲效果本身,而是想帮大家回忆起一个道理:看上去复杂的东西,未必就真的复杂。

本文所有示例,在 GitHub 库 里都有完整的代码。

感谢您的阅读,我们下篇文章见。

传送门

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