GmailアプリみたいなちょっとオシャレなEmptyStateを実装する

iOS版Gmailアプリでは、未読メールがなかったときに以下のようなアニメーション付きの[Empty State](http://emptystat.es/)が実装されています。
動きが一瞬なのでわかりづらいですが、ナビゲーションドロワーから「未読メール」を選択して表示している様子です。

![empty-state.gif](https://qiita-image-store.s3.amazonaws.com/0/81715/30d36be4-661a-24de-6ed0-85321e6b38d0.gif)

これと同じようなことがやりたくて実装方法を調べたのでご紹介したいと思います。

## 動作イメージ
こんな感じになりました。
Gmailアプリでは画像と文字が一緒にアニメーションされていますが、使用するライブラリの都合でこれができませんでした。
画像だけ下からフェードインするのがなんか変な感じだったので、画像が少しバウンドするようなアニメーションを入れてみました。

![empty-state-ios.gif](https://qiita-image-store.s3.amazonaws.com/0/81715/973f165e-c27b-5185-f594-bd70ad6d563a.gif)

## 使用するライブラリ
Empty Stateを簡単に実装できる[DZNEmptyDataSet](https://github.com/dzenbot/DZNEmptyDataSet)という有名なライブラリがあります。
基本的にはこのライブラリが提供する`DZNEmptyDataSetSource`と`DZNEmptyDataSetDelegate`を実装するだけです。

インジケータはGmailアプリと同じ[Material Designのもの](https://material.io/develop/ios/components/progress-indicators/activity-indicators/)を使用しています。

## 実装
実装はこんな感じです。

```swift
import UIKit
import RxSwift
import RxCocoa
import DZNEmptyDataSet
import MaterialComponents.MaterialActivityIndicator

class ViewController: UITableViewController {
    private var items = [String]() {
        didSet {
            tableView.reloadData()
        }
    }

    private let progressIndicator = MDCActivityIndicator()
    private let disposeBag = DisposeBag()
    private let emptyImage = UIImage(named: "email")!

    override func viewDidLoad() {
        super.viewDidLoad()
        setupTableView()
        bind()
        startIndicator()
    }

    private func setupTableView() {
        // [1]
        tableView.emptyDataSetSource = self
        tableView.emptyDataSetDelegate = self
        tableView.tableFooterView = UIView()
    }

    private func bind() {
        // [2]
        Observable.just([String]())
            .delay(1.5, scheduler: MainScheduler.instance)
            .subscribe(onNext: { [unowned self] in
                self.stopIndicator()
                self.items = $0
            })
            .disposed(by: disposeBag)
    }

    private func startIndicator() {
        let parentFrame = tableView.frame
        progressIndicator.frame = CGRect(
            x: parentFrame.origin.x, y: parentFrame.origin.y - 60,
            width: parentFrame.width, height: parentFrame.height)
        tableView.addSubview(progressIndicator)
        progressIndicator.startAnimating()
    }

    private func stopIndicator() {
        progressIndicator.stopAnimating()
        progressIndicator.removeFromSuperview()
    }
}

extension ViewController {
    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return items.count
    }

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "Cell")!
        let item = items[indexPath.row]
        cell.textLabel?.text = item
        return cell
    }
}

extension ViewController: DZNEmptyDataSetSource {
    // [3]
    func image(forEmptyDataSet scrollView: UIScrollView!) -> UIImage! {
        return emptyImage
    }

    // [4]
    func title(forEmptyDataSet scrollView: UIScrollView!) -> NSAttributedString! {
        return NSAttributedString(string: "データがありません", attributes: [
            .foregroundColor: UIColor.gray,
            .font: UIFont.boldSystemFont(ofSize: 20)
        ])
    }

    // [5]
    func verticalOffset(forEmptyDataSet scrollView: UIScrollView!) -> CGFloat {
        return -60
    }

    // [6]
    func imageAnimation(forEmptyDataSet scrollView: UIScrollView!) -> CAAnimation! {
        let animation = CABasicAnimation(keyPath: "position")
        animation.fromValue = NSValue(cgPoint: CGPoint(x: scrollView.center.x, y: 80))
        animation.duration = 0.5
        animation.timingFunction = CAMediaTimingFunction(controlPoints: 0.33 ,1.77 ,0.62 ,0.78)
        return animation
    }
}

extension ViewController: DZNEmptyDataSetDelegate {
    // [7]
    func emptyDataSetShouldDisplay(_ scrollView: UIScrollView!) -> Bool {
        return !progressIndicator.isAnimating
    }

    // [8]
    func emptyDataSetShouldAnimateImageView(_ scrollView: UIScrollView!) -> Bool {
        return true
    }
}
```

**[1]**のところでtableViewに`emptyDataSetSource`と`emptyDataSetSource`を設定しています。
今回はViewControllerにこれらのプロトコルを実装しています。

**[2]**ネットワーク越しにデータを取って来ている様子をエミュレートするためにこの実装を入れています。
画面が表示されてから1.5秒後にデータが取得されTableViewがリロードされます。
また、このタイミングでインジケータを非表示にします。

**[3]**データが空の場合に表示する画像を指定します。

**[4]**画像の下に表示されるテキストを指定します。

**[5]**デフォルトの画像表示位置からのオフセットです。
デフォルトではTableViewの中心に画像が表示されますが、ナビゲーションバーを考慮してTableViewの真ん中より少し上に画像が表示されるようにしています。

**[6]**画像を表示する際のアニメーションを指定します。
ここでは画像がバウンドするようなアニメーションを指定しています。

**[7]**データが空の場合に画像を表示するかどうかを制御します。
このメソッドはTableViewがロードされ、データが空だった場合に呼ばれます。
ここではインジケータが表示されているかどうかで画像を表示するかどうかを制御しています。

**[8]**画像を表示するときにアニメーションさせるかどうかを制御します。
これが`true`になっていないとアニメーションの設定をしていてもアニメーションされないので注意です。

## 画像しかアニメーションできない
本当はGmailアプリと同じように画像と文字を一緒にアニメーションさせたかったのですが、DZNEmptyDataSetが画像のアニメーションにしか対応していないため断念しました。
文字を含んだ画像を使えば同じようなことができるかと思います。