Skip to content

Instantly share code, notes, and snippets.

@mecid
Last active October 21, 2024 00:38
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) }
}
}
@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.

@AbdullohBahromjonov
Copy link

@mecid, @basememara, @iletai, @acegreen How I can make Monday to come first instead of Sunday

@iletai
Copy link

iletai commented Jul 1, 2024

@mecid, @basememara, @iletai, @acegreen How I can make Monday to come first instead of Sunday

Just change first week date of your calendar.

@mecid
Copy link
Author

mecid commented Jul 10, 2024

@AbdullohBahromjonov, it depends on your calendar preferences. So, if in your phone settings, the week starts on Monday, then it will be the same here.

@AbdullohBahromjonov
Copy link

@AbdullohBahromjonov, it depends on your calendar preferences. So, if in your phone settings, the week starts on Monday, then it will be the same here.

thank you!
But I have another issue here. I am using EnvironmentObject to make changes on the view but the calendar is not displaying changes instantly, it displays changes after I some random day on the calendar.
Can someone help me with this?

@nikolaknez
Copy link

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

@nikolaknez
Copy link

Excellent work. In the VStack I've changed to .background(Color(.systemBackground)) cause in dark mode once cannot see a thing. :)

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