A hands-on SwiftUI project that teaches how to integrate native iOS system APIs into your own app. Each module covers a real framework, working code, and the patterns you'll reuse across every integration you ever build.
| Task | |
|---|---|
| ☐ | Open iOSIntegrationsWorkshop.xcodeproj in Xcode |
| ☐ | Set Development Team if running on a physical device (skip for simulator) |
| ☐ | Select a simulator or connected device in the toolbar |
| ☐ | Press ⌘R to build and run |
| ☐ | Tap a module card on the home screen |
| ☐ | Tap "Grant Access" when prompted |
Everything in this project is self-contained — there are no Swift packages to install, no CocoaPods, no Homebrew dependencies. The only things that require manual action in Xcode are listed below.
The project is configured for automatic code signing but no team is pre-selected.
To run on a real iPhone or iPad:
- Open
iOSIntegrationsWorkshop.xcodeprojin Xcode. - Select the project in the Navigator (the blue icon at the top).
- Select the iOSIntegrationsWorkshop target.
- Open the Signing & Capabilities tab.
- Under Signing, choose your Apple ID team from the Team dropdown.
- If your Apple ID isn't listed: Xcode → Settings → Accounts → + → Apple ID.
- A free Apple Developer account is sufficient for running on your own device.
Simulator only? Skip this step — the simulator does not require code signing.
This project targets iOS 26.2 and uses APIs introduced in iOS 17:
| API | Introduced |
|---|---|
requestFullAccessToEvents() |
iOS 17 |
requestFullAccessToReminders() |
iOS 17 |
@Observable macro |
iOS 17 |
ContentUnavailableView |
iOS 17 |
Any device or simulator running iOS 17 or later will work. The Xcode deployment target in project.pbxproj is set to 26.2, so Xcode will enforce this automatically.
Simulator
EventKit works fully in the iOS Simulator. A few things to know:
- The simulator has its own isolated Calendar and Reminders databases. Your Mac's events and reminders are not visible — the lists start empty.
- To have sample data to browse, open the Calendar or Reminders app inside the simulator and create a few entries before launching the workshop app.
- Permission dialogs appear and work normally.
Real Device
Running on a real device gives you access to your actual Calendar and Reminders data. Events and reminders you create will appear in Apple's apps immediately (and sync via iCloud if enabled).
First run
The permission dialogs appear automatically the first time you tap "Grant Access" in the app. iOS shows each dialog only once.
Resetting permissions (simulator)
If you tapped "Don't Allow" and want to re-test the permission flow:
- Option A — Reset the entire simulator: Device menu → Erase All Content and Settings… This resets all permissions, apps, and data on that simulator.
- Option B — Reset only this app's permissions: Open Settings in the simulator → Privacy & Security → Calendars or Reminders → toggle the app off, then on.
- Option C — Delete and reinstall the app: Long-press the app icon → Remove App. Reinstalling resets its permissions.
Resetting permissions (real device)
Go to Settings → Privacy & Security → Calendars (or Reminders) and toggle the app off. To re-trigger the dialog (iOS doesn't show it a second time after a denial), delete and reinstall the app, or toggle the permission off then back on in Settings.
The human-readable strings shown in the system permission dialogs are already configured in the project's build settings — no manual Info.plist editing is needed.
To view or change them:
- In Xcode, select the project → iOSIntegrationsWorkshop target → Build Settings.
- Search for
INFOPLIST_KEY_NS. - You'll see:
NSCalendarsFullAccessUsageDescription— shown in the Calendar permission dialog.NSRemindersFullAccessUsageDescription— shown in the Reminders permission dialog.
To edit: double-click the value and type a new string. Changes take effect on the next build.
Why build settings instead of Info.plist? The project uses
GENERATE_INFOPLIST_FILE = YES, which auto-generatesInfo.plistat build time fromINFOPLIST_KEY_*build settings. This avoids maintaining a separate XML file and keeps all configuration in one place.
EventKit (Calendar and Reminders) does not require adding a Capability in Xcode. Unlike frameworks such as HealthKit, CloudKit, or Push Notifications, EventKit only needs Info.plist entries — which are already in place.
You do not need to visit Signing & Capabilities to enable anything for this workshop.
iOSIntegrationsWorkshop/
├── ContentView.swift ← Workshop home screen (module cards)
├── iOSIntegrationsWorkshopApp.swift
├── Calendar/
│ ├── CalendarManager.swift ← EventKit logic for Calendar
│ ├── CalendarView.swift ← Permission gate + event list UI
│ └── AddEventView.swift ← Form to create a new event
└── Reminders/
├── RemindersManager.swift ← EventKit logic for Reminders
├── RemindersView.swift ← Permission gate + reminder list UI
└── AddReminderView.swift ← Form to create a new reminder
New files placed anywhere inside the iOSIntegrationsWorkshop/ folder are compiled automatically — no Xcode project file edits required. This is because the project uses Xcode 16's file system synchronized groups (PBXFileSystemSynchronizedRootGroup).
Every module follows the same three-layer structure:
SwiftUI View ──reads──► Manager (@Observable) ──calls──► System Framework
│ │
└── triggers re-render └── updates published state
on state change (events, reminders, isAuthorized…)
Each module has a *Manager class marked @Observable. The @Observable macro (Swift 5.9+) instruments each stored property so SwiftUI knows precisely which views depend on which properties — only those views re-render when a property changes. No @Published, no ObservableObject, no manual objectWillChange.
@Observable
final class CalendarManager {
var events: [EKEvent] = [] // any view reading this re-renders on change
var isAuthorized = false
var authorizationStatus: EKAuthorizationStatus = .notDetermined
var errorMessage: String?
private let eventStore = EKEventStore()
// ...
}The manager is owned by the view using @State:
@State private var manager = CalendarManager()@State keeps the instance alive for the view's lifetime and ensures SwiftUI can observe it. Because CalendarManager is @Observable, SwiftUI automatically subscribes to any properties the view reads.
The project build setting SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor makes every type in the app implicitly @MainActor. This means:
- All manager state updates happen on the main thread without explicit annotations.
- All
asyncmethods are called from the main actor context. - You get data-race safety for UI state at zero boilerplate cost.
Framework: EventKit
Key types: EKEventStore, EKEvent, EKCalendar
Before any code runs, iOS checks Info.plist for a human-readable explanation of why your app wants access. In this project the key is injected via build settings (no manual Info.plist file needed):
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription
iOS 17+ supports two levels of calendar access:
| API | Info.plist key | What it allows |
|---|---|---|
requestFullAccessToEvents() |
NSCalendarsFullAccessUsageDescription |
Read + write all events |
requestWriteOnlyAccessToEvents() |
NSCalendarsWriteOnlyAccessUsageDescription |
Write events only, no reading |
The workshop uses full access so we can display events. In a production app, prefer write-only if reading isn't required — it's more privacy-preserving.
func requestAccess() async {
do {
let granted = try await eventStore.requestFullAccessToEvents()
isAuthorized = granted
authorizationStatus = EKEventStore.authorizationStatus(for: .event)
if granted { await fetchEvents() }
} catch {
errorMessage = error.localizedDescription
}
}Key facts:
- iOS only shows the permission dialog once. After the user responds, subsequent calls return immediately with the cached result — no dialog.
- The dialog text comes from
NSCalendarsFullAccessUsageDescription. EKEventStore.authorizationStatus(for:)is synchronous and can be called at any time without prompting.
EventKit always requires a date range — you cannot request all events ever created.
func fetchEvents(daysAhead: Int = 30) async {
let now = Date()
let endDate = Calendar.current.date(byAdding: .day, value: daysAhead, to: now) ?? now
let predicate = eventStore.predicateForEvents(
withStart: now,
end: endDate,
calendars: nil // nil = search all calendars
)
events = eventStore.events(matching: predicate)
.sorted { $0.startDate < $1.startDate }
}events(matching:) is synchronous — EventKit maintains a local cache synced with CalDAV/iCloud in the background.
func createEvent(title: String, startDate: Date, endDate: Date, notes: String? = nil) async throws {
let event = EKEvent(eventStore: eventStore) // must use the same store
event.title = title
event.startDate = startDate
event.endDate = endDate
event.notes = notes
event.calendar = eventStore.defaultCalendarForNewEvents // user's preferred calendar
try eventStore.save(event, span: .thisEvent)
await fetchEvents() // refresh the UI
}span: .thisEvent saves only this occurrence. For recurring events, span: .futureEvents modifies all following instances.
func deleteEvent(_ event: EKEvent) async throws {
try eventStore.remove(event, span: .thisEvent)
await fetchEvents()
}CalendarView switches on authorizationStatus to show contextually appropriate UI for every possible state:
switch manager.authorizationStatus {
case .notDetermined: // → show "Grant Access" button
case .denied: // → show "Open Settings" button
case .restricted: // → explain the device is managed
case .writeOnly: // → explain the limitation, guide to Settings
case .fullAccess: // → show the actual data
@unknown default: EmptyView()
}Always handle @unknown default — new authorization states may be added in future iOS versions.
.task {
manager.checkAuthorizationStatus()
if manager.isAuthorized {
await manager.fetchEvents()
}
}.task is the SwiftUI-native way to run async work tied to a view's lifecycle. It starts when the view appears and is automatically cancelled when the view disappears — no manual cancellation tokens, no onAppear/onDisappear pairing.
Framework: EventKit (same framework as Calendar)
Key types: EKEventStore, EKReminder, EKAlarm
Calendar and Reminders are separate permissions. Granting one does not grant the other. Each requires its own requestFullAccess…() call and its own Info.plist key.
// Calendar:
let granted = try await eventStore.requestFullAccessToEvents()
// Reminders (separate call, separate key):
let granted = try await eventStore.requestFullAccessToReminders()fetchReminders(matching:completion:) is an Objective-C era API that uses a completion callback instead of async/await. You'll encounter many APIs like this in UIKit, CoreBluetooth, CoreLocation, and others. The bridge is withCheckedContinuation:
func fetchReminders() async {
let predicate = eventStore.predicateForReminders(in: nil) // nil = all lists
return await withCheckedContinuation { continuation in
eventStore.fetchReminders(matching: predicate) { [weak self] fetchedReminders in
// This completion block runs on a background thread
Task { @MainActor in
self?.reminders = (fetchedReminders ?? []).sorted { ... }
continuation.resume() // resumes the suspended async task
}
}
}
}How it works:
withCheckedContinuationsuspends the currentasynctask and hands you acontinuationobject.- You pass that continuation into the callback-based API.
- When the callback fires (on any thread), you call
continuation.resume()to wake up the suspended task. - Execution continues after the
awaitas if it had always beenasync.
withCheckedContinuation is "checked" because the runtime will warn you if you forget to resume, or resume more than once — common mistakes when bridging callbacks.
EKEvent uses Date for start/end times. EKReminder uses DateComponents instead, which allows reminders without a specific time (date-only):
// Time-specific reminder:
let components = Calendar.current.dateComponents(
[.year, .month, .day, .hour, .minute],
from: dueDate
)
reminder.dueDateComponents = components
// Date-only reminder (no time):
let components = Calendar.current.dateComponents(
[.year, .month, .day],
from: dueDate
)
reminder.dueDateComponents = componentsA reminder without an alarm appears in Reminders.app but does not produce a notification banner. Add an EKAlarm to schedule a notification:
reminder.addAlarm(EKAlarm(absoluteDate: dueDate))absoluteDate fires at an exact point in time. You can also use relativeOffset to fire a number of seconds before/after the due date (e.g., -3600 for one hour before).
Reminders use a commit: parameter instead of span::
try eventStore.save(reminder, commit: true) // write immediately
try eventStore.remove(reminder, commit: true) // delete immediatelyPass commit: false and call eventStore.commit() explicitly when batching multiple saves for efficiency.
func toggleCompletion(_ reminder: EKReminder) async throws {
reminder.isCompleted = !reminder.isCompleted
// EventKit automatically sets completionDate = Date() when isCompleted becomes true
try eventStore.save(reminder, commit: true)
await fetchReminders()
}When a user has denied permission, direct them to your app's Settings page:
@Environment(\.openURL) var openURL
Button("Open Settings") {
if let url = URL(string: "app-settings:") {
openURL(url)
}
}"app-settings:" is the URL scheme iOS uses to open the calling app's page in the Settings app. Using @Environment(\.openURL) avoids importing UIKit.
SwiftUI's DatePicker uses individual component flags, not a single .dateAndTime value:
// Show date and time pickers:
DatePicker("Time", selection: $date, displayedComponents: [.date, .hourAndMinute])
// Show only date:
DatePicker("Time", selection: $date, displayedComponents: .date)
// Restrict to future dates only:
DatePicker("Time", selection: $date, in: Date()..., displayedComponents: [.date, .hourAndMinute])Buttons in SwiftUI cannot be async directly. Wrap them in Task:
Button("Save") {
Task { await save() }
}Creating multiple EKEventStore instances wastes memory and can produce inconsistent permission states. In a real production app, create one store and inject it:
// AppState or dependency container:
let sharedEventStore = EKEventStore()
// Inject into managers:
CalendarManager(eventStore: sharedEventStore)
RemindersManager(eventStore: sharedEventStore)In this workshop, each manager creates its own store for simplicity and clarity. The comments in both manager files flag this as the place to refactor.
Every iOS system framework integration follows the same four steps regardless of the framework:
- Declare intent — Add a
NS*UsageDescriptionkey toInfo.plistexplaining why you need access. - Request at runtime — Call the framework's authorization API. iOS presents a one-time system dialog.
- Handle all states —
.notDetermined,.authorized/.fullAccess,.denied,.restricted. Never assume access. - Access the data — Read, write, and delete using the framework's objects. Handle errors from each call.
This pattern applies to Calendar, Reminders, Contacts, Photos, Camera, Microphone, Location, HealthKit, and every other protected resource on iOS.