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 库 里都有完整的代码。

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

传送门

阅读 2k

51 声望
61 粉丝
0 条评论
51 声望
61 粉丝
宣传栏