Skip to content

Calendar for SwiftUI

iOS overview · Integration · Shared foundations

Requires iOS 26.0+ and the SwiftUI integration. Read the shared foundations for data ownership and date semantics before integrating.

Calendar setup

Create and retain one model per calendar instance on the main actor, for example in consumer-owned @State. The initializer throws for an unrepresentable month range. Handle input errors at your app's composition boundary.

swift
import ChronoSketchCore
import ChronoSketchUI
import SwiftUI

// Inside a throwing @MainActor setup function:
let date = try CivilDate("2026-09-08")
let event = try CalendarEvent(
    id: "workshop", title: "Workshop",
    start: date, end: date.addingDays(2)
)
let model = try CalendarModel(
    date: date,
    events: CalendarEvents([event]),
    language: .german
)

// In your view, using that retained model:
CalendarView(
    model: model,
    today: date,
    onDateSelected: { selected in /* consumer action */ },
    onEventSelected: { event in /* consumer dialog */ }
)

The consumer supplies today's civil date according to its own clock/time-zone policy. The initial date is only an initial value; use model.select(date) for subsequent programmatic selection. Both selection and navigate(months:) throw if the resulting month range cannot be represented.

Data and state contract

  • CivilDate validates Gregorian YYYY-MM-DD values in years 1...9999. It is neither an instant nor a zoned timestamp. Day arithmetic is proleptic Gregorian; month navigation clamps the day to the destination month's last day.
  • CalendarEvent represents explicit all-day or floating timed intervals, with exclusive ends. September 8 through September 10 occupies September 8 and 9.
  • CalendarEvents rejects duplicate identifiers; event IDs must not be empty or whitespace-only. Events are sorted by start then identifier. Overlaps are allowed.
  • The consumer owns confirmed event data. model.setEvents(validatedEvents) replaces the read-only snapshot without resetting navigation or selection.
  • try model.setLanguage(.english) updates labels and week start while preserving selection. German starts Monday; English starts Sunday.
  • The month always contains 42 dates including adjacent months. Boundary months whose full grid exceeds years 1...9999 are rejected; navigation buttons disable unavailable adjacent months.
  • Date/today taps report onDateSelected after selection. Month arrows and programmatic model changes do not emit selection callbacks. Event taps report the event; they do not edit or persist it.

Presentation and current limits

Month displays a compact date grid with event dots. Events appear only in the selected-day agenda below the grid, once each with their complete original interval. There are no event bars inside the month grid.

Week uses connected bars in its pinned all-day header. Day shows each all-day event once with its full civil date range. Accessibility text sizes retain readable lists: Month puts its selected-day agenda first, and Week lists each event once under its first visible date. No per-day copies of a multi-day event are used as separate Week header cards.

Title editing and TaskGantt remain unimplemented natively. See the parity extensions below for newly added Agenda, availability, presentation and action APIs. Remaining differences are tracked in the central feature catalog.

The view grows with its content; place it in a consumer-owned ScrollView when needed. SwiftUI .tint(...) sets its accent and system light/dark appearance is inherited. The Web CSS contract does not apply. German/English controls, full-date accessibility labels, selected-state traits and Dynamic Type scaling are included; a complete VoiceOver/device audit remains outstanding.

Day view and local times

Create the model with view: .day, or call try model.setView(.day). The segmented month/week/day control uses the same state. Switching views, replacing events and changing language preserve the selected civil date. try model.navigate(steps: 1) advances a day, week or month according to that view. navigate(months:) remains explicitly month-based.

Use minute-precision local values for timed events:

swift
let meeting = try CalendarEvent(
    id: "meeting", title: "Meeting",
    start: LocalDateTime("2026-09-08T09:00"),
    end: LocalDateTime("2026-09-08T10:30")
)

LocalDateTime accepts exactly YYYY-MM-DDTHH:mm; seconds, offsets, invalid dates and 24:00 are rejected. It is a floating local value, with no time-zone or DST conversion. Both interval kinds require an end strictly after the start.

Read event boundaries by switching on event.interval:

swift
switch event.interval {
case .allDay(let start, let end):
    // CivilDate values; exclusive end.
    print(start, end)
case .timed(let start, let end):
    // LocalDateTime values; original exclusive interval.
    print(start, end)
}

This replaces the first local Swift slice's civil-only event.start/end fields. The civil-date initializer remains available. MonthCalendarModel and MonthCalendarView remain aliases for the general types, defaulting to month.

The day view places all-day events above a 24-hour scrollable grid. Connected overlap groups use parallel columns. Overnight events are clipped to each occupied day, while callbacks retain the original event interval. An end at 00:00 does not occupy that next day. TimedDayLayout.placements(events:date:) exposes the pure minute/column projection.

Day and Week render timed cards at least 44 points high. Their upper edge stays at the true start, and the title/time label retains the actual interval. Visual collision lanes include this minimum height: successive short appointments may appear beside each other even when their real intervals do not overlap. Columns can be reused once the visible cards no longer overlap. This UI projection does not change Core overlap semantics, callbacks, movement or duration arithmetic. Extra canvas space keeps minimum-height cards near midnight reachable.

Entering day view or changing date scrolls to the first timed event, or 08:00 for an empty day. Data/language/theme updates preserve the current scroll position. Many overlap columns allow horizontal scrolling. A disclosure list below the grid provides readable, full-size buttons for short events; accessibility text sizes replace the grid with that chronological list.

The day grid enables horizontal scrolling only when overlap columns exceed the available width. Otherwise it scrolls vertically only, with horizontal bounce disabled for content that fits. Vertical scrolling exposes the full 24-hour day.

Week view

Use view: .week on initialization or try model.setView(.week). The week contains seven dates, Monday–Sunday in German and Sunday–Saturday in English. navigate(steps:) advances seven civil days and preserves the weekday. The entire visible range must fit years 1...9999.

Week headings and connected all-day bars share one horizontal viewport with the time columns. Wide layouts show all seven dates; narrow layouts scroll horizontally. The time raster scrolls vertically. Initial positioning uses the earliest timed segment in the visible week, or 08:00 when empty. Updates within the same week preserve manual scrolling. Tapping a day heading selects that date, opens Day and emits onDateSelected. try model.openDay(date) provides the same state transition programmatically without emitting a callback. Only the consumer-supplied today date receives a subtle outline in its heading and a localized accessibility value. The previously selected date has no Week background highlight, including after returning from Day. Weeks that do not contain today show no outline.

Overlap columns and midnight clipping reuse Day's rules. Event selection receives the original event. For a more detailed view of densely overlapping or very short events, open the corresponding day and its event list. At accessibility text sizes, Week becomes a chronological list grouped by date.

Requesting a new event

Supply the optional handler to display the add action in Month, Week and Day:

swift
CalendarView(
    model: model,
    today: today,
    onEventCreateRequested: { request in
        // Consumer opens its own form. request.interval is validated.
    }
)

The button requests an all-day interval for the selected civil date, with an exclusive end on the following day. A request has no ID or title and creates no event. try model.creationRequest() returns the same request without mutation. Requests that cannot be represented at the supported date boundary throw.

The consumer owns its form, validation, identity, save errors and persistence. Cancel or ignore the request to leave data unchanged. After saving, provide the latest confirmed snapshot with model.setEvents(...); do not replace newer data with an old snapshot captured when the form opened. No asynchronous result or pending state is consumed by the library.

The demo has a native title/all-day/date/time form. Its all-day “Last day” field is inclusive; save converts it to the exclusive Core interval. Timed picker values are interpreted as floating Gregorian wall-clock minutes, without DST conversion. User-created events survive language and sample-visibility changes during the app session. They are not persisted after restart. Existing intervals can be edited as described below. Title editing and gesture-based creation remain future work.

Move timed events (SwiftUI)

Provide the optional onEventMoveRequested argument to CalendarView to enable long-press movement in Day and Week. Hold an event for 0.4 seconds, then drag. The preview uses relative 15-minute deltas, preserving off-grid minutes (09:07 plus one step becomes 09:22) and duration. In Week, dragging sideways moves by days within that visible week. Clipped overnight segments move their complete underlying event.

The callback receives CalendarEventMoveRequest with immutable previous and proposed events. Replace the corresponding event in your own data, validate a CalendarEvents snapshot, then call model.setEvents(snapshot). Reject stale requests if previous no longer matches your stored event. Ignoring a request leaves the displayed confirmed data unchanged. No callback means no movement. The library does not perform optimistic event updates or persistence.

Navigation, language or event updates invalidate an active gesture. Cancellation and dropping outside the grid leave data unchanged. Automatic edge scrolling, dragging from the large-text list layout is not implemented in this slice. Scrolling and regular taps remain available.

Shared interaction feedback is described in Haptics.

Move all-day events in Week

The same onEventMoveRequested callback also enables movement in the pinned all-day header. Hold for 0.4 seconds, then drag horizontally to another visible day. Movement snaps to whole civil days. Grabbing any visible day inside a multi-day bar shifts its entire original interval, including endpoints outside the visible week, and preserves duration, identity, title and the exclusive end. The grabbed day is the anchor; preview clipping follows the proposed interval. Overlapping events retain separate lanes during the drag; only confirmation recalculates their lane allocation.

CalendarEventMoveRequest(event:days:) constructs an all-day proposal directly. The existing event:minutes: initializer remains timed-only; neither initializer changes event kind. Invalid date bounds throw without changing confirmed data. Consumer confirmation and stale-request rejection follow the timed movement contract above. Ignoring a request leaves the original data in place.

Releasing outside the visible all-day header, cancelling, or dropping on the same day leaves data unchanged. Normal pans still scroll before hold activation; active holds prevent the surrounding scroll view from taking over the drag. Navigation, event updates and disabling the handler discard active gestures. Shared haptics signal activation and valid day steps; confirmation feedback remains consumer-owned. There is no edge autoscroll or conversion into a timed event by dropping into the time raster. In Day, Month and accessibility list layouts, use selection and the consumer interval editor to change the dates.

Event duration and interval editing

Provide onEventResizeRequested to CalendarView to enable timed end handles in Day/Week. Long-press a card to activate it, release, then drag its end handle. The visual grip has a minimum 44-point touch area. Only the active event exposes a handle, and only on a segment whose time-proportional height is at least 44 points and which contains the actual end. Short cards and clipped intermediate segments use the consumer dialog.

The handle applies relative 15-minute deltas to the original end within the displayed date, including exclusive midnight. Start, identity and title stay fixed. End must remain after start. Preview data stays transient; a valid drop emits a CalendarEventEditRequest (previous, proposed). The consumer validates the original snapshot and confirms with setEvents. Ignored/cancelled/no-op requests do not modify events. Haptic meanings match Move.

The demo detail sheet offers Edit interval / Zeitraum ändern from Month, Week and Day. Native pickers edit both endpoints, retaining kind/title/identity. All-day last dates are inclusive in the form and exclusive in the Core request. CalendarEventEditRequest(event:interval:) supports this consumer editing flow; CalendarEventEditRequest(resizing:minutes:) changes only a timed end.

The library does not ship a product editor. Month handles, resizing across the visible day's boundary and automatic edge scrolling remain deferred. Use the consumer dialog for longer ranges and precise edits of short events.

Parity extensions in SwiftUI

The SwiftUI integration includes .agenda: a complete monthly list grouped by occupied dates, separate from Month's selected-day agenda. Month stays a compact grid without inline event bars. CalendarModel(..., views: [.week, .day]) controls enabled views and order; duplicates are removed, empty input is rejected. An excluded initial view falls back to the first enabled view. Use setViews and setView for updates. The period heading now opens a native navigation date picker.

Use .eventAppearance { event in CalendarEventAppearance(...) } on CalendarView for title/person/resources/details/status, tint/text color and decorative media. .eventContent { context in ... } replaces decorative content with SwiftUI. Its context contains the original event, view, subtitle and resolved appearance. CalendarDefaultEventContent lets a builder reuse the default content. Keep consumer metadata in your own state keyed by event ID. Outer sizing/gestures remain library-owned. Media reuses TimelineMedia asset/remote/symbol/initials support.

.eventActions({ event in [...] }, onAction: { id, event in ... }) provides consumer actions using CalendarEventAction (the shared native action value). Disabled actions do not dispatch. Invalid/duplicate actions follow the native Timeline filtering contract. Lists expose an action menu; held canvas events expose the menu after activation. Dispatch rechecks event freshness and enabled state. Actions do not persist changes automatically.

Availability

swift
let availability = try CalendarAvailability(
    constraints: [CalendarConstraint(
        id: "maintenance",
        interval: .timed(start: LocalDateTime("2026-09-09T12:00"),
                         end: LocalDateTime("2026-09-09T13:00")))],
    preventEventOverlap: true
)
model.setAvailability(availability)

Initializers above throw; use them inside a throwing scope with try as shown. CalendarEventProtection(beforeMinutes:afterMinutes:) supplies validated event buffers; pass it as protection to an event initializer. Movement/resize preserves protection. Touching exclusive boundaries are allowed; buffers can cross midnight. Constraints can restrict blocks to create/move/resize and specify an owning eventID, ignored only when editing that event. An authoritative setEvents import can still contain conflicts, as on Web.

model.checkInteraction(CalendarInteractionRequest(...)) returns allowed, conflicts and optional reason. setAvailability(..., validator:) accepts a pure synchronous custom rejection-reason callback. Native move/resize proposals and creation requests use these checks. Day/Week render blocked and buffer bands. Your application must still validate confirmed writes against current data; there is no backend or asynchronous validator. Clear with setAvailability(nil).

Initial time-grid position

CalendarView.dayScrollPosition(_:) and .weekScrollPosition(_:) independently accept .currentTime (default), .midnight, or .firstEvent (08:00 when empty). These match Web dayScrollPosition/weekScrollPosition; current time uses the device clock at minute precision. Event updates retain manual scroll within the period; entering another day/week or changing the policy reapplies it. The demo explicitly uses .firstEvent to retain its previous initial position.

.showWeekNumbers(true) enables ISO week numbers in Month/Agenda and the Day/Week navigation heading. Native compact Month presentation is retained.

Month marks days containing blocked intervals/buffers with a small lock and an accessibility description. Selecting the day shows labeled time ranges below the grid. Monthly Agenda includes days with availability intervals even when they have no events. These summaries use the same exclusive clipped intervals as the Day/Week bands; they do not disable an entire day for a partial-day constraint.

ChronoSketch · Calendar and scheduling components