-
-
Save kovs705/f1327fc03b82412a70fe47730c7d67ba to your computer and use it in GitHub Desktop.
SwiftUI Calendar view using LazyVGrid
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import SwiftUI | |
// MARK: - CustomCalendar | |
struct CustomCalendar: View { | |
var calendar: Calendar | |
let yearFormatter: DateFormatter | |
let monthFormatter: DateFormatter | |
let dayFormatter: DateFormatter | |
let weekDayFormatter: DateFormatter | |
let fullFormatter: DateFormatter | |
@State var selectedDate = Self.now | |
static var now = Date() // Cache now | |
@State var dates: [Date] = [] | |
init(calendar: Calendar) { | |
self.calendar = calendar | |
self.calendar.timeZone = TimeZone.autoupdatingCurrent | |
// LLLL для именительного падежа | |
self.yearFormatter = DateFormatter(dateFormat: "YYYY", calendar: calendar) | |
self.monthFormatter = DateFormatter(dateFormat: "LLLL", 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) | |
dates = [] | |
} | |
var body: some View { | |
VStack { | |
// Text("Selected date: \(fullFormatter.string(from: selectedDate))") | |
// .bold() | |
// .minimumScaleFactor(0.5) | |
// .lineLimit(1) | |
// .foregroundColor(.red) | |
// MARK: - Calendar | |
CalendarView( | |
calendar: calendar, | |
date: $selectedDate, | |
content: { date in | |
Button(action: { | |
selectedDate = date | |
manageDates(with: date) | |
}) { | |
ZStack { | |
Rectangle() | |
.fill(.clear) | |
.background( | |
calendar.isDate(date, inSameDayAs: CustomCalendar.now) ? Color.lkPurple.opacity(0.3) : .clear | |
) | |
.background( | |
Circle() | |
.fill( | |
calendar.isChosed(date: date, of: dates) ? Color.lkPurple : .clear | |
) | |
.frame(width: 62, height: 62) | |
) | |
Text(dayFormatter.string(from: date)) | |
.foregroundStyle(calendar.isChosed(date: date, of: dates) ? Color.white : Color(uiColor: UIColor.label).opacity(0.9)) | |
.minimumScaleFactor(0.5) | |
.lineLimit(1) | |
} | |
.ignoresSafeArea() | |
.background( | |
calendar.isInInterval(date, of: dates.first, and: dates.last) ? Color.lkPurple.opacity(0.1) : .clear | |
) | |
} | |
}, | |
trailing: { date in | |
Text(dayFormatter.string(from: date)) | |
.foregroundColor(.secondary) | |
.minimumScaleFactor(0.5) | |
.lineLimit(1) | |
}, | |
header: { date in | |
Text(weekDayFormatter.string(from: date)) | |
.minimumScaleFactor(0.5) | |
.lineLimit(1) | |
}, | |
title: { date in | |
HStack { | |
// MARK: - Назад | |
Button { | |
withAnimation(.spring) { | |
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(.system(size: 18, weight: .bold)).foregroundStyle(Color.lkGray) } | |
) | |
.labelStyle(IconOnlyLabelStyle()) | |
.padding(.horizontal) | |
.frame(maxHeight: .infinity) | |
} | |
Spacer() | |
// MARK: - Месяц, год | |
Text("\(monthFormatter.string(from: date).capitalized) \(yearFormatter.string(from: date))") | |
.fontWeight(.medium) | |
.minimumScaleFactor(0.5) | |
.lineLimit(1) | |
.foregroundStyle(Color.blue) | |
.padding() | |
Spacer() | |
// MARK: - Вперед | |
Button { | |
withAnimation(.spring) { | |
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(.system(size: 18, weight: .bold)).foregroundStyle(Color.lkGray) } | |
) | |
.labelStyle(IconOnlyLabelStyle()) | |
.padding(.horizontal) | |
.frame(maxHeight: .infinity) | |
} | |
} | |
.padding(.bottom, 6) | |
} | |
) | |
.equatable() | |
} | |
.padding() | |
} | |
// MARK: - DateManager | |
func manageDates(with selectedDate: Date) { | |
if dates.count == 2 { | |
dates.removeAll() | |
} | |
if dates.contains(selectedDate) { | |
dates.removeAll() | |
return | |
} | |
dates.append(selectedDate) | |
if dates.count == 2 { | |
for date in dates { | |
print(date.description(with: .current)) | |
} | |
} | |
} | |
} | |
// MARK: - Previews | |
#if DEBUG | |
struct CalendarView_Previews: PreviewProvider { | |
static var previews: some View { | |
Group { | |
CustomCalendar(calendar: Calendar.current) | |
} | |
} | |
} | |
#endif |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// MARK: - Calendar Helpers | |
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) | |
} | |
} | |
// MARK: - Calendar extension | |
extension Calendar { | |
func isChosed(date: Date, of dates: Binding<[Date]>) -> Bool { | |
if dates.wrappedValue.contains(date) { | |
return true | |
} else { | |
return false | |
} | |
} | |
// MARK: - is in interval | |
func isInInterval(_ date: Date, of startDate: Date?, and endDate: Date?) -> Bool { | |
guard let startDate = startDate, let endDate = endDate else { return false } | |
var firstValue = startDate | |
var secondValue = endDate | |
if endDate < startDate { | |
firstValue = endDate | |
secondValue = startDate | |
} | |
let dateInterval = DateInterval(start: firstValue, end: secondValue) | |
if dateInterval.contains(date) { | |
return true | |
} | |
return false | |
} | |
func cornersReturner(_ date: Date, start: Date?, end: Date?) -> UIRectCorner { | |
guard let startDate = start, let endDate = end else { return [] } | |
var firstValue = startDate | |
var secondValue = endDate | |
if endDate < startDate { | |
firstValue = endDate | |
secondValue = startDate | |
} | |
if date == firstValue { | |
return [.topLeft, .bottomLeft] | |
} else if date == secondValue { | |
return [.topRight, .bottomRight] | |
} else { | |
return [] | |
} | |
} | |
// MARK: - Days generator | |
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) | |
) | |
} | |
} | |
// 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 | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// MARK: - CalendarView | |
struct CalendarView<Day: View, Title: View, Trailing: View>: View { | |
// Injected dependencies | |
var calendar: Calendar = Calendar.current | |
@Binding var date: Date | |
let content: (Date) -> Day | |
let trailing: (Date) -> Trailing | |
let title: (Date) -> Title | |
// Constants | |
let daysInWeek = 7 | |
let daysOfWeek: [Kotlinx_datetimeDayOfWeek] = [.monday, .tuesday, .wednesday, .thursday, .friday, .saturday, .sunday] | |
let minYear: Int | |
let maxYear: Int | |
@Binding var showYears: Bool | |
var years: [String] = [] | |
// MARK: - init | |
public init( | |
minYear: Int, | |
maxYear: Int, | |
calendar: Calendar = Calendar.current, | |
date: Binding<Date>, | |
@ViewBuilder content: @escaping (Date) -> Day, | |
@ViewBuilder trailing: @escaping (Date) -> Trailing, | |
@ViewBuilder title: @escaping (Date) -> Title, | |
showYears: Binding<Bool> | |
) { | |
self.minYear = minYear | |
self.maxYear = maxYear | |
self.calendar = calendar | |
self.calendar.timeZone = TimeZone.autoupdatingCurrent | |
self._date = date | |
self.content = content | |
self.trailing = trailing | |
self.title = title | |
self._showYears = showYears | |
self.years = (minYear...maxYear).map { String($0) } | |
} | |
// MARK: - Body | |
public var body: some View { | |
let month = date.startOfMonth(using: calendar) | |
let days = makeDays() | |
return LazyVGrid(columns: Array(repeating: GridItem(.flexible(minimum: 40, maximum: .infinity), spacing: 0, alignment: .center), count: daysInWeek)) { | |
Section(header: title(month)) { | |
ForEach(daysOfWeek, id: \.self) { day in | |
Text(DateDay().localizedWeek(day: day, isShort: true)) | |
.minimumScaleFactor(0.5) | |
.lineLimit(1) | |
.foregroundStyle(.secondary) | |
} | |
ForEach(days, id: \.self) { date in | |
if calendar.isDate(date, equalTo: month, toGranularity: .month) { | |
content(date) | |
.frame(minHeight: 40) | |
} else { | |
trailing(date) | |
.frame(minHeight: 40) | |
} | |
} | |
} | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// MARK: - Date extension | |
extension Date { | |
func startOfMonth(using calendar: Calendar) -> Date { | |
calendar.date( | |
from: calendar.dateComponents([.year, .month], from: self) | |
) ?? self | |
} | |
} | |
// MARK: - DateFormatter extension | |
extension DateFormatter { | |
convenience init(dateFormat: String, calendar: Calendar) { | |
self.init() | |
self.dateFormat = dateFormat | |
self.calendar = calendar | |
} | |
} |
2023 September 11th, I updated this code to look better with large fonts + now you can choose 2 dates or take only one
2024 February 9th, I cut the code into a few files - just like I have
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This code isn't completed yet, but you can already try the concept.
This is a calendar with ability to choose two dates to create the time interval between them and show it