Last active
November 28, 2024 14:25
-
Star
(243)
You must be signed in to star a gist -
Fork
(29)
You must be signed in to fork a gist
-
-
Save mecid/f8859ea4bdbd02cf5d440d58e936faec to your computer and use it in GitHub Desktop.
SwiftUI Calendar view using LazyVGrid
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 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) } | |
} | |
} |
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]⚠️ ![]()
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
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
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?