-
-
Save hishnash/a5da650e89a2a1908f448fd2c8d9c737 to your computer and use it in GitHub Desktop.
Accessible Swift Charts Calendar
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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