Posted at

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

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


はじめに

フロントエンドに興味が特化しているゆえに、そっち方面とお近づきになりたいわけです。

ってことで、今回はアニメーションとの身体的・精神的距離をどうにか(


テーマ

初雪にざわめく街で

雪が綺麗と笑うのは君がいい

・・・ってことで、雪を降らせよう!っていう感じ。


完成物

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

雪、降らせた。

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

まぁ、実際結晶の画像を使ってるので、雪ってかでっかい結晶が落ちてくる感じやけど。

この辺りは、まぁご愛嬌ってことで。


作り方

で、この動きをどうやって作ったかっていうのを、今回は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な冬の贈り物をお届けに参ったはぐっでした・ω・♪