-
-
Save mecid/f8859ea4bdbd02cf5d440d58e936faec to your computer and use it in GitHub Desktop.
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) } | |
} | |
} |
@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?
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. :)
@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.