Skip to content

Swift-Coding-Club-UCD/iOSAppIntegrationWorkshop

Repository files navigation

iOS Integrations Workshop

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.


Quick Start Checklist

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

Setup

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.

1. Set a Development Team (required to run on a physical device)

The project is configured for automatic code signing but no team is pre-selected.

To run on a real iPhone or iPad:

  1. Open iOSIntegrationsWorkshop.xcodeproj in Xcode.
  2. Select the project in the Navigator (the blue icon at the top).
  3. Select the iOSIntegrationsWorkshop target.
  4. Open the Signing & Capabilities tab.
  5. 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.

2. iOS Version Requirement

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.

3. Running in the Simulator vs. a Real Device

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).

4. Granting and Revoking Permissions During Testing

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 & SecurityCalendars 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.

5. Privacy Description Strings

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:

  1. In Xcode, select the project → iOSIntegrationsWorkshop target → Build Settings.
  2. Search for INFOPLIST_KEY_NS.
  3. 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-generates Info.plist at build time from INFOPLIST_KEY_* build settings. This avoids maintaining a separate XML file and keeps all configuration in one place.

6. No Capabilities Required

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.


Project Structure

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).


Shared Architecture

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…)

The Manager pattern

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.

MainActor isolation

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 async methods are called from the main actor context.
  • You get data-race safety for UI state at zero boilerplate cost.

Module 1 — Calendar

Framework: EventKit Key types: EKEventStore, EKEvent, EKCalendar

Step 1: Declare the privacy key

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.

Step 2: Request permission at runtime

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.

Step 3: Fetch events

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.

Step 4: Create an event

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.

Step 5: Delete an event

func deleteEvent(_ event: EKEvent) async throws {
    try eventStore.remove(event, span: .thisEvent)
    await fetchEvents()
}

The Permission Gate UI pattern

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.

View lifecycle with .task

.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.


Module 2 — Reminders

Framework: EventKit (same framework as Calendar) Key types: EKEventStore, EKReminder, EKAlarm

Authorization is independent

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()

Bridging a callback API to async/await

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:

  1. withCheckedContinuation suspends the current async task and hands you a continuation object.
  2. You pass that continuation into the callback-based API.
  3. When the callback fires (on any thread), you call continuation.resume() to wake up the suspended task.
  4. Execution continues after the await as if it had always been async.

withCheckedContinuation is "checked" because the runtime will warn you if you forget to resume, or resume more than once — common mistakes when bridging callbacks.

Due dates use DateComponents, not Date

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 = components

Alarms trigger local notifications

A 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).

Saving with commit

Reminders use a commit: parameter instead of span::

try eventStore.save(reminder, commit: true)   // write immediately
try eventStore.remove(reminder, commit: true) // delete immediately

Pass commit: false and call eventStore.commit() explicitly when batching multiple saves for efficiency.

Toggling completion

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()
}

Patterns Reference

Opening Settings from your app

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.

DatePicker components

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])

Async actions from buttons

Buttons in SwiftUI cannot be async directly. Wrap them in Task:

Button("Save") {
    Task { await save() }
}

EKEventStore — one instance per app

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.


The Common Integration Pattern

Every iOS system framework integration follows the same four steps regardless of the framework:

  1. Declare intent — Add a NS*UsageDescription key to Info.plist explaining why you need access.
  2. Request at runtime — Call the framework's authorization API. iOS presents a one-time system dialog.
  3. Handle all states.notDetermined, .authorized/.fullAccess, .denied, .restricted. Never assume access.
  4. 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.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages