axiom-eventkit
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseEventKit — Discipline
EventKit — 规范
Core Philosophy
核心原则
"Request the minimum access needed, and only when it's needed."
Mental model: EventKit has three access tiers. Most apps need only the first (no access + system UI). Requesting more than you need means more users deny your request, and more code to maintain.
"仅在需要时请求所需的最小权限。"
心智模型:EventKit有三个访问层级。大多数应用只需要第一个层级(无访问权限+系统UI)。请求超出需求的权限会导致更多用户拒绝请求,同时也需要维护更多代码。
When to Use This Skill
何时使用此技能
Use this skill when:
- Adding events or reminders to the user's calendar
- Choosing between EventKitUI, write-only, or full access
- Requesting calendar or reminder permissions
- Fetching, querying, or displaying existing events
- Migrating from pre-iOS 17 permission APIs
- Creating virtual conference extensions
- Implementing Siri Event Suggestions for reservations
- Debugging "access denied" or missing events
Do NOT use this skill for:
- Contacts framework questions (use contacts)
- General SwiftUI architecture (use swiftui-architecture)
- Background task scheduling (use background-processing)
在以下场景使用此技能:
- 向用户日历添加事件或提醒事项
- 在EventKitUI、仅写入或完全访问权限之间做选择
- 请求日历或提醒事项权限
- 获取、查询或显示现有事件
- 从iOS 17之前的权限API迁移
- 创建虚拟会议扩展
- 为预订功能实现Siri事件建议
- 调试“访问被拒绝”或事件丢失问题
请勿在以下场景使用此技能:
- 联系人框架相关问题(请使用contacts技能)
- 通用SwiftUI架构问题(请使用swiftui-architecture技能)
- 后台任务调度问题(请使用background-processing技能)
Related Skills
相关技能
- eventkit-ref — Complete EventKit/EventKitUI API reference
- contacts — Contacts framework discipline skill
- privacy-ux — General iOS privacy patterns and Permission UX
- extensions-widgets — WidgetKit if combining calendar with widgets
- background-processing — If scheduling background calendar sync
- eventkit-ref — 完整的EventKit/EventKitUI API参考
- contacts — 联系人框架规范技能
- privacy-ux — 通用iOS隐私模式与权限UX
- extensions-widgets — 若将日历与小组件结合使用,请使用WidgetKit
- background-processing — 若调度后台日历同步
Access Tier Decision Tree
访问层级决策树
dot
digraph access_decision {
rankdir=TB;
"What does your app need?" [shape=diamond];
"Add single events to Calendar?" [shape=diamond];
"Show custom create/edit UI?" [shape=diamond];
"Read existing events/calendars?" [shape=diamond];
"No access + EventKitUI" [shape=box, label="Tier 1: No Access\nPresent EKEventEditViewController\nNo permission prompt needed"];
"No access + Siri Suggestions" [shape=box, label="Tier 1: No Access\nSiri Event Suggestions\nFor reservations only"];
"Write-only access" [shape=box, label="Tier 2: Write-Only\nrequestWriteOnlyAccessToEvents()\nCan save but not read"];
"Full access" [shape=box, label="Tier 3: Full Access\nrequestFullAccessToEvents()\nor requestFullAccessToReminders()"];
"What does your app need?" -> "Add single events to Calendar?" [label="events"];
"What does your app need?" -> "Full access" [label="reminders\n(always full)"];
"Add single events to Calendar?" -> "No access + EventKitUI" [label="yes, one at a time"];
"Add single events to Calendar?" -> "Show custom create/edit UI?" [label="no, batch or silent"];
"Show custom create/edit UI?" -> "Write-only access" [label="yes, or batch save"];
"Show custom create/edit UI?" -> "Read existing events/calendars?" [label="no"];
"Read existing events/calendars?" -> "Full access" [label="yes"];
"Read existing events/calendars?" -> "Write-only access" [label="no"];
"Add single events to Calendar?" -> "No access + Siri Suggestions" [label="reservation-style\n(restaurant, flight, hotel)"];
}Key rule: Reminders ALWAYS require full access. There is no write-only tier for reminders.
dot
digraph access_decision {
rankdir=TB;
"What does your app need?" [shape=diamond];
"Add single events to Calendar?" [shape=diamond];
"Show custom create/edit UI?" [shape=diamond];
"Read existing events/calendars?" [shape=diamond];
"No access + EventKitUI" [shape=box, label="Tier 1: No Access\nPresent EKEventEditViewController\nNo permission prompt needed"];
"No access + Siri Suggestions" [shape=box, label="Tier 1: No Access\nSiri Event Suggestions\nFor reservations only"];
"Write-only access" [shape=box, label="Tier 2: Write-Only\nrequestWriteOnlyAccessToEvents()\nCan save but not read"];
"Full access" [shape=box, label="Tier 3: Full Access\nrequestFullAccessToEvents()\nor requestFullAccessToReminders()"];
"What does your app need?" -> "Add single events to Calendar?" [label="events"];
"What does your app need?" -> "Full access" [label="reminders\n(always full)"];
"Add single events to Calendar?" -> "No access + EventKitUI" [label="yes, one at a time"];
"Add single events to Calendar?" -> "Show custom create/edit UI?" [label="no, batch or silent"];
"Show custom create/edit UI?" -> "Write-only access" [label="yes, or batch save"];
"Show custom create/edit UI?" -> "Read existing events/calendars?" [label="no"];
"Read existing events/calendars?" -> "Full access" [label="yes"];
"Read existing events/calendars?" -> "Write-only access" [label="no"];
"Add single events to Calendar?" -> "No access + Siri Suggestions" [label="reservation-style\n(restaurant, flight, hotel)"];
}关键规则:提醒事项始终需要完全访问权限。提醒事项没有仅写入的层级。
The Three Access Tiers
三个访问层级
Tier 1: No Access (Preferred)
层级1:无访问权限(推荐)
Present — it runs out-of-process on iOS 17+ and requires zero permissions.
EKEventEditViewControllerswift
let store = EKEventStore()
let event = EKEvent(eventStore: store)
event.title = "Team Standup"
event.startDate = startDate
event.endDate = Calendar.current.date(byAdding: .hour, value: 1, to: startDate) ?? startDate
event.timeZone = TimeZone(identifier: "America/Los_Angeles")
event.location = "Conference Room A"
let editVC = EKEventEditViewController()
editVC.event = event
editVC.eventStore = store
editVC.editViewDelegate = self
present(editVC, animated: true)Why this is best: No permission prompt. No denial risk. System handles Calendar selection and save. Works on iOS 4+.
For reservations (restaurant, flight, hotel, event tickets), use Siri Event Suggestions instead — events appear in Calendar inbox without any permission. See the eventkit-ref skill for the INReservation donation pattern.
展示 —— 在iOS 17及以上版本中它是进程外运行的,不需要任何权限。
EKEventEditViewControllerswift
let store = EKEventStore()
let event = EKEvent(eventStore: store)
event.title = "Team Standup"
event.startDate = startDate
event.endDate = Calendar.current.date(byAdding: .hour, value: 1, to: startDate) ?? startDate
event.timeZone = TimeZone(identifier: "America/Los_Angeles")
event.location = "Conference Room A"
let editVC = EKEventEditViewController()
editVC.event = event
editVC.eventStore = store
editVC.editViewDelegate = self
present(editVC, animated: true)为何推荐:无权限提示,无被拒绝风险。系统处理日历选择和保存。支持iOS 4及以上版本。
对于预订类场景(餐厅、航班、酒店、活动门票),请使用Siri事件建议替代 —— 事件会出现在日历收件箱中,无需任何权限。请查看eventkit-ref技能中的INReservation捐赠模式。
Tier 2: Write-Only Access (iOS 17+)
层级2:仅写入权限(iOS 17+)
Use only when you need: custom editing UI, batch saves, or silent event creation.
swift
let store = EKEventStore()
guard try await store.requestWriteOnlyAccessToEvents() else {
// User denied — handle gracefully
return
}
let event = EKEvent(eventStore: store)
event.calendar = store.defaultCalendarForNewEvents // REQUIRED for write-only
event.title = "Recurring Standup"
event.startDate = startDate
event.endDate = endDate
try store.save(event, span: .thisEvent)Write-only constraints:
- Returns a single virtual calendar, not the user's real calendars
- Event queries return empty results
- System chooses destination calendar for created events
- Cannot read events back, even ones your app created
Info.plist required:
NSCalendarsWriteOnlyAccessUsageDescription仅在需要以下功能时使用:自定义编辑UI、批量保存或静默创建事件。
swift
let store = EKEventStore()
guard try await store.requestWriteOnlyAccessToEvents() else {
// 用户拒绝 —— 优雅处理
return
}
let event = EKEvent(eventStore: store)
event.calendar = store.defaultCalendarForNewEvents // 仅写入权限下必填
event.title = "Recurring Standup"
event.startDate = startDate
event.endDate = endDate
try store.save(event, span: .thisEvent)仅写入权限限制:
- 返回单个虚拟日历,而非用户的真实日历
- 事件查询返回空结果
- 系统为创建的事件选择目标日历
- 无法读取事件,即使是您的应用创建的事件
必填Info.plist键:
NSCalendarsWriteOnlyAccessUsageDescriptionTier 3: Full Access
层级3:完全访问权限
Use only when your app's core feature requires reading, modifying, or deleting existing events.
swift
let store = EKEventStore()
guard try await store.requestFullAccessToEvents() else { return }
// Now you can fetch events
let interval = Calendar.current.dateInterval(of: .month, for: Date())!
let predicate = store.predicateForEvents(withStart: interval.start, end: interval.end, calendars: nil)
let events = store.events(matching: predicate)
.sorted { $0.compareStartDate(with: $1) == .orderedAscending }Info.plist required:
NSCalendarsFullAccessUsageDescriptionFor reminders:
swift
guard try await store.requestFullAccessToReminders() else { return }Info.plist required:
NSRemindersFullAccessUsageDescription仅当您的应用核心功能需要读取、修改或删除现有事件时使用。
swift
let store = EKEventStore()
guard try await store.requestFullAccessToEvents() else { return }
// 现在您可以获取事件
let interval = Calendar.current.dateInterval(of: .month, for: Date())!
let predicate = store.predicateForEvents(withStart: interval.start, end: interval.end, calendars: nil)
let events = store.events(matching: predicate)
.sorted { $0.compareStartDate(with: $1) == .orderedAscending }必填Info.plist键:
NSCalendarsFullAccessUsageDescription对于提醒事项:
swift
guard try await store.requestFullAccessToReminders() else { return }必填Info.plist键:
NSRemindersFullAccessUsageDescriptionAnti-Patterns
反模式
| Pattern | Time Cost | Why It's Wrong | Fix |
|---|---|---|---|
| Requesting full access for "add to calendar" | 1-2 sprint days recovering denied users | Full access prompts are denied 30%+ of the time — users distrust reading ALL calendar data | Use EventKitUI or write-only |
| Missing Info.plist key on iOS 17+ | 1-2 hours debugging | Automatic silent denial, no crash, no error, no prompt | Add the correct usage description key |
| Missing Info.plist key on iOS 16 and below | Immediate crash | App crashes on permission request | Add |
Calling deprecated | Throws error | The old API throws, does not prompt | Use |
| Creating multiple EKEventStore instances | Stale data bugs | Objects from one store cannot be used with another | Create one store, reuse it |
Using | DST bugs | Adding 3600 seconds doesn't always equal 1 hour | Use |
Not sorting | Wrong display order | Results are NOT chronologically ordered | Sort with |
Setting | Silent failure | Reminders use | Convert via |
Not registering for | Stale UI | External Calendar changes are invisible | Register and refetch on notification |
Ignoring | Modifying all occurrences | | Always choose explicitly |
| 模式 | 时间成本 | 错误原因 | 修复方案 |
|---|---|---|---|
| 为“添加到日历”请求完全访问权限 | 1-2个冲刺日用于挽回被拒绝的用户 | 完全访问权限提示的拒绝率超过30% —— 用户不信任读取所有日历数据的请求 | 使用EventKitUI或仅写入权限 |
| iOS 17+版本缺少Info.plist键 | 1-2小时调试时间 | 自动静默拒绝,无崩溃、无错误、无提示 | 添加正确的使用描述键 |
| iOS 16及以下版本缺少Info.plist键 | 立即崩溃 | 应用在请求权限时崩溃 | 添加 |
在iOS 17上调用已弃用的 | 抛出错误 | 旧API会抛出错误,不会弹出提示 | 使用 |
| 创建多个EKEventStore实例 | 数据过时bug | 一个存储实例的对象无法与另一个存储实例一起使用 | 创建一个存储实例并复用 |
使用 | 夏令时bug | 添加3600秒并不总是等于1小时 | 使用 |
不对 | 显示顺序错误 | 结果并非按时间顺序排列 | 使用 |
使用 | 静默失败 | 提醒事项使用 | 通过 |
未注册 | UI数据过时 | 外部日历的变更无法被感知 | 注册通知并在收到时重新获取数据 |
处理重复事件时忽略 | 修改所有重复实例 | | 始终显式选择 |
Reminder Patterns
提醒事项模式
Reminders ALWAYS require .
requestFullAccessToReminders()提醒事项始终需要。
requestFullAccessToReminders()Creating a Reminder
创建提醒事项
swift
let reminder = EKReminder(eventStore: store)
reminder.title = "Review PR"
reminder.calendar = store.defaultCalendarForNewReminders() // Required
// Due dates use DateComponents, NOT Date
if let dueDate = dueDate {
reminder.dueDateComponents = Calendar.current.dateComponents(
[.year, .month, .day, .hour, .minute], from: dueDate
)
}
reminder.priority = EKReminderPriority.medium.rawValue
try store.save(reminder, commit: true)swift
let reminder = EKReminder(eventStore: store)
reminder.title = "Review PR"
reminder.calendar = store.defaultCalendarForNewReminders() // 必填
// 截止日期使用DateComponents,而非Date
if let dueDate = dueDate {
reminder.dueDateComponents = Calendar.current.dateComponents(
[.year, .month, .day, .hour, .minute], from: dueDate
)
}
reminder.priority = EKReminderPriority.medium.rawValue
try store.save(reminder, commit: true)Fetching Reminders (Async)
获取提醒事项(异步)
Unlike events, reminder fetches are asynchronous:
swift
let predicate = store.predicateForReminders(in: nil) // nil = all calendars
let reminders = try await withCheckedThrowingContinuation { continuation in
store.fetchReminders(matching: predicate) { reminders in
if let reminders {
continuation.resume(returning: reminders)
} else {
continuation.resume(throwing: TodayError.failedReadingReminders)
}
}
}与事件不同,提醒事项的获取是异步的:
swift
let predicate = store.predicateForReminders(in: nil) // nil = 所有日历
let reminders = try await withCheckedThrowingContinuation { continuation in
store.fetchReminders(matching: predicate) { reminders in
if let reminders {
continuation.resume(returning: reminders)
} else {
continuation.resume(throwing: TodayError.failedReadingReminders)
}
}
}Creating Reminder Lists
创建提醒事项列表
Reminder lists are objects filtered by entity type:
EKCalendarswift
let newList = EKCalendar(for: .reminder, eventStore: store)
newList.title = "Sprint Tasks"
// Source selection matters — prefer .local or .calDAV
guard let source = store.sources.first(where: {
$0.sourceType == .local || $0.sourceType == .calDAV
}) ?? store.defaultCalendarForNewReminders()?.source else {
throw EventKitError.noValidSource
}
newList.source = source
try store.saveCalendar(newList, commit: true)提醒事项列表是按实体类型过滤的对象:
EKCalendarswift
let newList = EKCalendar(for: .reminder, eventStore: store)
newList.title = "Sprint Tasks"
// 源选择很重要 —— 优先选择.local或.calDAV
guard let source = store.sources.first(where: {
$0.sourceType == .local || $0.sourceType == .calDAV
}) ?? store.defaultCalendarForNewReminders()?.source else {
throw EventKitError.noValidSource
}
newList.source = source
try store.saveCalendar(newList, commit: true)Store Lifecycle
存储生命周期
Singleton Pattern
单例模式
Create one and reuse it. Objects from one store instance cannot be used with another.
EKEventStore创建一个并复用。一个存储实例的对象无法与另一个存储实例一起使用。
EKEventStoreChange Notifications
变更通知
swift
NotificationCenter.default.addObserver(
self, selector: #selector(storeChanged),
name: .EKEventStoreChanged, object: store
)
@objc func storeChanged(_ notification: Notification) {
// Refetch your current date range
// Individual objects: call refresh() — if false, refetch
}swift
NotificationCenter.default.addObserver(
self, selector: #selector(storeChanged),
name: .EKEventStoreChanged, object: store
)
@objc func storeChanged(_ notification: Notification) {
// 重新获取当前日期范围的数据
// 单个对象:调用refresh() —— 如果返回false则重新获取
}Batch Operations
批量操作
swift
// Pass commit: false for batch, then commit once
try store.save(event1, span: .thisEvent, commit: false)
try store.save(event2, span: .thisEvent, commit: false)
try store.commit() // Atomic save
// On failure: store.reset() to rollbackswift
// 批量操作时传入commit: false,然后一次性提交
try store.save(event1, span: .thisEvent, commit: false)
try store.save(event2, span: .thisEvent, commit: false)
try store.commit() // 原子性保存
// 失败时:调用store.reset()回滚Migration from Pre-iOS 17
从iOS 17之前版本迁移
| Before iOS 17 | iOS 17+ Replacement |
|---|---|
| |
| |
| |
| |
| Check for |
Runtime compatibility:
swift
if #available(iOS 17.0, *) {
granted = try await store.requestFullAccessToEvents()
} else {
granted = try await store.requestAccess(to: .event)
}Keep old Info.plist keys alongside new ones to support iOS 16 and below.
Gotcha: Apps built with older Xcode SDKs map both and to . This means an app linked against an old SDK may fail to fetch events even after users granted full access — because the app sees but the system gave .
.writeOnly.fullAccess.authorized.authorized.writeOnly| iOS 17之前 | iOS 17+替代方案 |
|---|---|
| |
| |
| |
| |
| 检查 |
运行时兼容性:
swift
if #available(iOS 17.0, *) {
granted = try await store.requestFullAccessToEvents()
} else {
granted = try await store.requestAccess(to: .event)
}保留旧的Info.plist键,同时添加新键以支持iOS 16及以下版本。
注意事项:使用旧版Xcode SDK构建的应用会将和都映射为。这意味着链接到旧SDK的应用即使在用户授予完全访问权限后也可能无法获取事件 —— 因为应用看到的是,但系统授予的是。
.writeOnly.fullAccess.authorized.authorized.writeOnlyEventKitUI Decision Guide
EventKitUI决策指南
| Controller | Purpose | Permission Required |
|---|---|---|
| Create/edit events | None (iOS 17+ out-of-process) |
| Display event details | Full access |
| Calendar selection | Write-only or full |
Gotcha: inherits from , not . Do NOT embed it inside another navigation controller.
EKEventEditViewControllerUINavigationControllerUIViewControllerGotcha: inherits from and CAN be pushed onto a navigation stack.
EKEventViewControllerUIViewControllerGotcha: Under write-only access, ignores and always shows writable calendars only.
EKCalendarChooserdisplayStyle| 控制器 | 用途 | 所需权限 |
|---|---|---|
| 创建/编辑事件 | 无(iOS 17+进程外运行) |
| 显示事件详情 | 完全访问权限 |
| 日历选择 | 仅写入或完全访问权限 |
注意事项:继承自,而非。请勿将其嵌入另一个导航控制器中。
EKEventEditViewControllerUINavigationControllerUIViewController注意事项:继承自,可以推入导航栈。
EKEventViewControllerUIViewController注意事项:在仅写入权限下,会忽略,始终仅显示可写入的日历。
EKCalendarChooserdisplayStylePressure Scenarios
压力场景
Scenario 1: "Just request full access, we might need it later"
场景1:“直接请求完全访问权限,我们以后可能需要”
Pressure: Product manager asks for full access "just in case."
Why resist: Full access prompts are denied 30%+ of the time. Write-only or EventKitUI gets you event creation with near-zero denials. You can always upgrade later if a reading feature is added.
Response: "Full access shows a scary prompt about reading ALL calendar data. For adding events, EventKitUI needs no prompt at all. Let's start there and upgrade if we ship a feature that reads events."
压力:产品经理要求“以防万一”请求完全访问权限。
为何拒绝:完全访问权限提示的拒绝率超过30%。仅写入权限或EventKitUI可以让您在几乎无拒绝率的情况下实现事件创建。如果以后添加读取功能,您可以随时升级权限。
回应:“完全访问权限会显示一个读取所有日历数据的可怕提示。对于添加事件,EventKitUI完全不需要权限提示。我们先从这个方案开始,如果以后发布读取事件的功能再升级权限。”
Scenario 2: "The deprecated API still works, we'll migrate later"
场景2:“已弃用的API还能用,我们以后再迁移”
Pressure: Deadline pressure to skip migration from .
requestAccess(to:)Why resist: On iOS 17, calling throws an error — no prompt, no access, broken feature. Users on iOS 17+ get a silent failure.
requestAccess(to: .event)Response: "The deprecated API throws on iOS 17. It's not 'deprecated but works' — it's broken. The fix is a 3-line check."
#available压力:截止日期压力要求跳过从的迁移。
requestAccess(to:)为何拒绝:在iOS 17上,调用会抛出错误 —— 无提示、无访问权限、功能损坏。iOS 17+用户会遇到静默失败。
requestAccess(to: .event)回应:“已弃用的API在iOS 17上会抛出错误。它不是‘已弃用但可用’ —— 它已经损坏。修复只需要3行检查代码。”
#availableScenario 3: "Just create a new EKEventStore for each screen"
场景3:“每个屏幕都创建一个新的EKEventStore”
Pressure: Different view controllers each create their own store for isolation.
Why resist: Objects from one store cannot be used with another. Events fetched from store A cannot be saved by store B. Change notifications only fire on the store that's registered.
Response: "EventKit requires a single shared store. Objects are bound to the store that created them. Create one and inject it."
压力:不同的视图控制器各自创建自己的存储实例以实现隔离。
为何拒绝:一个存储实例的对象无法与另一个存储实例一起使用。从存储A获取的事件无法被存储B保存。变更通知只会在注册的存储实例上触发。
回应:“EventKit要求使用单个共享存储实例。对象与创建它们的存储实例绑定。创建一个实例并注入到需要的地方。”
Error Handling
错误处理
Key codes to handle:
EKErrorDomain| Code | Meaning | Fix |
|---|---|---|
| No permission | Check and request access first |
| Calendar not set on event | Set |
| Missing dates | Set both before save |
| End before start | Validate date order |
| Can't write to this calendar | Use |
| Cross-store usage | Use single store instance |
| Recurring reminder missing due date | Set |
需要处理的关键错误码:
EKErrorDomain| 错误码 | 含义 | 修复方案 |
|---|---|---|
| 无权限 | 先检查并请求权限 |
| 事件未设置日历 | 保存前设置 |
| 缺少日期 | 保存前设置开始和结束日期 |
| 结束日期早于开始日期 | 验证日期顺序 |
| 无法写入此日历 | 使用 |
| 跨存储实例使用对象 | 使用单个存储实例 |
| 重复提醒事项缺少截止日期 | 设置 |
Resources
资源
WWDC: 2023-10052, 2020-10197
Docs: /eventkit, /eventkitui, /technotes/tn3152, /technotes/tn3153
Skills: eventkit-ref, contacts, privacy-ux, extensions-widgets
WWDC:2023-10052, 2020-10197
文档:/eventkit, /eventkitui, /technotes/tn3152, /technotes/tn3153
技能:eventkit-ref, contacts, privacy-ux, extensions-widgets