LoginSignup
38

More than 3 years have passed since last update.

PokéAPIを利用したPokedexにUIアニメーションを導入してみた

Last updated at Posted at 2020-12-24

この記事は、and factory Advent Calendar 202024日目の記事です。
昨日は@yst_iさんの「MotionLayoutのKeyCycleを使ってアニメーションを実装してみた」でした!

はじめに

今回は、@fr0g_fr0g氏が執筆した
PokéAPIを利用してMVP+CleanArchitectureのiOSアプリを作ったので解説する
で解説されている、PokedexというアプリにUIアニメーションを導入してみたという記事です。

Pokedexの開発背景としては、以下の通りです。

最近、勉強会などで、iOSの業務未経験の人たちと話している時に、これからiOSエンジニアとして仕事を得るためにどういったことを学べばいいのか、業務で実際にどういうことを意識して設計やツールを駆使しているかということをよく聞かれるので、それについて説明することができたらなと感じていました。

これを見た時に、
僕の場合は今までのキャリアで、(WebフロントもiOSもやってきたのですが、どちらに関しても)UIアニメーション周りの話をする機会が多かったため、そういうところに興味を持つ人も少なからずいる。ということを感じてきたので、
Pokedexにその要素を加えることで、より初学者の人たちが学ぶことの多いプロダクトになるんじゃないかなと思いました。

ということで、今回はPokedexにUIアニメーションを導入してみたお話です。

こばなし

僕の考えとしては、プロダクトは内部品質(設計)と外部品質(見た目)の両方が大事で、
どれだけ内部品質が良くても外部品質が足りていないと、「使いづらい」や「しょぼい」と感じてしまったり愛着がわかなかったりするし、
どれだけ外部品質が良くても内部品質が足りていないと、不具合発生や改修・追加機能の実装に時間がかかってしまい、価値を届けにくくなってしまう。
ゆえに、どちらも高みを目指すことで、プロダクトの絶対価値が高くなり、ユーザに**「利用される」プロダクトとなっていきます。
特に、似たようなテーマのプロダクトが世に出ている場合、ユーザの
「どちらを利用しよう」という選択に勝つ**ためにはこの絶対価値は大事な一因となるでしょう。

エンジニアとして、
内部品質と外部品質の両方を備えるのも良いし、
内部品質(外部品質)に特化するのも良いし、
どちらも、チーム開発においては価値ある人材になるかと思います。

そのため、今回は内部品質がしっかりしているPokedexの外部品質を向上させるべく、UIアニメーションを入れて、
より、Pokedexの価値を向上させ、内部に興味ある人も外部に興味がある人も全員が学ぶことができるものにしようというプロジェクトです。(勝手にプロジェクト化)

各画面のアニメーション紹介

一覧画面

こういう画面です。
スクリーンショット 2020-12-11 16.37.59.png

ポケモンを一覧で見る画面です。
ここでは下記のようなことをします。

  1. ざっといろんなポケモンを見る
  2. 詳細情報を見る対象を決める

ってことで、それぞれどういうことを考えてアニメーションさせたかを説明します。

成果物

pokedex7.gif

各アニメーション説明

ざっといろんなポケモンを見る

スクロールによってポケモン図鑑としての楽しみを出せないか、というのが狙いです。現状、並び替えや絞り込み、フリーワード検索等、特定のポケモンを見つける術を用意しているわけではないので、この画面を利用する際にスクロールが多くなりがちなため、さらに効果が出そうなところですね。
そこで今回は、スクロールに応じてポケモンが次々に出てくるよう、ポケモン出現時にUIが右から左に流れ込んでくるアニメーションを組み込みました。
各要素のアニメーションは流れ込んでくるものだけなので結構シンプルですが、この画面は一画面に複数要素が表示されるので、画面全体として心地よい動きをしてくれます。個々のアニメーションに凝りすぎると複数出てきた時に目が疲れてしまうので、そこそこにしつつ、複数の要素が動いてもそれぞれが孤立せず心地よさが重なるように調整していきます。具体的には、スクロールによって要素が表示されるタイミングでアニメーションを開始することで要素ごとに開始タイミングをずらすことと、アニメーション時間・イージングを馴染むものにすることを意識しました。
個々の要素と画面全体のバランス感、が使い心地に作用するので、木も森も見ることが大事です。

詳細情報を見る対象を決める

対象のポケモンの詳細を見たい、と思って要素をタップすると詳細画面に遷移する。とまあ、これはユーザの期待通りの行動ですね。
ここにもう一工夫加えて、よりポケモンに愛着を持ってもらおうというのが狙いです。
ポケモンが生きている、って思った方が楽しいじゃないですか!(急にどうした)
ということで、ユーザがポケモンを選択したときに、ポケモンが受け応える、といった演出をしようと思いました。(君に決めた的なやつですね、ええ)
双方向コミュニケーション、といえるかはわからないですが、そういう想像をできる方が楽しいなって。願わくば、選択したときに鳴き声出せたらなあと思ったんですが、ゲームとアプリの違いってこういうところにも出ていて、あんまり音ありきで利用されないんですよね。なんなら勝手に音鳴ってびっくりする、やめて。まである環境なので、このアプリには合っていないなと思ったため、今回はポケモンがジャンプすることで受け答えを表すことにしました。
具体的には、押下時(押し込み時)にジャンプさせることで、遷移させつつジャンプも確認できる、というところに落ち着かせました。タップ時(押して離した時)にジャンプさせるとジャンプを見る間もなく遷移してしまうのでちょっと悲しいし、かといってジャンプを見せるために遷移を遅延させるのはユーザの行動をブロックすることになるので控えないと、となり、ここに落ち着きました。アプリにおいて、目的地でのアニメーションはそこそこ派手にしても良いと思うんですが、そこに行くまではユーザの邪魔をしないこと、が大事にしたいポイントですね。

実装

具体的にコード載せていきます。

まずはCellファイル。

  • アニメーション前の位置から目的の位置まで移動させる
  • 押下時にポケモンを跳ねさせる
PokemonListCell.swift
final class PokemonListCell: UITableViewCell {

    func abbreviate() {
        let x: CGFloat = UIScreen.main.bounds.width * 0.375 // 初期位置どれぐらいにするか
        self.innerView.transform = .init(translationX: x, y: 0.0)
        self.innerView.alpha = 0.3
    }

    func expand() {
        self.innerView.transform = .identity
        self.innerView.alpha = 1.0
    }

    func animateImage() {
        let keyframeTranslateY      = CAKeyframeAnimation(keyPath: "transform.translation.y") // keyPathは決まった文字列を入れないと動かない
        keyframeTranslateY.values   = [0.0, -5.0, 0,0, -2.5, 0.0]
        keyframeTranslateY.keyTimes = [0, 0.25, 0.4, 0.6, 1.0]
        keyframeTranslateY.duration = 0.2

        self.spriteImageView.layer.add(keyframeTranslateY, forKey: "jumping") // このforKeyの文字列は自由
    }
}

で、ViewController。
だいぶ抜粋しています。
スクロールに応じて出現させるので、UITableViewのwillDisplayにてCellのアニメーションを走らせています。

PokemonListViewController.swift
final class PokemonListViewController: UIViewController {}

// MARK: - UITableViewDelegate
extension PokemonListViewController: UITableViewDelegate {

    func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
        guard let cell = cell as? PokemonListCell else { return }
        cell.abbreviate() // 初期位置へ移動させて
        UIView.animate(withDuration: 0.4, delay: 0, options: [.allowUserInteraction, .curveEaseInOut], animations: {
            cell.expand() // アニメーション開始
        }, completion: nil)
    }
}

詳細画面

こういう画面です。
スクリーンショット 2020-12-11 16.38.08.png

ポケモンの詳細情報を見る画面です。
ここでは下記のようなことをします。

  1. 対象ポケモンの詳細情報を見る
  2. 対象ポケモンの進化前ポケモンを見る

今回は「対象ポケモンの詳細情報を見る」に関してのアニメーションについて説明します。(進化前の方は、一旦今回は省きます)

成果物

pokedex5.gif

各アニメーション説明

対象ポケモンの詳細情報を見る

詳細情報を見る対象のポケモンが決まって遷移してきたので、ここではもうポケモンを「魅せる」ことに着目します。言葉にすると、ユーザを待たせずに情報を表示するという部分よりも、愛着をわかせるためのアニメーションという部分に着目してUIアニメーション設計しました。パッと表示させるよりは時間がかかってしまいますが、ポケモン詳細画面から何をするでもなくここが一つの目的地なので、ポケモンを見る楽しみ、をどこまで増やせるかが大事だなと考えた次第です。かといって、過度な演出をすると「ポケモン詳細を見るのは時間がかかる」と不満の種にもなってしまうので、そこはバランス感が大事です。
主に重要視した部分をフェーズで分けると

  1. データロード中
  2. ポケモン表示時
  3. ステータス表示時

の3つです。
それぞれについて、意識したことを書いていこうかと思います。

データロード中

画面遷移してから、APIでポケモンの詳細情報を取得するまでの間、ですね。
一般的なアプリだとインジケータ等を表示して、データロード中であることをユーザに知らせるかと思います。Pokedexに関しても相違ないのですが、ただのインジケータではなくポケモンに因んだローディング表示にしたかったので、今回はモンスターボールを揺らしてポケモンがもうすぐ出るぞ感を演出することにしました。同時に、次のポケモン表示のフェーズにつなぎ合わせられるような形にしたかったというのもあります。フェーズ的に複数ある場合は、それぞれ分けられたアニメーションにするよりは、連続的なアニメーションにすることで全体として流れが心地よく感じます。

ポケモン表示時

データを取得し、どのようにポケモンを表示するか。
先ほどデータ取得中にモンスターボールを揺らしているので、そこから出てきたようなアニメーションにしたくて、ポケモンのアニメでそのシーンを幾度も見ました。どうやら、モンスターボールが開く、ポケモンが出てくる、の間に、もやもやというのか不思議なものがモンスターボールから出る、というフェーズがあるようです。なら、それをできるだけ踏まえて盛り込みたいとなりました。というのも、オリジナルのものが既にこの世に存在しているものに関しては、できるだけ再現するか、それを感じられるようなアニメーションにする方が、オリジナルを好きな方達には刺さります。なので今回のテーマがポケモンという、ファンが大勢いるものである以上、ここは外せないと思い、その方向で全体のアニメーション設計をしたといっても過言ではないくらい重要視しました。
具体的には、モンスターボールが揺れて、そこにもやもやが出てきた後に、モンスターボールサイズに縮んでいたモンスターがリアルサイズにスケーリングしながら現れる、という形に落ち着きました。で、全体を通してここが一番見どころだと思うので、他の情報は一切表示せずモンスターだけに注目できるよう、画面中央にてアニメーションを実行するようにしました。ここも一つポイントで、何かに注目させるために他の要素を非表示にする、というのは簡単ですが、それだけだとその非表示にしている分の余白に説明がつかない場合があります。今回でいうと、画面の最終状態としてはポケモンは上部に配置されているので、名前から下の部分を非表示にした場合、画面の半分ぐらいを異様な余白が包みます。それだと、なんだこの余白は。ってなってしまうので、そういう本来抱かせたくない感情や働かせたくない心理をできるだけなくし、見せたいものだけに集中させてあげることも意識することが大事です。

ステータス表示時

ポケモンの姿を表示した後に、ポケモンのステータスを表示する場面です。
先ほどのフェーズで、ポケモンを画面中央に表示しているので、そこから画面の最終状態の位置へと移動させる必要があります。と同時に、ステータスを表示する必要があるので、そこへの視線移しをできるだけスムーズに行うことが心地よさを生みます。切れ目を感じてしまうと違和感が生まれてしまうので、そうならないように意識します。
ステータスの部分が一覧画面と似たUIになっているので、動きはそれに合わせるようにします。アプリの全体設計として、似たようなUIは似たような動き・働きをすることで統一感が出るのと、ユーザが迷わず利用できるものとなります。
ステータスは、タイプや能力等を表示する左側と、攻撃・防御等のパラメータを表示する右側の二つに分かれていて、右側の方にはもう一つ、値と棒グラフの増加アニメーションが入っています。ポケモンそれぞれに攻撃や防御のパラメータが振り分けられていて、その値は高ければ高いほど強いものとなります。こういう性質の値とグラフに関しては、0から目的の値まで伸びるアニメーションをすることで、その大きさなり量を直感的に感じることができるのでわかりやすさが増します。

実装

元々アニメやらゲームやらのオリジナルが存在するものに関しては、それに寄せることでクオリティ感や愛着の元となるので、なんとかしたい。けど、あの動きは単純な変形等では到底対応できないということで、今回はSpriteKitを使用しました。

具体的にコード載せていきます。

ポイントとしては、モンスターボールの最低限のアニメーション時間を確保するために、その秒数のタイマーと画像のロードで遅い方をトリガーとして出現アニメーションを実行しています。モンスターボールの表示が一瞬すぎると何が起こったかよくわからないので、モンスターボールから出てくる感じを演出するために多少の時間を確保しています。

アニメーションは大きく分けて三つ。

  1. ポケモン画像
  2. モンスターボール
  3. ポケモンが出る際の白いもやもや

それぞれ見ていきます。

ポケモン画像

ポケモン画像のアニメーションはCABasicAnimationにて行っています。
この値からこの値までこの時間で動く、という単純なアニメーションが得意なやつですね。
今回は
出現前に透明度・スケールを落として、
出現時に透明度・スケールを上げて、途中で移動のアニメーションを加えることで
モンスターボールから立体的に出ている感じを表現しています。

また、モンスターボールから出てきた感じを表現したいのですが、標準で要素のアニメーションをさせようとすると、要素の中心が基点となってしまいます。
それだとモンスターボールから出た感じが表現できないので、

self.imageView.layer.anchorPoint = .init(x: 0.5, y: 1.0)

として、要素の中央最下部を基点にしています。

PokemonDetailImageView.swift
final class PokemonDetailImageView: XibLoadableView {

    @IBOutlet private weak var imageView: UIImageView! {
        willSet {
            newValue.layer.anchorPoint = .init(x: 0.5, y: 1.0)
            newValue.alpha = 0.0
        }
    }

    private func animate() {
        Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false, block: { [weak self] _ in
            guard let self = self else { return }
            self.appearImage()
        })
    }

    private func appearImage() {
        let opacityAnimate                   = CABasicAnimation(keyPath: "opacity")
        opacityAnimate.fromValue             = 0.0
        opacityAnimate.toValue               = 1.0
        opacityAnimate.duration              = 0.2
        opacityAnimate.timingFunction        = Easing.EaseOut.quart.function
        opacityAnimate.isRemovedOnCompletion = false
        opacityAnimate.fillMode              = .forwards

        let scaleAnimate                   = CABasicAnimation(keyPath: "transform.scale")
        scaleAnimate.fromValue             = 0.2
        scaleAnimate.toValue               = 1.0
        scaleAnimate.duration              = 0.2
        scaleAnimate.timingFunction        = Easing.EaseOut.quart.function
        scaleAnimate.isRemovedOnCompletion = false
        scaleAnimate.fillMode              = .forwards

        let startYAnimate                   = CABasicAnimation(keyPath: "transform.translation.y")
        startYAnimate.fromValue             = 0.0
        startYAnimate.toValue               = -20.0
        startYAnimate.duration              = 0.15
        startYAnimate.timingFunction        = Easing.EaseInOut.circ.function
        startYAnimate.isRemovedOnCompletion = false
        startYAnimate.fillMode              = .forwards

        let endYAnimate                   = CABasicAnimation(keyPath: "transform.translation.y")
        endYAnimate.fromValue             = -20.0
        endYAnimate.toValue               = 0.0
        endYAnimate.duration              = 0.2
        endYAnimate.beginTime             = CACurrentMediaTime() + 0.15
        endYAnimate.timingFunction        = Easing.EaseInOut.circ.function
        endYAnimate.isRemovedOnCompletion = false
        endYAnimate.fillMode              = .forwards
        endYAnimate.delegate              = self

        self.imageView.layer.add(opacityAnimate, forKey: "opacity")
        self.imageView.layer.add(scaleAnimate, forKey: "scale")
        self.imageView.layer.add(startYAnimate, forKey: "translation.y.start")
        self.imageView.layer.add(endYAnimate, forKey: "translation.y.end")
    }
}
モンスターボール

モンスターボールのアニメーションはCAKeyframeAnimationにて行っています。
キーフレームのアニメーションで繰り返し実行を行っています。
このタイミングではこの状態でいてほしい、っていうのを細かく設定できたり、同じ動きを繰り返しさせたりするのが得意なやつですね。

valuesとkeyTimesを指定することで、どのタイミングでどの状態に、というのを設定します。
repeatDurationを.infinityにすることで、無限繰り返しになります。

また、こちらも標準で回転する際、要素の中心点を軸に回転しようとします。
が、モンスターボールが揺れるのを表現するためには、中央最下部を軸に回転してほしいのでアンカーポイントを

self.monsterBallImageView.layer.anchorPoint = .init(x: 0.5, y: 1.0)

としています。

viewDidLoadでデータを取得しにいくのですが、その間にモンスターボールを揺らすために、viewDidAppearでモンスターボールを揺らします。(viewDidAppearでprepareLoadingを叩く)

PokemonDetailImageView.swift
final class PokemonDetailImageView: XibLoadableView {

    @IBOutlet private weak var monsterBallImageView: UIImageView!

    func prepareLoading() {
        self.showMonsterBall()
    }

    private func animate() {
        self.hideMonsterBall()
    }
}

// MARK: - MonsterBall
extension PokemonDetailImageView {

    private func showMonsterBall() {
        let keyframeRotate            = CAKeyframeAnimation(keyPath: "transform.rotation.z")
        keyframeRotate.values         = [0, 20 * CGFloat.pi / 180, 0, -20 * CGFloat.pi / 180, 0]
        keyframeRotate.keyTimes       = [0, 0.25, 0.5, 0.75, 1]
        keyframeRotate.duration       = 1.2
        keyframeRotate.repeatDuration = .infinity

        let position = self.monsterBallImageView.layer.position
        self.monsterBallImageView.layer.anchorPoint = .init(x: 0.5, y: 1.0)
        self.monsterBallImageView.layer.position = .init(x: position.x, y: position.y + self.monsterBallImageView.bounds.height / 2)
        self.monsterBallImageView.layer.add(keyframeRotate, forKey: "transform.rotation.z")

        UIView.animate(withDuration: 0.1, delay: 0, options: .curveEaseOut, animations: { [weak self] in
            guard let self = self else { return }
            self.monsterBallImageView.alpha = 1.0
        }, completion: nil)
    }

    private func hideMonsterBall() {
        UIView.animate(withDuration: 0.2, delay: 0.2, options: .curveEaseOut, animations: { [weak self] in
            guard let self = self else { return }
            self.monsterBallImageView.layer.sublayers?.forEach { $0.removeFromSuperlayer() }
            self.monsterBallImageView.alpha = 0.0
        }, completion: nil)
    }
}
ポケモンが出る際の白いもやもや

白いもやもやのアニメーションはSpriteKitにて行っています。

(ここで、particle作成の説明)

で、その作ったファイルを動かすために、SKViewを準備し、SKSceneをpresentに指定します。
SKViewという要素で指定されたSKSceneという場所で、先ほどのパーティクルが動くイメージです。

準備が整ったら、先ほど作成したパーティクルファイルをもとにSKEmitterNodeを作成します。
あとは、それをsceneに載せれば、アニメーション開始です!

これら三つのアニメーションを組み合わせることにより、
ポケモン出現アニメーションを実現することができました。

PokemonDetailImageView.swift
final class PokemonDetailImageView: XibLoadableView {

    private var skView: SKView?

    private func animate() {
        self.createEmitter()

        Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false, block: { [weak self] _ in
            guard let self = self else { return }
            self.hideEmitter()
        })
    }
}

// MARK: - Emitter
extension PokemonDetailImageView {

    private func createEmitter() {
        let size = self.bounds
        let doubleSize = CGSize(width: size.width * 2, height: size.height * 2)

        // particleが切れないようにview自体は倍サイズで準備しておく
        self.skView = SKView(frame: .init(origin: .init(x: -self.bounds.width / 2, y: -self.bounds.height / 2), size: doubleSize))
        self.skView?.backgroundColor = .clear
        self.addSubview(self.skView!)

        let scene = SKScene(size: self.skView?.bounds.size ?? .zero)
        scene.backgroundColor = .clear
        self.skView?.presentScene(scene)

        if let node = SKEmitterNode(fileNamed: "appearPokemon") {
            node.position = CGPoint(x: scene.frame.width / 2, y: scene.frame.height / 2 - self.bounds.height / 2 + 60)
            scene.addChild(node)
        }
    }

    private func hideEmitter() {
        UIView.animate(withDuration: 0.1, delay: 0.2, options: .curveEaseOut, animations: { [weak self] in
            guard let self = self else { return }
            self.skView?.subviews.forEach { $0.removeFromSuperview() }
            self.skView?.removeFromSuperview()
            self.skView = nil
        }, completion: nil)
    }
}

最後に全体コードを。

PokemonDetailImageView.swift
import UIKit
import SpriteKit

protocol PokemonDetailImageViewDelegate: AnyObject {
    func finishedPokemonImageViewShowAnimation()
}

final class PokemonDetailImageView: XibLoadableView {

    @IBOutlet private weak var imageView: UIImageView! {
        willSet {
            newValue.layer.anchorPoint = .init(x: 0.5, y: 1.0)
            newValue.alpha = 0.0
        }
    }
    @IBOutlet private weak var monsterBallImageView: UIImageView!

    private var skView: SKView?
    private var isLoading: Bool = false
    weak var delegate: PokemonDetailImageViewDelegate?

    func prepareLoading() {
        self.showMonsterBall()
    }

    func setImage(_ imageUrl: URL?) {
        self.isLoading = false

        Timer.scheduledTimer(withTimeInterval: 0.8, repeats: false, block: { [weak self] _ in
            guard let self = self else { return }
            self.animate()
        })

        self.imageView.loadImage(with: imageUrl, placeholder: nil, completion: { [weak self] _ in
            guard let self = self else { return }
            self.animate()
        })
    }

    private func animate() {
        // モンスターボールのアニメーション用の遅延と画像のロード、遅い方をトリガーにポケモン登場アニメーションを開始
        if !self.isLoading {
            self.isLoading = true
            return
        }

        self.hideMonsterBall()
        self.createEmitter()

        Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false, block: { [weak self] _ in
            guard let self = self else { return }
            self.hideEmitter()
            self.appearImage()
        })
    }

    private func appearImage() {
        let opacityAnimate                   = CABasicAnimation(keyPath: "opacity")
        opacityAnimate.fromValue             = 0.0
        opacityAnimate.toValue               = 1.0
        opacityAnimate.duration              = 0.2
        opacityAnimate.timingFunction        = Easing.EaseOut.quart.function
        opacityAnimate.isRemovedOnCompletion = false
        opacityAnimate.fillMode              = .forwards

        let scaleAnimate                   = CABasicAnimation(keyPath: "transform.scale")
        scaleAnimate.fromValue             = 0.2
        scaleAnimate.toValue               = 1.0
        scaleAnimate.duration              = 0.2
        scaleAnimate.timingFunction        = Easing.EaseOut.quart.function
        scaleAnimate.isRemovedOnCompletion = false
        scaleAnimate.fillMode              = .forwards

        let startYAnimate                   = CABasicAnimation(keyPath: "transform.translation.y")
        startYAnimate.fromValue             = 0.0
        startYAnimate.toValue               = -20.0
        startYAnimate.duration              = 0.15
        startYAnimate.timingFunction        = Easing.EaseInOut.circ.function
        startYAnimate.isRemovedOnCompletion = false
        startYAnimate.fillMode              = .forwards

        let endYAnimate                   = CABasicAnimation(keyPath: "transform.translation.y")
        endYAnimate.fromValue             = -20.0
        endYAnimate.toValue               = 0.0
        endYAnimate.duration              = 0.2
        endYAnimate.beginTime             = CACurrentMediaTime() + 0.15
        endYAnimate.timingFunction        = Easing.EaseInOut.circ.function
        endYAnimate.isRemovedOnCompletion = false
        endYAnimate.fillMode              = .forwards
        endYAnimate.delegate              = self

        self.imageView.layer.add(opacityAnimate, forKey: "opacity")
        self.imageView.layer.add(scaleAnimate, forKey: "scale")
        self.imageView.layer.add(startYAnimate, forKey: "translation.y.start")
        self.imageView.layer.add(endYAnimate, forKey: "translation.y.end")
    }
}

// MARK: - MonsterBall
extension PokemonDetailImageView {

    private func showMonsterBall() {
        let keyframeRotate            = CAKeyframeAnimation(keyPath: "transform.rotation.z")
        keyframeRotate.values         = [0, 20 * CGFloat.pi / 180, 0, -20 * CGFloat.pi / 180, 0]
        keyframeRotate.keyTimes       = [0, 0.25, 0.5, 0.75, 1]
        keyframeRotate.duration       = 1.2
        keyframeRotate.repeatDuration = .infinity

        let position = self.monsterBallImageView.layer.position
        self.monsterBallImageView.layer.anchorPoint = .init(x: 0.5, y: 1.0)
        self.monsterBallImageView.layer.position = .init(x: position.x, y: position.y + self.monsterBallImageView.bounds.height / 2)
        self.monsterBallImageView.layer.add(keyframeRotate, forKey: "transform.rotation.z")

        UIView.animate(withDuration: 0.1, delay: 0, options: .curveEaseOut, animations: { [weak self] in
            guard let self = self else { return }
            self.monsterBallImageView.alpha = 1.0
        }, completion: nil)
    }

    private func hideMonsterBall() {
        UIView.animate(withDuration: 0.2, delay: 0.2, options: .curveEaseOut, animations: { [weak self] in
            guard let self = self else { return }
            self.monsterBallImageView.layer.sublayers?.forEach { $0.removeFromSuperlayer() }
            self.monsterBallImageView.alpha = 0.0
        }, completion: nil)
    }
}

// MARK: - Emitter
extension PokemonDetailImageView {

    private func createEmitter() {
        let size = self.bounds
        let doubleSize = CGSize(width: size.width * 2, height: size.height * 2)

        // particleが切れないようにview自体は倍サイズで準備しておく
        self.skView = SKView(frame: .init(origin: .init(x: -self.bounds.width / 2, y: -self.bounds.height / 2), size: doubleSize))
        self.skView?.backgroundColor = .clear
        self.addSubview(self.skView!)

        let scene = SKScene(size: self.skView?.bounds.size ?? .zero)
        scene.backgroundColor = .clear
        self.skView?.presentScene(scene)

        if let node = SKEmitterNode(fileNamed: "appearPokemon") {
            node.position = CGPoint(x: scene.frame.width / 2, y: scene.frame.height / 2 - self.bounds.height / 2 + 60)
            scene.addChild(node)
        }
    }

    private func hideEmitter() {
        UIView.animate(withDuration: 0.1, delay: 0.2, options: .curveEaseOut, animations: { [weak self] in
            guard let self = self else { return }
            self.skView?.subviews.forEach { $0.removeFromSuperview() }
            self.skView?.removeFromSuperview()
            self.skView = nil
        }, completion: nil)
    }
}

extension PokemonDetailImageView: CAAnimationDelegate {

    func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
        self.delegate?.finishedPokemonImageViewShowAnimation()
    }
}

最後に

人には得意不得意があり、設計が得意な人もいればアニメーションが得意な人もいます。
どっちが正解とかはなくて、どれかに圧倒的に強い人も、全部満遍なくできる人も、価値があります。
だからこそ、初学者のためとなるこのPokedexが、様々なニーズに答えられるプロジェクトになったら良いなと思い、今回アニメーションを実装しました。
こういうアニメーションをするには、こういう技術が必要なんだー
とか
一つのアニメーションにも、様々な実装方法があるんだー
とかの参考になったら嬉しいですし、
なんなら、
ポケモンが出現するの楽しい!
って、ここからアニメーションに興味を持ってくれても嬉しいです。
世の中のプロダクトの価値が向上し、ユーザの生活の質が上がることに繋がっていけば良いなと思う心で締めさせていただきます。

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
38