LoginSignup
11
1

More than 5 years have passed since last update.

アニメーションとお近づき ~すのうふれいくを君と 第ゼロ章~

Last updated at Posted at 2018-12-18

どうも、はぐっです・ω・♪

はじめに

フロントエンドに興味が特化しているゆえに、そっち方面とお近づきになりたいわけです。
ってことで、今回はアニメーションとの身体的・精神的距離をどうにか(

テーマ

初雪にざわめく街で
雪が綺麗と笑うのは君がいい
・・・ってことで、雪を降らせよう!っていう感じ。

完成物

今回作ったものはこんな感じ。

advent.gif

雪、降らせた。
ループはさせてないので、永遠に降るわけではない。

まぁ、実際結晶の画像を使ってるので、雪ってかでっかい結晶が落ちてくる感じやけど。
この辺りは、まぁご愛嬌ってことで。

作り方

で、この動きをどうやって作ったかっていうのを、今回は2パターンやってみた。
ちょっと違うけど、だいたい似たようなことを異なる形でお届けしてみる。
正解は一つじゃないかもしれない!っていう、あれね。

どちらも、ボタンを押したら


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

に入ってきて、アニメーションが始まるっていう前提で進めまーす。

じゃあ、二通り実装を見ていく、と。
なお、どちらの手法もCoreAnimationを利用している。

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

キーワードは 「CABasicAnimation」
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
  }
}

コード的にはこんな感じなんですよ、ええ。

で、まぁ、注目すべきは下の三つかな。

まずこいつ。

// 移動アニメーション作成
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の基本のやーつ。
初期化時に何を動かすかっていうkeyPathを指定するんやけど、これは文字列で指定するガバガバな感じかと思いきや、何が指定できるか決まっている。
この辺りを参考に。
https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/CoreAnimation_guide/AnimatableProperties/AnimatableProperties.html#//apple_ref/doc/uid/TP40004514-CH11-SW2
https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/CoreAnimation_guide/Key-ValueCodingExtensions/Key-ValueCodingExtensions.html#//apple_ref/doc/uid/TP40004514-CH12-SW8

ここの場合は「transform.translation.y」
つまり、y軸方向の移動に関するアニメーションを作りまっせと。

で、こっからいろんな値を指定して動かすのだが、、、
もはや以前書いたのではしょる。
https://qiita.com/haguhoms/items/abc5635e8fa95719cb12

んで、こいつ。

// 回転アニメーション作成
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
}

こいつはもうほぼほぼ言うことない。
「transform.rotation.z」
z軸方向の回転に関するアニメーションを作る。いじょっ。

最後はちょっと新キャラが出てくる。

// 透過度アニメーション作成
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というやつを使いだした。
キーフレームアニメーションに関して詳しくは調べてもらうとして、
要は、タイミングと、そのタイミングでの値を指定して、それを一連の流れとして動かす手法。
今回でいうと、4つのタイミングを指定していて、
最初と最後で0.0、つまり不可視状態に
指定タイミングで1.0、つまり可視状態に
途中は、CoreAnimationくん、わかるよね?
っていう感じ。
こういう0.0から1.0にいって、一定期間保って、また0.0に、みたいなちょっと変わったことしたいときはCAKeyframeAnimationが便利。
(今回でいうと他にもやりようはあるけど、使ってみる)

で、この三つのアニメーションを作って、対象のCALayerにaddするとアニメーションが付与される。
きたこれー!

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

キーワードは 「CADisplayLink」
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) }
}

こいつが、ぽんぽん呼ばれるわけですね、ええ。
で、呼ばれた時の時間を取得して、
この時間を元に進捗を計算して反映させちゃってー
って、updateSnowに全てを託す。

ほんで、

// 雪の進捗を取得し、反映
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に持たせたので、ここではそれぞれ取得した値をアニメーション対象物に反映させるだけ。
もっというと、

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

これも、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
  }
}

一つ目の手法で、
始まり、終わり、秒数、動き方を指定したら、それに従ってよしなに動かせてくれるやーつ
ってなってた、そのよしなにの部分を自力で計算している。
さらに、今回はやっていないけど、こっちの手法やと毎タイミングで少しランダムに横にずらす、とかで、より雪に近づけることができたり。
かなり自由度が高く実装することができるので、いろいろやりたいときはこちらの手法がいいかなと。

おわりに

二つの手法で雪を降らせて、かなり ゆきやこんこ な気分になったかと思います。

用法用量を守って適材適所に、みたいな話を近日noteとかで書こうかなーと思うので、
twitterやnoteもチェックしてくれればと思います(九分九厘エンジニアっぽくないつぶやきなのでご了承を)
基本 「haguhoms」 で検索いただければ。

これからもアニメーションとの距離を近づけて、ゆくゆくはゼロ距離になれたらなと思います!
snow flakeな冬の贈り物をお届けに参ったはぐっでした・ω・♪

11
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
1