CoreAnimation
アニメーション
CADisplayLink
Swift
CABasicAnimation

SwiftのCoreAnimationを使って画面に雪を降らせてみた

はじめに

なにかしらアニメーションを実装したい!冬に降らせるものといえば、しかない!
ということで、今回はCoreAnimationを使って画面に雪を降らせてみました。

アニメーションって実装大変だよなーとか
CoreAnimationって言葉を見るだけで疲れるわーとか
そういった「なんとなく難しい」想定をしている方でもとっつきやすい内容でお届けします!

完成物

今回作ったものはこちら。
(動画に関しては同じものを使用します。)

advent.gif

見事、雪(氷の結晶)を降らせることに成功しました!

作り方

今回は、このアニメーションを2パターンの方法で実装してみました。
(できあがりは多少異なります。)

前提として、2パターンどちらも「すのうふれいく」と書かれたトリガー用のボタンを押下時に

final class SnowViewController: UIViewController {
  @IBAction private func didTapStartButton(_ sender: UIButton) {}
}

このイベントが発火し、アニメーションが始まるとします。

では、まずは1パターン目。

途中の計算をCoreAnimationに任せるパターン

キーワード:「CABasicAnimation」
概要:始まり、終わり、秒数、動き方を指定して、途中のことはプログラムにお任せ

まずはコードの全貌。
ざっくり見てください。

final class SnowViewController: UIViewController {

  @IBAction private func didTapStartButton(_ sender: UIButton) {
    self.createSnows()
  }

  // 雪いっぱい作って、動かす
  private func createSnows() {
    for sequence in 0..<80 {
      let imageView = self.createSnow()
      self.view.addSubview(imageView)
      self.animateSnow(imageView: imageView, sequence: sequence)
    }
  }

  // 雪いっこ作る
  private func createSnow() -> UIImageView {
    let image               = UIImage(named: "snow")
    let imageView           = UIImageView(image: image)
    let scale               = CGFloat(Double(arc4random_uniform(10)) / 50)
    imageView.center        = self.calculatePoint()
    imageView.transform     = imageView.transform.scaledBy(x: scale, y: scale)
    imageView.layer.opacity = 0.0
    return imageView
  }

  // 画面のどっかいい感じの位置取得
  private func calculatePoint() -> CGPoint {
    let x = CGFloat(arc4random_uniform(UInt32(self.view.bounds.width)))
    let y = CGFloat(arc4random_uniform(UInt32(self.view.bounds.height)))
    return CGPoint(x: x, y: y)
  }

  // 雪のアニメーションを諸々作成し、付与
  private func animateSnow(imageView: UIImageView, sequence: Int) {
    let duration = CFTimeInterval((arc4random_uniform(5) + 3) * 2)

    let translateAnimation = self.createTranslateAnimation(duration: duration)
    let rotateAnimation    = self.createRotateAnimation(duration: duration, sequence: sequence)
    let opacityAnimation   = self.createOpacityAnimation(duration: duration)

    imageView.layer.add(translateAnimation , forKey: "transform.translation.y")
    imageView.layer.add(rotateAnimation    , forKey: "transform.rotation.z")
    imageView.layer.add(opacityAnimation   , forKey: "opacity")
  }

  // 移動アニメーション作成
  private func createTranslateAnimation(duration: CFTimeInterval) -> CABasicAnimation {
    let animation            = CABasicAnimation(keyPath: "transform.translation.y")
    animation.fromValue      = 0.0
    animation.toValue        = CGFloat(arc4random_uniform(200))
    animation.duration       = duration
    animation.timingFunction = Easing.easeIn.sine.function
    return animation
  }

  // 回転アニメーション作成
  private func createRotateAnimation(duration: CFTimeInterval, sequence: Int) -> CABasicAnimation {
    let animation            = CABasicAnimation(keyPath: "transform.rotation.z")
    animation.fromValue      = 0.0
    animation.toValue        = CGFloat(arc4random_uniform(4) + 1) * (sequence % 2 == 0 ? CGFloat.pi : -CGFloat.pi)
    animation.duration       = duration
    animation.timingFunction = Easing.easeIn.sine.function
    return animation
  }

  // 透過度アニメーション作成
  private func createOpacityAnimation(duration: CFTimeInterval) -> CAKeyframeAnimation {
    let startRatio = arc4random_uniform(4)
    let endRatio   = CGFloat(arc4random_uniform(5 - startRatio) + startRatio)

    let animation            = CAKeyframeAnimation(keyPath: "opacity")
    animation.values         = [0.0, 1.0, 1.0, 0.0]
    animation.keyTimes       = [0.0, Double(startRatio + 2) / 10, Double(endRatio + 3) / 10, 1.0] as [NSNumber]
    animation.duration       = duration
    animation.timingFunction = Easing.easeInOut.sine.function
    return animation
  }
}

このパターンで注目すべきは以下3点です。

1点目

// 移動アニメーション作成
private func createTranslateAnimation(duration: CFTimeInterval) -> CABasicAnimation {
  let animation            = CABasicAnimation(keyPath: "transform.translation.y")
  animation.fromValue      = 0.0
  animation.toValue        = CGFloat(arc4random_uniform(200))
  animation.duration       = duration
  animation.timingFunction = Easing.easeIn.sine.function
  return animation
}

CABasicAnimationというのが、名前通りCoreAnimationの基本。

CABasicAnimation(keyPath: "~~~")

keyPathの中に動かすプロパティを表す文字列を指定します。
なお、これはなんでも自由にというわけではなく、動かす対象によって決められた文字列が用意されています。
決められた文字列に関しては本記事とは逸れるので、参考URLを貼っておきます。

この1点目でいうと、Y軸方向の移動に関するアニメーションを作成したいため
keyPathには「transform.translation.y」を指定します。

ここからアニメーションの詳細を設定していくわけなのですが、詳しい指定の仕方はこちらを参考にしていただけると。
https://qiita.com/haguhoms/items/abc5635e8fa95719cb12
 

続いて、2点目。

// 回転アニメーション作成
private func createRotateAnimation(duration: CFTimeInterval, sequence: Int) -> CABasicAnimation {
  let animation            = CABasicAnimation(keyPath: "transform.rotation.z")
  animation.fromValue      = 0.0
  animation.toValue        = CGFloat(arc4random_uniform(4) + 1) * (sequence % 2 == 0 ? CGFloat.pi : -CGFloat.pi)
  animation.duration       = duration
  animation.timingFunction = Easing.easeIn.sine.function
  return animation
}

CABasicAnimationなので、内容は1点目とほぼほぼ変わりません。
今回はz軸方向の回転に関するアニメーションを作成したいため、
keyPathには「transform.rotation.z」を指定します。
 

最後に、3点目。

// 透過度アニメーション作成
private func createOpacityAnimation(duration: CFTimeInterval) -> CAKeyframeAnimation {
  let startRatio = arc4random_uniform(4)
  let endRatio   = CGFloat(arc4random_uniform(5 - startRatio) + startRatio)

  let animation            = CAKeyframeAnimation(keyPath: "opacity")
  animation.values         = [0.0, 1.0, 1.0, 0.0]
  animation.keyTimes       = [0.0, Double(startRatio + 2) / 10, Double(endRatio + 3) / 10, 1.0] as [NSNumber]
  animation.duration       = duration
  animation.timingFunction = Easing.easeInOut.sine.function
  return animation
}

CAKeyframeAnimationという、キーフレームアニメーションのためのものを使います。
(キーフレームアニメーションについては割愛します。)

今回は透明度に関するアニメーションを作成したいため、
keyPathには「opacity」を指定します。
最初と最後のタイミングで0.0、つまり不可視状態に
指定タイミングで1.0、つまり可視状態に
指定したタイミングの間でどんな値を取るかはCoreAnimationにお任せします。
今回のような、値が0.0から1.0へと変わり、一定期間保って、また0.0に。といった動きをさせたいときはCAKeyframeAnimationが便利です。
(もちろん、他の方法でも実装自体はできますが。)
 

以上3点のアニメーションを作成し、対象のCALayerにaddするとレイヤーにアニメーションが付与されます。
付与すると同時に動き出し、完成物のアニメーションが実行されます。

これが途中の計算をCoreAnimationに任せるパターンです。

続いて、2パターン目。

途中の計算を自力でやるパターン

キーワード:「CADisplayLink」
概要:描画タイミングごとに呼ばれるループ

まず、計算するのに必要な情報を持たせるためstructを用意します。
後ほどポイント説明しますので、一旦ざっくり見てください。

struct Snow {
  let imageView          : UIImageView
  let x                  : CGFloat
  let startY             : CGFloat
  let endY               : CGFloat
  let angle              : CGFloat
  let scale              : CGFloat
  let duration           : TimeInterval
  let opacityFirstPoint  : TimeInterval
  let opacitySecondPoint : TimeInterval

  init(parentViewSize: CGSize = UIScreen.main.bounds.size) {
    self.imageView          = UIImageView(image: UIImage(named: "snow"))
    self.imageView.alpha    = 0.0
    self.x                  = CGFloat(arc4random_uniform(UInt32(parentViewSize.width + self.imageView.bounds.width * 1.5))) - self.imageView.bounds.width * 0.75
    self.startY             = CGFloat(arc4random_uniform(UInt32(parentViewSize.height + self.imageView.bounds.height * 1.5))) - self.imageView.bounds.height * 0.75
    self.endY               = self.startY + CGFloat(arc4random_uniform(200))
    self.angle              = CGFloat(arc4random_uniform(4) + 1) * (arc4random_uniform(1) == 0 ? CGFloat.pi : -CGFloat.pi)
    self.scale              = CGFloat(Double(arc4random_uniform(10)) / 10)
    self.duration           = TimeInterval((arc4random_uniform(5) + 3) * 2) // 6 - 16
    self.opacityFirstPoint  = TimeInterval(arc4random_uniform(UInt32(self.duration - 3)) + 1) // 2 - 12
    self.opacitySecondPoint = TimeInterval(arc4random_uniform(UInt32(self.duration - self.opacityFirstPoint - 2))) + self.opacityFirstPoint + 1 // 4 - 14
  }

  // Y軸方向の移動に関する進捗を取得
  func getProgressY(elapsed: TimeInterval) -> CGFloat {
    return Easing.easeIn.sine.getProgress(elapsed: elapsed, duration: self.duration, startValue: self.startY, endValue: self.endY)
  }

  // 回転に関する進捗を取得
  func getProgressAngle(elapsed: TimeInterval) -> CGFloat {
    return Easing.easeIn.sine.getProgress(elapsed: elapsed, duration: self.duration, startValue: 0.0, endValue: self.angle)
  }

  // 透明度に関する進捗を取得
  func getProgressOpacity(elapsed: TimeInterval) -> CGFloat {
    if elapsed < self.opacityFirstPoint {
      return Easing.easeInOut.sine.getProgress(elapsed: elapsed, duration: self.opacityFirstPoint, startValue: 0.0, endValue: 1.0)
    }
    else if self.opacitySecondPoint <= elapsed && elapsed <= self.opacitySecondPoint {
      return 1.0
    }
    else {
      return Easing.easeInOut.sine.getProgress(elapsed: elapsed - self.opacitySecondPoint, duration: self.duration - self.opacitySecondPoint, startValue: 1.0, endValue: 0.0)
    }
  }
}

ViewControllerの方も、全貌を。
こちらもざっくり見てください。

final class SnowViewController: UIViewController {

  override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    self.createLoop()
    self.createSnows()
  }

  @IBAction private func didTapStartButton(_ sender: UIButton) {
    self.startLoop()
  }

  // ループ作成
  private func createLoop() {
    self.loop = CADisplayLink(target: self, selector: #selector(self.update))
    self.loop?.preferredFramesPerSecond = 60
    self.loop?.add(to: .current, forMode: .common)
    self.loop?.isPaused = true
  }

  // ループ稼働
  private func startLoop() {
    self.startTime      = Date.timeIntervalSinceReferenceDate
    self.loop?.isPaused = false
  }

  // 雪いっぱい作る
  private func createSnows() {
    for _ in 0..<80 {
      let snow = Snow()
      self.snows.append(snow)
      self.view.addSubview(snow.imageView)
    }
  }

  // 描画タイミングごとに呼ばれる
  @objc
  private func update() {
    let currentTime = Date.timeIntervalSinceReferenceDate
    self.snows.forEach { self.updateSnow($0, currentTime: currentTime) }
  }

  // 雪の進捗を取得し、反映
  private func updateSnow(_ snow: Snow, currentTime: TimeInterval) {
    let elapsed = currentTime - self.startTime
    let y       = snow.getProgressY(elapsed: elapsed)
    let angle   = snow.getProgressAngle(elapsed: elapsed)
    let opacity = snow.getProgressOpacity(elapsed: elapsed)

    snow.imageView.transform = CGAffineTransform.identity.translatedBy(x: snow.x, y: y).rotated(by: angle).scaledBy(x: snow.scale, y: snow.scale)
    snow.imageView.alpha = opacity
  }
}

描画タイミングごとにメソッドを呼ぶためにはこうする・・・といった話は、こちらの記事に書きましたので参考に貼っておきます。
https://qiita.com/haguhoms/items/c87d335756042bc867c4

そしてここでは、実際にそのタイミングでの進捗を計算して反映する部分について触れていきます。

まずは、アニメーション開始時の時間を取得します。

// ループ稼働
private func startLoop() {
  self.startTime      = Date.timeIntervalSinceReferenceDate
  self.loop?.isPaused = false
}

開始時間から○秒経ったらここにいるはず、という計算を行うので開始時間は必須です。
そして、isPausedをfalseにすることで、止めていたループが動き出します。

すると、描画タイミングごとに以下のメソッドが呼ばれます。

// 描画タイミングごとに呼ばれる
@objc
private func update() {
  let currentTime = Date.timeIntervalSinceReferenceDate
  self.snows.forEach { self.updateSnow($0, currentTime: currentTime) }
}

そして、呼ばれた時間を取得して、進捗計算のメソッドに渡します。

// 雪の進捗を取得し、反映
private func updateSnow(_ snow: Snow, currentTime: TimeInterval) {
  let elapsed = currentTime - self.startTime
  let y       = snow.getProgressY(elapsed: elapsed)
  let angle   = snow.getProgressAngle(elapsed: elapsed)
  let opacity = snow.getProgressOpacity(elapsed: elapsed)

  snow.imageView.transform = CGAffineTransform.identity.translatedBy(x: snow.x, y: y).rotated(by: angle).scaledBy(x: snow.scale, y: snow.scale)
  snow.imageView.alpha     = opacity
}

現在時間を元に進捗を計算する役割はstructに任せたので、ここではそれぞれ取得した値をアニメーション対象に反映させるだけとなります。

structにある計算用メソッドは以下のような形になっていて

// Y軸方向の移動に関する進捗を取得
func getProgressY(elapsed: TimeInterval) -> CGFloat {
  return Easing.easeIn.sine.getProgress(elapsed: elapsed, duration: self.duration, startValue: self.startY, endValue: self.endY)
}

自身が持つ情報と渡ってきた経過時間から、Easingクラスが持つメソッドで進捗を取得します。
Easingクラスの中身は、このような形になっています。

func getProgress(elapsed: TimeInterval, duration: TimeInterval, startValue: CGFloat, endValue: CGFloat) -> CGFloat {
  if elapsed < 0 {
    return startValue
  }
  if elapsed > duration {
    return endValue
  }

  var progress: CGFloat
  switch self {
  case .sine:
    let position: TimeInterval = elapsed / duration
    progress = CGFloat( -cos(position * .pi / 2) + 1.0 )
  }

  if startValue > endValue {
    return startValue - abs(endValue - startValue) * progress
  }
  else {
    return startValue + abs(endValue - startValue) * progress
  }
}

このメソッドによって進捗を計算でき、それを毎描画タイミングで対象に反映させていくことでアニメーションを実行できました!
さらに、今回はここまでしかやっていないのですが、こちらのパターンでは自力計算ゆえに融通が利くため、毎タイミングで少しランダムに横にずらす、といった風な自由自在な動きを実装できるので、より雪に近づけたい!とか、もっといろいろ動かしたい!といったときはこちらのパターンの方が向いているかと思います。

おわりに

2パターンの方法で雪を降らせることに成功しました。
楽に実装したいときもあれば、自由度を持って実装したい時もあるかと思うので、適宜実装方法は選択していくことになるかと思います!

アニメーションの実装方法は以上となりますが、いざプロダクトにアニメーションを実装する際は用法用量を守って正しくお使いください。
という話をnoteに書いたので、こちらも見ていただければ嬉しいです!
https://note.mu/haguhoms/n/ncae560805a0c

この記事が、少しでもアニメーションを始めるきっかけとなれば、冥利に尽きます。
良きアニメーションライフを!