どうも、はぐっです・ω・♪
はじめに
フロントエンドに興味が特化しているゆえに、そっち方面とお近づきになりたいわけです。
ってことで、今回はアニメーションとの身体的・精神的距離をどうにか(
テーマ
初雪にざわめく街で
雪が綺麗と笑うのは君がいい
・・・ってことで、雪を降らせよう!っていう感じ。
完成物
今回作ったものはこんな感じ。
雪、降らせた。
ループはさせてないので、永遠に降るわけではない。
まぁ、実際結晶の画像を使ってるので、雪ってかでっかい結晶が落ちてくる感じやけど。
この辺りは、まぁご愛嬌ってことで。
作り方
で、この動きをどうやって作ったかっていうのを、今回は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な冬の贈り物をお届けに参ったはぐっでした・ω・♪