Day Schedule Timeline
The hours of a day down a column, with each event drawn over the time it takes, overlapping events side by side and a line at the current time.

- Calendars
- iOS 18.0+
- advanced
- Origin
- Inspired by The day view of Apple's Calendar, written from scratch
- Found by
- day view · schedule · agenda · timeline · calendar events · hours · overlapping events · current time · vista de día · horario · agenda · línea de tiempo · eventos del calendario · horas · eventos solapados · hora actual
Description
Section titled “Description”The hours of a day down a column, with each event drawn over the time it takes. Events that overlap share the width, and a line marks the time it is.
A tap on an event sets selection. The column starts scrolled to its first event.
struct DayScheduleTimelineDemo: View { private let events = [ DayScheduleTimelineEvent(id: "standup", title: "Stand-up", start: 9 * 60, end: 9 * 60 + 30, color: .teal), DayScheduleTimelineEvent(id: "review", title: "Design review", start: 10 * 60, end: 11 * 60 + 30, color: .indigo), DayScheduleTimelineEvent(id: "interview", title: "Interview", start: 10 * 60 + 30, end: 11 * 60 + 15, color: .orange), DayScheduleTimelineEvent(id: "lunch", title: "Lunch with Ana", start: 13 * 60, end: 14 * 60, color: .green), DayScheduleTimelineEvent(id: "focus", title: "Focus time", start: 15 * 60, end: 17 * 60, color: .purple), DayScheduleTimelineEvent(id: "gym", title: "Gym", start: 18 * 60 + 30, end: 19 * 60 + 30, color: .pink), ]
@State private var selection: String? = "review"
var body: some View { VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 2) { Text(Date.now, format: .dateTime.weekday(.wide)) .font(.subheadline.weight(.semibold)) .foregroundStyle(.secondary) Text(Date.now, format: .dateTime.day().month(.wide)) .font(.largeTitle.bold()) } .padding(.horizontal, 20) .padding(.vertical, 12)
Divider()
DayScheduleTimeline(events: events, selection: $selection) .padding(.trailing, 16) } }}API reference
Section titled “API reference”| Parameter | Type | Default |
|---|---|---|
events | [DayScheduleTimelineEvent] | Required |
selection | Binding<DayScheduleTimelineEvent.ID?> | Required |
hours | ClosedRange<Int> | 7...21 |
showsCurrentTime | Bool | true |
Copy this code into your project, or download the file.
// SPDX-License-Identifier: MIT// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
/// An event of the day. `start` and `end` are minutes after midnight: 570 is half past nine.struct DayScheduleTimelineEvent: Identifiable { let id: String let title: String let start: Int let end: Int var color: Color = .blue}
nonisolated enum DayScheduleTimelineMath { static let minutesPerHour = 60
struct Slot: Equatable, Sendable { /// The column of the event, from the leading edge. let column: Int /// How many columns share the width where the event is. let columns: Int }
/// Puts events that overlap side by side. Each takes the first free column, and every /// event of a run of overlaps is as wide as the others, so none hides another. static func slots(for spans: [ClosedRange<Int>]) -> [Slot] { var slots = [Slot](repeating: Slot(column: 0, columns: 1), count: spans.count) let order = spans.indices.sorted { (spans[$0].lowerBound, spans[$0].upperBound) < (spans[$1].lowerBound, spans[$1].upperBound) }
var run: [Int] = [] var runEnd = Int.min var columnEnds: [Int] = []
func close() { for index in run { slots[index] = Slot(column: slots[index].column, columns: max(columnEnds.count, 1)) } run = [] columnEnds = [] }
for index in order { let span = spans[index] if span.lowerBound >= runEnd { close() }
let column = columnEnds.firstIndex { $0 <= span.lowerBound } ?? columnEnds.count if column == columnEnds.count { columnEnds.append(span.upperBound) } else { columnEnds[column] = span.upperBound } slots[index] = Slot(column: column, columns: 1) run.append(index) runEnd = max(runEnd, span.upperBound) } close() return slots }
/// How far down a minute of the day is, in a column that starts at `firstHour`. static func offset(minute: Int, firstHour: Int, hourHeight: CGFloat) -> CGFloat { CGFloat(minute - firstHour * minutesPerHour) / CGFloat(minutesPerHour) * hourHeight }}
/// The hours of a day down a column, with each event drawn over the time it takes. Events/// that overlap share the width, and a line marks the time it is.////// A tap on an event sets `selection`. The column starts scrolled to its first event.struct DayScheduleTimeline: View { let events: [DayScheduleTimelineEvent] @Binding var selection: DayScheduleTimelineEvent.ID? var hours: ClosedRange<Int> = 7...21 var showsCurrentTime = true
@ScaledMetric(relativeTo: .body) private var hourHeight = 88.0 @ScaledMetric(relativeTo: .caption) private var labelWidth = 52.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion
private let calendar = Calendar.current private let gap: CGFloat = 3 private let cornerRadius: CGFloat = 10 private let minutesPerHour = DayScheduleTimelineMath.minutesPerHour
private var firstHour: Int { min(max(hours.lowerBound, 0), 23) } private var lastHour: Int { min(max(hours.upperBound, firstHour + 1), 24) }
private func date(atMinute minute: Int) -> Date { calendar.date(byAdding: .minute, value: minute, to: calendar.startOfDay(for: .now)) ?? .now }
var body: some View { ScrollViewReader { proxy in ScrollView { ZStack(alignment: .topLeading) { grid eventLayer if showsCurrentTime { currentTime } } .padding(.vertical, 12) } .onAppear { guard let first = events.map(\.start).min() else { return } proxy.scrollTo(min(max(first / minutesPerHour, firstHour), lastHour - 1), anchor: .top) } } }
private var grid: some View { VStack(spacing: 0) { ForEach(firstHour..<lastHour, id: \.self) { hour in HStack(alignment: .top, spacing: 8) { Text(date(atMinute: hour * minutesPerHour), format: .dateTime.hour()) .font(.caption) .foregroundStyle(.secondary) .frame(width: labelWidth, alignment: .trailing) // The label sits on its line, not under it. .alignmentGuide(.top) { $0[VerticalAlignment.center] }
Rectangle() .fill(.quaternary) .frame(height: 1) } .frame(height: hourHeight, alignment: .top) .id(hour) } } }
private var eventLayer: some View { let slots = DayScheduleTimelineMath.slots(for: events.map { $0.start...max($0.end, $0.start) })
return GeometryReader { proxy in let width = max(proxy.size.width - labelWidth - 8, 0)
ForEach(Array(events.enumerated()), id: \.element.id) { index, event in let slot = slots[index] let column = width / CGFloat(slot.columns) let height = max(DayScheduleTimelineMath.offset(minute: event.end, firstHour: 0, hourHeight: hourHeight) - DayScheduleTimelineMath.offset(minute: event.start, firstHour: 0, hourHeight: hourHeight), hourHeight / 2)
block(event) .frame(width: max(column - gap, 0), height: max(height - gap, 0), alignment: .topLeading) .offset( x: labelWidth + 8 + column * CGFloat(slot.column), y: DayScheduleTimelineMath.offset(minute: event.start, firstHour: firstHour, hourHeight: hourHeight) ) } } }
private func block(_ event: DayScheduleTimelineEvent) -> some View { let isSelected = selection == event.id
return Button { withAnimation(reduceMotion ? nil : .snappy(duration: 0.25)) { selection = isSelected ? nil : event.id } } label: { VStack(alignment: .leading, spacing: 2) { Text(event.title) .font(.subheadline.weight(.semibold)) .lineLimit(2) Text(date(atMinute: event.start)...date(atMinute: event.end)) .font(.caption) .opacity(0.8) .lineLimit(1) } .foregroundStyle(isSelected ? AnyShapeStyle(DayScheduleTimelineContrastingForeground(background: event.color)) : AnyShapeStyle(.primary)) .padding(.horizontal, cornerRadius) .padding(.vertical, 6) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .background(event.color.opacity(isSelected ? 1 : 0.22), in: .rect(cornerRadius: cornerRadius)) .overlay(alignment: .leading) { UnevenRoundedRectangle(cornerRadii: .init(topLeading: cornerRadius, bottomLeading: cornerRadius)) .fill(event.color) .frame(width: 4) } .contentShape(.rect) } .buttonStyle(.plain) .accessibilityAddTraits(isSelected ? .isSelected : []) }
private var currentTime: some View { TimelineView(.everyMinute) { context in let parts = calendar.dateComponents([.hour, .minute], from: context.date) let minute = (parts.hour ?? 0) * minutesPerHour + (parts.minute ?? 0)
if minute >= firstHour * minutesPerHour, minute <= lastHour * minutesPerHour { HStack(spacing: 0) { Circle() .fill(.red) .frame(width: 9, height: 9) Rectangle() .fill(.red) .frame(height: 1.5) } .padding(.leading, labelWidth + 3) .offset(y: DayScheduleTimelineMath.offset(minute: minute, firstHour: firstHour, hourHeight: hourHeight) - 4.5) .allowsHitTesting(false) .accessibilityHidden(true) } } }}
/// White or black, whichever reads better on `background`. Black only on a/// light color, so the system's own colors keep white.nonisolated struct DayScheduleTimelineContrastingForeground: ShapeStyle { let background: Color
func resolve(in environment: EnvironmentValues) -> Color { let color = background.resolve(in: environment) let luminance = 0.2126 * color.linearRed + 0.7152 * color.linearGreen + 0.0722 * color.linearBlue return luminance > 0.5 ? .black : .white }}
// MARK: - Demo
struct DayScheduleTimelineDemo: View { private let events = [ DayScheduleTimelineEvent(id: "standup", title: "Stand-up", start: 9 * 60, end: 9 * 60 + 30, color: .teal), DayScheduleTimelineEvent(id: "review", title: "Design review", start: 10 * 60, end: 11 * 60 + 30, color: .indigo), DayScheduleTimelineEvent(id: "interview", title: "Interview", start: 10 * 60 + 30, end: 11 * 60 + 15, color: .orange), DayScheduleTimelineEvent(id: "lunch", title: "Lunch with Ana", start: 13 * 60, end: 14 * 60, color: .green), DayScheduleTimelineEvent(id: "focus", title: "Focus time", start: 15 * 60, end: 17 * 60, color: .purple), DayScheduleTimelineEvent(id: "gym", title: "Gym", start: 18 * 60 + 30, end: 19 * 60 + 30, color: .pink), ]
@State private var selection: String? = "review"
var body: some View { VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 2) { Text(Date.now, format: .dateTime.weekday(.wide)) .font(.subheadline.weight(.semibold)) .foregroundStyle(.secondary) Text(Date.now, format: .dateTime.day().month(.wide)) .font(.largeTitle.bold()) } .padding(.horizontal, 20) .padding(.vertical, 12)
Divider()
DayScheduleTimeline(events: events, selection: $selection) .padding(.trailing, 16) } }}
#Preview { DayScheduleTimelineDemo()}