Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified Assets/demo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 10 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,16 @@ of source changes.

### Added

- `selectionMode` with `.single`, `.multiple` and `.range`, `selectRange(_:)`
and the `didSelectRange` delegate method. In range mode two taps select
every selectable day between them, in either order and across months
(#32). `multipleSelectionEnable` maps to the first two modes.
- `calendar(_:styleForDate:)` on the delegate, a style for one day: colour a
holiday or grey out the days `canSelectDate` refuses (#136, #122).
- A calendar without a data source shows the current month (#135).
- `KDCalendarView`, a SwiftUI wrapper with a two-way selection binding and
modifiers for style, direction, events, selection mode and callbacks (#108).
modifiers for style, direction, events, selection mode, per-day style and
callbacks (#108).
- `KDCalendarEventKit`, a separate product for system calendar events, with
`async` `loadEvents()` and `EventsManager.load(from:to:)` (#129).
- `isScrollEnabled` on `CalendarView` (#126).
Expand All @@ -23,7 +31,7 @@ of source changes.
fonts, and VoiceOver labels and traits on every day.
- `dateRange`, the days the data source spans.
- Swift Package Manager manifest with two products, privacy manifest, DocC
catalog, GitHub Actions CI, Swift Testing suite with 53 tests.
catalog, GitHub Actions CI, Swift Testing suite with 65 tests.
- Example app on the UIScene lifecycle with three tabs: the classic styled
calendar, the default style in light and dark mode, and the SwiftUI wrapper.

Expand Down
23 changes: 22 additions & 1 deletion Example/KDCalendarDemo/DefaultStyleDemoViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import UIKit

/// The default style, untouched: dynamic colours for dark mode, fonts that follow Dynamic Type,
/// vertical paging, multiple selection, adjacent days and the calendar's own first weekday.
/// The delegate greys out Sundays, which cannot be selected, and colours the 15th as a holiday.
final class DefaultStyleDemoViewController: UIViewController {

private let calendarView = CalendarView(frame: .zero)
Expand Down Expand Up @@ -47,7 +48,7 @@ final class DefaultStyleDemoViewController: UIViewController {
selectionLabel.font = UIFont.preferredFont(forTextStyle: .footnote)
selectionLabel.numberOfLines = 0
selectionLabel.adjustsFontForContentSizeCategory = true
selectionLabel.text = "Tap days to select several. Swipe up and down for other months."
selectionLabel.text = "Tap days to select several. Sundays are disabled, the 15th is a holiday."

let stack = UIStackView(arrangedSubviews: [calendarView, monthLabel, selectionLabel])
stack.axis = .vertical
Expand Down Expand Up @@ -90,4 +91,24 @@ extension DefaultStyleDemoViewController: CalendarViewDelegate {
func calendar(_ calendar: CalendarView, didDeselectDate date: Date) {
describeSelection()
}

func calendar(_ calendar: CalendarView, canSelectDate date: Date) -> Bool {
Calendar.current.component(.weekday, from: date) != 1
}

func calendar(_ calendar: CalendarView, styleForDate date: Date) -> CalendarView.Style? {
var style = calendar.style
switch Calendar.current.component(.day, from: date) {
case 15:
style.cellColorDefault = .systemTeal.withAlphaComponent(0.35)
style.cellTextColorDefault = .label
style.cellTextColorWeekend = .label
return style
default:
guard Calendar.current.component(.weekday, from: date) == 1 else { return nil }
style.cellColorDefault = .clear
style.cellTextColorWeekend = style.cellColorOutOfRange
return style
}
}
}
27 changes: 21 additions & 6 deletions Example/KDCalendarDemo/SwiftUIDemoView.swift
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import KDCalendar
import SwiftUI

/// The SwiftUI wrapper with a two-way selection binding and a month title driven by the
/// scroll callback.
/// The SwiftUI wrapper with a two-way selection binding, the three selection modes and a month
/// title driven by the scroll callback.
struct SwiftUIDemoView: View {

@State private var selection: [Date] = []
@State private var month: Date = Date()
@State private var singleSelection = true
@State private var mode: CalendarView.SelectionMode = .range

private let range: ClosedRange<Date> = {
let calendar = Calendar.current
Expand All @@ -31,16 +31,31 @@ struct SwiftUIDemoView: View {
VStack(spacing: 16) {
KDCalendarView(range: range, selection: $selection)
.calendarStyle(style)
.allowsMultipleSelection(!singleSelection)
.selectionMode(mode)
.events(SampleEvents.around())
.displayDate(Date())
.onScrollToMonth { month = $0 }
.aspectRatio(1, contentMode: .fit)

Toggle("Single selection", isOn: $singleSelection)
Picker("Selection", selection: $mode) {
Text("Single").tag(CalendarView.SelectionMode.single)
Text("Multiple").tag(CalendarView.SelectionMode.multiple)
Text("Range").tag(CalendarView.SelectionMode.range)
}
.pickerStyle(.segmented)

HStack {
Button("Select today") { selection = [Date()] }
Button("Select this week") {
let calendar = Calendar.current
let week = calendar.dateInterval(of: .weekOfYear, for: Date())!
var day = week.start
var days: [Date] = []
while day < week.end {
days.append(day)
day = calendar.date(byAdding: .day, value: 1, to: day)!
}
selection = days
}
Spacer()
Button("Clear") { selection = [] }
}
Expand Down
38 changes: 29 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ A month calendar for iOS. Drop it in, give it a start and an end date, and it sc
- **UIKit and SwiftUI.** `CalendarView` for UIKit, `KDCalendarView` for SwiftUI with a two-way selection binding.
- **Any calendar.** The user's current calendar and time zone by default; Gregorian, Persian, Hebrew or anything else through `Style.calendar`, with the week starting on the day the locale says.
- **System events.** The optional `KDCalendarEventKit` product loads events from the user's calendars and adds new ones.
- **Styled by value.** `CalendarView.Style` is a struct; change a property and the view follows. Dynamic system colours and Dynamic Type by default.
- **Single, multiple or range selection.** Two taps select every day between them, across months.
- **Styled by value.** `CalendarView.Style` is a struct; change a property and the view follows. Dynamic system colours and Dynamic Type by default, and a per-day style hook for holidays and disabled days.
- **Swift 6, iOS 17+.** Main-actor isolated, `Sendable` where it matters, VoiceOver labels on every day.

<p align="center">
Expand Down Expand Up @@ -56,10 +57,10 @@ let calendarView = CalendarView(frame: .zero)
calendarView.dataSource = self
calendarView.delegate = self
calendarView.direction = .horizontal
calendarView.multipleSelectionEnable = false
calendarView.selectionMode = .single
```

The data source returns the first and last selectable day. Every month those days touch is shown; days before the start or after the end are greyed out.
The data source returns the first and last selectable day. Every month those days touch is shown; days before the start or after the end are greyed out. Without a data source the view shows the current month.

```swift
extension ViewController: CalendarViewDataSource {
Expand Down Expand Up @@ -109,14 +110,16 @@ Call `setDisplayDate` from `viewDidAppear` or later; before that the view has no
### Selection

```swift
calendarView.selectDate(date) // same rules as a tap: in range, canSelectDate allows it
calendarView.selectionMode = .multiple // .single, .multiple or .range
calendarView.selectDate(date) // same rules as a tap: in range, canSelectDate allows it
calendarView.deselectDate(date)
calendarView.selectedDates // in selection order
calendarView.clearAllSelectedDates() // no delegate callbacks
calendarView.selectRange(monday...friday)
calendarView.selectedDates // in selection order
calendarView.clearAllSelectedDates() // no delegate callbacks
calendarView.enableDeselection = false // taps cannot deselect; deselectDate still can
```

With `multipleSelectionEnable` off, selecting a day deselects the previous one and reports it.
In `.single` mode selecting a day deselects the previous one and reports it. In `.range` mode the first tap picks one end, the second picks the other and every selectable day between them is selected, in either order and across months; the delegate then receives `didSelectRange`. A third tap starts a new range and tapping a selected day clears it. `multipleSelectionEnable` still works and maps to `.single` and `.multiple`.

### Styling

Expand Down Expand Up @@ -145,6 +148,22 @@ The data source can replace the month title:
func headerString(_ date: Date) -> String? { date.formatted(.dateTime.month(.wide)) }
```

The delegate can style single days. Return `nil` for the calendar's own style. Together with `canSelectDate` this greys out days the user must not pick:

```swift
func calendar(_ calendar: CalendarView, canSelectDate date: Date) -> Bool {
!Calendar.current.isDateInWeekend(date)
}

func calendar(_ calendar: CalendarView, styleForDate date: Date) -> CalendarView.Style? {
guard Calendar.current.isDateInWeekend(date) else { return nil }
var disabled = calendar.style
disabled.cellTextColorWeekend = disabled.cellColorOutOfRange
disabled.cellColorDefault = .clear
return disabled
}
```

### Events

`events` is an array of `CalendarEvent`; a day gets a dot for every event that covers it, and `didSelectDate` receives that day's events.
Expand Down Expand Up @@ -175,7 +194,8 @@ struct ContentView: View {
var body: some View {
KDCalendarView(range: start...end, selection: $selection)
.calendarStyle(style)
.allowsMultipleSelection(false)
.selectionMode(.range)
.styleForDate { date in holidays.contains(date) ? holidayStyle : nil }
.events(events)
.displayDate(Date())
.onScrollToMonth { month in print(month) }
Expand All @@ -188,7 +208,7 @@ Taps update the binding; assigning to it selects or deselects days.

## Example app

`Example/KDCalendarDemo.xcodeproj` has three tabs: the classic styled calendar with system events, the default style in a vertical calendar, and the SwiftUI wrapper. Launch with `-sampleEvents` to seed events without calendar access and `-tab 1` or `-tab 2` to open a tab.
`Example/KDCalendarDemo.xcodeproj` has three tabs: the classic styled calendar with system events, the default style in a vertical calendar with disabled Sundays and a holiday, and the SwiftUI wrapper with the three selection modes. Launch with `-sampleEvents` to seed events without calendar access and `-tab 1` or `-tab 2` to open a tab.

## Development

Expand Down
14 changes: 7 additions & 7 deletions Sources/KDCalendar/CalendarView+DataSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,14 +117,11 @@ struct MonthGrid {
extension CalendarView {

/// The month grid for the data source's current range, rebuilt when the range moves to
/// other days. Zero months when there is no data source or the range is invalid.
/// other days. The current month alone without a data source; zero months when the range
/// is invalid.
var currentMonths: MonthGrid? {
guard let dataSource = self.dataSource else {
months = nil
return nil
}
let start = dataSource.startDate()
let end = dataSource.endDate()
let start = dataSource?.startDate() ?? Date()
let end = dataSource?.endDate() ?? start
if let months = months,
calendar.isDate(months.startDay, inSameDayAs: start),
calendar.isDate(months.endDay, inSameDayAs: end),
Expand Down Expand Up @@ -226,6 +223,9 @@ extension CalendarView: UICollectionViewDataSource {
let isInRange = (firstDayIndex..<lastDayIndex).contains(indexPath.item)

if isInRange {
if let date = months.date(at: indexPath), let dayStyle = delegate?.calendar(self, styleForDate: date) {
dayCell.style = dayStyle
}
dayCell.day = indexPath.item - firstDayIndex + 1
dayCell.isOutOfRange = months.isOutOfRange(indexPath)
dayCell.isToday = indexPath == todayIndexPath
Expand Down
65 changes: 56 additions & 9 deletions Sources/KDCalendar/CalendarView+Delegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,19 @@ extension CalendarView: UICollectionViewDelegateFlowLayout {
guard let date = self.dateFromIndexPath(indexPath) else { return }
guard !selectedIndexPaths.contains(indexPath) else { return }

if !multipleSelectionEnable {
for previous in selectedIndexPaths {
collectionView.deselectItem(at: previous, animated: false)
}
let previousDates = selectedDates
selectedIndexPaths.removeAll()
selectedDates.removeAll()
for previousDate in previousDates {
delegate?.calendar(self, didDeselectDate: previousDate)
switch selectionMode {
case .single:
deselectAll(notifying: true)
case .range:
if let anchor = rangeAnchor, !rangeIsComplete {
completeRange(from: anchor, to: indexPath, tapped: date)
return
}
deselectAll(notifying: true)
rangeAnchor = indexPath
rangeIsComplete = false
case .multiple:
break
}

selectedIndexPaths.append(indexPath)
Expand All @@ -60,9 +63,53 @@ extension CalendarView: UICollectionViewDelegateFlowLayout {
delegate?.calendar(self, didSelectDate: date, withEvents: eventsForDaySelected)
}

/// Fills the range between the anchor and the second tap with every selectable day, in either
/// order and across months, then reports the tapped day and the range.
private func completeRange(from anchor: IndexPath, to end: IndexPath, tapped: Date) {
guard let anchorDate = dateFromIndexPath(anchor) else { return }
let lower = min(anchorDate, tapped)
let upper = max(anchorDate, tapped)
var day = lower
while day <= upper {
if let indexPath = indexPathForDate(day), !selectedIndexPaths.contains(indexPath), shouldSelect(indexPath) {
collectionView.selectItem(at: indexPath, animated: false, scrollPosition: [])
selectedIndexPaths.append(indexPath)
selectedDates.append(day)
}
guard let next = calendar.date(byAdding: .day, value: 1, to: day) else { break }
day = next
}
rangeIsComplete = true
delegate?.calendar(self, didSelectDate: tapped, withEvents: eventsByIndexPath[end] ?? [])
if let first = selectedDates.min(), let last = selectedDates.max() {
delegate?.calendar(self, didSelectRange: first...last)
}
}

/// Deselects everything, optionally telling the delegate about each day.
private func deselectAll(notifying: Bool) {
for previous in selectedIndexPaths {
collectionView.deselectItem(at: previous, animated: false)
}
let previousDates = selectedDates
selectedIndexPaths.removeAll()
selectedDates.removeAll()
rangeAnchor = nil
rangeIsComplete = false
guard notifying else { return }
for previousDate in previousDates {
delegate?.calendar(self, didDeselectDate: previousDate)
}
}

/// Records a deselection the collection view already applied and notifies the delegate.
/// In range mode a deselection clears the whole range.
func didDeselect(_ indexPath: IndexPath) {
guard let index = selectedIndexPaths.firstIndex(of: indexPath) else { return }
if selectionMode == .range {
deselectAll(notifying: true)
return
}
let date = selectedDates[index]
selectedIndexPaths.remove(at: index)
selectedDates.remove(at: index)
Expand Down
30 changes: 26 additions & 4 deletions Sources/KDCalendar/CalendarView+SwiftUI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,14 @@ public struct KDCalendarView: UIViewRepresentable {

var style = CalendarView.Style.default
var direction = UICollectionView.ScrollDirection.horizontal
var allowsMultipleSelection = true
var selectionMode = CalendarView.SelectionMode.multiple
var allowsDeselection = true
var marksWeekends = true
var isScrollEnabled = true
var events: [CalendarEvent] = []
var displayDate: Date?
var canSelect: ((Date) -> Bool)?
var styleForDate: ((Date) -> CalendarView.Style?)?
var onScrollToMonth: ((Date) -> Void)?
var onLongPress: ((Date, [CalendarEvent]) -> Void)?

Expand All @@ -62,10 +63,15 @@ public struct KDCalendarView: UIViewRepresentable {
return copy
}

/// Whether more than one day can be selected.
/// Whether more than one day can be selected. Shorthand for `.single` or `.multiple`.
public func allowsMultipleSelection(_ allows: Bool) -> Self {
selectionMode(allows ? .multiple : .single)
}

/// How taps combine into a selection: one day, any number of days, or a range.
public func selectionMode(_ mode: CalendarView.SelectionMode) -> Self {
var copy = self
copy.allowsMultipleSelection = allows
copy.selectionMode = mode
return copy
}

Expand Down Expand Up @@ -111,6 +117,13 @@ public struct KDCalendarView: UIViewRepresentable {
return copy
}

/// A style for one day, or `nil` for the calendar's style.
public func styleForDate(_ style: @escaping (Date) -> CalendarView.Style?) -> Self {
var copy = self
copy.styleForDate = style
return copy
}

/// Called with the first day of each month the calendar settles on.
public func onScrollToMonth(_ action: @escaping (Date) -> Void) -> Self {
var copy = self
Expand Down Expand Up @@ -146,7 +159,7 @@ public struct KDCalendarView: UIViewRepresentable {

if view.style != self.style { view.style = self.style }
if view.direction != self.direction { view.direction = self.direction }
view.multipleSelectionEnable = self.allowsMultipleSelection
if view.selectionMode != self.selectionMode { view.selectionMode = self.selectionMode }
view.enableDeselection = self.allowsDeselection
if view.marksWeekends != self.marksWeekends { view.marksWeekends = self.marksWeekends }
view.isScrollEnabled = self.isScrollEnabled
Expand Down Expand Up @@ -206,6 +219,15 @@ public struct KDCalendarView: UIViewRepresentable {
parent.canSelect?(date) ?? true
}

public func calendar(_ calendar: CalendarView, styleForDate date: Date) -> CalendarView.Style? {
parent.styleForDate?(date)
}

public func calendar(_ calendar: CalendarView, didSelectRange range: ClosedRange<Date>) {
guard !isUpdating else { return }
parent.selection = calendar.selectedDates
}

public func calendar(_ calendar: CalendarView, didSelectDate date: Date, withEvents events: [CalendarEvent]) {
guard !isUpdating else { return }
parent.selection = calendar.selectedDates
Expand Down
Loading
Loading