Skip to content

Instantly share code, notes, and snippets.

@hishnash
Created August 27, 2026 08:27
Show Gist options
  • Select an option

  • Save hishnash/a5da650e89a2a1908f448fd2c8d9c737 to your computer and use it in GitHub Desktop.

Select an option

Save hishnash/a5da650e89a2a1908f448fd2c8d9c737 to your computer and use it in GitHub Desktop.
Accessible Swift Charts Calendar
import Algorithms
import Charts
import SwiftUI
struct AccessibleStepCalendar: View {
let dailyStepCounts: [DailyStepCount]
var body: some View {
Chart(dailyStepCounts) { dailyStepCount in
DailyStepCountMark(
dailyStepCount: dailyStepCount
)
}
.chartForegroundStyleScale(range: StepCalendarPalette.gradient)
.chartXAxis(.hidden)
.chartYAxis(.hidden)
.chartLegend(.hidden)
.chartPlotStyle { chartContent in
chartContent.accessibilityChildren {
StepCalendarAccessibilityContent(
dailyStepCounts: dailyStepCounts
)
}
}
.aspectRatio(
StepCalendarLayout.aspectRatio(for: dailyStepCounts),
contentMode: .fit
)
}
}
private struct StepCalendarAccessibilityContent: View {
let dailyStepCounts: [DailyStepCount]
var body: some View {
VStack(spacing: 0) {
ForEach(
paddedDays.chunks(ofCount: 7),
id: \.startIndex
) { week in
StepCalendarAccessibilityWeekRow(
days: week
)
}
}
}
private var paddedDays: [DailyStepCount?] {
guard let firstDate = dailyStepCounts.first?.date else {
return []
}
let calendar = Calendar.current
let weekday = calendar.component(.weekday, from: firstDate)
let leadingDayCount =
(weekday - calendar.firstWeekday + 7) % 7
var days = Array(
repeating: DailyStepCount?.none,
count: leadingDayCount
)
days.append(contentsOf: dailyStepCounts.map(Optional.some))
let trailingDayCount = (7 - days.count % 7) % 7
days.append(
contentsOf: repeatElement(
DailyStepCount?.none,
count: trailingDayCount
)
)
return days
}
}
private struct StepCalendarAccessibilityWeekRow: View {
let days: ArraySlice<DailyStepCount?>
var body: some View {
HStack(spacing: 0) {
ForEach(days.indices, id: \.self) { index in
let day = days[index]
RoundedRectangle(cornerRadius: 4)
.aspectRatio(1, contentMode: .fit)
.accessibilityLabel(
day?.accessibilityDate ?? Text("")
)
.accessibilityValue(
day?.accessibilityStepCount ?? Text("")
)
.accessibilityAddTraits(
day?.isFirstDayOfMonth == true ? .isHeader : []
)
.accessibilityHidden(day == nil)
}
}
}
}
private enum CalendarTileLayout {
static func heightInset(
for position: PositionWithinMonth
) -> CGFloat {
switch position {
case .firstWeek, .lastWeek:
13
case .interior:
1
}
}
static func verticalOffset(
for position: PositionWithinMonth
) -> CGFloat {
switch position {
case .firstWeek:
12
case .interior:
0
case .lastWeek:
-12
}
}
}
private struct DailyStepCountMark: ChartContent {
let dailyStepCount: DailyStepCount
var body: some ChartContent {
RectangleMark(
x: .value(
"Day of week",
dailyStepCount.normalizedWeekday,
unit: .weekday
),
y: .value(
"Week of year",
dailyStepCount.date,
unit: .weekOfYear
),
width: .inset(1),
height: .inset(
CalendarTileLayout.heightInset(
for: dailyStepCount.positionWithinMonth
)
)
)
.foregroundStyle(
by: .value("Step count", dailyStepCount.stepCount)
)
.cornerRadius(4)
.offset(
y: CalendarTileLayout.verticalOffset(
for: dailyStepCount.positionWithinMonth
)
)
.annotation(position: .overlay, alignment: .topLeading, spacing: 0) {
if dailyStepCount.isFirstDayOfMonth {
MonthLabel(
date: dailyStepCount.date
)
}
}
.annotation(
position: .overlay,
alignment: .bottomTrailing,
spacing: 0
) {
DayOfMonthLabel(
date: dailyStepCount.date
)
}
}
}
private struct MonthLabel: View {
let date: Date
var body: some View {
Text(date, format: .dateTime.month(.abbreviated))
.fontDesign(.rounded)
.foregroundStyle(.white)
.shadow(color: .black.opacity(0.5), radius: 1)
.padding(4)
}
}
private struct DayOfMonthLabel: View {
let date: Date
var body: some View {
Text(date, format: .dateTime.day())
.fontDesign(.rounded)
.foregroundStyle(.white)
.shadow(color: .black.opacity(0.5), radius: 1)
.padding(4)
}
}
struct DailyStepCount: Identifiable, Sendable {
let date: Date
let stepCount: Int
var id: Date { date }
var normalizedWeekday: Date {
let calendar = Calendar.current
let referenceDate = Date(timeIntervalSinceReferenceDate: 0)
var normalizedComponents = calendar.dateComponents(
[.weekday, .hour, .minute, .second],
from: date
)
let referenceWeekComponents = calendar.dateComponents(
[.yearForWeekOfYear, .weekOfYear],
from: referenceDate
)
normalizedComponents.yearForWeekOfYear =
referenceWeekComponents.yearForWeekOfYear
normalizedComponents.weekOfYear =
referenceWeekComponents.weekOfYear
return calendar.date(from: normalizedComponents) ?? referenceDate
}
var isFirstDayOfMonth: Bool {
Calendar.current.component(.day, from: date) == 1
}
fileprivate var positionWithinMonth: PositionWithinMonth {
let calendar = Calendar.current
guard let month = calendar.dateInterval(of: .month, for: date),
let finalDay = calendar.date(
byAdding: .day,
value: -1,
to: month.end
) else {
return .interior
}
if calendar.isDate(date, equalTo: month.start, toGranularity: .weekOfYear) {
return .firstWeek
}
if calendar.isDate(date, equalTo: finalDay, toGranularity: .weekOfYear) {
return .lastWeek
}
return .interior
}
var accessibilityDate: Text {
Text(
date,
format: .dateTime.weekday(.wide).day().month(.wide)
)
}
var accessibilityStepCount: Text {
Text("^[\(stepCount) step](inflect: true)")
}
}
fileprivate enum PositionWithinMonth: Sendable {
case firstWeek
case interior
case lastWeek
}
private enum StepCalendarLayout {
static func aspectRatio(
for dailyStepCounts: [DailyStepCount]
) -> CGFloat {
let calendar = Calendar.current
guard let first = dailyStepCounts.first,
let last = dailyStepCounts.last,
let firstWeek = calendar.dateInterval(
of: .weekOfYear,
for: first.date
),
let lastWeek = calendar.dateInterval(
of: .weekOfYear,
for: last.date
) else {
return 7
}
let weeksBetween = calendar.dateComponents(
[.weekOfYear],
from: firstWeek.start,
to: lastWeek.start
).weekOfYear ?? 0
return 7 / CGFloat(weeksBetween + 1)
}
}
private enum StepCalendarPalette {
static let gradient = Gradient(colors: [
Color(red: 0.379, green: 0.669, blue: 0.566),
Color(red: 0.201, green: 0.521, blue: 0.553),
Color(red: 0.116, green: 0.366, blue: 0.527)
])
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment