Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
SwiftUI Calendar view using LazyVGrid
import SwiftUI
fileprivate extension DateFormatter {
static var month: DateFormatter {
let formatter = DateFormatter()
formatter.dateFormat = "MMMM"
return formatter
}
static var monthAndYear: DateFormatter {
let formatter = DateFormatter()
formatter.dateFormat = "MMMM yyyy"
return formatter
}
}
fileprivate 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
}
}
struct WeekView<DateView>: View where DateView: View {
@Environment(\.calendar) var calendar
let week: Date
let content: (Date) -> DateView
init(week: Date, @ViewBuilder content: @escaping (Date) -> DateView) {
self.week = week
self.content = content
}
private var days: [Date] {
guard
let weekInterval = calendar.dateInterval(of: .weekOfYear, for: week)
else { return [] }
return calendar.generateDates(
inside: weekInterval,
matching: DateComponents(hour: 0, minute: 0, second: 0)
)
}
var body: some View {
HStack {
ForEach(days, id: \.self) { date in
HStack {
if self.calendar.isDate(self.week, equalTo: date, toGranularity: .month) {
self.content(date)
} else {
self.content(date).hidden()
}
}
}
}
}
}
struct MonthView<DateView>: View where DateView: View {
@Environment(\.calendar) var calendar
let month: Date
let showHeader: Bool
let content: (Date) -> DateView
init(
month: Date,
showHeader: Bool = true,
@ViewBuilder content: @escaping (Date) -> DateView
) {
self.month = month
self.content = content
self.showHeader = showHeader
}
private var weeks: [Date] {
guard
let monthInterval = calendar.dateInterval(of: .month, for: month)
else { return [] }
return calendar.generateDates(
inside: monthInterval,
matching: DateComponents(hour: 0, minute: 0, second: 0, weekday: calendar.firstWeekday)
)
}
private var header: some View {
let component = calendar.component(.month, from: month)
let formatter = component == 1 ? DateFormatter.monthAndYear : .month
return Text(formatter.string(from: month))
.font(.title)
.padding()
}
var body: some View {
VStack {
if showHeader {
header
}
ForEach(weeks, id: \.self) { week in
WeekView(week: week, content: self.content)
}
}
}
}
struct CalendarView<DateView>: View where DateView: View {
@Environment(\.calendar) var calendar
let interval: DateInterval
let content: (Date) -> DateView
init(interval: DateInterval, @ViewBuilder content: @escaping (Date) -> DateView) {
self.interval = interval
self.content = content
}
private var months: [Date] {
calendar.generateDates(
inside: interval,
matching: DateComponents(day: 1, hour: 0, minute: 0, second: 0)
)
}
var body: some View {
ScrollView(.vertical, showsIndicators: false) {
VStack {
ForEach(months, id: \.self) { month in
MonthView(month: month, content: self.content)
}
}
}
}
}
struct RootView: View {
@Environment(\.calendar) var calendar
private var year: DateInterval {
calendar.dateInterval(of: .year, for: Date())!
}
var body: some View {
CalendarView(interval: year) { date in
Text("30")
.hidden()
.padding(8)
.background(Color.blue)
.clipShape(Circle())
.padding(.vertical, 4)
.overlay(
Text(String(self.calendar.component(.day, from: date)))
)
}
}
}
@ddl-elo
Copy link

ddl-elo commented Oct 9, 2020

Hi @alahdal

Thank you very much for your great response.

@polofu
Copy link

polofu commented Oct 9, 2020

@ddl-elo,

Please note:

  1. I have made slight modification with the shape and month navigation (took idea from @jagan510710), but core still the same.
  2. I am using a previous code of @mecid, before he changed to LazyVGrid. I did not update because that would add value If I am showing full year. But I do show month by month.
    The full code in this link : https://gist.github.com/alahdal/deb37df908be07d2a64456229276665e

The current look: If you still look to have the same previous appearance, I will need to get it from previous version.

Hi My friend,
how to achieve that every row order by Monday Tuesday Wednesday Thursday Friday Saturday Sunday. Every row is start with Monday and end with Sunday.

@mecid
Copy link
Author

mecid commented Oct 9, 2020

@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.

@filimo
Copy link

filimo commented Oct 9, 2020

@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?

@jz709u
Copy link

jz709u commented Oct 19, 2020

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.

@stevhens
Copy link

stevhens commented Nov 23, 2020

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,
Screen Shot 2020-11-23 at 9 08 21 PM

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

@viktorsec
Copy link

viktorsec commented Dec 6, 2020

@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.

@xuanzi23
Copy link

xuanzi23 commented Jan 28, 2021

Is it possible to do a infinite scroll just like iPhone default calendar apps?

@MrCarter31
Copy link

MrCarter31 commented Feb 27, 2021

Is there anyway to pin the header to the top of the calendar view?

@mecid
Copy link
Author

mecid commented Feb 28, 2021

@MrCarter31 there is pinnedViews parameter on LazyVGrid. You can use .sectionHeaders to pin month headers.

@eostarman
Copy link

eostarman commented Apr 7, 2021

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 :)

@basememara
Copy link

basememara commented Apr 15, 2021

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:

  1. Made it Equatable so it wouldn't re-render unnecessarily. I'm comparing DateInterval which I think is a safe assumption to make. Also be careful not just to use Date() since it will always be different, lock it to some date... which was driving me nuts in testing 😅
  2. I exposed title and header as a ViewBuilder to the caller so they can render it as they please. This also naturally resolves custom calendar.firstWeekDay by itself since the caller is just formatting the date.
  3. Add optional trailing dates for few days before and after the month if they're in the week.
  4. Fixed accessibility for the equal background trick (I wish there as a cleaner way to do this but couldn't figure either).

Screen Shot 2021-04-15 at 7 41 03 PM

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) }
}

@QiYuNew
Copy link

QiYuNew commented May 10, 2021

@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.

@basememara
Copy link

basememara commented May 10, 2021

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.

@QiYuNew
Copy link

QiYuNew commented May 10, 2021

@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?

@basememara
Copy link

basememara commented May 10, 2021

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:

Screen Shot 2021-05-10 at 1 02 36 PM

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

@QiYuNew
Copy link

QiYuNew commented May 10, 2021

@basememara Thank you for your excellent work!

@basememara
Copy link

basememara commented May 10, 2021

built on the shoulders of giants.. thx to @mecid for the inspiration and leading the charge 🎯

@gesabo
Copy link

gesabo commented May 18, 2021

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
           
        }
        
    }

@alahdal
Copy link

alahdal commented Sep 27, 2021

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)
                }

@fabiofloris
Copy link

fabiofloris commented Sep 29, 2021

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

@startcode-io
Copy link

startcode-io commented Feb 3, 2022

How to change start of day to Monday?

@A320Peter
Copy link

A320Peter commented Feb 3, 2022

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))
        }
    }
}

@cliftonlabrum
Copy link

cliftonlabrum commented Feb 19, 2022

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! 🙏🏼

@haskins-io
Copy link

haskins-io commented Apr 11, 2022

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.

Screenshot 2022-04-11 at 23 04 48

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

@h-elbeheiry
Copy link

h-elbeheiry commented May 24, 2022

Is it possible to show whats inside a "Day" ? like hourly intervals and events in a day ?

@Plus1XP
Copy link

Plus1XP commented May 25, 2022

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.

Screenshot 2022-05-25 at 17 52 56

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

@h-elbeheiry
Copy link

h-elbeheiry commented May 27, 2022

Awesome thank you

@taymiyyah
Copy link

taymiyyah commented May 30, 2022

@Plus1XP there is a bug, the selected day is always -1 clicked date ,
selecting may-30 , trigger 29 In Button(action: { selectedDate = date })

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment