Skip to content

Instantly share code, notes, and snippets.

@mecid
Last active May 8, 2024 13:30
Show Gist options
  • Save mecid/f8859ea4bdbd02cf5d440d58e936faec to your computer and use it in GitHub Desktop.
Save mecid/f8859ea4bdbd02cf5d440d58e936faec to your computer and use it in GitHub Desktop.
SwiftUI Calendar view using LazyVGrid
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) }
}
}
@acegreen
Copy link

acegreen commented Aug 8, 2023

Thanks for sharing this gist and to others for contributing to it. I have been working on a version with a fullscreen calendar view that is in a scrollview. Still need to implement the scrollview delegates so I can add months above and below as the user scrolls.

⚠️ [WIP] ⚠️

image

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
                    VStack {
                        Button(action: { selectedDate = date }) {
                            Text(dayFormatter.string(from: date))
                                .padding(8)
                                .foregroundColor(calendar.isDateInToday(date) ? Color.white : .primary)
                                .background(
                                    calendar.isDateInToday(date) ? Color.green
                                    : calendar.isDate(date, inSameDayAs: selectedDate) ? .gray
                                    : .clear
                                )
                                .frame(maxHeight: .infinity)
                                .contentShape(Rectangle())
                                .cornerRadius(7)
                        }

                        if (isFasting(on: date)) {
                            Circle()
                                .size(CGSize(width: 5, height: 5))
                                .foregroundColor(Color.green)
                                .offset(x: CGFloat(23),
                                        y: CGFloat(35))
                        }
                    }

                },
                trailing: { date in
                    Button(action: { selectedDate = date }) {
                        Text(dayFormatter.string(from: date))
                            .padding(8)
                            .foregroundColor(calendar.isDateInToday(date) ? .white : .gray)
                            .background(
                                calendar.isDateInToday(date) ? .green
                                : calendar.isDate(date, inSameDayAs: selectedDate) ? .gray
                                : .clear
                            )
                            .cornerRadius(7)
                    }
                },
                header: { date in
                    Text(weekDayFormatter.string(from: date)).fontWeight(.bold)
                },
                title: { date in
                    Text(monthFormatter.string(from: date))
                        .font(.title)
                        .hSpacing(.leading)
                        .padding(.vertical, 8)
                }
            )
            .equatable()
        }
        .padding()
    }

    func isFasting(on: 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
    private var months: [Date] = []
    @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
    let spaceName = "scroll"
    @State var wholeSize: CGSize = .zero
    @State var scrollViewSize: CGSize = .zero
    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

        months = makeMonths()
    }

    public var body: some View {
        ChildSizeReader(size: $wholeSize){
            ScrollView {
                ChildSizeReader(size: $scrollViewSize) {
                    VStack {
                        ForEach(months, id: \.self) { month in
                            // Switched from Lazy to VStack to avoid layout glitches
                            VStack {
                                let month = month.startOfMonth(using: calendar)
                                let days = makeDays(from: month)

                                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)
                            }
                        }
                    }
                    .background(
                        GeometryReader { proxy in
                            Color.clear.preference(
                                key: ViewOffsetKey.self,
                                value: -1 * proxy.frame(in: .named(spaceName)).origin.y
                            )
                        }
                    )
                    .onPreferenceChange(
                        ViewOffsetKey.self,
                        perform: { value in
                            print("offset: \(value)") // offset: 1270.3333333333333 when User has reached the bottom
                            print("height: \(scrollViewSize.height)") // height: 2033.3333333333333

                            if value <= 0 {
                                print("User has reached the top of the ScrollView.")
                            } else if value >= scrollViewSize.height - wholeSize.height {

                                guard let firstMonth = months.first,
                                      let newDate = calendar.date(
                                        byAdding: .month,
                                        value: 1,
                                        to: firstMonth
                                      ) else { return }
                                print("User has reached the bottom of the ScrollView.", newDate)
                            } else {
                                print("not reached.")
                            }
                        }
                    )
                }
            }
            .coordinateSpace(name: spaceName)
            .scrollIndicators(.never)
        }
        //        .onChange(
        //            of: scrollViewSize,
        //            perform: { value in
        //                print(value)
        //            }
        //        )
    }
}

// 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 makeMonths() -> [Date] {
        guard let yearInterval = calendar.dateInterval(of: .year, for: date),
              let yearFirstMonth = calendar.dateInterval(of: .month, for: yearInterval.start),
              let yearLastMonth = calendar.dateInterval(of: .month, for: yearInterval.end - 1)
        else {
            return []
        }

        let dateInterval = DateInterval(start: yearFirstMonth.start, end: yearLastMonth.end)
        return calendar.generateDates(for: dateInterval,
                                      matching: calendar.dateComponents([.day], from: dateInterval.start))
    }

    func makeDays(from date: Date) -> [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
    }
}

struct ViewOffsetKey: PreferenceKey {
    typealias Value = CGFloat
    static var defaultValue = CGFloat.zero
    static func reduce(value: inout Value, nextValue: () -> Value) {
        value += nextValue()
    }
}

struct ChildSizeReader<Content: View>: View {
    @Binding var size: CGSize

    let content: () -> Content
    var body: some View {
        ZStack {
            content().background(
                GeometryReader { proxy in
                    Color.clear.preference(
                        key: SizePreferenceKey.self,
                        value: proxy.size
                    )
                }
            )
        }
        .onPreferenceChange(SizePreferenceKey.self) { preferences in
            self.size = preferences
        }
    }
}

struct SizePreferenceKey: PreferenceKey {
    typealias Value = CGSize
    static var defaultValue: Value = .zero

    static func reduce(value _: inout Value, nextValue: () -> Value) {
        _ = nextValue()
    }
}

// MARK: - Previews

#if DEBUG
struct CalendarView_Previews: PreviewProvider {
    static var previews: some View {
        CalendarView(calendar: .current)
        //            .environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
    }
}
#endif

@saul-melo
Copy link

@basememara for your month-traversing implementation is there a way to retain the selected date when switching to another month? as it stands it updates selected date to the same day in the month that is moved to.

@phuongit0301
Copy link

Hi you guys.
Do you know how I can draw a background like image below?

Calendar—Left Frame

@vinhnx
Copy link

vinhnx commented Oct 21, 2023

thank you @mecid for this gist,

I even built an Calendar app using your component

https://github.com/vinhnx/Clendar

@iletai
Copy link

iletai commented Nov 23, 2023

Thank you for sharing. It's changed my mindset about my calendar before. I had to think about the calendar, it's faster if you use the library
But with swiftui, it's more flexible. It doesn't take as much time as I thought

  • I personally think it should become a repository for more contributions, updates, and migration. Wait for it.
    @mecid

@iletai
Copy link

iletai commented Nov 30, 2023

From the idea of this gist. I had created an example for a Calendar that can be customized more. Thank you.
Hope this helps someone.
https://github.com/iletai/SwiftUICalendarView
image

@anbu-tlc
Copy link

@mecid, @filimo, @funkenstrahlen How can I enable horizontal scrolling for this custom table calendar? Currently, when I scroll horizontally, it navigates to the next or previous month. I've been searching for a solution so far. If anyone knows the answer, please share it with me. Thank you.

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