diff --git a/Assets/demo.png b/Assets/demo.png index b0c2ac3..bd5e3ef 100644 Binary files a/Assets/demo.png and b/Assets/demo.png differ diff --git a/CHANGELOG.md b/CHANGELOG.md index 91dc3f0..88c5907 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). @@ -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. diff --git a/Example/KDCalendarDemo/DefaultStyleDemoViewController.swift b/Example/KDCalendarDemo/DefaultStyleDemoViewController.swift index 515869d..019ee52 100644 --- a/Example/KDCalendarDemo/DefaultStyleDemoViewController.swift +++ b/Example/KDCalendarDemo/DefaultStyleDemoViewController.swift @@ -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) @@ -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 @@ -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 + } + } } diff --git a/Example/KDCalendarDemo/SwiftUIDemoView.swift b/Example/KDCalendarDemo/SwiftUIDemoView.swift index 5d42b51..52124c0 100644 --- a/Example/KDCalendarDemo/SwiftUIDemoView.swift +++ b/Example/KDCalendarDemo/SwiftUIDemoView.swift @@ -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 = { let calendar = Calendar.current @@ -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 = [] } } diff --git a/README.md b/README.md index 552a19a..832f15c 100644 --- a/README.md +++ b/README.md @@ -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.

@@ -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 { @@ -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 @@ -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. @@ -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) } @@ -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 diff --git a/Sources/KDCalendar/CalendarView+DataSource.swift b/Sources/KDCalendar/CalendarView+DataSource.swift index ac61342..5d6e470 100644 --- a/Sources/KDCalendar/CalendarView+DataSource.swift +++ b/Sources/KDCalendar/CalendarView+DataSource.swift @@ -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), @@ -226,6 +223,9 @@ extension CalendarView: UICollectionViewDataSource { let isInRange = (firstDayIndex.. Bool)? + var styleForDate: ((Date) -> CalendarView.Style?)? var onScrollToMonth: ((Date) -> Void)? var onLongPress: ((Date, [CalendarEvent]) -> Void)? @@ -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 } @@ -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 @@ -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 @@ -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) { + 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 diff --git a/Sources/KDCalendar/CalendarView.swift b/Sources/KDCalendar/CalendarView.swift index c96aa8d..de5b9a4 100644 --- a/Sources/KDCalendar/CalendarView.swift +++ b/Sources/KDCalendar/CalendarView.swift @@ -76,12 +76,20 @@ public protocol CalendarViewDelegate: AnyObject { func calendar(_ calendar: CalendarView, didDeselectDate date: Date) /// A day was long-pressed. `events` is `nil` when the day has none. func calendar(_ calendar: CalendarView, didLongPressDate date: Date, withEvents events: [CalendarEvent]?) + /// A range of days was selected, by the second tap in ``CalendarView/SelectionMode/range`` mode or + /// by ``CalendarView/selectRange(_:)``. `selectedDates` holds every selectable day in it. + func calendar(_ calendar: CalendarView, didSelectRange range: ClosedRange) + /// A style for one day, or `nil` for the calendar's own ``CalendarView/style``. Use it to colour + /// a holiday, grey out days that `canSelectDate` refuses, or change a single day's font. + func calendar(_ calendar: CalendarView, styleForDate date: Date) -> CalendarView.Style? } extension CalendarViewDelegate { public func calendar(_ calendar: CalendarView, canSelectDate date: Date) -> Bool { true } public func calendar(_ calendar: CalendarView, didDeselectDate date: Date) {} public func calendar(_ calendar: CalendarView, didLongPressDate date: Date, withEvents events: [CalendarEvent]?) {} + public func calendar(_ calendar: CalendarView, didSelectRange range: ClosedRange) {} + public func calendar(_ calendar: CalendarView, styleForDate date: Date) -> CalendarView.Style? { nil } } /// A month calendar that scrolls horizontally or vertically, one month per page. @@ -154,19 +162,51 @@ public class CalendarView: UIView { /// another one reports its end at the wrong offset; the target tells them apart. var animationTargetMonth: Date? - /// Whether more than one day can be selected at a time. - public var multipleSelectionEnable = true { + /// How taps and ``selectDate(_:)`` combine into a selection. + public enum SelectionMode: Sendable { + /// One day at a time; selecting another replaces it. + case single + /// Any number of days, each toggled on its own. + case multiple + /// Two taps pick the ends of a range and every selectable day between them is selected. + /// A third tap starts a new range; tapping a selected day clears the range. + case range + } + + /// How taps and ``selectDate(_:)`` combine into a selection. `.multiple` by default. + public var selectionMode: SelectionMode = .multiple { didSet { - guard !multipleSelectionEnable, selectedIndexPaths.count > 1 else { return } - let keep = selectedIndexPaths.last! - for indexPath in selectedIndexPaths where indexPath != keep { - collectionView?.deselectItem(at: indexPath, animated: false) + guard selectionMode != oldValue else { return } + rangeAnchor = nil + switch selectionMode { + case .single: + guard selectedIndexPaths.count > 1 else { return } + let keep = selectedIndexPaths.last! + for indexPath in selectedIndexPaths where indexPath != keep { + collectionView?.deselectItem(at: indexPath, animated: false) + } + selectedIndexPaths = [keep] + selectedDates = [selectedDates.last!] + case .range: + clearAllSelectedDates() + case .multiple: + break } - selectedIndexPaths = [keep] - selectedDates = [selectedDates.last!] } } + /// Whether more than one day can be selected at a time. `false` is ``SelectionMode/single``, + /// `true` is ``SelectionMode/multiple``. + public var multipleSelectionEnable: Bool { + get { selectionMode != .single } + set { selectionMode = newValue ? .multiple : .single } + } + + /// The first end of the range being picked in ``SelectionMode/range`` mode. + var rangeAnchor: IndexPath? + /// Whether the range starting at ``rangeAnchor`` has both ends. + var rangeIsComplete = false + /// Whether tapping a selected day deselects it. Programmatic deselection always works. public var enableDeselection = true @@ -462,6 +502,29 @@ extension CalendarView { goToMonth(offsetBy: -1) } + /// Selects every selectable day in `range`, replacing the current selection, and tells the + /// delegate with `didSelectRange`. Works in every ``SelectionMode``; in `.range` mode the next + /// tap starts a new range. + public func selectRange(_ range: ClosedRange) { + clearAllSelectedDates() + let lower = calendar.startOfDay(for: range.lowerBound) + let upper = calendar.startOfDay(for: range.upperBound) + var day = lower + while day <= upper { + if let indexPath = indexPathForDate(day), 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 + } + rangeAnchor = selectedIndexPaths.first + rangeIsComplete = true + guard let first = selectedDates.first, let last = selectedDates.last else { return } + delegate?.calendar(self, didSelectRange: first...last) + } + /// Deselects every day without notifying the delegate. public func clearAllSelectedDates() { for indexPath in selectedIndexPaths { @@ -469,6 +532,8 @@ extension CalendarView { } selectedIndexPaths.removeAll() selectedDates.removeAll() + rangeAnchor = nil + rangeIsComplete = false } func goToMonth(offsetBy offset: Int) { diff --git a/Tests/KDCalendarTests/CalendarViewTests.swift b/Tests/KDCalendarTests/CalendarViewTests.swift index 62776cb..2fafef8 100644 --- a/Tests/KDCalendarTests/CalendarViewTests.swift +++ b/Tests/KDCalendarTests/CalendarViewTests.swift @@ -367,8 +367,8 @@ struct CalendarViewTests { @Test func eventsCanBeSetBeforeTheViewHasADataSource() { let view = CalendarView(frame: .zero) view.events = [CalendarEvent(title: "a", startDate: Date(), endDate: Date())] - #expect(view.eventsByIndexPath.isEmpty) - #expect(view.numberOfSections(in: view.collectionView) == 0) + #expect(view.numberOfSections(in: view.collectionView) == 1, "the current month stands in for a data source") + #expect(view.eventsByIndexPath[view.indexPathForDate(Date())!]?.count == 1) } // MARK: Header and display date diff --git a/Tests/KDCalendarTests/IssueTests.swift b/Tests/KDCalendarTests/IssueTests.swift new file mode 100644 index 0000000..d0ed8ff --- /dev/null +++ b/Tests/KDCalendarTests/IssueTests.swift @@ -0,0 +1,276 @@ +import Testing +import UIKit + +@testable import KDCalendar + +/// Tests named after the GitHub issues they close. +@Suite(.serialized) +@MainActor +struct IssueTests { + + final class FixedDataSource: CalendarViewDataSource { + var start: Date + var end: Date + init(start: Date, end: Date) { + self.start = start + self.end = end + } + func startDate() -> Date { start } + func endDate() -> Date { end } + } + + final class RecordingDelegate: CalendarViewDelegate { + var selected: [Date] = [] + var deselected: [Date] = [] + var ranges: [ClosedRange] = [] + var canSelect: (Date) -> Bool = { _ in true } + var styleForDate: (Date) -> CalendarView.Style? = { _ in nil } + + func calendar(_ calendar: CalendarView, didScrollToMonth date: Date) {} + func calendar(_ calendar: CalendarView, didSelectDate date: Date, withEvents events: [CalendarEvent]) { + selected.append(date) + } + func calendar(_ calendar: CalendarView, canSelectDate date: Date) -> Bool { canSelect(date) } + func calendar(_ calendar: CalendarView, didDeselectDate date: Date) { deselected.append(date) } + func calendar(_ calendar: CalendarView, didSelectRange range: ClosedRange) { ranges.append(range) } + func calendar(_ calendar: CalendarView, styleForDate date: Date) -> CalendarView.Style? { styleForDate(date) } + } + + let utc: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + return calendar + }() + + static let window: UIWindow = { + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 350, height: 500)) + window.isHidden = false + return window + }() + + final class Retained { + var objects: [AnyObject] = [] + } + let retained = Retained() + + init() { + Self.window.subviews.forEach { $0.removeFromSuperview() } + } + + private func date(_ year: Int, _ month: Int, _ day: Int) -> Date { + utc.date(from: DateComponents(year: year, month: month, day: day))! + } + + private func days(_ from: Date, _ to: Date) -> [Date] { + var result: [Date] = [] + var day = from + while day <= to { + result.append(day) + day = utc.date(byAdding: .day, value: 1, to: day)! + } + return result + } + + private func makeCalendar(start: Date, end: Date, frame: CGRect = CGRect(x: 0, y: 0, width: 350, height: 420)) + -> CalendarView + { + var style = CalendarView.Style() + style.locale = Locale(identifier: "en_US") + style.calendar = utc + let view = CalendarView(frame: frame) + view.style = style + let dataSource = FixedDataSource(start: start, end: end) + let delegate = RecordingDelegate() + retained.objects.append(dataSource) + retained.objects.append(delegate) + view.dataSource = dataSource + view.delegate = delegate + Self.window.addSubview(view) + view.layoutIfNeeded() + return view + } + + private func delegate(of view: CalendarView) -> RecordingDelegate { + view.delegate as! RecordingDelegate + } + + private func cell(_ view: CalendarView, _ indexPath: IndexPath) -> CalendarDayCell? { + view.collectionView.cellForItem(at: indexPath) as? CalendarDayCell + } + + /// Simulates a tap: the collection view's own selection followed by the delegate callbacks. + private func tap(_ view: CalendarView, _ date: Date) { + let indexPath = view.indexPathForDate(date)! + if view.selectedIndexPaths.contains(indexPath) { + guard view.collectionView(view.collectionView, shouldDeselectItemAt: indexPath) else { return } + view.collectionView.deselectItem(at: indexPath, animated: false) + view.collectionView(view.collectionView, didDeselectItemAt: indexPath) + } else { + guard view.collectionView(view.collectionView, shouldSelectItemAt: indexPath) else { return } + view.collectionView.selectItem(at: indexPath, animated: false, scrollPosition: []) + view.collectionView(view.collectionView, didSelectItemAt: indexPath) + } + } + + // MARK: #32 range selection + + @Test func issue32_twoTapsSelectEveryDayBetweenThem() { + let view = makeCalendar(start: date(2024, 1, 1), end: date(2024, 1, 31)) + view.selectionMode = .range + tap(view, date(2024, 1, 8)) + #expect(view.selectedDates == [date(2024, 1, 8)]) + #expect(delegate(of: view).ranges.isEmpty) + tap(view, date(2024, 1, 12)) + #expect(view.selectedDates == days(date(2024, 1, 8), date(2024, 1, 12))) + #expect(delegate(of: view).selected == [date(2024, 1, 8), date(2024, 1, 12)]) + #expect(delegate(of: view).ranges == [date(2024, 1, 8)...date(2024, 1, 12)]) + #expect(Set(view.collectionView.indexPathsForSelectedItems ?? []).count == 5) + #expect(cell(view, IndexPath(item: 9, section: 0))?.isSelected == true, "the 10th sits between the ends") + } + + @Test func issue32_rangeWorksInReverseOrderAndAcrossMonths() { + let view = makeCalendar(start: date(2024, 1, 1), end: date(2024, 3, 31)) + view.selectionMode = .range + view.selectDate(date(2024, 2, 3)) + view.selectDate(date(2024, 1, 30)) + #expect(view.selectedDates.sorted() == days(date(2024, 1, 30), date(2024, 2, 3))) + #expect(delegate(of: view).ranges == [date(2024, 1, 30)...date(2024, 2, 3)]) + view.setDisplayDate(date(2024, 2, 1)) + view.layoutIfNeeded() + #expect(cell(view, view.indexPathForDate(date(2024, 2, 1))!)?.isSelected == true) + } + + @Test func issue32_aThirdTapStartsANewRange() { + let view = makeCalendar(start: date(2024, 1, 1), end: date(2024, 1, 31)) + view.selectionMode = .range + tap(view, date(2024, 1, 8)) + tap(view, date(2024, 1, 10)) + tap(view, date(2024, 1, 20)) + #expect(view.selectedDates == [date(2024, 1, 20)]) + #expect(delegate(of: view).deselected == days(date(2024, 1, 8), date(2024, 1, 10))) + #expect(view.collectionView.indexPathsForSelectedItems == [IndexPath(item: 19, section: 0)]) + } + + @Test func issue32_deselectingClearsTheRangeAndTheSameDayCanStartAgain() { + let view = makeCalendar(start: date(2024, 1, 1), end: date(2024, 1, 31)) + view.selectionMode = .range + tap(view, date(2024, 1, 8)) + tap(view, date(2024, 1, 10)) + tap(view, date(2024, 1, 9)) + #expect(view.selectedDates == []) + #expect(view.collectionView.indexPathsForSelectedItems == []) + #expect(delegate(of: view).deselected.count == 3) + tap(view, date(2024, 1, 8)) + #expect(view.selectedDates == [date(2024, 1, 8)]) + tap(view, date(2024, 1, 9)) + #expect(view.selectedDates == [date(2024, 1, 8), date(2024, 1, 9)]) + } + + @Test func issue32_rangeSkipsDaysTheDelegateRefusesAndDaysOutOfRange() { + let view = makeCalendar(start: date(2024, 1, 5), end: date(2024, 1, 31)) + view.selectionMode = .range + delegate(of: view).canSelect = { [utc] in utc.component(.weekday, from: $0) != 1 } // no Sundays + view.selectDate(date(2024, 1, 6)) + view.selectDate(date(2024, 1, 9)) + #expect(view.selectedDates.sorted() == [date(2024, 1, 6), date(2024, 1, 8), date(2024, 1, 9)]) + view.selectRange(date(2024, 1, 1)...date(2024, 1, 8)) + #expect(view.selectedDates == [date(2024, 1, 5), date(2024, 1, 6), date(2024, 1, 8)]) + #expect(delegate(of: view).ranges.last == date(2024, 1, 5)...date(2024, 1, 8)) + } + + @Test func issue32_selectRangeWorksInEveryModeAndReplacesTheSelection() { + let view = makeCalendar(start: date(2024, 1, 1), end: date(2024, 1, 31)) + view.selectDate(date(2024, 1, 20)) + view.selectRange(date(2024, 1, 2)...date(2024, 1, 4)) + #expect(view.selectedDates == days(date(2024, 1, 2), date(2024, 1, 4))) + view.selectionMode = .range + #expect(view.selectedDates == [], "switching to range mode starts clean") + view.selectRange(date(2024, 1, 2)...date(2024, 1, 4)) + tap(view, date(2024, 1, 10)) + #expect(view.selectedDates == [date(2024, 1, 10)], "a tap after a programmatic range starts a new one") + } + + @Test func issue32_multipleSelectionEnableBridgesToTheMode() { + let view = makeCalendar(start: date(2024, 1, 1), end: date(2024, 1, 31)) + #expect(view.multipleSelectionEnable == true) + view.multipleSelectionEnable = false + #expect(view.selectionMode == .single) + view.selectionMode = .range + #expect(view.multipleSelectionEnable == true) + } + + // MARK: #136 and #122 per-date styles + + @Test func issue136_aDelegateCanStyleOneDay() { + let view = makeCalendar(start: date(2024, 1, 1), end: date(2024, 1, 31)) + var holiday = view.style + holiday.cellColorDefault = .systemPink + holiday.cellTextColorDefault = .white + delegate(of: view).styleForDate = { [utc] in utc.component(.day, from: $0) == 6 ? holiday : nil } + view.reloadData() + view.layoutIfNeeded() + #expect(cell(view, IndexPath(item: 5, section: 0))?.bgView.backgroundColor == .systemPink) + #expect(cell(view, IndexPath(item: 5, section: 0))?.style == holiday) + #expect(cell(view, IndexPath(item: 4, section: 0))?.bgView.backgroundColor == view.style.cellColorDefault) + #expect(cell(view, IndexPath(item: 4, section: 0))?.style == view.style) + } + + @Test func issue122_nonSelectableDaysCanBeGreyedOut() { + let view = makeCalendar(start: date(2024, 1, 1), end: date(2024, 1, 31)) + var disabled = view.style + disabled.cellTextColorDefault = disabled.cellColorOutOfRange + disabled.cellColorDefault = .clear + let isSunday: (Date) -> Bool = { [utc] in utc.component(.weekday, from: $0) == 1 } + delegate(of: view).canSelect = isSunday + delegate(of: view).styleForDate = { isSunday($0) ? nil : disabled } + view.reloadData() + view.layoutIfNeeded() + let monday = cell(view, IndexPath(item: 0, section: 0)) + let sunday = cell(view, IndexPath(item: 6, section: 0)) + #expect(monday?.textLabel.textColor == view.style.cellColorOutOfRange) + #expect(sunday?.textLabel.textColor == view.style.cellTextColorWeekend) + view.selectDate(date(2024, 1, 1)) + view.selectDate(date(2024, 1, 7)) + #expect(view.selectedDates == [date(2024, 1, 7)]) + } + + // MARK: #135 no data source + + @Test func issue135_aCalendarWithoutADataSourceShowsTheCurrentMonth() { + let view = CalendarView(frame: CGRect(x: 0, y: 0, width: 350, height: 420)) + Self.window.addSubview(view) + view.layoutIfNeeded() + #expect(view.numberOfSections(in: view.collectionView) == 1) + #expect(view.displayDate == view.calendar.dateInterval(of: .month, for: Date())?.start) + let today = view.indexPathForDate(Date())! + #expect(cell(view, today)?.isToday == true) + #expect(cell(view, today)?.isOutOfRange == false) + #expect(view.dateRange == view.calendar.startOfDay(for: Date())...view.calendar.startOfDay(for: Date())) + } + + // MARK: #117 cell font + + @Test func issue117_theCellFontFromAStyleIsApplied() { + let view = makeCalendar(start: date(2024, 1, 1), end: date(2024, 1, 31)) + let font = UIFont.italicSystemFont(ofSize: 11) + view.style.cellFont = font + view.layoutIfNeeded() + #expect(cell(view, IndexPath(item: 0, section: 0))?.textLabel.font == font) + #expect(view.style.firstWeekday == .monday, "the other attributes are untouched") + } + + // MARK: #128 tiny frames + + @Test func issue128_aFrameShorterThanTheHeaderDoesNotAssert() { + let view = makeCalendar( + start: date(2024, 1, 1), end: date(2024, 1, 31), frame: CGRect(x: 0, y: 0, width: 350, height: 50)) + view.setDisplayDate(date(2024, 1, 10)) + view.layoutIfNeeded() + #expect(view.flowLayout.itemSize.width > 0 && view.flowLayout.itemSize.height > 0) + #expect(view.collectionView.frame.height == 0) + view.frame = CGRect(x: 0, y: 0, width: 350, height: 200) + view.layoutIfNeeded() + #expect(view.flowLayout.itemSize == CGSize(width: 50, height: 20)) + #expect(cell(view, IndexPath(item: 9, section: 0))?.day == 10) + } +}