iOSでハートのボタンアニメーションをつくる

先日「いいね！」的なハートのボタンアニメーションを作る機会がありまして。

先輩がライブラリ公開しているものを参考にしつつ、
https://qiita.com/darquro/items/c5cdf7dbcad1d5bb0188

自分はライブラリではなく、自作で、
@IBDesignableと@IBInspectableを使って
ハートボタンとそのアニメーションを作ってみましたので、その備忘録。

![](https://i.imgur.com/dQ8W0by.gif)

## 開発環境

Mac OS Mojave 10.14.6
Xcode 11.0
Swift 5

## つくりたいもの

- ハートの画像2枚をボタン押下で切り替える
- ボタン押下時（Highlight時）にハートをちょっと小さくする
- ボタン押下後（Touch up inside）にハートをちょっと大きくする

## ハートの画像2枚をボタン押下で切り替える

アニメーションとは付加機能。
まずアニメーションなしで、つくりたいものを実現します。

他にも方法はあると思いますが、今回はハートの画像2枚をボタン押下で切り替えます。

### @IBDesignableと@IBInspectableを使った汎用的なボタン

@IBDesignableと@IBInspectableは非常に便利です。
（SwiftUIの世の波とは逆行してる感！）

コードである程度の設定を書いておくと、
Storyboardでカスタマイズが容易になります。

まずImageButtonという汎用的なボタンを設定します。

```swift
import UIKit

@IBDesignable
final class ImageButton: UIButton {

    @IBInspectable var unselectedImage: UIImage = UIImage()
    @IBInspectable var selectedImage: UIImage = UIImage()

    public var selectedStatus: Bool = false {
        didSet {
            setupImageView()
        }
    }

    override func awakeFromNib() {
        super.awakeFromNib()
        setupImageView()
    }

    override func prepareForInterfaceBuilder() {
        super.prepareForInterfaceBuilder()
        setupImageView()
        setNeedsDisplay()
    }

    private func setupImageView() {
        self.setImage(self.selectedStatus ? self.selectedImage : self.unselectedImage, for: .normal)
        self.setImage(self.selectedStatus ? self.selectedImage : self.unselectedImage, for: .highlighted)
    }
}
```

`@IBInspectable`で指定した、`unselectedImage` と `selectedImage` は、
Storyboard上でカスタマイズできるようになります。

### Storyboardで設定

Storyboardでボタンを配置して、

<img width=240px src="https://i.imgur.com/o44NEi7.png">

上記のように、Custom Classに作成したImageButtonを設定すると、

<img width=240px src="https://i.imgur.com/l3aXj23.png">

このように、`unselectedImage`と`selectedImage`に
好きな画像を設定できるようになります。

つまりImageButtonはハートボタン以外にも利用できるので、便利感ありますね。
以下の画像を設定します。

- unselectedImage
- selectedImage
- DefaultのImage <- 忘れないように

<img width=240px src="https://i.imgur.com/glz5fmt.png">

### アクションを設定

あとはボタンのクリック動作をハンドリングして、処理を書きます。
※Storyboardとコードを結びつけるとこは省略。

```swift
@IBOutlet private weak var likeButton: ImageButton!
@IBAction private func clickLikeButton(_ sender: Any) {
    likeButton.selectedStatus = !likeButton.selectedStatus
}
```

### 画像切り替え 完成

これでアニメーションのない、ハートボタンが完成です。

![](https://i.imgur.com/3AEXAPx.gif)

## ハート押下時にアニメーションを

今回は `CASpringAnimation` を利用します。
ばねっぽいAnimationを表現しやすいと聞いたので。

今回アニメーションを付けるのは2箇所あります。

- ボタン押下時（Highlight時）にハートをちょっと小さくする
- ボタン押下後（Touch up inside）にハートをちょっと大きくする

### ボタン押下時（Highlight時）にハートをちょっと小さくする

方法は色々あると思いますが、自分は以下のコードで実現しました。
`isHighlighted` でボタンが押されている状態かを識別します。

```swift
override var isHighlighted: Bool {
    didSet {
        if isHighlighted {
            if imageView?.layer.animation(forKey: "reduced-size") == nil {
                let animation = CASpringAnimation(keyPath: "transform.scale")
                animation.duration = 0.05 // animation時間
                animation.fromValue = 1.0 // animation前サイズ
                animation.toValue = 0.95 // animation後サイズ
                animation.mass = 0.1 // 質量
                animation.autoreverses = false // 自動でfromの値に戻らない
                animation.initialVelocity = 40.0 // 初速度
                animation.damping = 1.0 // 硬さ
                animation.stiffness = 40.0 // バネの弾性力
                animation.isRemovedOnCompletion = false // animation動作後に完了状態としない
                animation.fillMode = .forwards // 一方向モード。fromの形状に戻らない
                imageView?.layer.add(animation, forKey: "reduced-size")
            }
        }
    }
}
```

ポイントは以下。

- `mass` `initialVelocity` `damping` `stiffness` は動作を見てなんとなく値決め
- 押下中にサイズが戻ってしまわないように、`autoreverses` `isRemovedOnCompletion` はfalse、`fillMode`は`.forward`
- animationは1回だけ実行にしたいので、animationが既に実行されているかどうかの判別式を記載 `if imageView?.layer.animation(forKey: "reduced-size") == nil`
- animation実行判別のために、任意のkey名`reduced-size`を設定

これでボタン押下時のAnimationは実現できました。
しかし、これでは `imageView?.layer.add(animation, …)`で、追加された
animationがremoveされていません。それについては後述。

### ボタン押下後（Touch up inside）にハートをちょっと大きくする

こちらも方法は色々あるかと思いますが、今回は上記で設定した`setupImageView()`
funcにanimationを記載することで実現しました。

```swift
private func setupImageView() {
    self.setImage(self.selectedStatus ? self.selectedImage : self.unselectedImage, for: .normal)
    self.setImage(self.selectedStatus ? self.selectedImage : self.unselectedImage, for: .highlighted)
    let animation = CASpringAnimation(keyPath: "transform.scale")
    animation.duration = 0.3 // animation時間
    animation.fromValue = 0.95 // animation前サイズ
    animation.toValue = 1.0 // animation前サイズ
    animation.mass = 0.6 // 質量
    animation.initialVelocity = 40.0 // 初速度
    animation.damping = 3.0 // 硬さ
    animation.stiffness = 40.0 // バネの弾性力
    imageView?.layer.add(animation, forKey: nil)
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
        self.imageView?.layer.removeAllAnimations() // highlightのAnimationも含め、removeする
    }
}
```

既述したもの以外のポイントは以下。

- animationが全て完了したらremoveする必要があるので、animation時間が経過したらremoveを実施 `self.imageView?.layer.removeAllAnimations()`

animationのremoveに関してはもっと良い方法があるかも...
指摘などあればぜひコメントお願いしますm(_ _)m

### 完成

これでアニメーション付きのハートが完成しました。

![](https://i.imgur.com/rCaUiJR.gif)

## まとめ

CASpringAnimationについて、
@IBDesignableと@IBInspectableについて、
詳細の説明は省略しました。参考資料をご確認頂ければと。

## 参考

https://qiita.com/kaway/items/b9e85403a4d78c11f8df
http://www.cl9.info/entry/2018/05/26/175246
https://qiita.com/h-nag/items/7af47ea665332c3ac1bf
https://qiita.com/tasaiii725/items/c70bf648242b061e0734
https://qiita.com/son_s/items/7ca2acf690d10f9fd1b7