コーチマーク+アニメーションでスワイプで削除する操作を表現する

## はじめに
コーチマークとUIViewのアニメーションを組み合わせて、スワイプで削除する操作を表現してみました。

## コーチマークについて
コーチマークとは・・・
　実際の画面の上にオーバーレイや吹き出しを出して
　画面上のボタンなどの使い方を案内してくれるやつです。

　アプリで初めて開く画面とかでときどき見かけますね（'・ω・`）

## スワイプ削除
iOSのTableViewでは、セルをスワイプで削除する機能が簡単に実装できますし、
アプリユーザにとっても慣れている操作の一つではないかと思います(^ω^)

ただ、削除ボタンみたいに目に見えてわかる機能ではないので、
ユーザ全員が当たり前のようにスワイプで削除できると認識してくれるとは限りません。。。

そこで、初めて画面を開いた時にコーチマークを利用して、
スワイプで削除できるよっていうことを案内してみたいと思います！

## 実装
今回は、[Instructions](https://github.com/ephread/Instructions)を使用してコーチマークを実装しました！

Instructionsの実装方法は、こちらの記事を参考にさせていただきましたm(_ _)m
　→ [swift 簡単でおしゃれなチュートリアルライブラリ,Instructions](https://qiita.com/niwango/items/05f92fd6905c9961f7c0)

今回は、Carthageでライブラリを導入しました。

```
github "ephread/Instructions" ~> 2.0
```

### まずは、コーチマークを表示するところ
実装は、Instructionsのリポジトリにもサンプルがあったので、そちらを参考にしながら進めました。

CoachMarksControllerのdataSourceを実装して、
`coachMarksController.start(in: )`を呼んであげるだけでコーチマークを表示できました(⌒ω⌒)

今回は、コーチマークのターゲットがTableViewCellなので、事前にIndexPathを指定して、
spotlightTargetViewにcellを保持するように実装してみました。

```swift:ViewController.swift(初期設定)
    // コーチマークの初期設定
    private func setupCoachMarksController() {
        self.coachMarksController.dataSource = self

        // オーバーレイをタップでもコーチマークを閉じれるようにする
        self.coachMarksController.overlay.isUserInteractionEnabled = true
        // コーチマークの背景色を設定
        self.coachMarksController.overlay.backgroundColor = UIColor.black.withAlphaComponent(0.5)
    }

    /// コーチマークを表示する
    private func startCoachMarks() {
        let indexPath = IndexPath(row: 0, section: 0)

        // [section, row] = [0, 0]のCellがあれば、コーチマークを表示する
        if tableView.numberOfRows(inSection: indexPath.section) > indexPath.row, let cell = tableView.cellForRow(at: indexPath) {
            self.spotlightTargetView = cell
            self.coachMarksController.start(in: .currentWindow(of: self))
        }
    }
```

CoachMarksControllerのdataSourceの実装はこんな感じでシンプル

```swift:ViewController.swift(CoachMarksControllerDataSource実装部)
extension ViewController: CoachMarksControllerDataSource {
    func numberOfCoachMarks(for coachMarksController: CoachMarksController) -> Int {
        return 1
    }

    func coachMarksController(_ coachMarksController: CoachMarksController, coachMarkAt index: Int) -> CoachMark {
        // 吹き出しを表示する対象のビューを指定する
        return coachMarksController.helper.makeCoachMark(for: spotlightTargetView)
    }

    func coachMarksController(_ coachMarksController: CoachMarksController,
                              coachMarkViewsAt index: Int,
                              madeFrom coachMark: CoachMark) -> (bodyView: (UIView & CoachMarkBodyView), arrowView: (UIView & CoachMarkArrowView)?) {

        let coachViews = coachMarksController.helper.makeDefaultCoachViews(withArrow: true,
                                                                           withNextText: false,
                                                                           arrowOrientation: coachMark.arrowOrientation)
        coachViews.bodyView.hintLabel.text = "スワイプでお気に入りから削除できます"
        return (bodyView: coachViews.bodyView, arrowView: coachViews.arrowView)
    }
}
```

ここまでで、このような感じなりました！
<img width="331" alt="sample1.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103695/6031f10d-f457-7780-97b8-adfc2ba328c7.png">

この状態だと、まだ分かりづらいかなーと思うので、アニメーションを加えていきます。

### スワイプで削除風のアニメーションを実装
スワイプで削除するようなアニメーションを実装します。

アニメーションは、Stack Overflowの投稿を~~パクらせて~~参考にさせていただきましたm(_ _)m
[UITableView invoke swipe actions programmatically](https://stackoverflow.com/questions/26766243/uitableview-invoke-swipe-actions-programmatically)

参考情報そのままだと少し使いづらいところがあったので、
アニメーション完了後の処理をクロージャで実装できるようにしています。

## 動作
アニメーションを加えたら、いい感じになりました(^-^)v
（Gifの問題で、「削除」ラベルの色が実際と少し違っています。）

![sample2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103695/18eb5921-33f2-baf1-de73-8dd2ef03217f.gif)


## 完成したコード

以下、アニメーション完了後からオーバーレイのタップを許可するなどの微調整をした最終的なソースコードです。
（コーチマーク表示済みかどうかの判定は、UserDefaultsなどで実現できると思うので、省いています。）

```swift:ViewController.swift
import UIKit
import Instructions

final class ViewController: UIViewController {

    private let coachMarksController = CoachMarksController()

    // コーチマークでスポットライトが当たるView
    private var spotlightTargetView: UIView!
    private var spotlightTargetIndexPath: IndexPath?

    private var items = ["apple", "banana", "cherry"]

    @IBOutlet private weak var tableView: UITableView! {
        didSet {
            tableView.dataSource = self
        }
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        self.setupCoachMarksController()
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        self.navigationItem.title = "お気に入り一覧"
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        self.startCoachMarks()
    }

    private func setupCoachMarksController() {
        self.coachMarksController.dataSource = self
        self.coachMarksController.delegate = self

        // コーチマークの背景色を設定
        self.coachMarksController.overlay.backgroundColor = UIColor.black.withAlphaComponent(0.5)
    }

    /// コーチマークを表示する
    private func startCoachMarks() {
        let indexPath = IndexPath(row: 0, section: 0)

        // [section, row] = [0, 0]のCellがあれば、コーチマークを表示する
        if tableView.numberOfRows(inSection: indexPath.section) > indexPath.row, let cell = tableView.cellForRow(at: indexPath) {
            self.spotlightTargetView = cell
            self.spotlightTargetIndexPath = indexPath
            self.coachMarksController.start(in: .currentWindow(of: self))
        }
    }
}

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

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

    func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
        return true
    }

    func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
        if editingStyle == .delete {
            self.items.remove(at: indexPath.row)
            tableView.deleteRows(at: [indexPath], with: .automatic)
        }
    }
}

extension ViewController: CoachMarksControllerDataSource {
    func numberOfCoachMarks(for coachMarksController: CoachMarksController) -> Int {
        return 1
    }

    func coachMarksController(_ coachMarksController: CoachMarksController, coachMarkAt index: Int) -> CoachMark {
        // 吹き出しを表示する対象のビューを指定する
        return coachMarksController.helper.makeCoachMark(for: spotlightTargetView)
    }

    func coachMarksController(_ coachMarksController: CoachMarksController,
                              coachMarkViewsAt index: Int,
                              madeFrom coachMark: CoachMark) -> (bodyView: (UIView & CoachMarkBodyView), arrowView: (UIView & CoachMarkArrowView)?) {

        let coachViews = coachMarksController.helper.makeDefaultCoachViews(withArrow: true,
                                                                           withNextText: false,
                                                                           arrowOrientation: coachMark.arrowOrientation)
        coachViews.bodyView.hintLabel.text = "スワイプでお気に入りから削除できます"
        return (bodyView: coachViews.bodyView, arrowView: coachViews.arrowView)
    }
}

extension ViewController: CoachMarksControllerDelegate {

    func coachMarksController(_ coachMarksController: CoachMarksController,
                              didShow coachMark: CoachMark,
                              afterChanging change: ConfigurationChange,
                              at index: Int) {

        guard let targetIndexPath = self.spotlightTargetIndexPath else { return }
        UIView.animateRevealHideActionForRow(tableView: self.tableView, indexPath: targetIndexPath) { [weak self] in
            // アニメーション完了後、オーバーレイをタップでもコーチマークを閉じれるようにする
            self?.coachMarksController.overlay.isUserInteractionEnabled = true
        }
    }
}
```

```swift:UIView+.swift
import UIKit

extension UIView {

    /// TableViewのスワイプアクションの擬似的なアニメーション
    class func animateRevealHideActionForRow(tableView: UITableView, indexPath: IndexPath, completion: (() -> Void)? = nil) {
        guard let cell = tableView.cellForRow(at: indexPath) else { return }
        
        let swipeLabelWidth = UIScreen.main.bounds.width / 2
        let swipeLabelFrame = CGRect(x: cell.bounds.size.width, y: 0, width: swipeLabelWidth, height: cell.bounds.size.height)
        var swipeLabel: UILabel? = .init(frame: swipeLabelFrame)
        swipeLabel?.text = "  削除"
        swipeLabel?.backgroundColor = .red
        swipeLabel?.textColor = .white
        cell.addSubview(swipeLabel!)

        UIView.animate(withDuration: 0.5, animations: {
            cell.frame = .init(x: cell.frame.origin.x - swipeLabelWidth / 2,
                               y: cell.frame.origin.y,
                               width: cell.bounds.size.width + swipeLabelWidth / 2,
                               height: cell.bounds.size.height)
        }) { finished in
            UIView.animate(withDuration: 0.5, animations: {
                cell.frame = .init(x: cell.frame.origin.x + swipeLabelWidth / 2,
                                   y: cell.frame.origin.y,
                                   width: cell.bounds.size.width - swipeLabelWidth / 2,
                                   height: cell.bounds.size.height)
            }, completion: { finished in
                swipeLabel?.removeFromSuperview()
                swipeLabel = nil

                completion?()
            })
        }
    }
}
```

## さいごに
コーチマークでテキストを添えるだけでも親切だけど、動きをつけるとより親切かなと思いました！
（やり過ぎると、しつこいですけどね・・・）