Skip to content
Skip
2.9k

Calendar

Cross-platform calendar access for Skip apps. SkipCalendar provides a unified Swift API for querying, creating, and updating calendar events on both iOS and Android.

Add skip-calendar to your Package.swift:

dependencies: [
.package(url: "https://source.skip.tools/skip-calendar.git", "0.0.0"..<"2.0.0")
]

And add it to your target:

.target(name: "MyApp", dependencies: [
.product(name: "SkipCalendar", package: "skip-calendar")
])

Add the following usage description keys to your Info.plist:

<key>NSCalendarsUsageDescription</key>
<string>This app needs access to your calendar to display and manage events.</string>
<!-- iOS 17+ requires full access description -->
<key>NSCalendarsFullAccessUsageDescription</key>
<string>This app needs full access to your calendar to create and edit events.</string>
<!-- Only if using reminders -->
<key>NSRemindersFullAccessUsageDescription</key>
<string>This app needs access to your reminders.</string>

Add the following permissions to your AndroidManifest.xml (or your app’s Skip/skip.yml):

<uses-permission android:name="android.permission.READ_CALENDAR" />
<uses-permission android:name="android.permission.WRITE_CALENDAR" />

To add these via skip.yml in your app module:

build:
contents:
- block: 'android'
contents:
- block: 'defaultConfig'
contents:
- 'manifestPlaceholders["READ_CALENDAR"] = "android.permission.READ_CALENDAR"'
- 'manifestPlaceholders["WRITE_CALENDAR"] = "android.permission.WRITE_CALENDAR"'

Always request permission before accessing calendar data:

import SkipCalendar
// Check current permission status (synchronous, does not prompt)
let status = CalendarManager.queryCalendarPermission()
// Request permission (async, may show system prompt)
let granted = await CalendarManager.requestCalendarPermission()
if granted == .authorized {
// Access calendar data
}

For iOS reminders (a separate permission on iOS; on Android, calendar permission covers both):

let reminderStatus = await CalendarManager.requestReminderPermission()
let manager = CalendarManager.shared
// Get all calendars
let calendars = try manager.getCalendars()
for cal in calendars {
print("\(cal.title) (id: \(cal.id), readOnly: \(cal.isReadOnly))")
}
// Get the default calendar for new events
if let defaultCal = try manager.getDefaultCalendar() {
print("Default: \(defaultCal.title)")
}
// Create a local calendar
let newCalID = try manager.createCalendar(title: "My Calendar", color: "#FF6B35")
// Delete a calendar
try manager.deleteCalendar(id: newCalID)
PropertyTypeDescription
idStringUnique identifier
titleStringDisplay name
colorString?Hex color (e.g. "#FF0000")
isReadOnlyBoolWhether the calendar can be modified
isPrimaryBoolWhether this is the default calendar
sourceCalendarSource?Account/source info
accountNameString?Account name (Android)
ownerAccountString?Owner account (Android)
timeZoneString?Time zone identifier (Android)
accessLevelCalendarAccessLevelAccess level
isVisibleBoolWhether the calendar is visible
let manager = CalendarManager.shared
// Get events in a date range
let startDate = Date()
let endDate = Calendar.current.date(byAdding: .month, value: 1, to: startDate)!
let events = try manager.getEvents(startDate: startDate, endDate: endDate)
// Filter by specific calendars
let filteredEvents = try manager.getEvents(
calendarIDs: ["cal-id-1", "cal-id-2"],
startDate: startDate,
endDate: endDate
)
// Get a single event by ID
if let event = try manager.getEvent(id: "event-id") {
print("\(event.title) at \(event.location ?? "no location")")
}
let event = CalendarEvent(
calendarID: defaultCalendar.id,
title: "Team Meeting",
location: "Conference Room A",
notes: "Quarterly review",
startDate: meetingStart,
endDate: meetingEnd,
isAllDay: false,
availability: .busy
)
// Add an alarm (15 minutes before)
event.alarms = [CalendarAlarm(relativeOffset: -15.0)]
let eventID = try manager.createEvent(event)
// event.id is also set to the new ID
event.title = "Updated Team Meeting"
event.location = "Conference Room B"
try manager.updateEvent(event)
// For recurring events, update this and all future occurrences
try manager.updateEvent(event, span: .futureEvents)
try manager.deleteEvent(id: eventID)
// For recurring events, delete this and all future occurrences
try manager.deleteEvent(id: eventID, span: .futureEvents)
PropertyTypeDescription
idString?Unique identifier (nil for new events)
calendarIDStringParent calendar ID
titleStringEvent title
locationString?Event location
notesString?Event notes/description
urlString?Associated URL (iOS)
startDateDateStart date/time
endDateDateEnd date/time
timeZoneString?Time zone identifier
isAllDayBoolWhether this is an all-day event
availabilityEventAvailability.busy, .free, .tentative, .unavailable
statusEventStatus.none, .confirmed, .tentative, .canceled
alarms[CalendarAlarm]Event alarms/reminders
recurrenceRules[RecurrenceRule]Recurrence rules
attendees[CalendarAttendee]Event attendees (read-only)
organizerEmailString?Organizer email
creationDateDate?When the event was created (read-only)
lastModifiedDateDate?When the event was last modified (read-only)

Create repeating events using RecurrenceRule, which follows the iCal RFC 5545 standard:

// Every day
let daily = RecurrenceRule(frequency: .daily)
// Every other week
let biweekly = RecurrenceRule(frequency: .weekly, interval: 2)
// Every Monday, Wednesday, Friday
let mwf = RecurrenceRule(
frequency: .weekly,
daysOfTheWeek: [
DayOfWeek(dayOfTheWeek: 2), // Monday
DayOfWeek(dayOfTheWeek: 4), // Wednesday
DayOfWeek(dayOfTheWeek: 6) // Friday
]
)
// 15th of every month
let monthly15 = RecurrenceRule(
frequency: .monthly,
daysOfTheMonth: [15]
)
// Second Friday of every month
let secondFriday = RecurrenceRule(
frequency: .monthly,
daysOfTheWeek: [DayOfWeek(dayOfTheWeek: 6, weekNumber: 2)]
)
// Last day of every month
let lastDay = RecurrenceRule(
frequency: .monthly,
daysOfTheMonth: [-1]
)
// Every year on March 15
let yearly = RecurrenceRule(
frequency: .yearly,
daysOfTheMonth: [15],
monthsOfTheYear: [3]
)
// Repeat 10 times
let limited = RecurrenceRule(frequency: .daily, occurrenceCount: 10)
// Repeat until a specific date
let untilDate = RecurrenceRule(frequency: .weekly, endDate: someDate)

RecurrenceRules can be converted to and from iCal RRULE strings:

let rule = RecurrenceRule(frequency: .weekly, interval: 2)
let rruleString = rule.toRRule() // "FREQ=WEEKLY;INTERVAL=2"
let parsed = RecurrenceRule.fromRRule("FREQ=MONTHLY;BYDAY=2FR;COUNT=12")
// Second Friday of every month, 12 times
dayOfTheWeekDay
1Sunday
2Monday
3Tuesday
4Wednesday
5Thursday
6Friday
7Saturday

Add reminders to events:

// 15 minutes before the event
let alarm15min = CalendarAlarm(relativeOffset: -15.0)
// 1 hour before the event
let alarm1hr = CalendarAlarm(relativeOffset: -60.0)
// At a specific date/time (iOS only)
let alarmAbsolute = CalendarAlarm(absoluteDate: reminderDate)
event.alarms = [alarm15min, alarm1hr]

The relativeOffset is in minutes. Negative values mean before the event start.

Read attendees for an event (read-only on iOS, managed by the calendar service):

let attendees = try manager.getAttendees(eventID: event.id!)
for attendee in attendees {
print("\(attendee.name ?? "Unknown") - \(attendee.status)")
}
PropertyTypeDescription
idString?Identifier (Android)
nameString?Display name
emailString?Email address
roleAttendeeRole.unknown, .required, .optional, .chair, .nonParticipant, .organizer
statusAttendeeStatus.unknown, .pending, .accepted, .declined, .tentative, .delegated, .completed, .inProcess
typeAttendeeType.unknown, .person, .room, .group, .resource
isCurrentUserBoolWhether this is the current user (iOS)

Present the system event editor using the withEventEditor view modifier:

import SwiftUI
import SkipCalendar
struct MyView: View {
@State var showEditor = false
var body: some View {
Button("Create Event") {
showEditor = true
}
.withEventEditor(
isPresented: $showEditor,
options: EventEditorOptions(
defaultTitle: "New Event",
defaultStartDate: Date(),
defaultEndDate: Date().addingTimeInterval(3600)
),
onComplete: { result in
switch result {
case .saved: print("Event saved")
case .deleted: print("Event deleted")
case .canceled: print("Cancelled")
case .unknown: print("Unknown result")
}
}
)
}
}
.withEventEditor(
isPresented: $showEditor,
options: EventEditorOptions(event: existingEvent)
)
.withEventViewer(
isPresented: $showViewer,
eventID: event.id!,
onComplete: { result in
print("Viewer dismissed: \(result)")
}
)

Platform behavior:

  • iOS: Presents the native EKEventEditViewController or EKEventViewController in a sheet.
  • Android: Launches the system calendar app via an intent. The onComplete callback receives .unknown since Android intents don’t report results back.

Access the underlying EKEventStore for advanced EventKit operations:

#if !SKIP
let eventStore = CalendarManager.shared.eventStore
// Use EventKit directly
let predicate = eventStore.predicateForEvents(
withStart: Date(),
end: Date().addingTimeInterval(86400),
calendars: nil
)
let ekEvents = eventStore.events(matching: predicate)
#endif

Access Android calendar data directly using the ContentResolver in #if SKIP blocks:

#if SKIP
import android.provider.CalendarContract
let context = ProcessInfo.processInfo.androidContext
let cursor = context.contentResolver.query(
CalendarContract.Events.CONTENT_URI,
nil, nil, nil, nil
)
// Process cursor...
cursor?.close()
#endif

This project is a Swift Package Manager module that uses the Skip plugin to build the package for both iOS and Android.

Building the module requires that Skip be installed using Homebrew with brew install skiptools/skip/skip. This will also install the necessary build prerequisites: Kotlin, Gradle, and the Android build tools.

The module can be tested using the standard swift test command or by running the test target for the macOS destination in Xcode, which will run the Swift tests as well as the transpiled Kotlin JUnit tests in the Robolectric Android simulation environment.

Parity testing can be performed with skip test, which will output a table of the test results for both platforms.