-
-
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) } | |
} | |
} |
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.
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
@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.
thank you @mecid for this gist,
I even built an Calendar app using your component
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
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
@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.
@mecid, @basememara, @iletai, @acegreen How I can make Monday to come first instead of Sunday
@mecid, @basememara, @iletai, @acegreen How I can make Monday to come first instead of Sunday
Just change first week date of your calendar.
@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, 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. :)
@mecid yes I took the latest gist code above, wrapped the EquatableCalendarView in a ScrollView and added the modifier…