import SwiftUI | |
extension Calendar { | |
func generateDates( | |
inside interval: DateInterval, | |
matching components: DateComponents | |
) -> [Date] { | |
var dates: [Date] = [] | |
dates.append(interval.start) | |
enumerateDates( | |
startingAfter: interval.start, | |
matching: components, | |
matchingPolicy: .nextTime | |
) { date, _, stop in | |
if let date = date { | |
if date < interval.end { | |
dates.append(date) | |
} else { | |
stop = true | |
} | |
} | |
} | |
return dates | |
} | |
} | |
extension DateFormatter { | |
static let monthAndYear: DateFormatter = { | |
let formatter = DateFormatter() | |
formatter.setLocalizedDateFormatFromTemplate("MMMM yyyy") | |
return formatter | |
}() | |
} | |
struct EquatableCalendarView<DateView: View, Value: Equatable>: View, Equatable { | |
static func == ( | |
lhs: EquatableCalendarView<DateView, Value>, | |
rhs: EquatableCalendarView<DateView, Value> | |
) -> Bool { | |
lhs.interval == rhs.interval && lhs.value == rhs.value && lhs.showHeaders == rhs.showHeaders | |
} | |
let interval: DateInterval | |
let value: Value | |
let showHeaders: Bool | |
let onHeaderAppear: (Date) -> Void | |
let content: (Date) -> DateView | |
init( | |
interval: DateInterval, | |
value: Value, | |
showHeaders: Bool = true, | |
onHeaderAppear: @escaping (Date) -> Void = { _ in }, | |
@ViewBuilder content: @escaping (Date) -> DateView | |
) { | |
self.interval = interval | |
self.value = value | |
self.showHeaders = showHeaders | |
self.onHeaderAppear = onHeaderAppear | |
self.content = content | |
} | |
var body: some View { | |
CalendarView( | |
interval: interval, | |
showHeaders: showHeaders, | |
onHeaderAppear: onHeaderAppear | |
) { date in | |
content(date) | |
} | |
} | |
} | |
struct CalendarView<DateView>: View where DateView: View { | |
let interval: DateInterval | |
let showHeaders: Bool | |
let onHeaderAppear: (Date) -> Void | |
let content: (Date) -> DateView | |
@Environment(\.sizeCategory) private var contentSize | |
@Environment(\.calendar) private var calendar | |
@State private var months: [Date] = [] | |
@State private var days: [Date: [Date]] = [:] | |
private var columns: [GridItem] { | |
let spacing: CGFloat = contentSize.isAccessibilityCategory ? 2 : 8 | |
return Array(repeating: GridItem(spacing: spacing), count: 7) | |
} | |
var body: some View { | |
LazyVGrid(columns: columns) { | |
ForEach(months, id: \.self) { month in | |
Section(header: header(for: month)) { | |
ForEach(days[month, default: []], id: \.self) { date in | |
if calendar.isDate(date, equalTo: month, toGranularity: .month) { | |
content(date).id(date) | |
} else { | |
content(date).hidden() | |
} | |
} | |
} | |
} | |
} | |
.onAppear { | |
months = calendar.generateDates( | |
inside: interval, | |
matching: DateComponents(day: 1, hour: 0, minute: 0, second: 0) | |
) | |
days = months.reduce(into: [:]) { current, month in | |
guard | |
let monthInterval = calendar.dateInterval(of: .month, for: month), | |
let monthFirstWeek = calendar.dateInterval(of: .weekOfMonth, for: monthInterval.start), | |
let monthLastWeek = calendar.dateInterval(of: .weekOfMonth, for: monthInterval.end) | |
else { return } | |
current[month] = calendar.generateDates( | |
inside: DateInterval(start: monthFirstWeek.start, end: monthLastWeek.end), | |
matching: DateComponents(hour: 0, minute: 0, second: 0) | |
) | |
} | |
} | |
} | |
private func header(for month: Date) -> some View { | |
Group { | |
if showHeaders { | |
Text(DateFormatter.monthAndYear.string(from: month)) | |
.font(.title) | |
.padding() | |
} | |
} | |
.onAppear { onHeaderAppear(month) } | |
} | |
} |
@mecid I use RKCalendar but your Calendar code is better. Could you implement showing more months at same time with scrolling and ability to select date range?
https://gist.github.com/jz709u/ed97507a8655ce5b23e205b0feea80bb
I have fixed performance issues:
- caching header strings on init
- caching dates on init
- remove headerView frame modifications and padding
headerView padding and frame modifications:
has drastic performance issues on layout engine. I think the LazyVStack view does not reuse section header frame calculations so any frame modifications or padding needs to be recalculated when header is re-displayed hence reloading the entire view while scrolling hence the performance drops in scrolling.
hi @polofu
on your last comment,
I think we could slice the weekdaySymbols since it returns array of string (Sunday - Saturday).
then take a look at a func I improved from @alahdal's gist,
note that above code only changes the weekdays header, make sure to update the calendar dates.
here also some resources you can look at:
https://developer.apple.com/documentation/foundation/calendar/2293235-weekdaysymbols
https://developer.apple.com/documentation/foundation/calendar/2293656-firstweekday
@mehdi thank you for a great tutorial and component! You might want to update the code in your blog post. It lacks some changes, most notably the duplicit first week bugfix.
Is it possible to do a infinite scroll just like iPhone default calendar apps?
Is there anyway to pin the header to the top of the calendar view?
@MrCarter31 there is pinnedViews
parameter on LazyVGrid. You can use .sectionHeaders to pin month headers.
Wonderful code! I tweaked the MonthView to take a parameter to force the display of the year for the first displayed month (useful for me since my calendar may only have a few displayed months).
Every time I read your code I learn something new about SwiftUI - e.g. @ViewBuilder parameter (and DateInterval).
Many thanks from an old programmer who's new to Swift :)
Awesome work again @mecid! It's amazing how little code something complex as a calendar could be with the SwiftUI grid.
I evolved it in a few ways:
- Made it
Equatable
so it wouldn't re-render unnecessarily. I'm comparingDateInterval
which I think is a safe assumption to make. Also be careful not just to useDate()
since it will always be different, lock it to some date... which was driving me nuts in testing😅 - I exposed title and header as a
ViewBuilder
to the caller so they can render it as they please. This also naturally resolves customcalendar.firstWeekDay
by itself since the caller is just formatting the date. - Add optional trailing dates for few days before and after the month if they're in the week.
- Fixed accessibility for the equal background trick (I wish there as a cleaner way to do this but couldn't figure either).
struct ContentView: View {
private let dateInterval = DateInterval(
start: Date(timeIntervalSince1970: 1617316527),
end: Date(timeIntervalSince1970: 1627794000)
)
@State private var selectedDate: Date?
var body: some View {
VStack {
if let date = selectedDate {
Text("Selected date: \(DateFormatter.day.string(from: date))")
}
CalendarView(dateInterval: dateInterval) { date in
Button(action: { selectedDate = date }) {
Text("00")
.padding(8)
.foregroundColor(.clear)
.background(Color.blue)
.cornerRadius(8)
.accessibilityHidden(true)
.overlay(
Text(DateFormatter.day.string(from: date))
.foregroundColor(.white)
)
}
} header: {
Text(DateFormatter.weekDay.string(from: $0))
// EmptyView() if no header wanted
} title: {
Text(DateFormatter.monthAndYear.string(from: $0))
.font(.headline)
.padding()
// EmptyView() if no title wanted
} trailing: {
Text(DateFormatter.day.string(from: $0))
.foregroundColor(.secondary)
//.hidden() // To remove trailing dates
}
.equatable()
}
}
}
struct CalendarView<Day: View, Header: View, Title: View, Trailing: View>: View {
@Environment(\.calendar) private var calendar
private let dateInterval: DateInterval
private let content: (Date) -> Day
private let header: (Date) -> Header
private let title: (Date) -> Title
private let trailing: (Date) -> Trailing
private let daysInWeek = 7
init(
dateInterval: DateInterval,
@ViewBuilder content: @escaping (Date) -> Day,
@ViewBuilder header: @escaping (Date) -> Header,
@ViewBuilder title: @escaping (Date) -> Title,
@ViewBuilder trailing: @escaping (Date) -> Trailing
) {
self.dateInterval = dateInterval
self.content = content
self.header = header
self.title = title
self.trailing = trailing
}
var body: some View {
let dates = makeDates()
return ScrollView {
LazyVGrid(columns: Array(repeating: GridItem(), count: daysInWeek)) {
ForEach(dates, id: \.month) { (month, days) in
Section(header: title(month)) {
ForEach(makeHeaderDays(for: month), id: \.self, content: header)
ForEach(days, id: \.self) { date in
if calendar.isDate(date, equalTo: month, toGranularity: .month) {
content(date)
} else {
trailing(date)
}
}
}
}
}
}
}
}
private extension CalendarView {
func makeDates() -> [(month: Date, days: [Date])] {
calendar
.generateDates(
for: dateInterval,
matching: DateComponents(day: 1, hour: 0, minute: 0, second: 0)
)
.map { ($0, days(for: $0)) }
}
func makeHeaderDays(for month: Date) -> [Date] {
guard let week = calendar.dateInterval(of: .weekOfMonth, for: month) else { return [] }
return calendar
.generateDates(for: week, matching: DateComponents(hour: 0, minute: 0, second: 0))
.prefix(daysInWeek) // Ensure number of days matches grid
.array
}
func days(for month: Date) -> [Date] {
guard let monthInterval = calendar.dateInterval(of: .month, for: month),
let monthFirstWeek = calendar.dateInterval(of: .weekOfMonth, for: monthInterval.start),
let monthLastWeek = calendar.dateInterval(of: .weekOfMonth, for: monthInterval.end - 1)
else {
return []
}
return calendar.generateDates(
for: DateInterval(start: monthFirstWeek.start, end: monthLastWeek.end),
matching: DateComponents(hour: 0, minute: 0, second: 0)
)
}
}
extension CalendarView: Equatable {
static func == (lhs: CalendarView<Day, Header, Title, Trailing>, rhs: CalendarView<Day, Header, Title, Trailing>) -> Bool {
lhs.dateInterval == rhs.dateInterval
}
}
// MARK: - Helpers
private extension DateFormatter {
static var monthAndYear: DateFormatter {
let formatter = DateFormatter()
formatter.dateFormat = "MMMM yyyy"
return formatter
}
static var day: DateFormatter {
let formatter = DateFormatter()
formatter.dateFormat = "d"
return formatter
}
static var weekDay: DateFormatter {
let formatter = DateFormatter()
formatter.dateFormat = "EEEEE"
return formatter
}
}
private extension Calendar {
/// Returns the dates which match a given set of components between the specified dates.
/// - Parameters:
/// - dateInterval: The `DateInterval` between which to compute the search.
/// - components: The `DateComponents` to use as input to the search algorithm.
/// - Returns: The dates between the date interval that match the specified date component.
func generateDates(
for dateInterval: DateInterval,
matching components: DateComponents
) -> [Date] {
var dates = [dateInterval.start]
enumerateDates(
startingAfter: dateInterval.start,
matching: components,
matchingPolicy: .nextTime
) { date, _, stop in
guard let date = date else { return }
guard date < dateInterval.end else {
stop = true
return
}
dates.append(date)
}
return dates
}
}
private extension ArraySlice {
/// Returns the array of the slice
var array: [Element] { Array(self) }
}
@basememara Thank you for your code! But I have performance issue when running on iOS 14.5, do you have the same problem?
Edit: after I remove .clipshape(circle) and use .cornerRadius, the performance is better but not smooth.
I did have performance issues before adding the equatable code, but was better afterwards. Also, when supplying a date to it, don’t use Date()
for the calendar date range. Instead cache the current date like:
private static let now = Date()
private let dateInterval = DateInterval(
start: Self.now,
end: Self.now + //days, weeks, etc
)
Otherwise the calendar will refresh every time the view redraws and the equatable logic will have no effect.
I must confess I’m also just using this to display one month only. I briefly tried it for 3 months but wasn’t a requirement for my app. I would imagine displaying the whole year would be intense and the equatable logic would have to get more sophisticate for caching per month otherwise changes would re-render the whole year unnecessarily.
@basememara Hi! Thank you for your quick reply. May I ask If I want to display one month only and there will be buttons to switch prev/next month, what view should I use to wrap the calendar component? Should I generate 365 days at the beginning or generate one month per display?
Here's an updated version to show traversing the months. It generates one month at a time, then recalculates only when selecting the next/previous month. It also accepts any calendar and selection date, then it expands the date to encapsulate the current month:
struct ContentView: View {
private let calendar: Calendar
private let monthFormatter: DateFormatter
private let dayFormatter: DateFormatter
private let weekDayFormatter: DateFormatter
private let fullFormatter: DateFormatter
@State private var selectedDate = Self.now
private static var now = Date() // Cache now
init(calendar: Calendar) {
self.calendar = calendar
self.monthFormatter = DateFormatter(dateFormat: "MMMM", calendar: calendar)
self.dayFormatter = DateFormatter(dateFormat: "d", calendar: calendar)
self.weekDayFormatter = DateFormatter(dateFormat: "EEEEE", calendar: calendar)
self.fullFormatter = DateFormatter(dateFormat: "MMMM dd, yyyy", calendar: calendar)
}
var body: some View {
VStack {
Text("Selected date: \(fullFormatter.string(from: selectedDate))")
.bold()
.foregroundColor(.red)
CalendarView(
calendar: calendar,
date: $selectedDate,
content: { date in
Button(action: { selectedDate = date }) {
Text("00")
.padding(8)
.foregroundColor(.clear)
.background(
calendar.isDate(date, inSameDayAs: selectedDate) ? Color.red
: calendar.isDateInToday(date) ? .green
: .blue
)
.cornerRadius(8)
.accessibilityHidden(true)
.overlay(
Text(dayFormatter.string(from: date))
.foregroundColor(.white)
)
}
},
trailing: { date in
Text(dayFormatter.string(from: date))
.foregroundColor(.secondary)
},
header: { date in
Text(weekDayFormatter.string(from: date))
},
title: { date in
HStack {
Text(monthFormatter.string(from: date))
.font(.headline)
.padding()
Spacer()
Button {
withAnimation {
guard let newDate = calendar.date(
byAdding: .month,
value: -1,
to: selectedDate
) else {
return
}
selectedDate = newDate
}
} label: {
Label(
title: { Text("Previous") },
icon: { Image(systemName: "chevron.left") }
)
.labelStyle(IconOnlyLabelStyle())
.padding(.horizontal)
.frame(maxHeight: .infinity)
}
Button {
withAnimation {
guard let newDate = calendar.date(
byAdding: .month,
value: 1,
to: selectedDate
) else {
return
}
selectedDate = newDate
}
} label: {
Label(
title: { Text("Next") },
icon: { Image(systemName: "chevron.right") }
)
.labelStyle(IconOnlyLabelStyle())
.padding(.horizontal)
.frame(maxHeight: .infinity)
}
}
.padding(.bottom, 6)
}
)
.equatable()
}
.padding()
}
}
// MARK: - Component
public struct CalendarView<Day: View, Header: View, Title: View, Trailing: View>: View {
// Injected dependencies
private var calendar: Calendar
@Binding private var date: Date
private let content: (Date) -> Day
private let trailing: (Date) -> Trailing
private let header: (Date) -> Header
private let title: (Date) -> Title
// Constants
private let daysInWeek = 7
public init(
calendar: Calendar,
date: Binding<Date>,
@ViewBuilder content: @escaping (Date) -> Day,
@ViewBuilder trailing: @escaping (Date) -> Trailing,
@ViewBuilder header: @escaping (Date) -> Header,
@ViewBuilder title: @escaping (Date) -> Title
) {
self.calendar = calendar
self._date = date
self.content = content
self.trailing = trailing
self.header = header
self.title = title
}
public var body: some View {
let month = date.startOfMonth(using: calendar)
let days = makeDays()
return LazyVGrid(columns: Array(repeating: GridItem(), count: daysInWeek)) {
Section(header: title(month)) {
ForEach(days.prefix(daysInWeek), id: \.self, content: header)
ForEach(days, id: \.self) { date in
if calendar.isDate(date, equalTo: month, toGranularity: .month) {
content(date)
} else {
trailing(date)
}
}
}
}
}
}
// MARK: - Conformances
extension CalendarView: Equatable {
public static func == (lhs: CalendarView<Day, Header, Title, Trailing>, rhs: CalendarView<Day, Header, Title, Trailing>) -> Bool {
lhs.calendar == rhs.calendar && lhs.date == rhs.date
}
}
// MARK: - Helpers
private extension CalendarView {
func makeDays() -> [Date] {
guard let monthInterval = calendar.dateInterval(of: .month, for: date),
let monthFirstWeek = calendar.dateInterval(of: .weekOfMonth, for: monthInterval.start),
let monthLastWeek = calendar.dateInterval(of: .weekOfMonth, for: monthInterval.end - 1)
else {
return []
}
let dateInterval = DateInterval(start: monthFirstWeek.start, end: monthLastWeek.end)
return calendar.generateDays(for: dateInterval)
}
}
private extension Calendar {
func generateDates(
for dateInterval: DateInterval,
matching components: DateComponents
) -> [Date] {
var dates = [dateInterval.start]
enumerateDates(
startingAfter: dateInterval.start,
matching: components,
matchingPolicy: .nextTime
) { date, _, stop in
guard let date = date else { return }
guard date < dateInterval.end else {
stop = true
return
}
dates.append(date)
}
return dates
}
func generateDays(for dateInterval: DateInterval) -> [Date] {
generateDates(
for: dateInterval,
matching: dateComponents([.hour, .minute, .second], from: dateInterval.start)
)
}
}
private extension Date {
func startOfMonth(using calendar: Calendar) -> Date {
calendar.date(
from: calendar.dateComponents([.year, .month], from: self)
) ?? self
}
}
private extension DateFormatter {
convenience init(dateFormat: String, calendar: Calendar) {
self.init()
self.dateFormat = dateFormat
self.calendar = calendar
}
}
// MARK: - Previews
#if DEBUG
struct CalendarView_Previews: PreviewProvider {
static var previews: some View {
Group {
ContentView(calendar: Calendar(identifier: .gregorian))
ContentView(calendar: Calendar(identifier: .islamicUmmAlQura))
ContentView(calendar: Calendar(identifier: .hebrew))
ContentView(calendar: Calendar(identifier: .indian))
}
}
}
#endif
@basememara Thank you for your excellent work!
built on the shoulders of giants.. thx to @mecid for the inspiration and leading the charge
Has anyone been able to load the calendar to the current date on appear? The below isn't working for me.
var body: some View {
ScrollView {
ScrollViewReader { value in
VStack {
ZStack {
CalendarView(interval: month) { date in
if date <= Date() {
Text("30")
.hidden()
.padding(8)
.clipShape(Circle())
.padding(.vertical, 5)
.overlay(
NavigateFromCalendarButton(
action: {
},
destination: {
DayView(exertionObject: effortFromDay, date: date)
},
label: {
ZStack {
RoundedRectangle(cornerRadius: 12)
Text("\(date)")
.foregroundColor(.black)
.fontWeight(.bold)
}
}
)
)
}
}
} // end of calender
.onAppear {
value.scrollTo(Date(), anchor: .center)
}
} //end of Zstack
}
}
} //end of scroll view
}
}
Can anyone help me to add a horizontal line above every week in the calendar?
I am not sure if this would work, I didn’t test. I am away from my computer.
just add a Divider() above WeekView in the below section of the code.
ForEach(weeks, id: \.self) { week in
WeekView(week: week, content: self.content)
}
Hello everyone, I'm working on this code while following a tutorial from the owner of this git... SelectedDate stores the date selected by the user and is updated when you move on to the next month or the previous one. I need to delete the content of selectedDate when the user switches to the next month is getting a new date only when the user selects a day on the calendar. Currently if I move on to the next month selectedDate updates the month keeping the old day selected by the user ... how can I delete the date of selectedDate when the user switches to a next month? Thank you all and congratulations
How to change start of day to Monday?
It depends on the input calendar's localization. E.g. for the preview, you may use Calendar.current
instead of Calendar(identifier: .gregorian)
if your locale is using Monday as the first day of the week.
Alternatively, you may change the first day of the week in the input calendar regardless of the Calendar's locale.
struct CalendarView_Previews: PreviewProvider {
private static var cal: Calendar {
var cal = Calendar.current
cal.firstWeekday = 2
return cal
}
static var previews: some View {
Group {
ContentView(calendar: cal)
ContentView(calendar: Calendar(identifier: .islamicUmmAlQura))
ContentView(calendar: Calendar(identifier: .hebrew))
ContentView(calendar: Calendar(identifier: .indian))
}
}
}
Just wanted to chime in and say thank you to all of you that have shared here!
@mecid Great job showcasing what can be done with SwiftUI!
@basememara Your example was exactly what I was looking for. It's perfect! 🙏🏼
I needed a calendar for a SwiftUI app that I'm working on. Luckily I came upon the original blog by @mecid and this gist. Using the latest version of the code kindly provided by @basememara I have made some changes to the look and feel to better suit my app.
I've also added the ability to show up to 3 events indicators in the calendar, and a list that provides a NavLink view of the events happening on a specific date. Here I'm using Core Data to store the events. I call an event a Fixture, but as long as the model has date field on it you should be good.
If I make any more improvements that I think are worth posting here, I will.
import SwiftUI
struct CalendarView: View {
private let calendar: Calendar
private let monthFormatter: DateFormatter
private let dayFormatter: DateFormatter
private let weekDayFormatter: DateFormatter
private let fullFormatter: DateFormatter
@State private var selectedDate = Self.now
private static var now = Date()
@FetchRequest(sortDescriptors: []) var fixtures: FetchedResults<Fixture>
init(calendar: Calendar) {
self.calendar = calendar
self.monthFormatter = DateFormatter(dateFormat: "MMMM YYYY", calendar: calendar)
self.dayFormatter = DateFormatter(dateFormat: "d", calendar: calendar)
self.weekDayFormatter = DateFormatter(dateFormat: "EEEEE", calendar: calendar)
self.fullFormatter = DateFormatter(dateFormat: "MMMM dd, yyyy", calendar: calendar)
}
var body: some View {
VStack {
CalendarViewComponent(
calendar: calendar,
date: $selectedDate,
content: { date in
ZStack {
Button(action: { selectedDate = date }) {
Text(dayFormatter.string(from: date))
.padding(6)
.foregroundColor(calendar.isDateInToday(date) ? Color.white : .primary)
.background(
calendar.isDateInToday(date) ? Color.gray
: calendar.isDate(date, inSameDayAs: selectedDate) ? .green
: .clear
)
.cornerRadius(7)
}
if (dateHasFixtures(date: date)) {
Circle()
.size(CGSize(width: 5, height: 5))
.foregroundColor(Color.red)
.offset(x: CGFloat(15),
y: CGFloat(35))
}
if (dateHasFixtures(date: date)) {
Circle()
.size(CGSize(width: 5, height: 5))
.foregroundColor(Color.blue)
.offset(x: CGFloat(23),
y: CGFloat(35))
}
if (dateHasFixtures(date: date)) {
Circle()
.size(CGSize(width: 5, height: 5))
.foregroundColor(Color.green)
.offset(x: CGFloat(30),
y: CGFloat(35))
}
}
},
trailing: { date in
Text(dayFormatter.string(from: date))
.foregroundColor(.secondary)
},
header: { date in
Text(weekDayFormatter.string(from: date)).fontWeight(.bold)
},
title: { date in
HStack {
Button {
guard let newDate = calendar.date(
byAdding: .month,
value: -1,
to: selectedDate
) else {
return
}
selectedDate = newDate
} label: {
Label(
title: { Text("Previous") },
icon: {
Image(systemName: "chevron.left.circle.fill")
.foregroundColor(.black)
.font(.title)
}
)
.labelStyle(IconOnlyLabelStyle())
.padding(.horizontal)
}
Spacer()
Text(monthFormatter.string(from: date))
.font(.title)
.padding(2)
Spacer()
Button {
guard let newDate = calendar.date(
byAdding: .month,
value: 1,
to: selectedDate
) else {
return
}
selectedDate = newDate
} label: {
Label(
title: { Text("Next") },
icon: {
Image(systemName: "chevron.right.circle.fill")
.foregroundColor(.black)
.font(.title)
}
)
.labelStyle(IconOnlyLabelStyle())
.padding(.horizontal)
}
}.padding(.bottom, 10)
}
)
.equatable()
}
.padding()
}
func dateHasFixtures(date: Date) -> Bool {
for fixture in fixtures {
if calendar.isDate(date, inSameDayAs: fixture.date ?? Date()) {
return true
}
}
return false
}
}
// MARK: - Component
public struct CalendarViewComponent<Day: View, Header: View, Title: View, Trailing: View>: View {
// Injected dependencies
private var calendar: Calendar
@Binding private var date: Date
private let content: (Date) -> Day
private let trailing: (Date) -> Trailing
private let header: (Date) -> Header
private let title: (Date) -> Title
// Constants
private let daysInWeek = 7
@FetchRequest var fixtures: FetchedResults<Fixture>
public init(
calendar: Calendar,
date: Binding<Date>,
@ViewBuilder content: @escaping (Date) -> Day,
@ViewBuilder trailing: @escaping (Date) -> Trailing,
@ViewBuilder header: @escaping (Date) -> Header,
@ViewBuilder title: @escaping (Date) -> Title
) {
self.calendar = calendar
self._date = date
self.content = content
self.trailing = trailing
self.header = header
self.title = title
_fixtures = FetchRequest<Fixture>(sortDescriptors: [],
predicate: NSPredicate(
format: "date >= %@ && date <= %@",
Calendar.current.startOfDay(for: date.wrappedValue) as CVarArg,
Calendar.current.startOfDay(for: date.wrappedValue + 86400) as CVarArg))
}
public var body: some View {
let month = date.startOfMonth(using: calendar)
let days = makeDays()
VStack {
Section(header: title(month)) { }
VStack {
LazyVGrid(columns: Array(repeating: GridItem(), count: daysInWeek)) {
ForEach(days.prefix(daysInWeek), id: \.self, content: header)
}
Divider()
LazyVGrid(columns: Array(repeating: GridItem(), count: daysInWeek)) {
ForEach(days, id: \.self) { date in
if calendar.isDate(date, equalTo: month, toGranularity: .month) {
content(date)
} else {
trailing(date)
}
}
}
}
.frame(height: days.count == 42 ? 300 : 270)
.background(Color.white)
.clipShape(ClubsListsShape(corner: .bottomLeft, radii: 35))
.clipShape(ClubsListsShape(corner: .bottomRight, radii: 35))
.clipShape(ClubsListsShape(corner: .topLeft, radii: 35))
.clipShape(ClubsListsShape(corner: .topRight, radii: 35))
.shadow(radius: 5)
.padding(.bottom, 10)
List(fixtures) { fixture in
NavigationLink {
FixtureReadView(fixture: fixture)
} label: {
FixtureListItem(fixture: fixture)
}
}.listStyle(.plain)
}
}
}
// MARK: - Conformances
extension CalendarViewComponent: Equatable {
public static func == (lhs: CalendarViewComponent<Day, Header, Title, Trailing>, rhs: CalendarViewComponent<Day, Header, Title, Trailing>) -> Bool {
lhs.calendar == rhs.calendar && lhs.date == rhs.date
}
}
// MARK: - Helpers
private extension CalendarViewComponent {
func makeDays() -> [Date] {
guard let monthInterval = calendar.dateInterval(of: .month, for: date),
let monthFirstWeek = calendar.dateInterval(of: .weekOfMonth, for: monthInterval.start),
let monthLastWeek = calendar.dateInterval(of: .weekOfMonth, for: monthInterval.end - 1)
else {
return []
}
let dateInterval = DateInterval(start: monthFirstWeek.start, end: monthLastWeek.end)
return calendar.generateDays(for: dateInterval)
}
}
private extension Calendar {
func generateDates(
for dateInterval: DateInterval,
matching components: DateComponents
) -> [Date] {
var dates = [dateInterval.start]
enumerateDates(
startingAfter: dateInterval.start,
matching: components,
matchingPolicy: .nextTime
) { date, _, stop in
guard let date = date else { return }
guard date < dateInterval.end else {
stop = true
return
}
dates.append(date)
}
return dates
}
func generateDays(for dateInterval: DateInterval) -> [Date] {
generateDates(
for: dateInterval,
matching: dateComponents([.hour, .minute, .second], from: dateInterval.start)
)
}
}
private extension Date {
func startOfMonth(using calendar: Calendar) -> Date {
calendar.date(
from: calendar.dateComponents([.year, .month], from: self)
) ?? self
}
}
private extension DateFormatter {
convenience init(dateFormat: String, calendar: Calendar) {
self.init()
self.dateFormat = dateFormat
self.calendar = calendar
}
}
// MARK: - Previews
#if DEBUG
struct CalendarView_Previews: PreviewProvider {
static var previews: some View {
CalendarView(calendar: Calendar(identifier: .gregorian))
.environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
}
}
#endif
Is it possible to show whats inside a "Day" ? like hourly intervals and events in a day ?
I have customised @haskins-io code a little more to suit my needs, so I thought id share it.
- I have removed a lot of the padding to maximise space.
- I have changed the long date to UK format (dd/mmmm/yyyy).
- I have added a frame around the date selection so the highlighted colour is always even, this was a problem for single digit numbers.
- I have added
numberOfEventsInDate
Func to the if statements so the dots under the date correlate to the number of events (upto 3). - I have made the month text in the header a button to return to the date incase you have browsed far forward.
- I have sorted the list below the calendar in time order (newest first).
- I have fixed the colours to work in dark mode.
If I change anything else in the future ill add a revision.
import SwiftUI
struct CalendarView: View {
private let calendar: Calendar
private let monthFormatter: DateFormatter
private let dayFormatter: DateFormatter
private let weekDayFormatter: DateFormatter
private let fullFormatter: DateFormatter
@State private var selectedDate = Self.now
private static var now = Date()
@FetchRequest(sortDescriptors: []) var entries: FetchedResults<Entry>
init(calendar: Calendar) {
self.calendar = calendar
self.monthFormatter = DateFormatter(dateFormat: "MMMM YYYY", calendar: calendar)
self.dayFormatter = DateFormatter(dateFormat: "d", calendar: calendar)
self.weekDayFormatter = DateFormatter(dateFormat: "EEEEE", calendar: calendar)
self.fullFormatter = DateFormatter(dateFormat: "dd MMMM yyyy", calendar: calendar)
}
var body: some View {
VStack {
CalendarViewComponent(
calendar: calendar,
date: $selectedDate,
content: { date in
ZStack {
Button(action: { selectedDate = date }) {
Text(dayFormatter.string(from: date))
.padding(6)
// Added to make selection sizes equal on all numbers.
.frame(width: 33, height: 33)
.foregroundColor(calendar.isDateInToday(date) ? Color.white : .primary)
.background(
calendar.isDateInToday(date) ? Color.red
: calendar.isDate(date, inSameDayAs: selectedDate) ? .blue
: .clear
)
.cornerRadius(7)
}
if (numberOfEventsInDate(date: date) >= 2) {
Circle()
.size(CGSize(width: 5, height: 5))
.foregroundColor(Color.green)
.offset(x: CGFloat(17),
y: CGFloat(33))
}
if (numberOfEventsInDate(date: date) >= 1) {
Circle()
.size(CGSize(width: 5, height: 5))
.foregroundColor(Color.green)
.offset(x: CGFloat(24),
y: CGFloat(33))
}
if (numberOfEventsInDate(date: date) >= 3) {
Circle()
.size(CGSize(width: 5, height: 5))
.foregroundColor(Color.green)
.offset(x: CGFloat(31),
y: CGFloat(33))
}
}
},
trailing: { date in
Text(dayFormatter.string(from: date))
.foregroundColor(.secondary)
},
header: { date in
Text(weekDayFormatter.string(from: date)).fontWeight(.bold)
},
title: { date in
HStack {
Button {
guard let newDate = calendar.date(
byAdding: .month,
value: -1,
to: selectedDate
) else {
return
}
selectedDate = newDate
} label: {
Label(
title: { Text("Previous") },
icon: {
Image(systemName: "chevron.left")
.font(.title2)
}
)
.labelStyle(IconOnlyLabelStyle())
.padding(.horizontal)
}
Spacer()
Button {
selectedDate = Date.now
} label: {
Text(monthFormatter.string(from: date))
.foregroundColor(.blue)
.font(.title2)
.padding(2)
}
Spacer()
Button {
guard let newDate = calendar.date(
byAdding: .month,
value: 1,
to: selectedDate
) else {
return
}
selectedDate = newDate
} label: {
Label(
title: { Text("Next") },
icon: {
Image(systemName: "chevron.right")
.font(.title2)
}
)
.labelStyle(IconOnlyLabelStyle())
.padding(.horizontal)
}
}
}
)
.equatable()
}
}
func dateHasEvents(date: Date) -> Bool {
for entry in entries {
if calendar.isDate(date, inSameDayAs: entry.timestamp ?? Date()) {
return true
}
}
return false
}
func numberOfEventsInDate(date: Date) -> Int {
var count: Int = 0
for entry in entries {
if calendar.isDate(date, inSameDayAs: entry.timestamp ?? Date()) {
count += 1
}
}
return count
}
}
// MARK: - Component
public struct CalendarViewComponent<Day: View, Header: View, Title: View, Trailing: View>: View {
@Environment(\.colorScheme) var colorScheme
// Injected dependencies
private var calendar: Calendar
@Binding private var date: Date
private let content: (Date) -> Day
private let trailing: (Date) -> Trailing
private let header: (Date) -> Header
private let title: (Date) -> Title
// Constants
private let daysInWeek = 7
@FetchRequest var entries: FetchedResults<Entry>
public init(
calendar: Calendar,
date: Binding<Date>,
@ViewBuilder content: @escaping (Date) -> Day,
@ViewBuilder trailing: @escaping (Date) -> Trailing,
@ViewBuilder header: @escaping (Date) -> Header,
@ViewBuilder title: @escaping (Date) -> Title
) {
self.calendar = calendar
self._date = date
self.content = content
self.trailing = trailing
self.header = header
self.title = title
_entries = FetchRequest<Entry>(sortDescriptors: [NSSortDescriptor(key: "timestamp", ascending: false)],
predicate: NSPredicate(
format: "timestamp >= %@ && timestamp <= %@",
Calendar.current.startOfDay(for: date.wrappedValue) as CVarArg,
Calendar.current.startOfDay(for: date.wrappedValue + 86400) as CVarArg))
}
public var body: some View {
let month = date.startOfMonth(using: calendar)
let days = makeDays()
VStack {
Section(header: title(month)) { }
VStack {
LazyVGrid(columns: Array(repeating: GridItem(), count: daysInWeek)) {
ForEach(days.prefix(daysInWeek), id: \.self, content: header)
}
Divider()
LazyVGrid(columns: Array(repeating: GridItem(), count: daysInWeek)) {
ForEach(days, id: \.self) { date in
if calendar.isDate(date, equalTo: month, toGranularity: .month) {
content(date)
} else {
trailing(date)
}
}
}
}
.frame(height: days.count == 42 ? 300 : 270)
.shadow(color: colorScheme == .dark ? .white.opacity(0.4) : .black.opacity(0.35), radius: 5)
List(entries) { entry in
NavigationLink {
CalendarDetailView(entry: entry)
} label: {
CalendarCardView(entry: entry)
}
}.listStyle(.plain)
}
}
}
// MARK: - Conformances
extension CalendarViewComponent: Equatable {
public static func == (lhs: CalendarViewComponent<Day, Header, Title, Trailing>, rhs: CalendarViewComponent<Day, Header, Title, Trailing>) -> Bool {
lhs.calendar == rhs.calendar && lhs.date == rhs.date
}
}
// MARK: - Helpers
private extension CalendarViewComponent {
func makeDays() -> [Date] {
guard let monthInterval = calendar.dateInterval(of: .month, for: date),
let monthFirstWeek = calendar.dateInterval(of: .weekOfMonth, for: monthInterval.start),
let monthLastWeek = calendar.dateInterval(of: .weekOfMonth, for: monthInterval.end - 1)
else {
return []
}
let dateInterval = DateInterval(start: monthFirstWeek.start, end: monthLastWeek.end)
return calendar.generateDays(for: dateInterval)
}
}
private extension Calendar {
func generateDates(
for dateInterval: DateInterval,
matching components: DateComponents
) -> [Date] {
var dates = [dateInterval.start]
enumerateDates(
startingAfter: dateInterval.start,
matching: components,
matchingPolicy: .nextTime
) { date, _, stop in
guard let date = date else { return }
guard date < dateInterval.end else {
stop = true
return
}
dates.append(date)
}
return dates
}
func generateDays(for dateInterval: DateInterval) -> [Date] {
generateDates(
for: dateInterval,
matching: dateComponents([.hour, .minute, .second], from: dateInterval.start)
)
}
}
private extension Date {
func startOfMonth(using calendar: Calendar) -> Date {
calendar.date(
from: calendar.dateComponents([.year, .month], from: self)
) ?? self
}
}
private extension DateFormatter {
convenience init(dateFormat: String, calendar: Calendar) {
self.init()
self.dateFormat = dateFormat
self.calendar = calendar
}
}
// MARK: - Previews
#if DEBUG
struct CalendarView_Previews: PreviewProvider {
static var previews: some View {
CalendarView(calendar: Calendar(identifier: .gregorian))
.environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
}
}
#endif
Awesome thank you
@Plus1XP there is a bug, the selected day is always -1 clicked date ,
selecting may-30 , trigger 29 In Button(action: { selectedDate = date })
I've updated the gist with the latest version of CalendarView I use in my app with enabled date generation caching and conforming to Equatable for reducing the updates of the view.
@Plus1XP i just want to similarly and every selected date List will refresh. Im getting error for PersistenceController and Entry
please help me to solve
@polofu by default calendar view uses your locale to generate dates. So if you use the US locale you will get Sunday as the first day in the week, otherwise, you get Monday as the first day of the week. Change the locale of your device and take a look at the changes.