[iOS/Swift] カスタムデザインのローカル通知の実装方法

# こんなやつ
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106804/f0a8f22c-2d41-547e-2229-390a72f3af57.png" width="300">

**通常のローカル通知ではなく、オリジナルなデザインのローカル通知を出したい！！**

## ステップ
1. バナーを表示する
2. バナーを隠す
3. ジェスチャーを追加する
4. タイマーをセットする
5. 使いやすいように修正

ソースだけ見たい人は[こちら](https://github.com/HiromiSakurai/InAppNotification)

## 1. バナーを表示する

### **下準備**

今回の主役`InAppNotificationView`を用意する

```swift
class InAppNotificationView: UIView {
    // 1
    lazy var containerView: UIView = {
        let view = UIView()
        view.alpha = 0
        return view
    }()
    // 2
    lazy var messageLabel: UILabel = {
        let label = UILabel()
        label.text = "This is In App Notification!!"
        label.textAlignment = .center
        label.textColor = .white
        label.font = .boldSystemFont(ofSize: 20)
        label.backgroundColor = .systemBlue
        label.layer.cornerRadius = 10
        label.clipsToBounds = true
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()
    // 3
    override init(frame: CGRect) {
        super.init(frame: frame)

        addSubview(containerView)

        containerView.addSubview(messageLabel)
        let constraints = [
            messageLabel.topAnchor.constraint(equalTo: containerView.topAnchor),
            messageLabel.leftAnchor.constraint(equalTo: containerView.leftAnchor),
            messageLabel.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
            messageLabel.rightAnchor.constraint(equalTo: containerView.rightAnchor)
        ]
        NSLayoutConstraint.activate(constraints)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}
```
1. 表示したいviewを入れるコンテナviewを用意する。`alpha=0`で透明にしておく
2. 今回は簡単なメッセージを表示するだけなので、コンテナの中身は単純な`UILabel`
3. 表示したいviewをコンテナに入れ、コンテナを自身に`addSubview()`で追加する。この時、中身のレイアウトは設定するが、コンテナのレイアウト(frame)は決めない

### **バナー表示**
バナーを表示するメソッドを用意する。基本的に自身のframeは一度決めたら変更はせず、その上にのっけたコンテナviewのframeを操作してバナーの出し入れを表現することに注意。

```swift
var targetWindow: UIWindow? {
    UIApplication.shared.connectedScenes
        .first { $0.activationState == .foregroundActive }
        .map { $0 as? UIWindowScene }
        .flatMap { $0 }?
        .windows.first
}

func showBanner() {
    // 1
    guard let window = targetWindow else { return }
    // 2
    let width = window.frame.width
    let height = bannerHeight + window.safeAreaInsets.top
    frame = CGRect(x: 0, y: 0, width: width, height: height)
    // 3
    containerView.frame = CGRect(
        x: bannerMargin + window.safeAreaInsets.left,
        y: height - bannerHeight,
        width: width - ((bannerMargin * 2) + window.safeAreaInsets.left + window.safeAreaInsets.right),
        height: bannerHeight
    )
    containerView.setNeedsLayout()
    containerView.layoutIfNeeded()
    // 4
    targetWindow?.addSubview(self)
    containerView.alpha = 0
    containerView.transform = CGAffineTransform(translationX: 0, y: -frame.height)
    // 5
    UIView.animate(withDuration: 0.5, animations: {
        self.containerView.alpha = 1
        self.containerView.transform = .identity
    }, completion: nil)
}
```
1. バナーを乗っけるviewを取得する。`UIWindow`を取得するのは、バナーを画面の一番上から表示させるため。言い換えると、statusBarの上から重なる様に表示させるため
2. 自身(InAppNotificationView)のframeを設定する
3. コンテナのframeを設定する。frameの変更を即座に反映するために `setNeedsLayout()`と`layoutIfNeeded()`を実行する
4. windowに自身を`addSubview()`する。この時`alpha=0`で透明にすると共に、コンテナviewを画面の外に隠れる様にtransformしておく
5. `alpha=1`で透明にしていたコンテナを表示させつつ、`transform = .identity`で(4)で行った変形(コンテナの移動)を元に戻す処理をアニメーションさせながら実行する

### **ここまでを確認する場合**
```swift
override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
        // view controllerが表示されたら処理スタート！
        InAppNotificationView().showBanner()
    }
}
```
バナーが表示されればOK!!

## 2. バナーを隠す
(1)とは逆の処理

```swift
func closeBanner() {
    UIView.animate(withDuration: 0.5, animations: {
        self.containerView.alpha = 0
        self.containerView.transform = .init(translationX: 0, y: -self.frame.height)
    }, completion: { _ in
        print("on Closed!!")
        self.removeFromSuperview()
    })
}
```
バナーを表示させた時とは反対に、またコンテナを透明にしつつ、画面の上に隠す様にアニメーションさせる。完了したら、自身を親viewから削除する

基本的にInAppNotificationViewは画面上部に出しっぱなしで、その上にのせたコンテナviewを表示したり隠したりするイメージ。

## 3. ジェスチャーを追加する
### **バナーをタップした時**
```swift
lazy var tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTapped))

@objc func didTapped(_ sender: UITapGestureRecognizer) {
    closeBanner()
    print("on Tapped!!")
}
```

### **バナーをパンした時**
```swift
lazy var panGesture = UIPanGestureRecognizer(target: self, action: #selector(didPanned))

var currentPositionY: CGFloat = 0

@objc func didPanned(_ sender: UIPanGestureRecognizer) {
    switch sender.state {
    case .began:
        break
    case .changed:
        // 1
        let point = sender.translation(in: self)   
        // 2
        guard currentPositionY + point.y <= 0 else { return }
        // 3
        currentPositionY += point.y
        containerView.transform = .init(translationX: 0, y: currentPositionY)
        // 4
        sender.setTranslation(.zero, in: containerView)
    case .ended, .cancelled:
        // 5
        if (abs(currentPositionY) > bannerHeight / 2) {
            closeBanner()
        } else {
            // 6
            UIView.animate(withDuration: 0.2) {
                self.containerView.transform = .identity
                self.currentPositionY = 0
            }
        }
    default:
        break
    }
}
```
panジェスチャーを行っている途中

1. `sender.translation(in: self)`で、panされた移動量が取得できる
2. 下方向へのpanは無視する
3. panした移動量の分だけコンテナを変形(移動)させる
4. ここでリセットすることによって、(2)でまたpanした移動量を取得できる

panジェスチャーが終わった、又はキャンセルした時

5. panジェスチャーによって一定以上、バナーが画面上部に移動していた場合、そのままバナーを閉じる。(`abs()`は数値の絶対値を取得する。)
6. そうでない時は、バナーを元の位置に戻し、移動量も0にリセットする

## 4. タイマーをセットする
```swift
// 1
var bannerClosingTimer: Timer? {
    didSet {
        oldValue?.invalidate()
    }
}
// 2
func countDownToCloseBanner() {
    bannerClosingTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: false) { [weak self] _ in
        self?.closeBanner()
    }
}
// 3
func showBanner() {
    // ~ ~ ~ ~
    countDownToCloseBanner()
}
// 4
case .ended, .cancelled:
    if (abs(currentPositionY) > bannerHeight / 2) {
        closeBanner()
    } else {
        // ~ ~ ~ ~
        countDownToCloseBanner()
    }
// 5
case .began:
    bannerClosingTimer = nil

func closeBanner() {
    bannerClosingTimer = nil
    // ~ ~ ~ ~
}
```
1. Timerを保持するプロパティを宣言。新しいTimerがセットされた時、メモリリークを防ぐために古いTimerを`invalidate()`で無効にする
2. タイマーをスタートさせるメソッド。今回は５秒経過するとバナーを閉じる

**タイマーをスタートするのは**
3. バナーを表示させた時
4. 閉じかけたバナーが元の位置に戻った時

**タイマーをキャンセルするのは**
5. panジェスチャーが開始された時
6. バナーを閉じる時

## 5. 使いやすいように修正
### **処理やデータをまとめる**
```swift
struct InAppNotification {
    let message: String
    let onTap: (() -> Void)?
    let onClosed: (() -> Void)?
}

let notification: InAppNotification

init(notification: InAppNotification) {
    self.notification = notification
    // ~ ~ ~ ~
}
```
アプリ内通知に必要なデータ(or処理)をまとめたstructを用意し、InAppNotificationViewに渡す。
あとは適宜、必要なデータ(or処理)を必要な箇所で呼ぶ。
(具体的な箇所は最後に貼ったリンクを参照)

### **Protocolにまとめる**
```swift
protocol InAppNotificationShowable {
    func showInAppNotification(_ notification: InAppNotification)
}

extension InAppNotificationShowable where Self: UIViewController {
    func showInAppNotification(_ notification: InAppNotification) {
        InAppNotificationView(notification: notification).showBanner()
    }
}
```

### **使い方**
```swift
// InAppNotificationShowableに準拠したUIViewController内で
let notification = InAppNotification(
    message: "This is In App Notification!!",
    onTap: { print("on tapped!!") },
    onClosed: { print("on closed!!") }
)
self.showInAppNotification(notification)
```

## **完成！**
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106804/d79517a8-ff1b-4c8f-9b05-1d23b5300a31.gif" width="300">

## 最後に
コード全てを確認したい方は[こちら](https://github.com/HiromiSakurai/InAppNotification)！！