Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
SwiftUI Calendar view using LazyVGrid
import SwiftUI
fileprivate extension DateFormatter {
static var month: DateFormatter {
let formatter = DateFormatter()
formatter.dateFormat = "MMMM"
return formatter
}
static var monthAndYear: DateFormatter {
let formatter = DateFormatter()
formatter.dateFormat = "MMMM yyyy"
return formatter
}
}
fileprivate 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
}
}
struct CalendarView<DateView>: View where DateView: View {
@Environment(\.calendar) var calendar
let interval: DateInterval
let showHeaders: Bool
let content: (Date) -> DateView
init(
interval: DateInterval,
showHeaders: Bool = true,
@ViewBuilder content: @escaping (Date) -> DateView
) {
self.interval = interval
self.showHeaders = showHeaders
self.content = content
}
var body: some View {
LazyVGrid(columns: Array(repeating: GridItem(), count: 7)) {
ForEach(months, id: \.self) { month in
Section(header: header(for: month)) {
ForEach(days(for: month), id: \.self) { date in
if calendar.isDate(date, equalTo: month, toGranularity: .month) {
content(date).id(date)
} else {
content(date).hidden()
}
}
}
}
}
}
private var months: [Date] {
calendar.generateDates(
inside: interval,
matching: DateComponents(day: 1, hour: 0, minute: 0, second: 0)
)
}
private func header(for month: Date) -> some View {
let component = calendar.component(.month, from: month)
let formatter = component == 1 ? DateFormatter.monthAndYear : .month
return Group {
if showHeaders {
Text(formatter.string(from: month))
.font(.title)
.padding()
}
}
}
private func days(for month: Date) -> [Date] {
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 [] }
return calendar.generateDates(
inside: DateInterval(start: monthFirstWeek.start, end: monthLastWeek.end),
matching: DateComponents(hour: 0, minute: 0, second: 0)
)
}
}
struct CalendarView_Previews: PreviewProvider {
static var previews: some View {
CalendarView(interval: .init()) { _ in
Text("30")
.padding(8)
.background(Color.blue)
.cornerRadius(8)
}
}
}
@lailo

This comment has been minimized.

Copy link

@lailo lailo commented May 6, 2020

@mecid thanks for sharing this gist.

I'm asking myself why you used hidden() and overlay{} in the lines 157 - 165 instead of .frame(). Something like this:

struct RootView: View {
  @Environment(\.calendar) var calendar
  
  private var year: DateInterval {
    calendar.dateInterval(of: .year, for: Date())!
  }
  
  var body: some View {
    CalendarView(interval: year) { date in
      Text(String(self.calendar.component(.day, from: date)))
        .frame(width: 40, height: 40, alignment: .center)
        .background(Color.blue)
        .clipShape(Circle())
        .padding(.vertical, 4)
    }
  }
}
@mecid

This comment has been minimized.

Copy link
Owner Author

@mecid mecid commented May 6, 2020

Hi @lailo, I'm not sure if you read my post, it describes this technique. Frame modifier limits the space and it is not working well for Dynamic Type. Assume that the user uses extraExtraExtraLarge font size. Your frame will cut the text inside it. But I use a template view with the maximal width and hide it. The overlay will expand as much font size expand.

@lailo

This comment has been minimized.

Copy link

@lailo lailo commented May 7, 2020

I actually came across this snippet before I saw your post. Thanks for your explanation @mecid.

@funkenstrahlen

This comment has been minimized.

Copy link

@funkenstrahlen funkenstrahlen commented May 8, 2020

Thanks for sharing :)

@wmhass

This comment has been minimized.

Copy link

@wmhass wmhass commented May 12, 2020

I wonder what the performance/memory consumption will be here: https://gist.github.com/mecid/f8859ea4bdbd02cf5d440d58e936faec#file-calendar-swift-L141

Will SwiftUI do some kind of reuse, or it will allocate all the views in memory?

@A320Peter

This comment has been minimized.

Copy link

@A320Peter A320Peter commented May 17, 2020

Thanks for the idea. Would be nice to use this snippet but in many regions if the week starts on Monday the first week row is duplicated. I was trying to figure it out but no luck so far.

@mecid

This comment has been minimized.

Copy link
Owner Author

@mecid mecid commented May 17, 2020

@A320Peter I guess the problem is hidden inside generateDates function. I need to investigate more into it.

@mecid

This comment has been minimized.

Copy link
Owner Author

@mecid mecid commented May 17, 2020

@wmhass it is not using any reuse right now. But it can change anytime without our notice.

@A320Peter

This comment has been minimized.

Copy link

@A320Peter A320Peter commented May 28, 2020

Is there any progress with this duplicated week issue?

@mecid

This comment has been minimized.

Copy link
Owner Author

@mecid mecid commented May 28, 2020

@A320Peter I'm going to work on it during the week.

@ekoranek12

This comment has been minimized.

Copy link

@ekoranek12 ekoranek12 commented May 29, 2020

@mecid I found that replacing DateComponents(hour: 0, minute: 0, second: 0, weekday: 1) with DateComponents(hour: 0, minute: 0, second: 0, weekday: Calendar.current.firstWeekday) in months resolves the duplicate first week issue.

@mecid

This comment has been minimized.

Copy link
Owner Author

@mecid mecid commented May 30, 2020

@ekoranek12 thanks for your contribution 🙏🏻

@mecid

This comment has been minimized.

Copy link
Owner Author

@mecid mecid commented May 30, 2020

@A320Peter Let's say thanks to @ekoranek12 the issue is solved.

@A320Peter

This comment has been minimized.

Copy link

@A320Peter A320Peter commented May 30, 2020

Awesome, thank you! We were so close playing with the weekday parameter but did't realise the .firstWeekday availability.

@danieleprice123

This comment has been minimized.

Copy link

@danieleprice123 danieleprice123 commented May 31, 2020

This is a great code snippet and I was hoping to expand by adding a "day view." I added an .onTapGesture to the overlay in your template view but using DayView(date: date) sends 12/29/2019 regardless of the item I click on. What am I missing/not understanding?

@andreimateiro

This comment has been minimized.

Copy link

@andreimateiro andreimateiro commented Jun 11, 2020

Great work! It looks nice and it's highly customisable.
The only thing it bothers me (and it's mostly SwiftUI related) is the performance. I'm embedding it into a sheet and each time I open it, it takes more than a second (and i guess on older phones is even worst).

Is there any workaround for this? (loading only last 3 months helps, but sometimes I need more)

@mecid

This comment has been minimized.

Copy link
Owner Author

@mecid mecid commented Jun 11, 2020

@andreimateiro this is how ScrollView in SwiftUI works. It tries to render all the things at once. I hope Apple will change it during the next WWDC later this month.

@ekoranek12

This comment has been minimized.

Copy link

@ekoranek12 ekoranek12 commented Jun 11, 2020

@andreimateiro @mecid is this true of List as well? My understanding is that List uses UITableView under the hood. Maybe putting month views in a List could help performance?

@A320Peter

This comment has been minimized.

Copy link

@A320Peter A320Peter commented Jun 11, 2020

I think it would not work. As it was mentioned before SwiftUI renders everything at once. Maybe Apple will change the logic in the future, or you may build you own pagination version to only load the necessary data. This article describes the problem and a good start for List pagination in SwiftUI:

https://medium.com/@pedrorojas.dev/how-to-create-a-list-with-pagination-in-swiftui-215244840b48?source=linkShare-561d199e66b0-1591897696

@mecid

This comment has been minimized.

Copy link
Owner Author

@mecid mecid commented Jun 11, 2020

@ekoranek12 List doesn't use reusing for now 😔

@andreimateiro

This comment has been minimized.

Copy link

@andreimateiro andreimateiro commented Jun 12, 2020

Yeah, looking forward to see what WWDC brings this year :)

@jaykumar1986

This comment has been minimized.

Copy link

@jaykumar1986 jaykumar1986 commented Jun 25, 2020

Hi,

As I have requirement, to show calendar for 3 month from current date and drag to select multiple dates.
How do we achieve this features.

@danieleprice123

This comment has been minimized.

Copy link

@danieleprice123 danieleprice123 commented Jun 25, 2020

Jay,

Here's how to show 3 months. In RootView, the start and end dates of the calendar are defined by:
private var year: DateInterval { calendar.dateInterval(of: .year, for: Date())! }

That gives you the full year. Since you want 3 months (or quarterly), you can use
private var quarter: DateInterval { calendar.dateInterval(of: .quarter, for: Date())! }

And then change
CalendarView(interval: year) { date in
to
CalendarView(interval: quarter) { date in

@jaykumar1986

This comment has been minimized.

Copy link

@jaykumar1986 jaykumar1986 commented Jun 25, 2020

Hi Danieleprice

Thank you so much for your valuable response.

My another requirement is drag to select date. where I will drag from date to till date and once my drag over, I should get event with give list of all selected date.

@jaykumar1986

This comment has been minimized.

Copy link

@jaykumar1986 jaykumar1986 commented Jun 25, 2020

Also I can see here, its giving each quarterly details, and what I am excepting next 3 month from current month.

@jaykumar1986

This comment has been minimized.

Copy link

@jaykumar1986 jaykumar1986 commented Jun 25, 2020

Hi Danieleprice,

once again thank for your valuable response, As I am new for swiftUI, I am confused with your above example. if possible can you help me with some code snippet , where I can show only next 3 month of calendar from today and tap to each date change date color to different color. Not looking to move on next page as of now.

@jaykumar1986

This comment has been minimized.

Copy link

@jaykumar1986 jaykumar1986 commented Jun 25, 2020

Also After Implementation this

@State var desiredDate = Date()
var monthComponent = DateComponents()
monthComponent.month = 3
self.desiredDate = self.calendar.date(byAdding: monthComponent, to: self.desiredDate) ?? Date()})

and change
private var quarter: DateInterval { calendar.dateInterval(of: .quarter, for: Date())! }
to
private var quarter: DateInterval { calendar.dateInterval(of: .quarter, for: desiredDate())! }

Its giving month of next "quarter" which is not correct based on my requirement. I am excepting next 3month from current month or next 90days from current days.

Also I have added .onTapGesture{
print("dateView (date)");
} to "overlay" but its print previous date from taped date.

Any suggestion will be help full here.

@danieleprice123

This comment has been minimized.

Copy link

@danieleprice123 danieleprice123 commented Jun 26, 2020

And there was much rejoicing! 3 months from today. All dates have .onTapGesture. Working.
It's all about DateIntervals. Interesting stuff.
Don't forget to comment out ScrollView in CalendarView. You still want the VStack, we're just going to call a ScrollView in the ContentView

import SwiftUI

struct ContentView: View {
  @Environment(\.calendar) var calendar
  @State var showingDayView = false
  @State var components = DateComponents()
  @State var desiredDate = Date()
  
  
  private var monthly: DateInterval {
    var monthComponent = DateComponents()
    monthComponent.month = 2
    let endDate = self.calendar.date(byAdding: monthComponent, to: self.desiredDate) ?? Date()
    return DateInterval(start: Date(), end: endDate)
  }
 

  
  var body: some View {
   ScrollView{
      CalendarView(interval: monthly) { date in
        VStack {
          VStack{
            Text("888")
            Divider()
            }.font(.title)
            .aspectRatio(1.0, contentMode: .fit)
          .hidden()
          .padding()
          .background(Color.blue)
          .clipShape(Rectangle())
          
          .overlay(
            VStack{
              Text(String(self.calendar.component(.day, from: date)))
                .foregroundColor(Color.white).font(.title)
              Text("")
            }.padding()
              .onTapGesture {
                    self.showingDayView.toggle()
                    
                self.components.month = self.calendar.component(.month, from: date)
                self.components.day = self.calendar.component(.day, from: date)
                self.components.year = self.calendar.component(.year, from: date)
             
                }.sheet(isPresented: self.$showingDayView) {
                  DayView(date: self.calendar.date(from: self.components) ?? Date())
              }
          )
        }
      }
    }.padding()
  }
}

and here's DayView

struct DayView: View {
  @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
  
  var date: Date
    
  var dateFormatter: DateFormatter {
    let formatter = DateFormatter()
    formatter.dateStyle = .short
    return formatter
  }
    
  var body: some View {
    VStack {
      Text(self.dateFormatter.string(from: self.date))
      Button("Close") {
        self.presentationMode.wrappedValue.dismiss()
      }
    }
  }
}

Dates are hard. Still trying to figure out why sometimes I'm able to use date defined in CalendarView and other times I have use the components...

Looking forward to seeing your project!

@jaykumar1986

This comment has been minimized.

Copy link

@jaykumar1986 jaykumar1986 commented Jun 26, 2020

Hi Danieleprice,

Thank you once again for your help. It's so kind of you.
I am almost done with my requirement, I need 2 more feature in this app

  1. current date should be red color
  2. once I tap any of the dat, That background color should be gray color.

Its will be very grateful, if you could help me in that.

@jaykumar1986

This comment has been minimized.

Copy link

@jaykumar1986 jaykumar1986 commented Jun 26, 2020

Hi Danieleprice,

I have done with "current date should be red color" Also I have deselect previous date from current date of the month.

Unable to changes color for Taped Date.
Also drag to select date.

Kindly suggest how to achieve this.
Thanks in advance.

@danieleprice123

This comment has been minimized.

Copy link

@danieleprice123 danieleprice123 commented Jun 26, 2020

My user only needs to see 1 month at a time. So to avoid the loading issues (until lazy in iOS14) I only load the one month and implemented a DragGesture to increase the desiredMonth variable. Other than that, I'm still learning gestures. Majid has a couple of articles and check out hackingwithswift.com

But here's how to change the color for the tapped date. You need to create a State that will change the background. And you will change that state in the onTapGesture. So something like this...

struct ContentView: View {
...
@State var clickedDate = Date()
...
CalendarView(interval: monthly) { date in
...
VStack{
  Text("888")
}.background(date == self.clickedDate ? Color.gray : Color.blue)
...
.onTapGesture{
self.clickedDate = date
...
}

Let us know if you figure out the drag!
@jaykumar1986

This comment has been minimized.

Copy link

@jaykumar1986 jaykumar1986 commented Jun 27, 2020

Hi Danieleprice,

once again many thanks for you valuable time and help.

As you given solution is for single date selection.
Here I am posting my complete code for 3 month colander from today,

  1. Previous date from current date has background - Gray color
  2. Current has background - Read color
  3. Upcoming date has background - Blue color

Here user can select multiple date with background - Orange cohoe

               struct ContentView: View {
                             @Environment(\.calendar) var calendar
                             @State var components = DateComponents()
                            @State var desiredDate = Date()
                            @State var selectedDateList : [Date] = [].self
                            private var monthly: DateInterval {
                                    var monthComponent = DateComponents()
                                     monthComponent.month = 3
                                    let endDate = self.calendar.date(byAdding: monthComponent, to: self.desiredDate) ?? Date()
                                   return DateInterval(start: Date(), end: endDate)
                           }
                     var body: some View {
                                   CalendarView(interval: monthly) { date in

                                 //Previous Dates from current date
                                         if(self.getPrevioudDateViewFlag(date: date as NSDate)){
                                          Text("30").hidden()
                                         .padding(8)
                                          .background( Color.gray)
                                         .clipShape(Circle())
                                         .padding(.vertical, 4)
                                         .overlay(
                                              Text(self.getCurrentDate(date: date as NSDate))
                                       )
                             }
                         //Once user will select any date
                               else if(self.getSelectedDate(date: date as NSDate)){
                                     Text("30").hidden()
                                    .padding(8)
                                    .background(Color.orange)
                                    .clipShape(Circle())
                                   .padding(.vertical, 4)
                       .overlay(
                                 Text(self.getCurrentDate(date: date as NSDate))
                           )
                        .onTapGesture{
                                   self.updateSelectedDate(date: date as NSDate)
                           }
                       }
             //Current Date
                       else if(self.getCurrentDateViewFalg(date: date as NSDate)){
                                    Text("30").hidden()
                                    .padding(8)
                                    .background(Color.red)
                                    .clipShape(Circle())
                                    .padding(.vertical, 4)
                                  .overlay(
                                           Text(self.getCurrentDate(date: date as NSDate))
                                  )
                                .onTapGesture{
                                       self.updateSelectedDate(date: date as NSDate)
                                      self.components.month = self.calendar.component(.month, from: date)
                                       self.components.day = self.calendar.component(.day, from: date)
                                        self.components.year = self.calendar.component(.year, from: date)
                              }
                       }
                    //Other Dates
                            else{
                                    Text("30").hidden()
                                    .padding(8)
                                    .background(Color.blue)
                                    .clipShape(Circle())
                                    .padding(.vertical, 4)
                                    .overlay(
                                             Text(self.getCurrentDate(date: date as NSDate))
                                     )
                                     .onTapGesture{
                                               self.updateSelectedDate(date: date as NSDate)
                                               self.components.month = self.calendar.component(.month, from: date)
                                                self.components.day = self.calendar.component(.day, from: date)
                                                self.components.year = self.calendar.component(.year, from: date)
                                    }
                                }
                             }
                         }
                         func getCurrentDate(date : NSDate) -> String{
                                     return String(self.calendar.component(.day, from: date as Date))
                            }
                        func getCurrentDateViewFalg(date : NSDate) -> Bool{
                                       if(self.calendar.component(.day, from: date as Date) == self.calendar.component(.day, from: self.desiredDate)        &&  self.calendar.component(.month, from: date as Date) == self.calendar.component(.month, from: self.desiredDate)){
                                               return true
                                            }
                                             return false
                                       }
                         func getPrevioudDateViewFlag(date : NSDate) -> Bool{
                                       if(self.calendar.component(.day, from: date as Date) < self.calendar.component(.day, from: self.desiredDate) &&  self.calendar.component(.month, from: date as Date) == self.calendar.component(.month, from: self.desiredDate)){
                                                   return true
                                              }
                                                     return false
                                                 }
                              func getSelectedDate(date : NSDate) -> Bool{
                                        for item in selectedDateList{
                                                      if item == date as Date {
                                                                     return true
                                                     }
                                               }
                                                  return false
                                       }
                              func updateSelectedDate(date : NSDate){
                                                  if(!self.selectedDateList.contains(date as Date)){
                                                                self.selectedDateList.append(date as Date)
                                                    }
                                                    else{
                                                          if(self.selectedDateList.contains(date as Date)){
                                                                     let index =  self.selectedDateList.firstIndex(of: date as Date)
                                                                            self.selectedDateList.remove(at: index!)
                                                              }
                                                      }
                                               }
                                       }

Hope this may help to others as well.
Here user can add any number of month by changing this value -- monthComponent.month = 3/4/5/6/7/8/9/10

@mecid

This comment has been minimized.

Copy link
Owner Author

@mecid mecid commented Aug 10, 2020

I have refactored the calendar view to replace stacks with the grid.

@shengchalover

This comment has been minimized.

Copy link

@shengchalover shengchalover commented Aug 12, 2020

I've compared the performance of grid-based vs stack-based solutions on beta 4 iPhone X, and the latter one is much more smooth. Maybe that's due to buggy grid implementation in current SwiftUI beta or something else. I would recommend keeping this gist stack-based and update a blog post to include a grid based calendar and a separate gist file for it.

Also, the stack based solution is very straightforward and easy do digest despite that it's a bit leggy compared to grid.

@chrisriner

This comment has been minimized.

Copy link

@chrisriner chrisriner commented Aug 25, 2020

Did I miss somewhere how I can automatically scroll the calendar view to the current date when I load the calendar view? If not does anyone have any ideas on how to do this? Thanks.

@mecid

This comment has been minimized.

Copy link
Owner Author

@mecid mecid commented Aug 26, 2020

@chrisriner you can wrap calendar view with ScrollViewReader and ScrollView you can scroll to any date you want. Every date view has the id which is the date.

@chrisriner

This comment has been minimized.

Copy link

@chrisriner chrisriner commented Aug 26, 2020

@mecid that is true and I did put it in scrollview and I did use new ios14 feature to do scrollto(id) - it was not easy or at least to me it was not easy as I also wanted the view to scroll to today when it loaded. I had to do this: hacky but maybe someone has better idea. I had to use onappear and on change and was trying to just use on change but was not sure how to get the value changed so used on appear but maybe could have used same onappear code to just do the scrollto I guess

`var body: some View {
NavigationView {
VStack {
//SearchBar(text: $searchString)
CalendarView(interval: year) { date in
Text("30")
.hidden()
.padding(8)
.background(Calendar.current.isDateInToday(date) ? Color.blue : nil)
.clipShape(Circle())
.padding(.vertical, 4)
.overlay(
Text(String(self.calendar.component(.day, from: date)))
.foregroundColor(Calendar.current.isDateInToday(date) ? Color.white : nil)
)
.onTapGesture(count: /@START_MENU_TOKEN@/1/@END_MENU_TOKEN@/, perform: {
print("date is (date)")
print("now is (Date())")
})
}
}.navigationTitle("Calendar")
}

`

then in the calendar code

@State var dateToScrollTo: Date = Date()

var body: some View {
    ScrollViewReader { scrollProxy in
        ScrollView {
            LazyVGrid(columns: Array(repeating: GridItem(), count: 7)) {
                ForEach(months, id: \.self) { month in
                    Section(header: header(for: month)) {
                        ForEach(days(for: month), id: \.self) { date in
                            if calendar.isDate(date, equalTo: month, toGranularity: .month) {
                                content(date).id(date)
                            } else {
                                content(date).hidden()
                            }
                        }
                    }
                }
            }
        }
        .onAppear() {
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
                self.dateToScrollTo = Date()
            }
        }
        .onChange(of: self.dateToScrollTo, perform: { value in
            withAnimation {
                var components = Calendar.current.dateComponents([.day, .month, .year], from: value)
                components.hour = 0
                components.minute = 0
                let date = Calendar.current.date(from: components)
                scrollProxy.scrollTo(date, anchor: .center)
            }
        })
    }
}

private var months: [Date] {
    calendar.generateDates(
        inside: interval,
        matching: DateComponents(day: 1, hour: 0, minute: 0, second: 0)
    )
}

private func header(for month: Date) -> some View {
    let component = calendar.component(.month, from: month)
    let formatter = component == 1 ? DateFormatter.monthAndYear : .month

    return Group {
        if showHeaders {
            Text(formatter.string(from: month))
                .font(.title)
                .bold()
                .padding()
        }
        LazyVGrid(columns: Array(repeating: GridItem(), count: 7)) {
            ForEach(daysOfWeek, id: \.id) { day in
                Text(day.dayHeader)
                    .bold()
            }
        }
        Divider()
    }
}
@alahdal

This comment has been minimized.

Copy link

@alahdal alahdal commented Sep 19, 2020

This is cool 😁. Thank you.

@polofu

This comment has been minimized.

Copy link

@polofu polofu commented Sep 28, 2020

halo,my dear friend, thx for your sharing
if I want make the Calendar like this:
the Saturday and Sunday are in the end of the view ,how to do it ?
hope u would reply

1

@andreimateiro

This comment has been minimized.

Copy link

@andreimateiro andreimateiro commented Sep 28, 2020

@polofu that is based on the locale of the simulator/device.

@jagan510710

This comment has been minimized.

Copy link

@jagan510710 jagan510710 commented Oct 5, 2020

Screenshot 2020-10-05 at 11 44 51 AM

Integrated with scroll view and preview and next actions. But some performance issue as we are rendering all the months once. Any solution?

@ddl-elo

This comment has been minimized.

Copy link

@ddl-elo ddl-elo commented Oct 6, 2020

hi @alahdal,

Based on the image in your comment

I would like to know how do you achieve the view, complete code solution would be highly appreciated.

Thank you in advance.

@alahdal

This comment has been minimized.

Copy link

@alahdal alahdal commented Oct 6, 2020

@ddl-elo,

Please note:

  1. I have made slight modification with the shape and month navigation (took idea from @jagan510710), but core still the same.
  2. I am using a previous code of @mecid, before he changed to LazyVGrid. I did not update because that would add value If I am showing full year. But I do show month by month.
    The full code in this link : https://gist.github.com/alahdal/deb37df908be07d2a64456229276665e

The current look: If you still look to have the same previous appearance, I will need to get it from previous version.

@ddl-elo

This comment has been minimized.

Copy link

@ddl-elo ddl-elo commented Oct 9, 2020

Hi @alahdal

Thank you very much for your great response.

@polofu

This comment has been minimized.

Copy link

@polofu polofu commented Oct 9, 2020

@ddl-elo,

Please note:

  1. I have made slight modification with the shape and month navigation (took idea from @jagan510710), but core still the same.
  2. I am using a previous code of @mecid, before he changed to LazyVGrid. I did not update because that would add value If I am showing full year. But I do show month by month.
    The full code in this link : https://gist.github.com/alahdal/deb37df908be07d2a64456229276665e

The current look: If you still look to have the same previous appearance, I will need to get it from previous version.

Hi My friend,
how to achieve that every row order by Monday Tuesday Wednesday Thursday Friday Saturday Sunday. Every row is start with Monday and end with Sunday.

@mecid

This comment has been minimized.

Copy link
Owner Author

@mecid mecid commented Oct 9, 2020

@polofu by default calendar view uses your locale to generate dates. So if you use the US locale you will get Sunday as the first day in the week, otherwise, you get Monday as the first day of the week. Change the locale of your device and take a look at the changes.

@filimo

This comment has been minimized.

Copy link

@filimo filimo commented Oct 9, 2020

@mecid I use RKCalendar but your Calendar code is better. Could you implement showing more months at same time with scrolling and ability to select date range?

@jz709u

This comment has been minimized.

Copy link

@jz709u jz709u commented Oct 19, 2020

https://gist.github.com/jz709u/ed97507a8655ce5b23e205b0feea80bb

I have fixed performance issues:

  • caching header strings on init
  • caching dates on init
  • remove headerView frame modifications and padding

headerView padding and frame modifications:
has drastic performance issues on layout engine. I think the LazyVStack view does not reuse section header frame calculations so any frame modifications or padding needs to be recalculated when header is re-displayed hence reloading the entire view while scrolling hence the performance drops in scrolling.

@stevhens

This comment has been minimized.

Copy link

@stevhens stevhens commented Nov 23, 2020

hi @polofu

on your last comment,

I think we could slice the weekdaySymbols since it returns array of string (Sunday - Saturday).

then take a look at a func I improved from @alahdal's gist,
Screen Shot 2020-11-23 at 9 08 21 PM

note that above code only changes the weekdays header, make sure to update the calendar dates.

here also some resources you can look at:
https://developer.apple.com/documentation/foundation/calendar/2293235-weekdaysymbols
https://developer.apple.com/documentation/foundation/calendar/2293656-firstweekday

@viktorsec

This comment has been minimized.

Copy link

@viktorsec viktorsec commented Dec 6, 2020

@mehdi thank you for a great tutorial and component! You might want to update the code in your blog post. It lacks some changes, most notably the duplicit first week bugfix.

@xuanzi23

This comment has been minimized.

Copy link

@xuanzi23 xuanzi23 commented Jan 28, 2021

Is it possible to do a infinite scroll just like iPhone default calendar apps?

@MrCarter31

This comment has been minimized.

Copy link

@MrCarter31 MrCarter31 commented Feb 27, 2021

Is there anyway to pin the header to the top of the calendar view?

@mecid

This comment has been minimized.

Copy link
Owner Author

@mecid mecid commented Feb 28, 2021

@MrCarter31 there is pinnedViews parameter on LazyVGrid. You can use .sectionHeaders to pin month headers.

@eostarman

This comment has been minimized.

Copy link

@eostarman eostarman commented Apr 7, 2021

Wonderful code! I tweaked the MonthView to take a parameter to force the display of the year for the first displayed month (useful for me since my calendar may only have a few displayed months).

Every time I read your code I learn something new about SwiftUI - e.g. @ViewBuilder parameter (and DateInterval).

Many thanks from an old programmer who's new to Swift :)

@basememara

This comment has been minimized.

Copy link

@basememara basememara commented Apr 15, 2021

Awesome work again @mecid! It's amazing how little code something complex as a calendar could be with the SwiftUI grid.

I evolved it in a few ways:

  1. Made it Equatable so it wouldn't re-render unnecessarily. I'm comparing DateInterval which I think is a safe assumption to make. Also be careful not just to use Date() since it will always be different, lock it to some date... which was driving me nuts in testing 😅
  2. I exposed title and header as a ViewBuilder to the caller so they can render it as they please. This also naturally resolves custom calendar.firstWeekDay by itself since the caller is just formatting the date.
  3. Add optional trailing dates for few days before and after the month if they're in the week.
  4. Fixed accessibility for the equal background trick (I wish there as a cleaner way to do this but couldn't figure either).

Screen Shot 2021-04-15 at 7 41 03 PM

struct ContentView: View {
    private let dateInterval = DateInterval(
        start: Date(timeIntervalSince1970: 1617316527),
        end: Date(timeIntervalSince1970: 1627794000)
    )

    @State private var selectedDate: Date?

    var body: some View {
        VStack {
            if let date = selectedDate {
                Text("Selected date: \(DateFormatter.day.string(from: date))")
            }
            CalendarView(dateInterval: dateInterval) { date in
                Button(action: { selectedDate = date }) {
                    Text("00")
                        .padding(8)
                        .foregroundColor(.clear)
                        .background(Color.blue)
                        .cornerRadius(8)
                        .accessibilityHidden(true)
                        .overlay(
                            Text(DateFormatter.day.string(from: date))
                                .foregroundColor(.white)
                        )
                }
            } header: {
                Text(DateFormatter.weekDay.string(from: $0))
                // EmptyView() if no header wanted
            } title: {
                Text(DateFormatter.monthAndYear.string(from: $0))
                    .font(.headline)
                    .padding()
                // EmptyView() if no title wanted
            } trailing: {
                Text(DateFormatter.day.string(from: $0))
                    .foregroundColor(.secondary)
                //.hidden() // To remove trailing dates
            }
            .equatable()
        }
    }
}

struct CalendarView<Day: View, Header: View, Title: View, Trailing: View>: View {
    @Environment(\.calendar) private var calendar
    private let dateInterval: DateInterval
    private let content: (Date) -> Day
    private let header: (Date) -> Header
    private let title: (Date) -> Title
    private let trailing: (Date) -> Trailing
    private let daysInWeek = 7

    init(
        dateInterval: DateInterval,
        @ViewBuilder content: @escaping (Date) -> Day,
        @ViewBuilder header: @escaping (Date) -> Header,
        @ViewBuilder title: @escaping (Date) -> Title,
        @ViewBuilder trailing: @escaping (Date) -> Trailing
    ) {
        self.dateInterval = dateInterval
        self.content = content
        self.header = header
        self.title = title
        self.trailing = trailing
    }

    var body: some View {
        let dates = makeDates()

        return ScrollView {
            LazyVGrid(columns: Array(repeating: GridItem(), count: daysInWeek)) {
                ForEach(dates, id: \.month) { (month, days) in
                    Section(header: title(month)) {
                        ForEach(makeHeaderDays(for: month), id: \.self, content: header)
                        ForEach(days, id: \.self) { date in
                            if calendar.isDate(date, equalTo: month, toGranularity: .month) {
                                content(date)
                            } else {
                                trailing(date)
                            }
                        }
                    }
                }
            }
        }
    }
}

private extension CalendarView {
    func makeDates() -> [(month: Date, days: [Date])] {
        calendar
            .generateDates(
                for: dateInterval,
                matching: DateComponents(day: 1, hour: 0, minute: 0, second: 0)
            )
            .map { ($0, days(for: $0)) }
    }

    func makeHeaderDays(for month: Date) -> [Date] {
        guard let week = calendar.dateInterval(of: .weekOfMonth, for: month) else { return [] }

        return calendar
            .generateDates(for: week, matching: DateComponents(hour: 0, minute: 0, second: 0))
            .prefix(daysInWeek) // Ensure number of days matches grid
            .array
    }

    func days(for month: Date) -> [Date] {
        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 - 1)
        else {
            return []
        }

        return calendar.generateDates(
            for: DateInterval(start: monthFirstWeek.start, end: monthLastWeek.end),
            matching: DateComponents(hour: 0, minute: 0, second: 0)
        )
    }
}

extension CalendarView: Equatable {
    static func == (lhs: CalendarView<Day, Header, Title, Trailing>, rhs: CalendarView<Day, Header, Title, Trailing>) -> Bool {
        lhs.dateInterval == rhs.dateInterval
    }
}

// MARK: - Helpers

private extension DateFormatter {
    static var monthAndYear: DateFormatter {
        let formatter = DateFormatter()
        formatter.dateFormat = "MMMM yyyy"
        return formatter
    }

    static var day: DateFormatter {
        let formatter = DateFormatter()
        formatter.dateFormat = "d"
        return formatter
    }

    static var weekDay: DateFormatter {
        let formatter = DateFormatter()
        formatter.dateFormat = "EEEEE"
        return formatter
    }
}

private extension Calendar {
    /// Returns the dates which match a given set of components between the specified dates.
    /// - Parameters:
    ///   - dateInterval: The `DateInterval` between which to compute the search.
    ///   - components: The `DateComponents` to use as input to the search algorithm.
    /// - Returns: The dates between the date interval that match the specified date component.
    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
    }
}

private extension ArraySlice {
    /// Returns the array of the slice
    var array: [Element] { Array(self) }
}
@QiYuNew

This comment has been minimized.

Copy link

@QiYuNew QiYuNew commented May 10, 2021

@basememara Thank you for your code! But I have performance issue when running on iOS 14.5, do you have the same problem?
Edit: after I remove .clipshape(circle) and use .cornerRadius, the performance is better but not smooth.

@basememara

This comment has been minimized.

Copy link

@basememara basememara commented May 10, 2021

I did have performance issues before adding the equatable code, but was better afterwards. Also, when supplying a date to it, don’t use Date() for the calendar date range. Instead cache the current date like:

private static let now = Date()

private let dateInterval = DateInterval(
    start: Self.now,
    end: Self.now + //days, weeks, etc
)

Otherwise the calendar will refresh every time the view redraws and the equatable logic will have no effect.

I must confess I’m also just using this to display one month only. I briefly tried it for 3 months but wasn’t a requirement for my app. I would imagine displaying the whole year would be intense and the equatable logic would have to get more sophisticate for caching per month otherwise changes would re-render the whole year unnecessarily.

@QiYuNew

This comment has been minimized.

Copy link

@QiYuNew QiYuNew commented May 10, 2021

@basememara Hi! Thank you for your quick reply. May I ask If I want to display one month only and there will be buttons to switch prev/next month, what view should I use to wrap the calendar component? Should I generate 365 days at the beginning or generate one month per display?

@basememara

This comment has been minimized.

Copy link

@basememara basememara commented May 10, 2021

Here's an updated version to show traversing the months. It generates one month at a time, then recalculates only when selecting the next/previous month. It also accepts any calendar and selection date, then it expands the date to encapsulate the current month:

Screen Shot 2021-05-10 at 1 02 36 PM

struct ContentView: 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() // Cache now

    init(calendar: Calendar) {
        self.calendar = calendar
        self.monthFormatter = DateFormatter(dateFormat: "MMMM", 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 {
            Text("Selected date: \(fullFormatter.string(from: selectedDate))")
                .bold()
                .foregroundColor(.red)
            CalendarView(
                calendar: calendar,
                date: $selectedDate,
                content: { date in
                    Button(action: { selectedDate = date }) {
                        Text("00")
                            .padding(8)
                            .foregroundColor(.clear)
                            .background(
                                calendar.isDate(date, inSameDayAs: selectedDate) ? Color.red
                                    : calendar.isDateInToday(date) ? .green
                                    : .blue
                            )
                            .cornerRadius(8)
                            .accessibilityHidden(true)
                            .overlay(
                                Text(dayFormatter.string(from: date))
                                    .foregroundColor(.white)
                            )
                    }
                },
                trailing: { date in
                    Text(dayFormatter.string(from: date))
                        .foregroundColor(.secondary)
                },
                header: { date in
                    Text(weekDayFormatter.string(from: date))
                },
                title: { date in
                    HStack {
                        Text(monthFormatter.string(from: date))
                            .font(.headline)
                            .padding()
                        Spacer()
                        Button {
                            withAnimation {
                                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") }
                            )
                            .labelStyle(IconOnlyLabelStyle())
                            .padding(.horizontal)
                            .frame(maxHeight: .infinity)
                        }
                        Button {
                            withAnimation {
                                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") }
                            )
                            .labelStyle(IconOnlyLabelStyle())
                            .padding(.horizontal)
                            .frame(maxHeight: .infinity)
                        }
                    }
                    .padding(.bottom, 6)
                }
            )
            .equatable()
        }
        .padding()
    }
}

// MARK: - Component

public struct CalendarView<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

    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
    }

    public var body: some View {
        let month = date.startOfMonth(using: calendar)
        let days = makeDays()

        return LazyVGrid(columns: Array(repeating: GridItem(), count: daysInWeek)) {
            Section(header: title(month)) {
                ForEach(days.prefix(daysInWeek), id: \.self, content: header)
                ForEach(days, id: \.self) { date in
                    if calendar.isDate(date, equalTo: month, toGranularity: .month) {
                        content(date)
                    } else {
                        trailing(date)
                    }
                }
            }
        }
    }
}

// MARK: - Conformances

extension CalendarView: Equatable {
    public static func == (lhs: CalendarView<Day, Header, Title, Trailing>, rhs: CalendarView<Day, Header, Title, Trailing>) -> Bool {
        lhs.calendar == rhs.calendar && lhs.date == rhs.date
    }
}

// MARK: - Helpers

private extension CalendarView {
    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 {
        Group {
            ContentView(calendar: Calendar(identifier: .gregorian))
            ContentView(calendar: Calendar(identifier: .islamicUmmAlQura))
            ContentView(calendar: Calendar(identifier: .hebrew))
            ContentView(calendar: Calendar(identifier: .indian))
        }
    }
}
#endif
@QiYuNew

This comment has been minimized.

Copy link

@QiYuNew QiYuNew commented May 10, 2021

@basememara Thank you for your excellent work!

@basememara

This comment has been minimized.

Copy link

@basememara basememara commented May 10, 2021

built on the shoulders of giants.. thx to @mecid for the inspiration and leading the charge 🎯

@gesabo

This comment has been minimized.

Copy link

@gesabo gesabo commented May 18, 2021

Has anyone been able to load the calendar to the current date on appear? The below isn't working for me. 🤔


var body: some View {
            ScrollView {          
                ScrollViewReader { value in
                    VStack {
                        ZStack {
                            CalendarView(interval: month) { date in
                                
                                if date <= Date() {
                                    
                                        Text("30")
                                            .hidden()
                                            .padding(8)
                                        
                                            .clipShape(Circle())
                                            
                                            .padding(.vertical, 5)
                                            .overlay(
                                                NavigateFromCalendarButton(
                                                    action: {
                                                    
                                                    },
                                                    destination: {
                                                        DayView(exertionObject: effortFromDay, date: date)
                             
                                                    },
                                                    label: {
                                                        ZStack {
                                                            RoundedRectangle(cornerRadius: 12)
                                                              
                                                            Text("\(date)")
                                                                .foregroundColor(.black)
                                                                .fontWeight(.bold)
                                                        }
                                                    } 
                                                )
                                            )
                                        
                                    }
                                    
                                }
                            } // end of calender
                            .onAppear {
                                value.scrollTo(Date(), anchor: .center)
                            }
                     
                        } //end of Zstack               
                    }                    
                }
            } //end of scroll view
           
        }
        
    }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment