WWDC Quick Look 💓 By SwiftGGTeam
Discover Calendar and EventKit

Discover Calendar and EventKit

观看原视频

Highlight

iOS 17 的 EKEventEditViewController 运行在独立进程,无需请求日历权限即可添加事件;新增 write-only 权限让 App 只写不读;虚拟会议扩展让音视频通话 App 直接出现在日历的位置选择器中。

核心内容

日历集成的三种角色

App 与日历集成有三种常见角色:

  • 添加事件:预订、购票、约见面,把确认信息写进日历
  • 展示事件:自定义日历小组件、日程规划器
  • 双向管理:查看和编辑现有事件,创建新日历

不同角色对应不同的权限需求和实现方式。

三种权限级别

iOS 17 把日历权限分成三级:

  • 无权限:用 EventKitUI 或 Siri Event Suggestions 添加事件,不需要任何权限
  • Write-only:直接写事件到日历,但不能读现有事件
  • Full access:读取、修改、删除现有事件,创建新日历

权限请求应该遵循最小必要原则。大部分只需要添加事件的 App,用 EventKitUI 就够了,完全不需要弹权限框。

EventKitUI:零权限添加事件

EKEventEditViewController 在 iOS 17 中运行在独立进程。你创建事件、填好属性、弹出编辑器,用户确认后事件直接保存到日历。整个过程你的 App 不接触任何日历数据,所以不需要请求权限。

这是最简单、最安全的集成方式。用户可以在编辑器里选择日历、修改时间,有完全的控制权。

Siri Event Suggestions:轻量添加

适合餐厅预订、机票、演出票等场景。创建 INReservation 对象,包装成 INInteraction,donate 给系统。事件会出现在日历的收件箱里,用户可以一键添加或忽略。

不需要弹权限框,不显示任何 UI,适合后台自动推荐场景。

Write-only Access:自定义 UI 场景

如果你的 App 需要自定义编辑界面、批量添加事件、或静默添加事件,可以申请 write-only 权限。iOS 17 新增了 requestWriteOnlyAccessToEvents() 方法。

限制:不能读现有事件(包括自己添加的),不能读日历列表,不能创建新日历。

Full Access:谨慎使用

只有核心功能需要显示、更新或删除现有事件时才申请。需要在 Info.plist 中配置 NSCalendarsFullAccessUsageDescription

获取 full access 后,用 predicateForEvents 创建查询条件,events(matching:) 获取结果。注意返回的数组无序,需要自行排序。

虚拟会议扩展

音视频通话 App 可以实现 EKVirtualConferenceProvider,让日历 App 在添加位置时直接展示你的会议选项。用户选择后,日历事件会显示你的品牌图标和加入按钮,点击直接跳转回你的 App。

只需实现两个方法:fetchAvailableRoomTypes() 提供会议室类型,fetchVirtualConference() 根据选择的类型返回会议链接和详情。

详细内容

用 EventKitUI 添加事件

05:49

import EventKitUI

// 创建 event store
let store = EKEventStore()

// 创建事件并填写详情
let event = EKEvent(eventStore: store)
event.title = "WWDC23 Keynote"
let startDateComponents = DateComponents(year: 2023, month: 6, day: 5, hour: 10)
let startDate = Calendar.current.date(from: startDateComponents)!
event.startDate = startDate
event.endDate = Calendar.current.date(byAdding: .hour, value: 2, to: startDate)!
event.timeZone = TimeZone(identifier: "America/Los_Angeles")
event.location = "1 Apple Park Way, Cupertino, CA, United States"
event.notes = "Kick off an exhilarating week of technology and community."

// 创建编辑器视图控制器
let eventEditViewController = EKEventEditViewController()
eventEditViewController.event = event
eventEditViewController.eventStore = store
eventEditViewController.editViewDelegate = self

// 弹出编辑器
present(eventEditViewController, animated: true)

关键点:

  • EKEventEditViewController 在 iOS 17 运行在独立进程
  • 不需要请求日历权限
  • 用户可以在编辑器中修改事件详情和选择日历
  • 实现 EKEventEditViewDelegate 可以知道用户是否点击了”添加”

Siri Event Suggestions

09:17

import Intents
import CoreLocation

// 创建预订引用
let spokenPhrase = "Lunch at Caffè Macs"
let reservationReference = INSpeakableString(
    vocabularyIdentifier: "df9bc3f5",
    spokenPhrase: spokenPhrase,
    pronunciationHint: nil
)

// 设置时间范围
let duration = INDateComponentsRange(start: myEventStart, end: myEventEnd)

// 设置地点
let location = CLPlacemark(
    location: myCLLocation,
    name: "Caffè Macs",
    postalAddress: myAddress
)

// 创建餐厅预订
let reservation = INRestaurantReservation(
    itemReference: reservationReference,
    reservationStatus: .confirmed,
    reservationHolderName: "Jane Appleseed",
    reservationDuration: duration,
    restaurantLocation: location
)

// 创建 intent 和 response
let intent = INGetReservationDetailsIntent(reservationContainerReference: reservationReference)
let intentResponse = INGetReservationDetailsIntentResponse(code: .success, userActivity: nil)
intentResponse.reservations = [reservation]

// 创建并捐赠交互
let interaction = INInteraction(intent: intent, response: intentResponse)
interaction.donate()

关键点:

  • INSpeakableString 需要唯一的 vocabularyIdentifier
  • INDateComponentsRange 定义事件时间范围
  • INRestaurantReservationINReservation 的子类,还有机票、酒店等子类
  • interaction.donate() 把事件推荐给系统,出现在日历收件箱

Write-only 权限添加事件

12:41

import EventKit

// 创建 event store
let store = EKEventStore()

// 请求 write-only 权限
guard try await store.requestWriteOnlyAccessToEvents() else { return }

// 创建事件
let event = EKEvent(eventStore: store)
event.calendar = store.defaultCalendarForNewEvents
event.title = "WWDC23 Keynote"
event.startDate = myEventStartDate
event.endDate = myEventEndDate
event.timeZone = TimeZone(identifier: "America/Los_Angeles")
event.location = "1 Apple Park Way, Cupertino, CA, United States"
event.notes = "Kick off an exhilarating week of technology and community."

// 保存事件
guard try store.save(event, span: .thisEvent) else { return }

关键点:

  • requestWriteOnlyAccessToEvents() 是 iOS 17 新增方法
  • Info.plist 需要 NSCalendarsWriteOnlyAccessUsageDescription
  • 必须设置 calendar 属性,用 defaultCalendarForNewEvents
  • title、startDate、endDate 是必填项,其他可选

获取事件(Full Access)

15:51

import EventKit

// 创建 event store
let store = EKEventStore()

// 请求 full access
guard try await store.requestFullAccessToEvents() else { return }

// 创建查询条件:当前月份
guard let interval = Calendar.current.dateInterval(of: .month, for: Date()) else { return }
let predicate = store.predicateForEvents(
    withStart: interval.start,
    end: interval.end,
    calendars: nil
)

// 获取事件
let events = store.events(matching: predicate)

// 按开始时间排序
let sortedEvents = events.sorted {
    $0.compareStartDate(with: $1) == .orderedAscending
}

关键点:

  • requestFullAccessToEvents() 是 iOS 17 新增方法
  • Info.plist 需要 NSCalendarsFullAccessUsageDescription
  • predicateForEvents 指定时间范围和可选的日历列表
  • events(matching:) 返回无序数组,需要手动排序
  • 尽量用最短的时间范围,提升性能

虚拟会议扩展

19:18

import EventKit

class VirtualConferenceProvider: EKVirtualConferenceProvider {

    // 提供可用的会议室类型
    override func fetchAvailableRoomTypes() async throws -> [EKVirtualConferenceRoomTypeDescriptor] {
        let title = "My Room"
        let identifier = "my_room"
        let roomType = EKVirtualConferenceRoomTypeDescriptor(title: title, identifier: identifier)
        return [roomType]
    }

    // 根据选择的类型提供会议详情
    override func fetchVirtualConference(
        identifier: EKVirtualConferenceRoomTypeIdentifier
    ) async throws -> EKVirtualConferenceDescriptor {
        let urlDescriptor = EKVirtualConferenceURLDescriptor(title: nil, url: myURL)
        let details = "Enter the meeting code 12345 to enter the meeting."
        return EKVirtualConferenceDescriptor(
            title: nil,
            urlDescriptors: [urlDescriptor],
            conferenceDetails: details
        )
    }
}

关键点:

  • 在 Xcode 中创建 Virtual Conference Extension target
  • fetchAvailableRoomTypes() 返回会议室类型列表,显示在日历的位置选择器中
  • fetchVirtualConference() 根据用户选择的类型返回会议链接
  • EKVirtualConferenceURLDescriptor 的 URL 建议用 Universal Links,可以直接打开你的 App
  • conferenceDetails 显示在事件详情的虚拟会议区域

兼容旧版本系统

import EventKit

let store = EKEventStore()

if #available(iOS 17.0, macOS 14.0, *) {
    // iOS 17+ 使用新方法
    let granted = try await store.requestWriteOnlyAccessToEvents()
} else {
    // 旧版本使用兼容方法
    let granted = try await store.requestAccess(to: .event)
}

关键点:

  • iOS 17 之前用 requestAccess(to:),之后用 requestWriteOnlyAccessToEvents()requestFullAccessToEvents()
  • iOS 17 之前的 App 需要 NSCalendarsUsageDescription
  • 使用 EventKitUI 的 App 还需要 NSContactsUsageDescription
  • 缺少这些描述字符串会导致崩溃

核心启发

1. 票务预订 App 一键添加日历

  • 做什么:用户买电影票、演唱会票后,一键把场次信息添加到系统日历
  • 为什么值得做:用 EKEventEditViewController 不需要任何权限,用户信任度高
  • 怎么开始:创建 EKEvent,填入电影名、影院地址、开场时间,弹出编辑器让用户确认

2. 餐厅预订助手

  • 做什么:预订餐厅后,自动在日历收件箱中推荐事件,包含地址和导航
  • 为什么值得做:Siri Event Suggestions 不需要权限和 UI,用户体验无缝
  • 怎么开始:创建 INRestaurantReservation,donate 给系统,用户可以在日历收件箱中一键添加

3. 团队日程管理工具

  • 做什么:读取用户的系统日历,在 App 中展示工作日程和个人日程的整合视图
  • 为什么值得做:full access 可以获取所有日历事件,帮助用户统筹时间
  • 怎么开始:请求 requestFullAccessToEvents(),用 predicateForEvents 查询,把结果和自己的日程数据合并展示

4. 视频会议 App 的日历集成

  • 做什么:让用户在创建日历事件时,直接从位置选择器中选择你的会议服务
  • 为什么值得做:虚拟会议扩展让品牌出现在系统日历中,提供一键加入的快捷方式
  • 怎么开始:创建 Virtual Conference Extension target,实现两个协议方法,配置 Universal Links 跳回 App

关联 Session

评论

GitHub Issues · utterances