Develop in Swift Data Collections

Lesson 3.5 Local Notifications - Practice(Alarm)

GayoonKim 2024. 6. 6. 17:24

Get Permission ans Set Up the Notification Actions and Category

Alarm 구조체부터 정의

import UserNotifications

struct Alarm {
    
    func schedule(completion: @escaping (Bool) -> ()) {
        
    }
    
    func unschedule() {
        
    }
    
}

 

알림이 필요한 이유가 분명해지는 시점에 알림 권한을 요청해야 한다. 이 앱에서는 사용자가 처음으로 알람을 설정할 때 요청하면 된다.

 

이를 위한 메서드 정의

private func authorizeIfNeeded(completion: @escaping (Bool) -> ()) {
}
  • UNUserNotificationCenter의 공유 인스턴스에서 알림 설정 불러오기
  • 각 인증 상태 확인(.authorized, .provisional, .notDetermined, .denined)
  • 알람 앱이기 때문에 앱이 제대로 동작하기 위해서는 단순히 provisional notifications을 표시하는 것 이상이 필요
    • 인증 상태가 .authorized면 completion(true)를, .provisional, .denined면 completion(false) 호출
    • 인증 상태가 .notDetermined면 인증 요청
  • 인증 요청을 위해서는 .alert .sound 인증 필요
private func authorizeIfNeeded(completion: @escaping (Bool) -> ()) {
        
        let notificationCenter = UNUserNotificationCenter.current()
        notificationCenter.getNotificationSettings{ (settings) in
            switch settings.authorizationStatus {
            case .authorized:
                completion(true)
            case .notDetermined:
                notificationCenter.requestAuthorization(options: [.alert, .sound], completionHandler: { (granted, _) in
                    completion(false)
                })
            case .denied, .provisional, .ephemeral:
                completion(false)
            @unknown default:
                completion(false)
            }
        }
        
}

 

위 메서드는 나중에 schedule(completion:)에서 호출한다.

 

다음으로는 커스텀 알림 action과 category를 설정한다. 이를 위해서 카테고리 identifier와 snooze action을 위한 action identifier가 필요하다.

 

Action identifier는 AppDelegate에서 두 번 참조되고, category identifier는 AppDelegate와 Alarm에서 참조된다.

 

오타 방지를 위해서 Alarm extension에 static constant를 추가해 준다.

extension Alarm {
    static let notificationCategoryId = "AlarmNotification"
    static let snoozeActionId = "snooze"
}

 

이제 AppDelegate의 application(_:didFinishLaunchingWithOptions) 메서드에서 나머지 알림 설정을 하면 된다.

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        
	let center = UNUserNotificationCenter.current()
        
	let snoozeAction = UNNotificationAction(identifier: Alarm.snoozeActionId, title: "Snooze", options: [])
        
	let alarmCategory = UNNotificationCategory(identifier: Alarm.notificationCategoryId, actions: [snoozeAction], intentIdentifiers: [], options: [])
        
	center.setNotificationCategories([alarmCategory])
        
	return true
}

 

다음은 AppDelegate이 UNUserNotificationCenterDelegate 프로토콜을 채택하도록 하고 notification center의 delegate를 AppDelegate로 설정

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {

	func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        
	let center = UNUserNotificationCenter.current()
        
	let snoozeAction = UNNotificationAction(identifier: Alarm.snoozeActionId, title: "Snooze", options: [])
        
	let alarmCategory = UNNotificationCategory(identifier: Alarm.notificationCategoryId, actions: [snoozeAction], intentIdentifiers: [], options: [])
        
	center.setNotificationCategories([alarmCategory])
	center.delegate = self
        
	return true
}

Create and Schedule Notifications

이제 알람이 설정되고 사용자가 알람을 snooze 하기로 결정했을 때 로컬 알림을 생성해야 한다.

 

먼저 authorizeIfNeeded(completion:)의 completion handler에서 Boolean 파라미터로 권한을 확인해야 한다.

func schedule(completion: @escaping (Bool) -> ()) {
        
	authorizeIfNeeded{ (granted) in
		guard granted else {
			DispatchQueue.main.async {
				completion(false)
			}
                
			return
		}
	}
        
}

 

위 Boolean 값이 true면 알림 내용과 알림 카테고리 identifier를 지정하면 된다.

func schedule(completion: @escaping (Bool) -> ()) {
        
        authorizeIfNeeded{ (granted) in
            guard granted else {
                DispatchQueue.main.async {
                    completion(false)
                }
                
                return
            }
        }
        
        let content = UNMutableNotificationContent()
        content.title = "Alarm"
        content.body = "Beep Beep"
        content.sound = UNNotificationSound.default
        content.categoryIdentifier = Alarm.notificationCategoryId
        
}

 

이제 notification trigger를 만들면 된다. 이를 위해서 날짜가 필요하기 때문에 Alarm에 date 프로퍼티를 추가해야 한다.

 

그리고 UNCalendarNotificationTrigger를 새로 만들 때 date 값을 사용하면 된다.

func schedule(completion: @escaping (Bool) -> ()) {
        
        authorizeIfNeeded{ (granted) in
            guard granted else {
                DispatchQueue.main.async {
                    completion(false)
                }
                
                return
            }
        }
        
        let content = UNMutableNotificationContent()
        content.title = "Alarm"
        content.body = "Beep Beep"
        content.sound = UNNotificationSound.default
        content.categoryIdentifier = Alarm.notificationCategoryId
        
        let triggerDateComponents = Calendar.current.dateComponents([.minute, .hour, .day, .month, .year], from: self.date)
        let trigger = UNCalendarNotificationTrigger(dateMatching: triggerDateComponents, repeats: false)
        
}

 

위에서 만든 content와 trigger를 이용해 notification request를 만들면 된다.

 

각 notification request는 identifier가 필요하다. Identifier는 알람을 끄면 알림을 해제하는 데 사용한다.

 func schedule(completion: @escaping (Bool) -> ()) {
        
        authorizeIfNeeded{ (granted) in
            guard granted else {
                DispatchQueue.main.async {
                    completion(false)
                }
                
                return
            }
        }
        
        let content = UNMutableNotificationContent()
        content.title = "Alarm"
        content.body = "Beep Beep"
        content.sound = UNNotificationSound.default
        content.categoryIdentifier = Alarm.notificationCategoryId
        
        let triggerDateComponents = Calendar.current.dateComponents([.minute, .hour, .day, .month, .year], from: self.date)
        let trigger = UNCalendarNotificationTrigger(dateMatching: triggerDateComponents, repeats: false)
        
        let request = UNNotificationRequest(identifier: self.notificationId, content: content, trigger: trigger)
        
}

 

이제 current UNUserNotificationCenter에 알림을 등록해 스케줄 하면 된다.

func schedule(completion: @escaping (Bool) -> ()) {
        
        authorizeIfNeeded{ (granted) in
            guard granted else {
                DispatchQueue.main.async {
                    completion(false)
                }
                
                return
            }
        }
        
        let content = UNMutableNotificationContent()
        content.title = "Alarm"
        content.body = "Beep Beep"
        content.sound = UNNotificationSound.default
        content.categoryIdentifier = Alarm.notificationCategoryId
        
        let triggerDateComponents = Calendar.current.dateComponents([.minute, .hour, .day, .month, .year], from: self.date)
        let trigger = UNCalendarNotificationTrigger(dateMatching: triggerDateComponents, repeats: false)
        
        let request = UNNotificationRequest(identifier: self.notificationId, content: content, trigger: trigger)
        
        UNUserNotificationCenter.current().add(request) { (error:Error?) in
            DispatchQueue.main.async {
                if let error = error {
                    print(error.localizedDescription)
                    completion(false)
                } else {
                    completion(true)
                }
            }
        }
        
}

 

이제 마지막으로 unschedule() 메서드를 구현하면 된다.

func unschedule() {
        
	UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [notificationId])
        
}

 

마지막에 Alarm 구조체에 notificationId 프로퍼티를 추가했는데, 이를 초기화할 생성자가 필요하다.

init(notificationId: String? = nil, date: Date) {
	self.notificationId = notificationId ?? UUID().uuidString
	self.date = date
}

Set Up the UI

  • 알람이 설정되면 시간과 선택된 날짜를 표시할 레이블 추가
  • 알람이 설정되면 date picker를 비활성화하고 버튼 타이틀을 "Set Alarm"이 아닌 "Remove Alarm"으로 변경

 

이를 위해서는 알람이 설정되고 해제되는 것에 대한 알림이 필요하다.

 

알림(사용자 알림이 아니라 코드 내 observers에 브로드캐스트 되는 Notification 인스턴스)을 게시해 다른 객체에 업데이트된 정보를 알릴 수 있다.

 

새 swift 파일을 생성하고 Notification.Name에 대한 extension 추가

extension Notification.Name {
    static let alarmUpdated = Notification.Name("alarmUpdated")
}

 

Alarm 파일 Alarm extension에 설정된 알람을 추적하기 위해 다음 추가

  • Alarm 타입 옵셔널 scheduled 이름을 가지는 static 프로퍼티
  • 커스텀 getter와 setter 설정, 파일 시스템에서 알람을 불러오거나 저장하는 데 사용
  • JSONEncoder와 JSONDecoder 사용을 위해 Alarm 구조체 Codable 프로토콜 채택
  • 파일 시스템에 데이터를 쓰기 위한 URL 타입 alarmURL private static 프로퍼티 정의, "ScheduledAlarm" path component를 가지는 document directory 반환
  • scheduled가 nil이면 alarmURL에 저장된 모든 데이터 삭제
  • schedueld setter에서 Notification.name.alarmUpdated 알림 보내기
extension Alarm: Codable {
    static let notificationCategoryId = "AlarmNotification"
    static let snoozeActionId = "snooze"
    
    private static let alarmURL: URL = {
        guard let baseURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
            fatalError("Can't get URL for documents directory.")
        }
        
        return baseURL.appendingPathComponent("ScheduledAlarm")
    }()
    
    static var scheduled: Alarm? {
        get {
            guard let data = try? Data(contentsOf: alarmURL) else {
                return nil
            }
            
            return try? JSONDecoder().decode(Alarm.self, from: data)
        }
        
        set {
            if let alarm = newValue {
                guard let data = try? JSONEncoder().encode(alarm) else {
                    return
                }
                
                try? data.write(to: alarmURL)
            } else {
                try? FileManager.default.removeItem(at: alarmURL)
            }
            
            NotificationCenter.default.post(name: .alarmUpdated, object: nil)
        }
    }
    
}

 

지금까지는 설정된 알람을 추적할 방법을 추가했고 이제 설정하면 된다.

 

schedule(completion:)에서는 UNUserNotificationCenter.current().add 호출을 성공하면 Alarm.scheduled을 self로 설정하면 된다.

UNUserNotificationCenter.current().add(request) { (error:Error?) in
            DispatchQueue.main.async {
                if let error = error {
                    print(error.localizedDescription)
                    completion(false)
                } else {
                    Alarm.scheduled = self
                    completion(true)
                }
            }
}

 

unschedule() 메서드에서는 scheduled를 nil로 설정하면 된다.

func unschedule() {
        
        UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [notificationId])
        Alarm.scheduled = nil
        
}

 

ViewController에 Alarm.scheduled이 존재하는지 확인하는 updateUI() 메서드 정의

  • 존재하면
    • 레이블 값을 알람이 설정된 시간과 날짜로 변경
    • date picker 비활성화
    • 버튼 타이틀 "Remove Alarm"으로 변경
  • 존재하지 않으면
    • 레이블 값 "Set an alarm below"
    • date picker 활성화
    • 버튼 타이틀 "Set Alarm"
  • 알람으로 설정된 시간과 날짜 값을 올바르게 표현하기 위해 formatted() 메서드 사용
func updateUI() {
        
        if let scheduledAlarm = Alarm.scheduled {
            let formattedAlarm = scheduledAlarm.date.formatted(.dateTime.day(.defaultDigits).month(.defaultDigits).year(.twoDigits).hour().minute())
            
            alarmLabel.text = "Your alarm is scheduled for \(formattedAlarm)"
            datePicker.isEnabled = false
            scheduleButton.setTitle("Remove Alarm", for: .normal)
        } else {
            alarmLabel.text = "Set an alarm below."
            datePicker.isEnabled = true
            scheduleButton.setTitle("Set Alarm", for: .normal)
        }
        
}

 

update() 메서드를 viewDidLoad()에서 호출하고, ViewController를 Notification.Name.alarmUpdated 알림의 옵저버로 등록

override func viewDidLoad() {
        super.viewDidLoad()
        
        updateUI()
        
        NotificationCenter.default.addObserver(self, selector: #selector(updateUI), name: .alarmUpdated, object: nil)

}

 

setAlarmButtonTapped(_:) 메서드에서도 비슷하게 Alarm.scheduled가 존재하는지 확인하면 된다.

  • 존재하면 unschedule() 메서드를 호출하고
  • 존재하지 않으면 date picker의 date를 이용해 새 알람 설정
@IBAction func setAlarmButtonTapped(_ sender: UIButton) {
    
        if let alarm = Alarm.scheduled {
            alarm.unschedule()
        } else {
            let alarm = Alarm(date: datePicker.date)
            alarm.schedule{ [weak self] (permissionGranted) in
                
            }
        }
        
}

 

schedule(completion:) 메서드를 보면 알림에 대한 권환이 없을 때를 다룬다.

 

사용자에게 Settings 앱에서 permission granted로 설정 변경에 대한 알림을 이용한다.

func presentNeedAuthorizationAlert() {
        
        let title = "Authorization Needed"
        let message = "Alarms don't work without notifications, and it looks like you haven't granted us permission to send you those. Please go to the iOS Settings app and grant us notification permissions."
        let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
        
        let okAction = UIAlertAction(title: "Okay", style: .default, handler: nil)
        
        alert.addAction(okAction)
        
        present(alert, animated: true, completion: nil)
        
}
@IBAction func setAlarmButtonTapped(_ sender: UIButton) {
    
        if let alarm = Alarm.scheduled {
            alarm.unschedule()
        } else {
            let alarm = Alarm(date: datePicker.date)
            alarm.schedule{ [weak self] (permissionGranted) in
                if !permissionGranted {
                    self?.presentNeedAuthorizationAlert()
                }
            }
        }
        
}

Handle the Notification

AppDelegate에서 UNUserNotificationCenterDelegate의 userNotificationCenter(_:didReceive:withCompletionHandler:) 메서드

func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
        
        if response.actionIdentifier == Alarm.snoozeActionId {
            let snoozeDate = Date().addingTimeInterval(9 * 60)
            
            let alarm = Alarm(date: snoozeDate)
            alarm.schedule{ granted in
                if !granted {
                    print("Can't schedule snooze because notification permissions were revoked.")
                }
            }
        }
        
        completionHandler()
}

 

알람이기 때문에 앱이 foreground 상태에 있을 때도 알림이 표시되어야 한다.

 

userNotificationCenter(_:willPresent:withCompletionHandler:) delegate 메서드로 앱이 foreground 상태에 있을 때 알림의 기본 설정을 변경할 수 있다. 그리고, 알림은 곧 울리기 때문에 사용자가 새로운 알람을 설정할 수 있도록 Alarm.scheduled을 nil로 설정해줘야 한다.

func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
        
	completionHandler([.list, .banner, .sound])
	Alarm.scheduled = nil
        
}