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.
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("Due", selection: $date, displayedComponents: [.date, .hourAndMinute])
// Show only date:
DatePicker("Due", selection: $date, displayedComponents: .date)
// Restrict to future dates only:
DatePicker("Due", 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.