-
-
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) } | |
} | |
} |
It depends on the input calendar's localization. E.g. for the preview, you may use Calendar.current
instead of Calendar(identifier: .gregorian)
if your locale is using Monday as the first day of the week.
Alternatively, you may change the first day of the week in the input calendar regardless of the Calendar's locale.
struct CalendarView_Previews: PreviewProvider {
private static var cal: Calendar {
var cal = Calendar.current
cal.firstWeekday = 2
return cal
}
static var previews: some View {
Group {
ContentView(calendar: cal)
ContentView(calendar: Calendar(identifier: .islamicUmmAlQura))
ContentView(calendar: Calendar(identifier: .hebrew))
ContentView(calendar: Calendar(identifier: .indian))
}
}
}
Just wanted to chime in and say thank you to all of you that have shared here!
@mecid Great job showcasing what can be done with SwiftUI! 💯😄
@basememara Your example was exactly what I was looking for. It's perfect! 🙏🏼
I needed a calendar for a SwiftUI app that I'm working on. Luckily I came upon the original blog by @mecid and this gist. Using the latest version of the code kindly provided by @basememara I have made some changes to the look and feel to better suit my app.
I've also added the ability to show up to 3 events indicators in the calendar, and a list that provides a NavLink view of the events happening on a specific date. Here I'm using Core Data to store the events. I call an event a Fixture, but as long as the model has date field on it you should be good.
If I make any more improvements that I think are worth posting here, I will.
import SwiftUI
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
ZStack {
Button(action: { selectedDate = date }) {
Text(dayFormatter.string(from: date))
.padding(6)
.foregroundColor(calendar.isDateInToday(date) ? Color.white : .primary)
.background(
calendar.isDateInToday(date) ? Color.gray
: calendar.isDate(date, inSameDayAs: selectedDate) ? .green
: .clear
)
.cornerRadius(7)
}
if (dateHasFixtures(date: date)) {
Circle()
.size(CGSize(width: 5, height: 5))
.foregroundColor(Color.red)
.offset(x: CGFloat(15),
y: CGFloat(35))
}
if (dateHasFixtures(date: date)) {
Circle()
.size(CGSize(width: 5, height: 5))
.foregroundColor(Color.blue)
.offset(x: CGFloat(23),
y: CGFloat(35))
}
if (dateHasFixtures(date: date)) {
Circle()
.size(CGSize(width: 5, height: 5))
.foregroundColor(Color.green)
.offset(x: CGFloat(30),
y: CGFloat(35))
}
}
},
trailing: { date in
Text(dayFormatter.string(from: date))
.foregroundColor(.secondary)
},
header: { date in
Text(weekDayFormatter.string(from: date)).fontWeight(.bold)
},
title: { date in
HStack {
Button {
guard let newDate = calendar.date(
byAdding: .month,
value: -1,
to: selectedDate
) else {
return
}
selectedDate = newDate
} label: {
Label(
title: { Text("Previous") },
icon: {
Image(systemName: "chevron.left.circle.fill")
.foregroundColor(.black)
.font(.title)
}
)
.labelStyle(IconOnlyLabelStyle())
.padding(.horizontal)
}
Spacer()
Text(monthFormatter.string(from: date))
.font(.title)
.padding(2)
Spacer()
Button {
guard let newDate = calendar.date(
byAdding: .month,
value: 1,
to: selectedDate
) else {
return
}
selectedDate = newDate
} label: {
Label(
title: { Text("Next") },
icon: {
Image(systemName: "chevron.right.circle.fill")
.foregroundColor(.black)
.font(.title)
}
)
.labelStyle(IconOnlyLabelStyle())
.padding(.horizontal)
}
}.padding(.bottom, 10)
}
)
.equatable()
}
.padding()
}
func dateHasFixtures(date: 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
@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
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
_fixtures = FetchRequest<Fixture>(sortDescriptors: [],
predicate: NSPredicate(
format: "date >= %@ && date <= %@",
Calendar.current.startOfDay(for: date.wrappedValue) as CVarArg,
Calendar.current.startOfDay(for: date.wrappedValue + 86400) as CVarArg))
}
public var body: some View {
let month = date.startOfMonth(using: calendar)
let days = makeDays()
VStack {
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)
.clipShape(ClubsListsShape(corner: .bottomLeft, radii: 35))
.clipShape(ClubsListsShape(corner: .bottomRight, radii: 35))
.clipShape(ClubsListsShape(corner: .topLeft, radii: 35))
.clipShape(ClubsListsShape(corner: .topRight, radii: 35))
.shadow(radius: 5)
.padding(.bottom, 10)
List(fixtures) { fixture in
NavigationLink {
FixtureReadView(fixture: fixture)
} label: {
FixtureListItem(fixture: fixture)
}
}.listStyle(.plain)
}
}
}
// 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 makeDays() -> [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
}
}
// MARK: - Previews
#if DEBUG
struct CalendarView_Previews: PreviewProvider {
static var previews: some View {
CalendarView(calendar: Calendar(identifier: .gregorian))
.environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
}
}
#endif
Is it possible to show whats inside a "Day" ? like hourly intervals and events in a day ?
I have customised @haskins-io code a little more to suit my needs, so I thought id share it.
- I have removed a lot of the padding to maximise space.
- I have changed the long date to UK format (dd/mmmm/yyyy).
- I have added a frame around the date selection so the highlighted colour is always even, this was a problem for single digit numbers.
- I have added
numberOfEventsInDate
Func to the if statements so the dots under the date correlate to the number of events (upto 3). - I have made the month text in the header a button to return to the date incase you have browsed far forward.
- I have sorted the list below the calendar in time order (newest first).
- I have fixed the colours to work in dark mode.
If I change anything else in the future ill add a revision.
import SwiftUI
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 entries: FetchedResults<Entry>
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: "dd MMMM yyyy", calendar: calendar)
}
var body: some View {
VStack {
CalendarViewComponent(
calendar: calendar,
date: $selectedDate,
content: { date in
ZStack {
Button(action: { selectedDate = date }) {
Text(dayFormatter.string(from: date))
.padding(6)
// Added to make selection sizes equal on all numbers.
.frame(width: 33, height: 33)
.foregroundColor(calendar.isDateInToday(date) ? Color.white : .primary)
.background(
calendar.isDateInToday(date) ? Color.red
: calendar.isDate(date, inSameDayAs: selectedDate) ? .blue
: .clear
)
.cornerRadius(7)
}
if (numberOfEventsInDate(date: date) >= 2) {
Circle()
.size(CGSize(width: 5, height: 5))
.foregroundColor(Color.green)
.offset(x: CGFloat(17),
y: CGFloat(33))
}
if (numberOfEventsInDate(date: date) >= 1) {
Circle()
.size(CGSize(width: 5, height: 5))
.foregroundColor(Color.green)
.offset(x: CGFloat(24),
y: CGFloat(33))
}
if (numberOfEventsInDate(date: date) >= 3) {
Circle()
.size(CGSize(width: 5, height: 5))
.foregroundColor(Color.green)
.offset(x: CGFloat(31),
y: CGFloat(33))
}
}
},
trailing: { date in
Text(dayFormatter.string(from: date))
.foregroundColor(.secondary)
},
header: { date in
Text(weekDayFormatter.string(from: date)).fontWeight(.bold)
},
title: { date in
HStack {
Button {
guard let newDate = calendar.date(
byAdding: .month,
value: -1,
to: selectedDate
) else {
return
}
selectedDate = newDate
} label: {
Label(
title: { Text("Previous") },
icon: {
Image(systemName: "chevron.left")
.font(.title2)
}
)
.labelStyle(IconOnlyLabelStyle())
.padding(.horizontal)
}
Spacer()
Button {
selectedDate = Date.now
} label: {
Text(monthFormatter.string(from: date))
.foregroundColor(.blue)
.font(.title2)
.padding(2)
}
Spacer()
Button {
guard let newDate = calendar.date(
byAdding: .month,
value: 1,
to: selectedDate
) else {
return
}
selectedDate = newDate
} label: {
Label(
title: { Text("Next") },
icon: {
Image(systemName: "chevron.right")
.font(.title2)
}
)
.labelStyle(IconOnlyLabelStyle())
.padding(.horizontal)
}
}
}
)
.equatable()
}
}
func dateHasEvents(date: Date) -> Bool {
for entry in entries {
if calendar.isDate(date, inSameDayAs: entry.timestamp ?? Date()) {
return true
}
}
return false
}
func numberOfEventsInDate(date: Date) -> Int {
var count: Int = 0
for entry in entries {
if calendar.isDate(date, inSameDayAs: entry.timestamp ?? Date()) {
count += 1
}
}
return count
}
}
// MARK: - Component
public struct CalendarViewComponent<Day: View, Header: View, Title: View, Trailing: View>: View {
@Environment(\.colorScheme) var colorScheme
// Injected dependencies
private var calendar: Calendar
@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
private let daysInWeek = 7
@FetchRequest var entries: FetchedResults<Entry>
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
_entries = FetchRequest<Entry>(sortDescriptors: [NSSortDescriptor(key: "timestamp", ascending: false)],
predicate: NSPredicate(
format: "timestamp >= %@ && timestamp <= %@",
Calendar.current.startOfDay(for: date.wrappedValue) as CVarArg,
Calendar.current.startOfDay(for: date.wrappedValue + 86400) as CVarArg))
}
public var body: some View {
let month = date.startOfMonth(using: calendar)
let days = makeDays()
VStack {
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)
.shadow(color: colorScheme == .dark ? .white.opacity(0.4) : .black.opacity(0.35), radius: 5)
List(entries) { entry in
NavigationLink {
CalendarDetailView(entry: entry)
} label: {
CalendarCardView(entry: entry)
}
}.listStyle(.plain)
}
}
}
// 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 makeDays() -> [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
}
}
// MARK: - Previews
#if DEBUG
struct CalendarView_Previews: PreviewProvider {
static var previews: some View {
CalendarView(calendar: Calendar(identifier: .gregorian))
.environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
}
}
#endif
Awesome thank you
@Plus1XP there is a bug, the selected day is always -1 clicked date ,
selecting may-30 , trigger 29 In Button(action: { selectedDate = date })
I've updated the gist with the latest version of CalendarView I use in my app with enabled date generation caching and conforming to Equatable for reducing the updates of the view.
@Plus1XP i just want to similarly and every selected date List will refresh. Im getting error for PersistenceController and Entry
please help me to solve
I tried to get this all working with the new .scrollPosition(initialAnchor: .bottom) in iOS 17.... very weird performance
@simbo64 did you try EquatableCalendarView?
@mecid yes I took the latest gist code above, wrapped the EquatableCalendarView in a ScrollView and added the modifier…
- Should also say, Date interval is set for 1 year of data
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. :)
How to change start of day to Monday?