5
3

More than 3 years have passed since last update.

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

Posted at

こんなやつ

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

ステップ

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

ソースだけ見たい人はこちら

1. バナーを表示する

下準備

今回の主役InAppNotificationViewを用意する

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を操作してバナーの出し入れを表現することに注意。

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)で行った変形(コンテナの移動)を元に戻す処理をアニメーションさせながら実行する

ここまでを確認する場合

override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
        // view controllerが表示されたら処理スタート!
        InAppNotificationView().showBanner()
    }
}

バナーが表示されればOK!!

2. バナーを隠す

(1)とは逆の処理

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. ジェスチャーを追加する

バナーをタップした時

lazy var tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTapped))

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

バナーをパンした時

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ジェスチャーが終わった、又はキャンセルした時

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

4. タイマーをセットする

// 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. タイマーをスタートさせるメソッド。今回は5秒経過するとバナーを閉じる

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

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

5. 使いやすいように修正

処理やデータをまとめる

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にまとめる

protocol InAppNotificationShowable {
    func showInAppNotification(_ notification: InAppNotification)
}

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

使い方

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

完成!

最後に

コード全てを確認したい方はこちら!!

5
3
0

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
5
3