본문 바로가기

학습 노트/iOS (2021)

143 ~ 144. Notification Settings and Notification Management

Notification Settings

Local notification과 Push Notification을 처리하기 위해서는 사용자의 명시적인 허가가 필요하다.
따라서 Notification의 구현중 권한 요청에 관련된 코드를 가장 먼저 구현한다.
사용자가 앱을 설치하고 최초로 실행하면 Notification의 권한을 묻는 경고창이 표시된다.
사용자가 수락하면 iOS가 관련 설정을 저장하고, 설정 앱을 통해 관리 메뉴를 제공한다.

해당 메뉴를 통해 권한을 수락한 이후에도 언제든지 설정을 변경할 수 있다.
따라서 사용자가 최초에 권한을 허용했다고 해서 항상 Notification을 처리할 수 있다고 가정하면 안 된다.
항상 현재 설정을 확인하고 필요한 작업을 실행해야 한다.
사용자의 설정을 거스르고 앱에서 해당 설정을 변경하는 것은 불가능하다.
즉, 설정을 읽을 수는 있지만, 설정을 변경할 수는 없다.

사용할 Scene에는 Notification의 설정 항목들이 나열되어있다.

//
//  NotificationSettingsTableViewController.swift
//  Local Notification Practice
//
//  Created by Martin.Q on 2021/11/27.
//

import UIKit
import UserNotifications

class NotificationSettingsTableViewController: UITableViewController {
	
	@IBOutlet weak var authorizationStatusLabel: UILabel!
	@IBOutlet weak var alertStyleLabel: UILabel!
	@IBOutlet weak var showPreviewLabel: UILabel!
	@IBOutlet weak var alertLabel: UILabel!
	@IBOutlet weak var badgeLabel: UILabel!
	@IBOutlet weak var soundLabel: UILabel!
	@IBOutlet weak var notificationCenterLabel: UILabel!
	@IBOutlet weak var lockScreenLabel: UILabel!
	
	
	func update(from settings: UNNotificationSettings) {
		
	}
	
	@objc func refresh() {
		
	}

    override func viewDidLoad() {
        super.viewDidLoad()
		
		refresh()
		
		NotificationCenter.default.addObserver(self, selector: #selector(refresh), name: NSNotification.Name.NSExtensionHostWillEnterForeground, object: nil)
    }
}

extension UNNotificationSettings {
	var stringValue: String {
		switch self {
		case .notSupported:
			return "Not Supported"
		case .enabled:
			return "Enabled"
		case .disabled:
			return "Disabled"
		}
	}
}

연결된 코드에는 Scene에 표시할 수 있도록 Label과 outlet으로 연결되어있고,
진입하거나 앱이 Foreground로 전환될 때 refresh 메서드를 호출하도록 구현되어있다.
userNotification Framework에서 제공하는 API를 통해 설정을 불러온 다음, Table에 표시하도록 구현한다.

@objc func refresh() {
	UNUserNotificationCenter.current().getNotificationSettings { (settings) in
	
	}
}

refresh 메서드에서 UNUserNotificationCenter의 getNotificationSettings 메서드를 호출한다.
해당 메서드는 Notification의 설정을 읽어올 때 사용한다.
현재 설정은 Closure를 통해 비동기 방식으로 전달된다.
Closure의 파라미터 형식은 UNNotificationSettings 클래스이고, 다양한 속성이 선언되어있다.

@objc func refresh() {
	UNUserNotificationCenter.current().getNotificationSettings { (settings) in
		DispatchQueue.main.async {
			self.update(from: settings)
		}
	}
}

main queue에서 update(from:) 메서드를 호출해 해당 파라미터를 전달한다.

update(from:) 메서드에서 속성들을 확인하고 Label을 업데이트하도록 구현한다.

func update(from settings: UNNotificationSettings) {
	switch settings.authorizationStatus {
	case .notDetermined:
		authorizationStatusLabel.text = "Not Determined"
	case .authorized:
		authorizationStatusLabel.text = "Authorized"
	case .denied:
		authorizationStatusLabel.text = "Denied"
	case .ephemeral:
		authorizationStatusLabel.text = "Emphemeral"
	case .provisional:
		authorizationStatusLabel.text = "Provisional"
	}
}

authorizationStatus는 Notification의 현재 권한 상태를 나타낸다.

  • notDetermined
    사용자가 아직 권한 여부를 결정하지 않는 상태
  • authorized
    권한을 허용한 상태
  • denied
    권한을 거부한 상태
  • ephemeral
    App Clip을 위해 제한된 시간 동안에만 허용된 상태
  • provistional
    앱이 중단되지 않는 상황에서만 임시로 허용된 상태
switch settings.soundSetting {
case .notSupported:
	soundLabel.text = "Not Supported"
case .disabled:
	soundLabel.text = "Disabled"
case .enabled:
	soundLabel.text = "Ebabled"
}

soundSetting은 Notification의 권한을 요청할 때 Sound 옵션을 추가한 경우에만 나타난다.

  • notSupported
    Sound 옵션을 추가하지 않아 해당 설정을 사용할 수 없는 상태
  • disabled
    비활성화된 상태
  • enabled
    활성화된 상태
switch settings.badgeSetting {
case .notSupported:
	badgeLabel.text = "Not Supported"
case .disabled:
	badgeLabel.text = "Disabled"
case .enabled:
	badgeLabel.text = "Enabled"
}

badgeSetting 속성은 Notification의 Badge 속성을 통해 확인할 수 있다.

  • notSupported
    Badge 옵션을 추가하지 않아 해당 설정을 사용할 수 없는 상태
  • disabled
    비활성화된 상태
  • enabled
    활성화된 상태
switch settings.lockScreenSetting {
case .notSupported:
	lockScreenLabel.text = "Not Supported"
case .disabled:
	lockScreenLabel.text = "Disabled"
case .enabled:
	lockScreenLabel.text = "Enabled"
}

lockscreen 설정은 lockScreenSetting 속성으로 확인할 수 있다.

  • notSupported
    Lockscreen 옵션을 추가하지 않아 해당 설정을 사용할 수 없는 상태
  • disabled
    비활성화된 상태
  • enabled
    활성화된 상태
switch settings.notificationCenterSetting {
case .notSupported:
	notificationCenterLabel.text = "Not Supported"
case .disabled:
	notificationCenterLabel.text = "Disabled"
case .enabled:
	notificationCenterLabel.text = "Enabled"
}

notificationCenterSetting이 활성화되어있으면 사용자가 확인하지 않은 알림을,
iOS Notification에서 확인할 수 있다.

  • notSupported
    Notification Center 옵션을 추가하지 않아 해당 설정을 사용할 수 없는 상태
  • disabled
    비활성화된 상태
  • enabled
    활성화된 상태
switch settings.alertSetting {
case .notSupported:
	alertLabel.text = "Not Supported"
case .disabled:
	alertLabel.text = "Disabled"
case .enabled:
	alertLabel.text = "Enabled"
}

alertSetting 속성을 통해 확인할 수 있다.

  • notSupported
    alert 옵션을 추가하지 않아 해당 설정을 사용할 수 없는 상태
  • disabled
    비활성화된 상태
  • enabled
    활성화된 상태
switch settings.alertStyle {
case .banner:
	alertStyleLabel.text = "Banner"
case .none:
	alertStyleLabel.text = "None"
case .alert:
	alertStyleLabel.text = "Alert"
}

권한 요청 시 alert 옵션을 추가해야 사용할 수 있다.
alertStyle 속성을 통해 확인할 수 있다.

  • banner
    Temporary 형식으로 banner가 잠깐 표시됐다가 사라진다.
  • alert
    Presistent 형식으로 사용자가 없앨 때까지 표시된다.
  • none
    show as banner 옵션이 비활성화돼있거나 권한이 없다면 none이 저장된다.
switch settings.showPreviewsSetting {
case .always:
	showPreviewLabel.text = "Always"
case .never:
	showPreviewLabel.text = "Never"
case .whenAuthenticated:
	showPreviewLabel.text = "When Authenticated"
}

Banner의 미리 보기 여부를 결정한다.
showPreviewsSetting 속성을 통해 확인할 수 있다.

  • always
    Banner의 Title과 Body를 항상 표시한다.
  • never
    Banner의 Body를 항상 표시하지 않는다.
  • whenAuthenticated
    Banner의 Body를 잠금이 해제된 상태에서만 표시한다.

설정 값에 따라 Label들이 변경된다.

override func viewDidLoad() {
	super.viewDidLoad()
	
	refresh()
	
	NotificationCenter.default.addObserver(self, selector: #selector(refresh), name: UIApplication.willEnterForegroundNotification, object: nil)
	
	let content = UNMutableNotificationContent()
	content.title = "Hello"
	content.body = "Good Evening!"
	content.sound = .default
	
	let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false)
	let request = UNNotificationRequest(identifier: "Test Notification", content: content, trigger: trigger)
	UNUserNotificationCenter.current().add(request) { (error) in
		print(error)
	}
}

설정값에 따른 Notification의 변화를 확인하기 위해 Notification을 예약하도록 코드를 수정한다.

그러면 콘솔에는 nil이 출력된다.
해당 로그는 Notification이 정상적으로 예약됐음을 의미하지만 기기에는 별 다른 반응이 없다.

다시 설정으로 돌아가 Notification을 활성화하면 그제야 알림이 도착한다.
UserNotificationCenter는 설정값에 관계없이 일단 예약을 진행한다.
이후 권한이 회복된다면 다시 전송을 진행한다.
단, 실제로 예약된 시점과는 시차가 존재하기 때문에 Notification의 내용이 잘못된 내용일 가능성이 있다.
심지어는 전달하지 못한 Notification이 한꺼번에 전달될 수 있기 때문에 사용자들이 불편을 겪을 수 있다.

따라서 LocalNotification을 예약하기 전에 설정값을 확인하고,
허가된 상태에서만 Notification을 예약하도록 구현하는 것이 바람직하다.

override func viewDidLoad() {
	super.viewDidLoad()
	
	refresh()
	
	NotificationCenter.default.addObserver(self, selector: #selector(refresh), name: UIApplication.willEnterForegroundNotification, object: nil)
	
	UNUserNotificationCenter.current().getNotificationSettings { (settings) in
		guard settings.authorizationStatus == .authorized else {
			return
		}
		
		let content = UNMutableNotificationContent()
		content.title = "Hello"
		content.body = "Good Evening!"
		content.sound = .default
		
		let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false)
		let request = UNNotificationRequest(identifier: "Test Notification", content: content, trigger: trigger)
		UNUserNotificationCenter.current().add(request) { (error) in
			print(error)
		}
	}
}

이렇게 구현 방식을 변경하면 앞서 언급한 문제는 모두 해결된다.

또한, LocalNotification 목록을 주기적으로 확인하고
원하는 시점에 전달되지 않았거나 잘못된 내용을 가진 Notification이 있다면 직접 삭제하는 것이 좋다.

Push Notification도 위의 메커니즘을 지키는 것이 좋다.
앱을 실행하는 시점에 Notification 설정을 Provider 서버로 전송하고, APNs로 전송하기 전에 확인하도록 구현해야 한다.

 

Notification Management

Local Notification을 예약하면 User Notification Center가 관리하는 목록에 추가된다.
해당 목록은 App에서 언제든지 확인할 수 있고, 특정 Notification을 삭제하거나 모두 삭제할 수 있다.
하지만 예약된 Notification들의 속성을 수정하는 것은 불가능하다.

Push Notification은 Provider가 예약하고, APNs가 목록을 관리한다.
때문에 User Notification Center를 통해 관리할 수는 없다.

전달된 Local Notification과 Push Notification은 사용자에게 표시되는 방식도, 관리하는 방식도 동일하기 때문에 차이가 없다.
사용자가 이를 터치해서 App을 실행하거나 Action을 선택해 원하는 작업을 실행하면 자동으로 제거된다.
사용자가 확인하지 않은 Notification은 Notification Center에 추가된다.
사용자는 언제든지 전달된 Notification을 확인할 수 있고, 직접 이를 삭제하거나 Action을 실행할 수 있다.
예약 목록과 마찬가지로 전달된 목록도 User Notification Center를 통해 관리할 수 있다.

//
//  PendingNotificationTableViewController.swift
//  Local Notification Practice
//
//  Created by Martin.Q on 2021/11/28.
//

import UIKit

class PendingNotificationTableViewController: UITableViewController {
	var pendingNotifications = [UNNotificationRequest]()
	
	func refresh() {
		pendingNotifications.removeAll()
	}
	
	@objc func scheduleNotifications() {
		for interval in 1...10 {
			let content = UNMutableNotificationContent()
			content.title = "Notification Title #\(interval)"
			content.body = "Notification Body #\(interval)"
			
			let trigger = UNTimeIntervalNotificationTrigger(timeInterval: TimeInterval(interval), repeats: false)
			let request = UNNotificationRequest(identifier: "nid\(interval)", content: content, trigger: trigger)
			
			UNUserNotificationCenter.current().add(request, withCompletionHandler: nil)
		}
		
		refresh()
	}

    override func viewDidLoad() {
        super.viewDidLoad()
		
		navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Schedule", style: .plain, target: self, action: #selector(scheduleNotifications))
		
		refresh()
    }

    // MARK: - Table view data source

    override func numberOfSections(in tableView: UITableView) -> Int {
        // #warning Incomplete implementation, return the number of sections
        return 1
    }

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        // #warning Incomplete implementation, return the number of rows
		return pendingNotifications.count
    }

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)

		let target = pendingNotifications[indexPath.row]
		cell.textLabel?.text = target.content.title
		cell.detailTextLabel?.text = target.identifier

        return cell
    }
}

사용할 Scene과 연결된 클래스는 위와 같다.
Right Bar Button을 사용해 scheduleNotification 메서드를 호출해 10개의 Notification을 예약한 뒤,
이를 Table View에 순서대로 표시한다.

func refresh() {
	pendingNotifications.removeAll()
	
	UNUserNotificationCenter.current().getPendingNotificationRequests { [weak self] (requests) in
		self?.pendingNotifications = requests
		
		DispatchQueue.main.async {
			self?.tableView.reloadData()
		}
	}
}

refresh 메서드에서 Table View에 표시할 Notification 예약 목록을 불러온다.
UNUserNotificationCenter에 구현되어있는 getPendingNotificationRequests 메서드를 통해 목록을 받을 수 있다.
이때 바로 반환하는 것이 아닌 Closure를 구현하고, 파라미터를 통해 받아야 한다.
파라미터로 전달된 목록을 pendingNotifications 속성에 저장하고, Table View를 업데이트한다.
이때 Closure는 Background에서 실행되기 때문에 Table View의 업데이트는 Main queue에서 진행해야 한다.

@objc func scheduleNotifications() {
	for interval in 1...10 {
		let content = UNMutableNotificationContent()
		content.title = "Notification Title #\(interval)"
		content.body = "Notification Body #\(interval)"
		
		let trigger = UNTimeIntervalNotificationTrigger(timeInterval: TimeInterval(interval), repeats: false)
		let request = UNNotificationRequest(identifier: "nid\(interval)", content: content, trigger: trigger)
		
		UNUserNotificationCenter.current().add(request, withCompletionHandler: nil)
	}
	
	refresh()
}

Notification을 예약하는 메서드를 확인해 보면 1초 간격으로 10개의 Notification을 생성하고,
request 생성 시 첫 번째 파라미터로 식별자를 사용한다.
예약된 Local Notification을 관리할 때는 식별자를 사용하기 때문에 가독성이 높은 식별자를 사용하는 것이 좋다.

실행해 보면 10개의 Notification이 예약되고, Table View에 표시된다.
상단에 표시된 것은 Notification의 Title이고, 아래에 표시된 것이 식별자이다.

override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
	if editingStyle == .delete {
		let target = pendingNotifications[indexPath.row]
		
		UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [target.identifier])
		
		pendingNotifications.remove(at: indexPath.row)
		tableView.deleteRows(at: [indexPath], with: .automatic)
	}
}

예약을 취소하기 위해서 UITableView의 tableView(commit:) 메서드를 추가한다.
삭제 버튼을 선택하면 해당하는 Notification을 배열에서 찾아 target으로 저장하고,
UNUserNotificationCenter의 removePendingNotificationRequests 메서드의 파라미터로
target의 identifier속성을 전달해 특정 Notification을 대기열에서 제거한다.
이후 Table View의 데이터 배열에서 삭제한 뒤, Cell을 삭제한다.

결과를 확인해 보면 정상적으로 삭제된다.

단, 사용자가 allowNotifications 속성을 비활성화해도 Local Notification자체는 예약이 진행된다.
Notification을 전달하는 시점에 여전히 비활성화되어있다면 계속 목록이 유지되고, 설정이 다시 허용되는 시점에 전달된다.
따라서 동시에 여러 Notification이 전달되거나 잘못된 내용이 전달될 가능성이 존재한다.
Notification이 허용되어 있어도 사용자의 설정에 따라 예약된 Notification이 만료될 수 있다.
따라서 예약된 Local Notification을 주기적으로 확인하고, 만료된 Notification을 삭제하는 것이 좋다.

//
//  DeleveredNotificationTableViewController.swift
//  Local Notification Practice
//
//  Created by Martin.Q on 2021/11/28.
//

import UIKit

class DeleveredNotificationTableViewController: UITableViewController {
	var deliveredNotifications = [UNNotification]()
	
	func refresh() {
		deliveredNotifications.removeAll()
	}

    override func viewDidLoad() {
        super.viewDidLoad()

        refresh()
    }

    // MARK: - Table view data source

    override func numberOfSections(in tableView: UITableView) -> Int {
        // #warning Incomplete implementation, return the number of sections
        return 1
    }

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        // #warning Incomplete implementation, return the number of rows
		return deliveredNotifications.count
    }
	
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "reuseIdentifier", for: indexPath)

		let target = deliveredNotifications[indexPath.row]
		cell.textLabel?.text = target.request.content.title
		cell.detailTextLabel?.text = target.request.identifier

        return cell
    }
}

새롭게 생성한 Delivered Notification Scene과 연결된 클래스는 위와 같다.
Notification에 추가되어있는 Notification을 Table View에 표시하고, 삭제 기능을 구현한다.

func refresh() {
	deliveredNotifications.removeAll()
	
	UNUserNotificationCenter.current().getDeliveredNotifications { [weak self] (notifications) in
		self?.deliveredNotifications = notifications
		
		DispatchQueue.main.async {
			self?.tableView.reloadData()
		}
	}
}

UNUserNotificationCenter의 getDeliveredNotifications 메서드는 Notification Center에 추가되어있는 Notification 중에
현재 앱으로 전달된 모든 Notificaiton을 Closure로 전달한다.
해당 Closure에는 Local Notification과 Push Notificaiton이 모두 포함된다.
이를 deliveredNotifications 배열에 저장하고 Table View를 업데이트한다.

Pending Notification Scene에서 Notification을 예약한 후,
확인하지 않은 상태로 Delivered Notification Scene으로 오면 확인하지 않은 모든 Notification의 목록을 확인할 수 있다.

override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
	if  editingStyle == .delete {
		let target = deliveredNotifications[indexPath.row]
		
		UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [target.request.identifier])
		
		deliveredNotifications.remove(at: indexPath.row)
		tableView.deleteRows(at: [indexPath], with: .automatic)
	}
}

이를 삭제하는 방법은 Pending Scene에서 삭제를 구현했던 패턴과 동일하다.
대신 removeDeliberdNotifications 메서드를 사용해 전달된 Notification의 목록에 접근한다.

Delivered Notification Scene에서 삭제하면 실제 Notification Center에서도 해당 Notification이 사라진다.
해당 Notification 중에서는 사용자가 이미 내용을 확인한 Notification이 있을 수 있다.
이러한 항목들을 자동으로 삭제하도록 구현하면 사용자 경험을 개선할 수 있다.