iOS 17 中的 EventKitUI 框架


WWDC给EventKit和EventKitUI框架带来了一些变化。在iOS 17中,人们的应用程序可以将事件添加到日历中,而无需提示用户使用IKEventEditViewController进行访问。

如果你的应用程序的目的是在编辑器UI中创建、配置和展示日历事件,可以考虑在你的应用程序中不提示用户授权而将事件保存到日历。

在这篇文章中,我将创建一个简单的应用来展示如何在iOS 17中向日历添加事件。上图显示了我们稍后将创建的应用程序。它只有一个显示票据信息的视图,当用户点击 "添加到日历 "按钮时,事件编辑器将显示出来,使用户能够保存事件或在保存事件之前改变一些信息。

首先,我们需要定义我们的票据结构,它包含一张票据的基本信息。

struct Ticket: Identifiable {
   var id: UUID = UUID()
   var title: String
   var theater: String
   var location: String
   var start: String
   var end: String
   var image: String
}

然后在你的ContentView.swift中定义一个Ticket对象:

private let ticket: Ticket = Ticket(title: "哆啦A梦:大雄与天空的理想乡",
                                    theater:
"Wanda Cinemas",
                                    location:
"Orient Cinema Rongchuangmao",
                                    start:
"2023-06-10T02:39:32Z",
                                    end:
"2023-06-10T04:58:32Z",
                                    image:
"movie")

在我们继续前进之前,我们需要检查我创建的票据视图。该票据视图包含几个部分:

  • 电影的海报
  • 剧院的名称
  • 电影的名称
  • 电影的开幕和闭幕日期
  • 剧院的位置
  • 添加"Add to Calendar "按钮

一些信息可以通过我们定义的Ticket轻松获取,但要注意电影的开场日期和结束日期的格式与Ticket.start和Ticket.end不同。我们需要在Ticket结构中添加一些成员变量来满足我们的需要。我将使用DateFormatter来将String日期改为目标日期:

struct Ticket: Identifiable {
...
private var dateFormatter: DateFormatter {
       let dfm = DateFormatter()
       dfm.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
       return dfm
   }
   private var _startDate: Date? {
       if let startDate = dateFormatter.date(from: self.start) {
           return startDate
       }
       return nil
   }
   private var _endDate: Date? {
       if let endDate = dateFormatter.date(from: self.end) {
           return endDate
       }
       return nil
   }
}

现在_startDate和_endDate以Date的格式存储电影的开场日期和结束日期。如果要访问单一信息,如年、月、日等,那就更好了。

为了达到这个目的,我将startDate和endDate添加到Ticket中,Ticket是包含每个日期的所有信息的tuples 图元:

struct Ticket: Identifiable {
...
var startDate: (year: Int, month: Int, day: Int, hour: Int, minute: Int)? {
       if let components = _startDate?.get(.day, .month, .year, .hour, .minute) {
           if let year = components.year,
               let day = components.day,
               let month = components.month,
               let hour = components.hour,
               let minute = components.minute {
               return (year, month, day, hour, minute)
           }
       }
       return nil
   }
   var endDate: (year: Int, month: Int, day: Int, hour: Int, minute: Int)? {
       if let components = _endDate?.get(.day, .month, .year, .hour, .minute) {
           if let year = components.year,
               let day = components.day,
               let month = components.month,
               let hour = components.hour,
               let minute = components.minute {
               return (year, month, day, hour, minute)
           }
       }
       return nil
   }
}

extension Date {
   func get(_ components: Calendar.Component..., calendar: Calendar = Calendar.current) -> DateComponents {
       return calendar.dateComponents(Set(components), from: self)
   }

   func get(_ component: Calendar.Component, calendar: Calendar = Calendar.current) -> Int {
       return calendar.component(component, from: self)
   }
}


现在我们可以通过使用ticket.startDate.year的语法来访问单个信息,如年份。

Ticket View
Ticket View很容易实现,这里我只给出完整代码:

struct ContentView: View {
   private let ticket: Ticket = Ticket(title: "哆啦A梦:大雄与天空的理想乡",
                                    theater:
"Wanda Cinemas",
                                    location:
"Orient Cinema Rongchuangmao",
                                    start:
"2023-06-10T02:39:32Z",
                                    end:
"2023-06-10T04:58:32Z",
                                    image:
"movie")
   
   var body: some View {
       ZStack(alignment: .bottom, content: {
           Image(ticket.image)
               .resizable()
               .aspectRatio(contentMode: .fit)
           HStack {
               VStack(alignment: .leading, spacing: 1) {
                   Text(ticket.theater)
                       .foregroundStyle(.blue)
                       .bold()
                   Text(ticket.title)
                       .font(.system(size: 20))
                   if let startDate = ticket.startDate, let endDate = ticket.endDate {
                       Text(
"\(startDate.month)月\(startDate.day)日 \(startDate.hour):\(startDate.minute)-\(endDate.hour):\(endDate.minute)")
                   }
                   HStack {
                       Image(systemName: "mappin")
                           .foregroundStyle(.red)
                       Text(ticket.location)
                           .foregroundStyle(.gray)
                   }
                   .padding(.top, 7)
                   .font(.system(size: 16))
                   
                   HStack {
                       Image(systemName: "calendar")
                           .foregroundStyle(.blue)
                       Button("Add to calendar") {
                           /// Add to calendar...
                       }
                   }
                   .padding(.top, 7)
               }
               Spacer()
           }
           .padding()
           .frame(width: 350)
           .background(Color.white)
       })
       .frame(width: 350)
       .clipShape(RoundedRectangle(cornerRadius: 10))
       .shadow(radius: 5)
   }
}

使用EventKitUI保存事件
在iOS上,EventKitUI框架被用来向用户适度地显示日历和提醒信息。

EventKitUI提供了视图控制器,用于查看和编辑日历和提醒信息,选择要查看的日历,以及确定将日历显示为只读还是可读可写。由于我们没有SwiftUI版本的EventKitUI,我们必须将UIViewController转换为View。

日历有什么新功能
正如我之前所说,在iOS 17中,你的应用程序可以在不提示用户使用IKEventEditViewController访问的情况下向日历添加事件。

这意味着你不必提供NSCalendarsUsageDescription键(这个键在iOS 17.0中已被废弃)或info.plist中的任何其他键,应用程序应该只请求完成其日历数据任务所需的特定访问级别。iOS 17 SDK还引入了新的日历使用描述字符串,能够在不提示用户访问的情况下向日历添加事件,以及新的只写访问。因为我们只添加事件,不需要任何密钥,所以我不打算在这里谈这些细节,你可以看看访问事件存储的细节。现在让我们看看如何创建EventEditViewController。

EKEventEditViewController
为了让IKEventEditViewController在SwiftUI中工作,我们需要向UIViewControllerRepresentable寻求帮助。我们的UIViewControllerType被定义为 EKEventEditViewController。

import EventKitUI

struct EventEditViewController: UIViewControllerRepresentable {
    typealias UIViewControllerType = EKEventEditViewController
    func makeUIViewController(context: Context) -> EKEventEditViewController {
       
    }
    func updateUIViewController(_ uiViewController: EKEventEditViewController, context: Context) {}
}

用EventKitUI添加一个事件是一个四步的过程:

  • 创建一个event store 。
  • 创建一个事件并填入细节。
  • 创建一个配置为编辑事件的视图控制器。
  • 展示视图控制器。

创建一个event store 很容易。只需一行代码:

private let store = EKEventStore()

创建一个ECEvent对象实例是一个比较复杂的过程,因为我们需要向对象实例添加详细信息。

在这里,我创建了一个名为event的私有变量,它将变量ticket中的信息转换为IKEvent类型:

struct EventEditViewController: UIViewControllerRepresentable {
let ticket: Ticket
   private var event: EKEvent {
       let event = EKEvent(eventStore: store)
       event.title = ticket.title
       if let startDate = ticket.startDate, let endDate = ticket.endDate {
           let startDateComponents = DateComponents(year: startDate.year,
                                                    month: startDate.month,
                                                    day: startDate.day,
                                                    hour: startDate.hour,
                                                    minute: startDate.minute)
           event.startDate = Calendar.current.date(from: startDateComponents)!
           let endDateComponents = DateComponents(year: endDate.year,
                                                    month: endDate.month,
                                                    day: endDate.day,
                                                    hour: endDate.hour,
                                                    minute: endDate.minute)
           event.endDate = Calendar.current.date(from: endDateComponents)!
           event.location = ticket.location
           event.notes = "Don't forget to bring popcorn!"
       }
       return event
   }
 ...
}

每个事件都需要一个标题。标题会在很多地方使用,包括小工具和通知,所以要保持简单。最重要的属性是开始和结束日期。使用日期组件来制作开始日期和结束日期。设置一个地点,让人们知道事件发生的地点。

包括一个完整的地址或使用一个MapKit句柄将启用地图建议和离开时间提醒等功能。最后,我添加一些注释以提供一些额外的细节。

现在你已经设置了事件属性,下一步是创建 EKEventEditViewController。赋予事件和事件存储属性。代码写在方法makeUIViewController(context:)里面。

func makeUIViewController(context: Context) -> EKEventEditViewController {
    let eventEditViewController = EKEventEditViewController()
    eventEditViewController.event = event
    eventEditViewController.eventStore = store
    return eventEditViewController
}


添加到日历
现在回到我们的票据视图ticket view,我们还有一些剩余的工作要完成。添加一个名为showEventEditView的变量,用来显示EventEditViewController:

@State private var showEventEditView: Bool = false

当用户点击 "添加到日历Add to Calendar "按钮时,showEventEditView应该变为true,然后显示EventEditViewController:

Button("Add to calendar") {
    self.showEventEditView.toggle()
}
.sheet(isPresented: $showEventEditView, content: {
    EventEditViewController(ticket: self.ticket)
})

现在,当我们点击该按钮时,事件编辑视图应该出现。然而,你可能会发现,当轻点取消或添加按钮时,事件编辑视图不会解散。

这是因为日历编辑发生在过程之外,检查被解雇控制器的属性可以帮助我们解雇视图。

启用撤消
由于UIViewControllerRepresentable不会自动将我们的视图控制器内发生的变化传达给我们的SwiftUI界面的其他部分。当我们希望我们的视图控制器与其他SwiftUI视图协调时,我们必须提供一个协调员实例来促进这些互动。

struct EventEditViewController: UIViewControllerRepresentable {
    @Environment(\.presentationMode) var presentationMode
    ...
    func makeCoordinator() -> Coordinator {
       return Coordinator(self)
    }
   
    class Coordinator: NSObject, EKEventEditViewDelegate {
        var parent: EventEditViewController
       
        init(_ controller: EventEditViewController) {
            self.parent = controller
        }
       
        func eventEditViewController(_ controller: EKEventEditViewController, didCompleteWith action: EKEventEditViewAction) {
            parent.presentationMode.wrappedValue.dismiss()
        }
    }
}

最后在方法makeUIViewController中添加以下代码:

func makeUIViewController(context: Context) -> EKEventEditViewController {
    ...
    eventEditViewController.editViewDelegate = context.coordinator
    return eventEditViewController
}

可以在GitHub上找到源代码