본문 바로가기

iOS+

EventKit

개요

이 글에서는 EventKit에 대해 정리한다

 

EventKit으로 캘린더 권한 허가 상태 및 요청을 수행하고, 캘린더와 캘린더이벤트 그리고 미리 보기와 관련된 데이터를 관리할 수 있다

이 모든 것은 EKEventStore를 통해 할 수 있고, 이때 사용되는 타입으로는 EKCalendarEKEvent가 있다

 

해당 정리글에서 만든 예시 프로젝트에서는 캘린더의 이벤트 처리에 대해서만 수행한다

간략하게 핵심 요소들만 나타내면 다음과 같다

 

  • EKEventStore
    • .authorizationStatus
    • .requestFullAceessToEvents
    • .calendars → [EKCalendar]
    • .events(NSPredicate) → [EKEvent]
      • .predicateForEvents → NSPredicate
    • .save
    • .remove

 

예시 프로젝트는 CalendarManager라는 클래스를 만들고, EKEventStore를 내부적으로 사용하도록 코드를 작성했다

 

처음엔 EKEvent를 바로 View에서 사용하는 간단한 예시를 작성했었지만

기능들이 추가됨에 따라 따로 ViewModel을 만들어서 처리하는 방향으로 처리했다

 

이때, EKEvent를 Model로 만들어서 ViewModel에서 비즈니스 로직들을 수행하도록 했다

 

 

권한 요청 및 상태 확인하기

  • Info - “Privacy - Calendars Full Access Usage Description”
  • EKEventStore.authorizationStatus(for:)
  • EKEventStore.requestFullAceessToEvents()

 

우선 Info에서 캘린더 접근 권한 관련해서 등록을 해둬야 한다

authorizationStatus 메서드를 사용하면, 현재 권한 상태에 대해 얻을 수 있는데,

이를 상태로 등록해 두면, 실행 중인 앱에서 권한이 변경되었을 때 캘린더에 접근을 못하게 할 수 있다

 

requestFullAccessToEvents 메서드를 사용하면, 권한요청을 수행한다

이는 콜백형식으로 동기메서드를 사용할 수도 있고, 비동기 메서드를 사용할 수도 있다

 

권한 명칭과 권한과 관련하여, iOS 17 이전에는 .authorized로 퉁쳤는데

이후부턴 좀 더 디테일하게 권한을 확인할 수 있어서 아래와 같이 버전 따라 처리를 해준다

 

다음은 CalendarManager에서 권한 관련 처리 부분이다

 

class CalendarManager: ObservableObject {
    private var eventStore: EKEventStore
    
    @Published var isAuthorized: Bool = false
    
    init() {
        self.eventStore = EKEventStore()
    }
}

extension CalendarManager {
    func checkAuthorization() {
        var _isAuthorized = false
        
        if #available(iOS 17.0, *) {
            _isAuthorized = EKEventStore.authorizationStatus(for: .event) == .fullAccess
        } else {
            _isAuthorized = EKEventStore.authorizationStatus(for: .event) == .authorized
        }
        
        isAuthorized = _isAuthorized
        if !_isAuthorized {
            Task {
                try? await requestAuthorization()
            }
        }
    }
    
    func requestAuthorization() async throws -> Bool {
        if #available(iOS 17.0, *) {
            return try await eventStore.requestFullAccessToEvents()
        } else {
            return try await eventStore.requestAccess(to: .event)
        }
    }
}

해당 예시 프로젝트에선 캘린더 이벤트를 CRUD 하기 때문에 .fullAccess 권한을 요구한다

 

캘린더 불러오기

  • EKEventStore.calendars(for:)

 

기본 캘린더 앱을 보면 다음과 같이 여러 캘린더가 존재하고, 좌측에서 캘린더를 선택하여 관련 이벤트만 볼 수 있다

 

다음과 같이 calendars 메서드를 이용하면, 현재 존재하는 캘린더들을 얻어올 수 있는데, 이때 캘린더의 타입은 EKCalendar

 

extension CalendarManager {
    func fetchCalendars() -> [EKCalendar] {
        eventStore.calendars(for: .event)
    }
}

 

이벤트 CRUD

캘린더는 타입이 EKCalendar라면, 이벤트는 EKEvent

 

EKEvent에 어떤 속성들이 필요할지는, 다음과 같이 기본 캘린더 앱에서 생성 시 나타나는 요소들을 생각해 보면 이해하기 쉽다

 

사진에서 나타난 대부분의 요소들은 다음과 같이 EKEvent의 속성으로 존재한다

 

EKEvent

  • eventIdentifier - ID
  • title - 이벤트 이름
  • startDate / endDate - 시작/종료 날짜
  • isAllDay - 하루종일
  • calendar - 캘린더
  • location - 위치
  • notes - 메모
  • attendees - 참가자
  • recurrenceRules - 반복
    • addRecurrenceRule
    • removeRecurrenceRule

 

이벤트 불러오기

  • EKEventStore.predicateForEvents(withStart:end:calendars:)
  • EKEventStore.events(matching:)

 

불러오기는 우선 불러 올 조건인 NSPredicate를 만들고, 이를 events 메서드에 matching 인자로 전달하면 된다

조건으로는 날짜 범위와 해당 이벤트가 존재하는 캘린더를 전달할 수 있다

 

이때 predicateForEvents 메서드 인자에서 캘린더가 nil이라면, 전체 캘린더에서 이벤트를 찾게 된다

 

extension CalendarManager {    
    func fetchEvents(startDate: Date, endDate: Date, calendars: [EKCalendar]? = nil) -> [EKEvent] {
        let predicate = eventStore.predicateForEvents(withStart: startDate, end: endDate, calendars: calendars)
        return eventStore.events(matching: predicate)
    }
}

 

이벤트 생성 및 업데이트하기

  • EKEventStore.save(_:span:)

 

span 인자는 해당 이벤트가 반복되는 경우, 모든 경우에 적용할 지에 대한 설정이다(.thisEvent는 현재 이벤트에만 적용한다)

이벤트를 추가할 때는 아래와 같은 순서로 추가한다

 

  1. EKEventStore를 인자로 EKEvent를 생성
  2. 생성한 EKEvent의 내용 업데이트
  3. EKEventStore.save 메서드를 통해 저장

 

이때 만약 업데이트하는 경우라면, EKEvent를 생성할 필요 없이 해당 객체를 통해 업데이트하면 된다

 

extension CalendarManager {    
    func addEvent(title: String, startDate: Date, endDate: Date, isAllDay: Bool, calendar: EKCalendar?=nil) throws {
        let event = EKEvent(eventStore: eventStore)
        
        event.title = title
				event.isAllDay = isAllDay
        event.startDate = isAllDay ? startDate.startOfDay : startDate
        event.endDate = isAllDay ? startDate.endOfDay : endDate
        event.calendar = calendar ?? eventStore.defaultCalendarForNewEvents

        try eventStore.save(event, span: .thisEvent)
    }
    
    func updateEvent(_ event: EKEvent, title: String, startDate: Date, endDate: Date, isAllDay: Bool) throws {
        event.title = title
        event.isAllDay = isAllDay
        event.startDate = isAllDay ? startDate.startOfDay : startDate
        event.endDate = isAllDay ? startDate.endOfDay : endDate

        try eventStore.save(event, span: .thisEvent)
    }
}

extension Date {
    var startOfDay: Date {
        Calendar.current.startOfDay(for: self)
    }
    
    var endOfDay: Date {
        let components = DateComponents(day: 1, second: -1)
        return Calendar.current.date(byAdding: components, to: startOfDay)!
    }
}

 

이벤트 삭제하기

  • EKEventStore.remove(_:span)

 

extension CalendarManager {    
    func removeEvent(_ event: EKEvent) throws {
        try eventStore.remove(event, span: .thisEvent)
    }
}

 

EKEvent 관리하기

이전과 같이 코드를 작성하면, View에서 EKEvent들을 관리하기 귀찮아진다

 

그래서 따로 [EKEvent]를 관리하는 ViewModel을 만든다

그러면 이벤트 생성과 업데이트 View에서 약간 귀찮아지는데, 이를위해 EKEvent도 모델로 정의하고 처리한다

 

모델정의

EKEvent는 이제 EventModel로 정의하여 View에서 처리하고, CalendarManager는 EventModel을 전달받아 처리한다

 

@Observable
class EventModel {
    var id: String = UUID().uuidString
    var title: String = "제목없음"
    var isAllDay: Bool = true
    var startDate: Date = .now.startOfDay
    var endDate: Date = .now.endOfDay
    
    init() {}
    
    init(event: EKEvent) {
        self.id = event.eventIdentifier
        self.title = event.title
        self.isAllDay = event.isAllDay
        self.startDate = event.startDate
        self.endDate = event.endDate
    }
    
    init(event: EventModel) {
        self.id = event.id
        self.title = event.title
        self.isAllDay = event.isAllDay
        self.startDate = event.startDate
        self.endDate = event.endDate
    }
    
    func update(with event: EventModel) {
        self.title = event.title
        self.isAllDay = event.isAllDay
        self.startDate = event.startDate
        if self.isAllDay {
            self.endDate = event.startDate.endOfDay
        } else {
            self.endDate = event.endDate
        }
    }
}

 

EventModel을 업데이트 시, 현재 관리 중인 이벤트를 바로 업데이트하기 위해 @Observable을 이용한다

 

이벤트모델 CRUD

  • EKEventStore.event(withIdentifier:)

 

기존에 작성했던 CalendarManager의 CRUD 메서드에서 이젠 EKEvent를 바로 전달하여 사용하는 게 아니라

정의한 EventModel을 사용하도록 코드를 작성한다

 

extension CalendarManager {
    func fetchEvents(startDate: Date, endDate: Date, calendars: [EKCalendar]? = nil) -> [EventModel] {
        let predicate = eventStore.predicateForEvents(withStart: startDate, end: endDate, calendars: calendars)
        let events = eventStore.events(matching: predicate)
        return events.map { EventModel(event: $0) }
    }
    
    func addEvent(event: EventModel, calendar: EKCalendar? = nil) throws -> EventModel {
        let ekEvent = EKEvent(eventStore: eventStore)
        
        ekEvent.title = event.title
        ekEvent.startDate = event.startDate
        ekEvent.endDate = event.endDate
        ekEvent.isAllDay = event.isAllDay
        ekEvent.calendar = calendar ?? eventStore.defaultCalendarForNewEvents
        try eventStore.save(ekEvent, span: .thisEvent)
        
        event.id = ekEvent.eventIdentifier
        return event
    }
    
    func updateEvent(_ eventModel: EventModel) throws {
        guard let ekEvent = eventStore.event(withIdentifier: eventModel.id) else { return }
        ekEvent.title = eventModel.title
        ekEvent.startDate = eventModel.startDate
        ekEvent.isAllDay = eventModel.isAllDay
        if ekEvent.isAllDay {
            ekEvent.endDate = eventModel.startDate.endOfDay
        } else {
            ekEvent.endDate = eventModel.endDate
        }
        try eventStore.save(ekEvent, span: .thisEvent)
    }
    
    func removeEvent(_ eventModel: EventModel) throws {
        guard let ekEvent = eventStore.event(withIdentifier: eventModel.id) else { return }
        try eventStore.remove(ekEvent, span: .thisEvent)
    }
}

 

기존에 EKEvent를 바로 사용한 것에 비해, EventModel로 변환과정을 추가한다

또한 EKEvent가 가진 eventIdentifier 속성을 EKEvent 업데이트에 이용하는 과정이 필요하다

 

프로젝트

 

지금까지 내용들을 바탕으로 간단한 앱으로 만들어 봤다

ViewModel을 통해 EventModel을 관리하여 캘린더 이벤트를 CRUD 할 수 있다

 

CoreLocation은 위치권한 관련하여 Delegate를 제공해서 권한 상태 업데이트가 간편했는데

EventKit은 Scene 변화에 따라 아래처럼 직접 권한상태를 업데이트해야 하는 번거로움이 있었다

 

@main
struct LearnEventKitApp: App {
    @Environment(\\.scenePhase) private var scenePhase
    @StateObject private var calendarManager = CalendarManager()
    
    var body: some Scene {
        WindowGroup {
            Group {
                if calendarManager.isAuthorized {
                    ContentView(vm: .init(calendarManager: calendarManager))
                        .environmentObject(calendarManager)
                } else {
                    Text("NEED AUTHORIZATION")
                }
            }
            .onAppear {
                calendarManager.checkAuthorization()
            }
            .onChange(of: scenePhase) { _, newPhase in
                if newPhase == .active {
                    calendarManager.checkAuthorization()
                }
            }
        }
    }
}

 

마무리

EventKit으로 캘린더의 이벤트를 CRUD 하는 방법에 대해 정리해 보았다

 

EKEventStore를 통해 권한 확인 및 요청과 캘린더와 이벤트를 얻어올 수 있다

 

이때 캘린더는 EKCalendar 타입이고, 이벤트는 EKEvent 타입이다

 

이벤트를 CRUD 작업을 할 때, 뷰에서 좀 더 편리하게 코드를 작성하기 위해서는

EKEvent를 모델로 따로 정의해서 사용하는게 좋은 것 같다

 

예시로 만든 앱에선 CalendarManager를 만들어서 해당 모델을 인자로 사용하며

ViewModel에선 이 객체를 이용하여 비즈니스 로직을 수행한다

 

CalendarManager 자체도 ObservableObject로 정의했는데, 그 이유는 권한 상태를 즉각 반영하기 위해서다

해당 앱에선 App 도입부에 scenePhase를 통해 앱이 다시 열릴 때마다 권한 확인을 하여 권한 상태를 업데이트한다

'iOS+' 카테고리의 다른 글

Cannot preview in this file  (0) 2024.08.18
UserNotifications  (0) 2024.08.11
MapKit  (1) 2024.07.27
AVFoundation  (0) 2024.07.14
App Intents  (0) 2024.07.06