diff --git a/.gitignore b/.gitignore index b03ae972..b0c231d6 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,4 @@ fastlane/test_output xcshareddata UserInterfaceState.xcuserstate .DS_Store +#Example/HorizonCalendarExample/HorizonCalendarExample.xcodeproj/project.pbxproj diff --git a/Example/HorizonCalendarExample/HorizonCalendarExample.xcodeproj/project.pbxproj b/Example/HorizonCalendarExample/HorizonCalendarExample.xcodeproj/project.pbxproj index 70664014..9e0cfd08 100644 --- a/Example/HorizonCalendarExample/HorizonCalendarExample.xcodeproj/project.pbxproj +++ b/Example/HorizonCalendarExample/HorizonCalendarExample.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 645FFEFC2D6D5E61002EE721 /* WeekdayOnlyDemoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 645FFEFB2D6D5E61002EE721 /* WeekdayOnlyDemoViewController.swift */; }; + 645FFF022D73B86A002EE721 /* WeekNumberDemoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 645FFF012D73B86A002EE721 /* WeekNumberDemoViewController.swift */; }; 9381769C249B74BB00E18FA3 /* DemoPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9381769B249B74BB00E18FA3 /* DemoPickerViewController.swift */; }; 9381769F249B79DC00E18FA3 /* ScrollToDayWithAnimationDemoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9381769E249B79DC00E18FA3 /* ScrollToDayWithAnimationDemoViewController.swift */; }; 938176A1249B7CE600E18FA3 /* DayRangeSelectionDemoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 938176A0249B7CE600E18FA3 /* DayRangeSelectionDemoViewController.swift */; }; @@ -22,7 +24,11 @@ 939E69942484D55200A8BCC7 /* HorizonCalendar.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 939E69912484D11000A8BCC7 /* HorizonCalendar.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 93A7258E24A1F26C00B4F08F /* PartialMonthVisibilityDemoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93A7258D24A1F26C00B4F08F /* PartialMonthVisibilityDemoViewController.swift */; }; 93AF5545248DCC8900BDB0FF /* DayRangeIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93AF5544248DCC8900BDB0FF /* DayRangeIndicatorView.swift */; }; - FD53899F299476AD007D56EB /* DayRangeSelectionTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD53899E299476AD007D56EB /* DayRangeSelectionTracker.swift */; }; + AA79A7D12D74319B00C07DD0 /* SwiftUIDisabledDayDemoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA79A7D02D74319B00C07DD0 /* SwiftUIDisabledDayDemoViewController.swift */; }; + AA79A7D52D74346200C07DD0 /* DayRangeSelectionHelperExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA79A7D42D74346200C07DD0 /* DayRangeSelectionHelperExtension.swift */; }; + AA79A7E02D74E1F400C07DD0 /* SwiftUIWeekViewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA79A7DF2D74E1F400C07DD0 /* SwiftUIWeekViewViewController.swift */; }; + AA79A83E2D750BCC00C07DD0 /* SwiftUIFlexWeekViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA79A83D2D750BCC00C07DD0 /* SwiftUIFlexWeekViewController.swift */; }; + AA79A88A2D751C0600C07DD0 /* SelectedDayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA79A8892D751C0600C07DD0 /* SelectedDayView.swift */; }; FD55C5D7298B138A00A9B5D6 /* SwiftUIScreenDemoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD55C5D6298B138A00A9B5D6 /* SwiftUIScreenDemoViewController.swift */; }; FDA0FB3528F5EFD90066DEFA /* SwiftUIDayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA0FB3428F5EFD90066DEFA /* SwiftUIDayView.swift */; }; FDA0FB3728F5EFF60066DEFA /* SwiftUIItemModelsDemoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA0FB3628F5EFF60066DEFA /* SwiftUIItemModelsDemoViewController.swift */; }; @@ -44,6 +50,8 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 645FFEFB2D6D5E61002EE721 /* WeekdayOnlyDemoViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekdayOnlyDemoViewController.swift; sourceTree = ""; }; + 645FFF012D73B86A002EE721 /* WeekNumberDemoViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekNumberDemoViewController.swift; sourceTree = ""; }; 9381769B249B74BB00E18FA3 /* DemoPickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoPickerViewController.swift; sourceTree = ""; }; 9381769E249B79DC00E18FA3 /* ScrollToDayWithAnimationDemoViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollToDayWithAnimationDemoViewController.swift; sourceTree = ""; }; 938176A0249B7CE600E18FA3 /* DayRangeSelectionDemoViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayRangeSelectionDemoViewController.swift; sourceTree = ""; }; @@ -60,7 +68,11 @@ 939E69912484D11000A8BCC7 /* HorizonCalendar.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = HorizonCalendar.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 93A7258D24A1F26C00B4F08F /* PartialMonthVisibilityDemoViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PartialMonthVisibilityDemoViewController.swift; sourceTree = ""; }; 93AF5544248DCC8900BDB0FF /* DayRangeIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayRangeIndicatorView.swift; sourceTree = ""; }; - FD53899E299476AD007D56EB /* DayRangeSelectionTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayRangeSelectionTracker.swift; sourceTree = ""; }; + AA79A7D02D74319B00C07DD0 /* SwiftUIDisabledDayDemoViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIDisabledDayDemoViewController.swift; sourceTree = ""; }; + AA79A7D42D74346200C07DD0 /* DayRangeSelectionHelperExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayRangeSelectionHelperExtension.swift; sourceTree = ""; }; + AA79A7DF2D74E1F400C07DD0 /* SwiftUIWeekViewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIWeekViewViewController.swift; sourceTree = ""; }; + AA79A83D2D750BCC00C07DD0 /* SwiftUIFlexWeekViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIFlexWeekViewController.swift; sourceTree = ""; }; + AA79A8892D751C0600C07DD0 /* SelectedDayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectedDayView.swift; sourceTree = ""; }; FD55C5D6298B138A00A9B5D6 /* SwiftUIScreenDemoViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIScreenDemoViewController.swift; sourceTree = ""; }; FDA0FB3428F5EFD90066DEFA /* SwiftUIDayView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftUIDayView.swift; sourceTree = ""; }; FDA0FB3628F5EFF60066DEFA /* SwiftUIItemModelsDemoViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftUIItemModelsDemoViewController.swift; sourceTree = ""; }; @@ -83,6 +95,7 @@ isa = PBXGroup; children = ( 938176A6249B85CE00E18FA3 /* DemoViewController.swift */, + 645FFF012D73B86A002EE721 /* WeekNumberDemoViewController.swift */, 939E696E2484CD8E00A8BCC7 /* SingleDaySelectionDemoViewController.swift */, 938176A0249B7CE600E18FA3 /* DayRangeSelectionDemoViewController.swift */, 938176A2249B7F0800E18FA3 /* SelectedDayTooltipDemoViewController.swift */, @@ -92,6 +105,10 @@ FDD8EE7329885AF500F6EC9D /* MonthBackgroundDemoViewController.swift */, FDA0FB3628F5EFF60066DEFA /* SwiftUIItemModelsDemoViewController.swift */, FD55C5D6298B138A00A9B5D6 /* SwiftUIScreenDemoViewController.swift */, + 645FFEFB2D6D5E61002EE721 /* WeekdayOnlyDemoViewController.swift */, + AA79A7D02D74319B00C07DD0 /* SwiftUIDisabledDayDemoViewController.swift */, + AA79A7DF2D74E1F400C07DD0 /* SwiftUIWeekViewViewController.swift */, + AA79A83D2D750BCC00C07DD0 /* SwiftUIFlexWeekViewController.swift */, ); path = "Demo View Controllers"; sourceTree = ""; @@ -119,10 +136,11 @@ 939E696A2484CD8E00A8BCC7 /* AppDelegate.swift */, 9381769B249B74BB00E18FA3 /* DemoPickerViewController.swift */, 9381769D249B79CB00E18FA3 /* Demo View Controllers */, - FD53899E299476AD007D56EB /* DayRangeSelectionTracker.swift */, 93AF5544248DCC8900BDB0FF /* DayRangeIndicatorView.swift */, + AA79A7D42D74346200C07DD0 /* DayRangeSelectionHelperExtension.swift */, FDA0FB3428F5EFD90066DEFA /* SwiftUIDayView.swift */, 939853512498992E0022A3A1 /* TooltipView.swift */, + AA79A8892D751C0600C07DD0 /* SelectedDayView.swift */, 939E69732484CD9000A8BCC7 /* Assets.xcassets */, 939E69752484CD9000A8BCC7 /* LaunchScreen.storyboard */, 939E69782484CD9000A8BCC7 /* Info.plist */, @@ -214,16 +232,22 @@ 939E696F2484CD8E00A8BCC7 /* SingleDaySelectionDemoViewController.swift in Sources */, 938176A3249B7F0800E18FA3 /* SelectedDayTooltipDemoViewController.swift in Sources */, FDA0FB3528F5EFD90066DEFA /* SwiftUIDayView.swift in Sources */, + 645FFEFC2D6D5E61002EE721 /* WeekdayOnlyDemoViewController.swift in Sources */, 938176A1249B7CE600E18FA3 /* DayRangeSelectionDemoViewController.swift in Sources */, - FD53899F299476AD007D56EB /* DayRangeSelectionTracker.swift in Sources */, 9381769C249B74BB00E18FA3 /* DemoPickerViewController.swift in Sources */, 939853522498992E0022A3A1 /* TooltipView.swift in Sources */, + AA79A7D52D74346200C07DD0 /* DayRangeSelectionHelperExtension.swift in Sources */, + AA79A88A2D751C0600C07DD0 /* SelectedDayView.swift in Sources */, + AA79A83E2D750BCC00C07DD0 /* SwiftUIFlexWeekViewController.swift in Sources */, 938176A7249B85CE00E18FA3 /* DemoViewController.swift in Sources */, 9381769F249B79DC00E18FA3 /* ScrollToDayWithAnimationDemoViewController.swift in Sources */, 93AF5545248DCC8900BDB0FF /* DayRangeIndicatorView.swift in Sources */, + 645FFF022D73B86A002EE721 /* WeekNumberDemoViewController.swift in Sources */, 938176A5249B828600E18FA3 /* LargeDayRangeDemoViewController.swift in Sources */, + AA79A7E02D74E1F400C07DD0 /* SwiftUIWeekViewViewController.swift in Sources */, FDD8EE7429885AF500F6EC9D /* MonthBackgroundDemoViewController.swift in Sources */, FD55C5D7298B138A00A9B5D6 /* SwiftUIScreenDemoViewController.swift in Sources */, + AA79A7D12D74319B00C07DD0 /* SwiftUIDisabledDayDemoViewController.swift in Sources */, 939E696B2484CD8E00A8BCC7 /* AppDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Example/HorizonCalendarExample/HorizonCalendarExample/DayRangeIndicatorView.swift b/Example/HorizonCalendarExample/HorizonCalendarExample/DayRangeIndicatorView.swift index 3de8aed6..78105b8b 100644 --- a/Example/HorizonCalendarExample/HorizonCalendarExample/DayRangeIndicatorView.swift +++ b/Example/HorizonCalendarExample/HorizonCalendarExample/DayRangeIndicatorView.swift @@ -102,8 +102,7 @@ extension DayRangeIndicatorView: CalendarItemViewRepresentable { static func makeView( withInvariantViewProperties invariantViewProperties: InvariantViewProperties) - -> DayRangeIndicatorView - { + -> DayRangeIndicatorView { DayRangeIndicatorView(indicatorColor: invariantViewProperties.indicatorColor) } diff --git a/Example/HorizonCalendarExample/HorizonCalendarExample/DayRangeSelectionHelperExtension.swift b/Example/HorizonCalendarExample/HorizonCalendarExample/DayRangeSelectionHelperExtension.swift new file mode 100644 index 00000000..2ca3636d --- /dev/null +++ b/Example/HorizonCalendarExample/HorizonCalendarExample/DayRangeSelectionHelperExtension.swift @@ -0,0 +1,59 @@ +// Created by Bryan Keller on 2/8/23. +// Copyright © 2023 Airbnb Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import HorizonCalendar +import UIKit + +extension DayRangeSelectionHelper { + + @discardableResult + static func updateDayRange(afterDragSelectionOf day: Day, + existingDayRange: inout DayComponentsRange?, + initialDayRange: inout DayComponentsRange?, + state: UIGestureRecognizer.State, + calendar: Calendar) -> Set { + + let invalidDates = getInvalidDateSet(day, existingDayRange, calendar) + + guard invalidDates == [] else { return invalidDates } + + switch state { + case .began: + if day != existingDayRange?.lowerBound, day != existingDayRange?.upperBound { + existingDayRange = day...day + } + initialDayRange = existingDayRange + + case .changed, .ended: + guard initialDayRange != nil else { + fatalError("`initialDayRange` should not be `nil`") + } + + performUpdateRange(day, + &existingDayRange, + &initialDayRange, + calendar) + + default: + existingDayRange = nil + initialDayRange = nil + } + + + + return [] + } + +} diff --git a/Example/HorizonCalendarExample/HorizonCalendarExample/DayRangeSelectionTracker.swift b/Example/HorizonCalendarExample/HorizonCalendarExample/DayRangeSelectionTracker.swift deleted file mode 100644 index 39294abd..00000000 --- a/Example/HorizonCalendarExample/HorizonCalendarExample/DayRangeSelectionTracker.swift +++ /dev/null @@ -1,86 +0,0 @@ -// Created by Bryan Keller on 2/8/23. -// Copyright © 2023 Airbnb Inc. All rights reserved. - -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import HorizonCalendar -import UIKit - -enum DayRangeSelectionHelper { - - static func updateDayRange( - afterTapSelectionOf day: DayComponents, - existingDayRange: inout DayComponentsRange?) - { - if - let _existingDayRange = existingDayRange, - _existingDayRange.lowerBound == _existingDayRange.upperBound, - day > _existingDayRange.lowerBound - { - existingDayRange = _existingDayRange.lowerBound...day - } else { - existingDayRange = day...day - } - } - - static func updateDayRange( - afterDragSelectionOf day: DayComponents, - existingDayRange: inout DayComponentsRange?, - initialDayRange: inout DayComponentsRange?, - state: UIGestureRecognizer.State, - calendar: Calendar) - { - switch state { - case .began: - if day != existingDayRange?.lowerBound, day != existingDayRange?.upperBound { - existingDayRange = day...day - } - initialDayRange = existingDayRange - - case .changed, .ended: - guard let initialDayRange else { - fatalError("`initialDayRange` should not be `nil`") - } - - let startingLowerDate = calendar.date(from: initialDayRange.lowerBound.components)! - let startingUpperDate = calendar.date(from: initialDayRange.upperBound.components)! - let selectedDate = calendar.date(from: day.components)! - - let numberOfDaysToLowerDate = calendar.dateComponents( - [.day], - from: selectedDate, - to: startingLowerDate).day! - let numberOfDaysToUpperDate = calendar.dateComponents( - [.day], - from: selectedDate, - to: startingUpperDate).day! - - if - abs(numberOfDaysToLowerDate) < abs(numberOfDaysToUpperDate) || - day < initialDayRange.lowerBound - { - existingDayRange = day...initialDayRange.upperBound - } else if - abs(numberOfDaysToLowerDate) > abs(numberOfDaysToUpperDate) || - day > initialDayRange.upperBound - { - existingDayRange = initialDayRange.lowerBound...day - } - - default: - existingDayRange = nil - initialDayRange = nil - } - } - -} diff --git a/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/DayRangeSelectionDemoViewController.swift b/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/DayRangeSelectionDemoViewController.swift index 36bf8430..1b9769cc 100644 --- a/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/DayRangeSelectionDemoViewController.swift +++ b/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/DayRangeSelectionDemoViewController.swift @@ -69,10 +69,12 @@ final class DayRangeSelectionDemoViewController: BaseDemoViewController { calendar: calendar, visibleDateRange: startDate...endDate, monthsLayout: monthsLayout) + .interMonthSpacing(24) .verticalDayMargin(8) .horizontalDayMargin(8) + .dayItemProvider { [calendar, dayDateFormatter] day in var invariantViewProperties = DayView.InvariantViewProperties.baseInteractive diff --git a/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/HolidayCalendarViewController.swift b/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/HolidayCalendarViewController.swift new file mode 100644 index 00000000..42ea9902 --- /dev/null +++ b/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/HolidayCalendarViewController.swift @@ -0,0 +1,128 @@ +// Created by Bryan Keller on 8/23/22. +// Copyright © 2022 Airbnb Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import HorizonCalendar +import UIKit + +final class HolidayCalendarViewController: BaseDemoViewController { + + // MARK: Lifecycle + + required init(monthsLayout: MonthsLayout) { + super.init(monthsLayout: monthsLayout) + selectedDate = calendar.date(from: DateComponents(year: 2020, month: 01, day: 19))! + } + + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: Internal + + override func viewDidLoad() { + super.viewDidLoad() + + title = "Holiday Calendar" + + calendarView.daySelectionHandler = { [weak self] day in + guard let self = self else { return } + + self.selectedDate = self.calendar.date(from: day.components) + self.calendarView.setContent(self.makeContent()) + } + } + + override func makeContent() -> CalendarViewContent { + let startDate = calendar.date(from: DateComponents(year: 2023, month: 01, day: 01))! + let endDate = calendar.date(from: DateComponents(year: 2023, month: 12, day: 31))! + + let holidays = loadHolidays() + + return CalendarViewContent( + calendar: calendar, + visibleDateRange: startDate...endDate, + monthsLayout: monthsLayout) + .interMonthSpacing(24) + .verticalDayMargin(8) + .horizontalDayMargin(8) + .dayItemProvider { [calendar] day in + let date = calendar.date(from: day.components) + var invariantViewProperties = DayView.InvariantViewProperties.baseInteractive + + if let holidayName = holidays[date ?? Date()] { + invariantViewProperties.backgroundShapeDrawingConfig.borderColor = .red + invariantViewProperties.backgroundShapeDrawingConfig.fillColor = .red.withAlphaComponent(0.15) + + return DayView.calendarItemModel( + invariantViewProperties: invariantViewProperties, + content: .init( + dayText: "\(day.day)\n\(holidayName)", + accessibilityLabel: date.map { DateFormatter.localizedString(from: $0, dateStyle: .medium, timeStyle: .none) }, + accessibilityHint: nil)) + } else { + return DayView.calendarItemModel( + invariantViewProperties: invariantViewProperties, + content: .init( + dayText: "\(day.day)", + accessibilityLabel: date.map { DateFormatter.localizedString(from: $0, dateStyle: .medium, timeStyle: .none) }, + accessibilityHint: nil)) + } + } + } + + // MARK: Private + + private var selectedDate: Date? + + private func loadHolidays() -> [Date: String] { + // Example static holidays; replace with your data source logic + var holidays: [Date: String] = [:] + let formatter = DateFormatter() + formatter.dateFormat = "yyyy/MM/dd" + // Common International Holidays + holidays[formatter.date(from: "2023/01/01")!] = "New Year's Day" + holidays[formatter.date(from: "2023/02/14")!] = "Valentine's Day" + holidays[formatter.date(from: "2023/03/17")!] = "St. Patrick's Day" + holidays[formatter.date(from: "2023/04/01")!] = "April Fool's Day" + holidays[formatter.date(from: "2023/10/31")!] = "Halloween" + holidays[formatter.date(from: "2023/12/25")!] = "Christmas Day" + holidays[formatter.date(from: "2023/12/31")!] = "New Year's Eve" + + // USA Specific Holidays + holidays[formatter.date(from: "2023/07/04")!] = "Independence Day" + holidays[formatter.date(from: "2023/11/24")!] = "Thanksgiving Day" + + // Other Significant Dates + holidays[formatter.date(from: "2023/05/05")!] = "Cinco de Mayo" + holidays[formatter.date(from: "2023/07/14")!] = "Bastille Day" + holidays[formatter.date(from: "2023/10/03")!] = "German Unity Day" + + // Religious Holidays + holidays[formatter.date(from: "2023/04/09")!] = "Easter Sunday" + holidays[formatter.date(from: "2023/12/12")!] = "Hanukkah Starts" + holidays[formatter.date(from: "2023/07/28")!] = "Eid al-Adha" + holidays[formatter.date(from: "2023/11/12")!] = "Diwali" + + // Additional Holidays + holidays[formatter.date(from: "2023/03/08")!] = "International Women's Day" + holidays[formatter.date(from: "2023/05/01")!] = "International Workers' Day" + holidays[formatter.date(from: "2023/06/19")!] = "Juneteenth" + holidays[formatter.date(from: "2023/08/09")!] = "International Day of the World's Indigenous Peoples" + holidays[formatter.date(from: "2023/09/21")!] = "International Day of Peace" + holidays[formatter.date(from: "2023/11/20")!] = "Universal Children's Day" + return holidays + } + +} diff --git a/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/ScrollToDayWithAnimationDemoViewController.swift b/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/ScrollToDayWithAnimationDemoViewController.swift index 9213943e..bba377e9 100644 --- a/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/ScrollToDayWithAnimationDemoViewController.swift +++ b/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/ScrollToDayWithAnimationDemoViewController.swift @@ -22,21 +22,30 @@ final class ScrollToDayWithAnimationDemoViewController: BaseDemoViewController { super.viewDidLoad() title = "Scroll to Day with Animation" + + // Add a Today button in the navigation bar + let todayButton = UIBarButtonItem( + title: "Today", + style: .plain, + target: self, + action: #selector(scrollToToday)) + navigationItem.rightBarButtonItem = todayButton } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) let july2020 = calendar.date(from: DateComponents(year: 2020, month: 07, day: 11))! - calendarView.scroll( - toDayContaining: july2020, - scrollPosition: .centered, - animated: true) + calendarView.scrollToToday() + } + + @objc private func scrollToToday() { + calendarView.scrollToToday() } override func makeContent() -> CalendarViewContent { let startDate = calendar.date(from: DateComponents(year: 2016, month: 07, day: 01))! - let endDate = calendar.date(from: DateComponents(year: 2020, month: 12, day: 31))! + let endDate = calendar.date(from: DateComponents(year: 2027, month: 12, day: 31))! return CalendarViewContent( calendar: calendar, @@ -44,5 +53,4 @@ final class ScrollToDayWithAnimationDemoViewController: BaseDemoViewController { monthsLayout: monthsLayout) .interMonthSpacing(24) } - } diff --git a/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/SwiftUIDisabledDayDemoViewController.swift b/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/SwiftUIDisabledDayDemoViewController.swift new file mode 100644 index 00000000..461148df --- /dev/null +++ b/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/SwiftUIDisabledDayDemoViewController.swift @@ -0,0 +1,325 @@ +// +// SwiftUIDisabledDayDemoViewController.swift +// HorizonCalendarExample +// +// Created by Kyle Parker on 3/1/25. +// Copyright © 2025 Airbnb. All rights reserved. +// + +import HorizonCalendar +import SwiftUI + +// MARK: - SwiftUIDisabledDayDemoViewController + +final class SwiftUIDisabledDayDemoViewController: UIViewController, DemoViewController { + // MARK: Lifecycle + + init(monthsLayout: MonthsLayout) { + self.monthsLayout = monthsLayout + super.init(nibName: nil, bundle: nil) + } + + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: Internal + + let calendar = Calendar.current + let monthsLayout: MonthsLayout + + override func viewDidLoad() { + super.viewDidLoad() + + title = "SwiftUI Disabled Day" + + let hostingController = UIHostingController( + rootView: SwiftUIDisabledDayDemo(calendar: calendar, monthsLayout: monthsLayout)) + addChild(hostingController) + + view.addSubview(hostingController.view) + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + hostingController.view.topAnchor.constraint(equalTo: view.topAnchor), + hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + + hostingController.didMove(toParent: self) + } +} + +// MARK: - SwiftUIDisabledDayDemo + +struct SwiftUIDisabledDayDemo: View, DayAvailabilityProvider { + func isEnabled(_ day: HorizonCalendar.DayComponents) -> Bool { + let dateComponents = day.components + guard let date = calendar.date(from: dateComponents) else { + return true + } + + let calendar = Calendar.current + let year = dateComponents.year ?? 0 + let mth = dateComponents.month ?? 0 + let day = dateComponents.day ?? 0 + + // Disable the 18th + if day == 18 { + return false + } + + /// Disable each Friday in Jan. + if mth == 1 && calendar.component(.weekday, from: date) == 6 { + return false + } + + // Disable the first week in Feb. every 4 years + if mth == 2 && (year % 4 == 0) && day <= 7 { + return false + } + + // Disable the second week in March (per day, not cal week) + if mth == 3 && (8...14).contains(day) { + return false + } + + // Disable May 5th - 10th + if mth == 5 && (5...10).contains(day) { + return false + } + + // Disable Multiples of 9 in sep + if mth == 9 && day.isMultiple(of: 9) { + return false + } + + // Disable halloween + if mth == 10 && day == 31 { + return false + } + + // Disable week around thanksgiving + if mth == 11 && (day >= 22 && day <= 30) { + return false + } + + let federalHolidays: [Date] = [ + Calendar.current.date(from: DateComponents(year: year, month: 1, day: 1))!, + Calendar.current.date(from: DateComponents(year: year, month: 7, day: 4))!, + Calendar.current.date(from: DateComponents(year: year, month: 12, day: 25))! + ] + + if federalHolidays.contains(where: { Calendar.current.isDate($0, inSameDayAs: date) }) { + return false + } + + return true + } + + func isEnabled(_ date: Date) -> Bool { + return isEnabled(DayComponents(date: date)) + } + + // MARK: - Lifecycle + + init(calendar: Calendar, monthsLayout: MonthsLayout) { + self.calendar = calendar + self.monthsLayout = monthsLayout + + let startDate = calendar.date(from: DateComponents(year: 2025, month: 01, day: 01))! + let endDate = calendar.date(from: DateComponents(year: 2035, month: 12, day: 31))! + visibleDateRange = startDate...endDate + + monthDateFormatter = DateFormatter() + monthDateFormatter.calendar = calendar + monthDateFormatter.locale = calendar.locale + monthDateFormatter.dateFormat = DateFormatter.dateFormat( + fromTemplate: "MMMM yyyy", + options: 0, + locale: calendar.locale ?? Locale.current) + } + + // MARK: Internal + + @State private var showErrorMessage: Bool = false + + var body: some View { + ZStack { + CalendarViewRepresentable( + calendar: calendar, + visibleDateRange: visibleDateRange, + monthsLayout: monthsLayout, + dataDependency: selectedDayRange, + proxy: calendarViewProxy) + + .interMonthSpacing(24) + .verticalDayMargin(8) + .horizontalDayMargin(8) + + .monthHeaders { month in + let monthHeaderText = monthDateFormatter.string(from: calendar.date(from: month.components)!) + Group { + if case .vertical = monthsLayout { + HStack { + Text(monthHeaderText) + .font(.title2) + Spacer() + } + .padding() + } else { + Text(monthHeaderText) + .font(.title2) + .padding() + } + } + .accessibilityAddTraits(.isHeader) + } + + .days { day in + SwiftUIDayView(day: day, isSelected: isDaySelected(day)) + } + + .dayRangeItemProvider(for: selectedDateRanges) { dayRangeLayoutContext in + let framesOfDaysToHighlight = dayRangeLayoutContext.daysAndFrames.map { $0.frame } + // UIKit view + return DayRangeIndicatorView.calendarItemModel( + invariantViewProperties: .init(), + content: .init(framesOfDaysToHighlight: framesOfDaysToHighlight)) + } + + .onDaySelection { day in + invalidDates = DayRangeSelectionHelper.updateDayRange( + afterTapSelectionOf: day, + existingDayRange: &selectedDayRange) + + showErrorMessage = invalidDates != [] + } + + .onMultipleDaySelectionDrag( + began: { day in + invalidDates = DayRangeSelectionHelper.updateDayRange( + afterDragSelectionOf: day, + existingDayRange: &selectedDayRange, + initialDayRange: &selectedDayRangeAtStartOfDrag, + state: .began, + calendar: calendar) + + showErrorMessage = invalidDates != [] + }, + changed: { day in + invalidDates = DayRangeSelectionHelper.updateDayRange( + afterDragSelectionOf: day, + existingDayRange: &selectedDayRange, + initialDayRange: &selectedDayRangeAtStartOfDrag, + state: .changed, + calendar: calendar) + + showErrorMessage = invalidDates != [] + }, + ended: { day in + invalidDates = DayRangeSelectionHelper.updateDayRange( + afterDragSelectionOf: day, + existingDayRange: &selectedDayRange, + initialDayRange: &selectedDayRangeAtStartOfDrag, + state: .ended, + calendar: calendar) + + showErrorMessage = invalidDates != [] + }) + + .onAppear { + Day.availabilityProvider = self + calendarViewProxy.scrollToDay( + containing: calendar.date(from: DateComponents(year: 2025, month: 04, day: 01))!, + scrollPosition: .centered, + animated: false) + } + + .onDisappear { + Day.availabilityProvider = nil + } + + .frame(maxWidth: 375, maxHeight: .infinity) + + if showErrorMessage { + overlayView + .transition(.opacity) + .animation(.easeInOut, value: showErrorMessage) + } + } + } + + // MARK: Private + + // MARK: Views + private var overlayView: some View { + VStack { + Color.black.opacity(0.5) + .edgesIgnoringSafeArea(.all) + .overlay( + Text("Unfornately, these dates are unavailable:\n\(getInvalidDatesString())") + .foregroundColor(.white) + .font(.system(size: 25)) + .padding() + ) + Spacer() + } + .onTapGesture { + self.showErrorMessage = false + self.invalidDates = [] + } + } + + private let calendar: Calendar + private let monthsLayout: MonthsLayout + private let visibleDateRange: ClosedRange + + private var dateToolTip: Date? + + private let monthDateFormatter: DateFormatter + + @State private var invalidDates: Set = [] + + @State private var dayDateFormatter: DateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.calendar = Calendar.current + dateFormatter.locale = Calendar.current.locale + dateFormatter.dateFormat = DateFormatter.dateFormat( + fromTemplate: "EEEE, MMM d, yyyy", + options: 0, + locale: Locale.current) + return dateFormatter + }() + + @StateObject private var calendarViewProxy = CalendarViewProxy() + + @State private var selectedDayRange: DayComponentsRange? + @State private var selectedDayRangeAtStartOfDrag: DayComponentsRange? + + private var selectedDateRanges: Set> { + guard let selectedDayRange else { return [] } + let selectedStartDate = calendar.date(from: selectedDayRange.lowerBound.components)! + let selectedEndDate = calendar.date(from: selectedDayRange.upperBound.components)! + return [selectedStartDate...selectedEndDate] + } + + private func isDaySelected(_ day: Day) -> Bool { + if let selectedDayRange { + return (day == selectedDayRange.lowerBound || day == selectedDayRange.upperBound) + } else { + return false + } + } + + private func getInvalidDatesString() -> String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "EEEE, MMMM dd, yyyy" + + let formattedDates = invalidDates.sorted().enumerated().map { index, date in + return "\(index + 1)) \(dateFormatter.string(from: date))" + } + + return formattedDates.joined(separator: "\n") + } +} diff --git a/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/SwiftUIFlexWeekViewController.swift b/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/SwiftUIFlexWeekViewController.swift new file mode 100644 index 00000000..f5f9048f --- /dev/null +++ b/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/SwiftUIFlexWeekViewController.swift @@ -0,0 +1,215 @@ +// +// SwiftUIFlexWeekViewController.swift +// HorizonCalendarExample +// +// Created by Kyle Parker on 3/2/25. +// Copyright © 2025 Airbnb. All rights reserved. +// + +import Foundation + +import HorizonCalendar +import SwiftUI + +// MARK: - SwiftUIFlexWeekViewController + +final class SwiftUIFlexWeekViewController: UIViewController, DemoViewController { + // MARK: Lifecycle + + init(monthsLayout _: MonthsLayout) { + monthsLayout = MonthsLayout.vertical(options: .init(pinDaysOfWeekToTop: true, + alwaysShowCompleteBoundaryMonths: true, + scrollsToFirstMonthOnStatusBarTap: true)) + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: Internal + + let calendar = Calendar.current + let monthsLayout: MonthsLayout + + override func viewDidLoad() { + super.viewDidLoad() + + title = "SwiftUI Flexible Week View" + + let hostingController = UIHostingController( + rootView: SwiftUIFlexWeekDemo(calendar: calendar, monthsLayout: monthsLayout)) + addChild(hostingController) + + view.addSubview(hostingController.view) + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + hostingController.view.topAnchor.constraint(equalTo: view.topAnchor), + hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + hostingController.didMove(toParent: self) + } +} + +// MARK: - SwiftUIFlexWeekDemo + +struct SwiftUIFlexWeekDemo: View { + // MARK: - Lifecycle + + init(calendar: Calendar, monthsLayout: MonthsLayout) { + self.calendar = calendar + self.monthsLayout = monthsLayout + + let startDate = calendar.date(from: DateComponents(year: 2025, month: 01, day: 01))! + let endDate = calendar.date(from: DateComponents(year: 2035, month: 12, day: 31))! + visibleDateRange = startDate ... endDate + + monthDateFormatter = DateFormatter() + monthDateFormatter.calendar = calendar + monthDateFormatter.locale = calendar.locale + monthDateFormatter.dateFormat = DateFormatter.dateFormat( + fromTemplate: "MMMM yyyy", + options: 0, + locale: calendar.locale ?? Locale.current + ) + } + + // MARK: Internal + + @State var overlaidItemLocations: Set = [] + @State var selectedDate: Date = .init() + + var body: some View { + ZStack { + CalendarViewRepresentable( + calendar: calendar, + visibleDateRange: visibleDateRange, + monthsLayout: monthsLayout, + dataDependency: selectedDayRange, + proxy: calendarViewProxy + ) + + .interMonthSpacing(24) + .verticalDayMargin(lineSpacing) + .horizontalDayMargin(10) + .monthHeaders { month in + let monthHeaderText = monthDateFormatter.string(from: calendar.date(from: month.components)!) + Group { + if case .vertical = monthsLayout { + HStack { + Text(monthHeaderText) + .font(.title2) + Spacer() + } + .padding() + } else { + Text(monthHeaderText) + .font(.title2) + .padding() + } + } + .accessibilityAddTraits(.isHeader) + } + + .days { day in + SwiftUIDayView(day: day, isSelected: isDaySelected(day)) + } + + .dayRangeItemProvider(for: selectedDateRanges) { dayRangeLayoutContext in + let framesOfDaysToHighlight = dayRangeLayoutContext.daysAndFrames.map(\.frame) + // UIKit view + return DayRangeIndicatorView.calendarItemModel( + invariantViewProperties: .init(), + content: .init(framesOfDaysToHighlight: framesOfDaysToHighlight) + ) + } + + .onDaySelection { day in + selectedDayRange = day ... day + selectedDate = calendar.date(from: day.components)! + overlaidItemLocations = [.day(containingDate: selectedDate)] + lineSpacing = 100 + } + + .overlayItemProvider(for: overlaidItemLocations) { overlayLayoutContext in + + let screenWidth = UIScreen.main.bounds.width + let yValue = overlayLayoutContext.overlaidItemFrame.origin.y + let frame = CGRect(x: 0, y: yValue, width: screenWidth, height: 100) + + return SelectedDayView.calendarItemModel( + invariantViewProperties: .init(), + content: SelectedDayView.Content( + frameOfTooltippedItem: frame, + text: dayDateFormatter.string(from: selectedDate), + notes: "None", scrollToSelectedDate: { + calendarViewProxy.scrollToDay( + containing: selectedDate, + scrollPosition: .lastFullyVisiblePosition(padding: 50), + animated: false + ) + } + ) + ) + } + + .onAppear { + calendarViewProxy.scrollToDay( + containing: calendar.date(from: DateComponents(year: 2025, month: 04, day: 01))!, + scrollPosition: .firstFullyVisiblePosition(padding: 50), + animated: true + ) + } + .frame(maxWidth: 375, maxHeight: .infinity) + } + } + + // MARK: Private + + private let calendar: Calendar + private let monthsLayout: MonthsLayout + private let visibleDateRange: ClosedRange + + private var dateToolTip: Date? + + private let monthDateFormatter: DateFormatter + + @State private var dayDateFormatter: DateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.calendar = Calendar.current + dateFormatter.locale = Calendar.current.locale + dateFormatter.dateFormat = DateFormatter.dateFormat( + fromTemplate: "EEEE, MMM d, yyyy", + options: 0, + locale: Locale.current + ) + return dateFormatter + }() + + @StateObject private var calendarViewProxy = CalendarViewProxy() + + @State private var selectedDayRange: DayComponentsRange? + @State private var selectedDayRangeAtStartOfDrag: DayComponentsRange? + + @State private var showErrorMessage: Bool = false + @State private var lineSpacing: CGFloat = 28 + + private var selectedDateRanges: Set> { + guard let selectedDayRange else { return [] } + let selectedStartDate = calendar.date(from: selectedDayRange.lowerBound.components)! + let selectedEndDate = calendar.date(from: selectedDayRange.upperBound.components)! + return [selectedStartDate ... selectedEndDate] + } + + private func isDaySelected(_ day: Day) -> Bool { + if let selectedDayRange { + day == selectedDayRange.lowerBound || day == selectedDayRange.upperBound + } else { + false + } + } +} diff --git a/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/SwiftUIItemModelsDemoViewController.swift b/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/SwiftUIItemModelsDemoViewController.swift index ba53806b..ca95317f 100644 --- a/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/SwiftUIItemModelsDemoViewController.swift +++ b/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/SwiftUIItemModelsDemoViewController.swift @@ -66,7 +66,7 @@ final class SwiftUIItemModelsDemoViewController: BaseDemoViewController { .dayItemProvider { [calendar, selectedDate] day in let date = calendar.date(from: day.components) let isSelected = date == selectedDate - return SwiftUIDayView(dayNumber: day.day, isSelected: isSelected).calendarItemModel + return SwiftUIDayView(day: day, isSelected: isSelected).calendarItemModel } } diff --git a/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/SwiftUIScreenDemoViewController.swift b/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/SwiftUIScreenDemoViewController.swift index 204e7879..cee32cf2 100644 --- a/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/SwiftUIScreenDemoViewController.swift +++ b/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/SwiftUIScreenDemoViewController.swift @@ -116,7 +116,7 @@ struct SwiftUIScreenDemo: View { } .days { day in - SwiftUIDayView(dayNumber: day.day, isSelected: isDaySelected(day)) + SwiftUIDayView(day: day, isSelected: isDaySelected(day)) } .dayRangeItemProvider(for: selectedDateRanges) { dayRangeLayoutContext in @@ -189,7 +189,7 @@ struct SwiftUIScreenDemo: View { return [selectedStartDate...selectedEndDate] } - private func isDaySelected(_ day: DayComponents) -> Bool { + private func isDaySelected(_ day: Day) -> Bool { if let selectedDayRange { return day == selectedDayRange.lowerBound || day == selectedDayRange.upperBound } else { diff --git a/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/SwiftUIWeekViewViewController.swift b/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/SwiftUIWeekViewViewController.swift new file mode 100644 index 00000000..0dd4be32 --- /dev/null +++ b/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/SwiftUIWeekViewViewController.swift @@ -0,0 +1,195 @@ +// +// SwiftUIWeekViewViewController.swift +// HorizonCalendarExample +// +// Created by Kyle Parker on 3/2/25. +// Copyright © 2025 Airbnb. All rights reserved. +// + +import Foundation + +import HorizonCalendar +import SwiftUI + +// MARK: - SwiftUIWeekViewViewController + +final class SwiftUIWeekViewViewController: UIViewController, DemoViewController { + // MARK: Lifecycle + + init(monthsLayout _: MonthsLayout) { + monthsLayout = MonthsLayout.vertical(options: .init(pinDaysOfWeekToTop: true, + alwaysShowCompleteBoundaryMonths: true, + scrollsToFirstMonthOnStatusBarTap: true)) + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: Internal + + let calendar = Calendar.current + let monthsLayout: MonthsLayout + + override func viewDidLoad() { + super.viewDidLoad() + + title = "SwiftUI Week View" + + let hostingController = UIHostingController( + rootView: SwiftUIWeekViewDemo(calendar: calendar, monthsLayout: monthsLayout)) + addChild(hostingController) + + view.addSubview(hostingController.view) + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + hostingController.view.topAnchor.constraint(equalTo: view.topAnchor), + hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + + hostingController.didMove(toParent: self) + } +} + +// MARK: - SwiftUIWeekViewDemo + +struct SwiftUIWeekViewDemo: View { + // MARK: - Lifecycle + + init(calendar: Calendar, monthsLayout: MonthsLayout) { + self.calendar = calendar + self.monthsLayout = monthsLayout + + let startDate = calendar.date(from: DateComponents(year: 2025, month: 01, day: 01))! + let endDate = calendar.date(from: DateComponents(year: 2035, month: 12, day: 31))! + visibleDateRange = startDate ... endDate + + monthDateFormatter = DateFormatter() + monthDateFormatter.calendar = calendar + monthDateFormatter.locale = calendar.locale + monthDateFormatter.dateFormat = DateFormatter.dateFormat( + fromTemplate: "MMMM yyyy", + options: 0, + locale: calendar.locale ?? Locale.current + ) + } + + // MARK: Internal + + @State private var showErrorMessage: Bool = false + @Environment(\.window) var window: UIWindow? + + var body: some View { + ZStack { + CalendarViewRepresentable( + calendar: calendar, + visibleDateRange: visibleDateRange, + monthsLayout: monthsLayout, + dataDependency: selectedDayRange, + proxy: calendarViewProxy + ) + + .interMonthSpacing((window?.windowScene?.screen.bounds.height ?? 400) - 100) + .verticalDayMargin((window?.windowScene?.screen.bounds.height ?? 400) - 100) + .horizontalDayMargin(10) + .monthHeaders { month in + let monthHeaderText = monthDateFormatter.string(from: calendar.date(from: month.components)!) + Group { + if case .vertical = monthsLayout { + HStack { + Text(monthHeaderText) + .font(.title2) + Spacer() + } + .padding() + } else { + Text(monthHeaderText) + .font(.title2) + .padding() + } + } + .accessibilityAddTraits(.isHeader) + } + + .days { day in + SwiftUIDayView(day: day, isSelected: isDaySelected(day)) + } + + .dayRangeItemProvider(for: selectedDateRanges) { dayRangeLayoutContext in + let framesOfDaysToHighlight = dayRangeLayoutContext.daysAndFrames.map(\.frame) + // UIKit view + return DayRangeIndicatorView.calendarItemModel( + invariantViewProperties: .init(), + content: .init(framesOfDaysToHighlight: framesOfDaysToHighlight) + ) + } + + .onDaySelection { day in + DayRangeSelectionHelper.updateDayRange( + afterTapSelectionOf: day, + existingDayRange: &selectedDayRange + ) + } + .onAppear { + calendarViewProxy.scrollToDay( + containing: calendar.date(from: DateComponents(year: 2025, month: 04, day: 01))!, + scrollPosition: .centered, + animated: false + ) + } + .frame(maxWidth: 375, maxHeight: .infinity) + } + } + + // MARK: Private + + private let calendar: Calendar + private let monthsLayout: MonthsLayout + private let visibleDateRange: ClosedRange + + private var dateToolTip: Date? + + private let monthDateFormatter: DateFormatter + + @State private var dayDateFormatter: DateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.calendar = Calendar.current + dateFormatter.locale = Calendar.current.locale + dateFormatter.dateFormat = DateFormatter.dateFormat( + fromTemplate: "EEEE, MMM d, yyyy", + options: 0, + locale: Locale.current + ) + return dateFormatter + }() + + @StateObject private var calendarViewProxy = CalendarViewProxy() + + @State private var selectedDayRange: DayComponentsRange? + @State private var selectedDayRangeAtStartOfDrag: DayComponentsRange? + + private var selectedDateRanges: Set> { + guard let selectedDayRange else { return [] } + let selectedStartDate = calendar.date(from: selectedDayRange.lowerBound.components)! + let selectedEndDate = calendar.date(from: selectedDayRange.upperBound.components)! + return [selectedStartDate ... selectedEndDate] + } + + private func isDaySelected(_ day: Day) -> Bool { + if let selectedDayRange { + day == selectedDayRange.lowerBound || day == selectedDayRange.upperBound + } else { + false + } + } +} + +extension EnvironmentValues { + var window: UIWindow? { + UIApplication.shared.windows.first { $0.isKeyWindow } + } +} diff --git a/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/WeekNumberDemoViewController.swift b/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/WeekNumberDemoViewController.swift new file mode 100644 index 00000000..284ea97e --- /dev/null +++ b/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/WeekNumberDemoViewController.swift @@ -0,0 +1,117 @@ +// Created by Bryan Keller on 6/18/20. +// Copyright © 2020 Airbnb Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import HorizonCalendar +import UIKit + +final class WeekNumberDemoViewController: BaseDemoViewController { + + // MARK: Internal + + override func viewDidLoad() { + super.viewDidLoad() + + title = "Week Numbers showing" + + calendarView.daySelectionHandler = { [weak self] day in + guard let self else { return } + + DayRangeSelectionHelper.updateDayRange( + afterTapSelectionOf: day, + existingDayRange: &selectedDayRange) + + calendarView.setContent(makeContent()) + } + + calendarView.multiDaySelectionDragHandler = { [weak self, calendar] day, state in + guard let self else { return } + + DayRangeSelectionHelper.updateDayRange( + afterDragSelectionOf: day, + existingDayRange: &selectedDayRange, + initialDayRange: &selectedDayRangeAtStartOfDrag, + state: state, + calendar: calendar) + + calendarView.setContent(makeContent()) + } + } + + override func makeContent() -> CalendarViewContent { + let startDate = calendar.date(from: DateComponents(year: 2020, month: 01, day: 01))! + let endDate = calendar.date(from: DateComponents(year: 2021, month: 12, day: 31))! + + let dateRanges: Set> + let selectedDayRange = selectedDayRange + if + let selectedDayRange, + let lowerBound = calendar.date(from: selectedDayRange.lowerBound.components), + let upperBound = calendar.date(from: selectedDayRange.upperBound.components) + { + dateRanges = [lowerBound...upperBound] + } else { + dateRanges = [] + } + + return CalendarViewContent( + calendar: calendar, + visibleDateRange: startDate...endDate, + monthsLayout: monthsLayout) + + .interMonthSpacing(24) + .verticalDayMargin(8) + .horizontalDayMargin(8) + .showWeekNumbers(true, textColor: .systemBlue, width: 30) + + .dayItemProvider { [calendar, dayDateFormatter] day in + var invariantViewProperties = DayView.InvariantViewProperties.baseInteractive + + let isSelectedStyle: Bool + if let selectedDayRange { + isSelectedStyle = day == selectedDayRange.lowerBound || day == selectedDayRange.upperBound + } else { + isSelectedStyle = false + } + + if isSelectedStyle { + invariantViewProperties.backgroundShapeDrawingConfig.fillColor = .systemBackground + invariantViewProperties.backgroundShapeDrawingConfig.borderColor = UIColor(.accentColor) + } + + let date = calendar.date(from: day.components) + + return DayView.calendarItemModel( + invariantViewProperties: invariantViewProperties, + content: .init( + dayText: "\(day.day)", + accessibilityLabel: date.map { dayDateFormatter.string(from: $0) }, + accessibilityHint: nil)) + } + + .dayRangeItemProvider(for: dateRanges) { dayRangeLayoutContext in + DayRangeIndicatorView.calendarItemModel( + invariantViewProperties: .init(), + content: .init( + framesOfDaysToHighlight: dayRangeLayoutContext.daysAndFrames.map { $0.frame })) + } + } + + // MARK: Private + + private var selectedDayRange: DayComponentsRange? + private var selectedDayRangeAtStartOfDrag: DayComponentsRange? + +} + diff --git a/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/WeekdayOnlyDemoViewController.swift b/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/WeekdayOnlyDemoViewController.swift new file mode 100644 index 00000000..2d607680 --- /dev/null +++ b/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/WeekdayOnlyDemoViewController.swift @@ -0,0 +1,211 @@ +// Created by Bryan Keller on 2/1/23. +// Copyright © 2023 Airbnb Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import HorizonCalendar +import SwiftUI + +// MARK: - SwiftUIScreenDemoViewController + +final class WeekdayOnlyDemoViewController: UIViewController, DemoViewController { + + // MARK: Lifecycle + + init(monthsLayout: MonthsLayout) { + self.monthsLayout = monthsLayout + super.init(nibName: nil, bundle: nil) + } + + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: Internal + + let calendar = Calendar.current + let monthsLayout: MonthsLayout + + override func viewDidLoad() { + super.viewDidLoad() + + title = "Weekday Only Calendar" + + let hostingController = UIHostingController( + rootView: WeekdayOnlyDemoView(calendar: calendar, monthsLayout: monthsLayout)) + addChild(hostingController) + + view.addSubview(hostingController.view) + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + hostingController.view.topAnchor.constraint(equalTo: view.topAnchor), + hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + hostingController.didMove(toParent: self) + } + +} + +// MARK: - WeekdayOnlyDemoView + +struct WeekdayOnlyDemoView: View { + + // MARK: Lifecycle + + init(calendar: Calendar, monthsLayout: MonthsLayout) { + self.calendar = calendar + self.monthsLayout = monthsLayout + + + let startDate = calendar.date(from: DateComponents(year: 2023, month: 01, day: 01))! + let endDate = calendar.date(from: DateComponents(year: 2026, month: 12, day: 31))! + visibleDateRange = startDate...endDate + + monthDateFormatter = DateFormatter() + monthDateFormatter.calendar = calendar + monthDateFormatter.locale = calendar.locale + monthDateFormatter.dateFormat = DateFormatter.dateFormat( + fromTemplate: "MMMM yyyy", + options: 0, + locale: calendar.locale ?? Locale.current) + } + + // MARK: Internal + + var body: some View { + CalendarViewRepresentable( + calendar: calendar, + visibleDateRange: visibleDateRange, + monthsLayout: monthsLayout, + dataDependency: selectedDayRange, + proxy: calendarViewProxy, + visibleWeekdays: Set(2...6)) + + .interMonthSpacing(24) + .verticalDayMargin(8) + .horizontalDayMargin(8) + + .monthHeaders { month in + let monthHeaderText = monthDateFormatter.string(from: calendar.date(from: month.components)!) + Group { + if case .vertical = monthsLayout { + HStack { + Text(monthHeaderText) + .font(.title2) + Spacer() + } + .padding() + } else { + Text(monthHeaderText) + .font(.title2) + .padding() + } + } + .accessibilityAddTraits(.isHeader) + } + + .days { day in + SwiftUIDayView(dayNumber: day.day, isSelected: isDaySelected(day)) + } + + .dayRangeItemProvider(for: selectedDateRanges) { dayRangeLayoutContext in + let framesOfDaysToHighlight = dayRangeLayoutContext.daysAndFrames.map { $0.frame } + // UIKit view + return DayRangeIndicatorView.calendarItemModel( + invariantViewProperties: .init(), + content: .init(framesOfDaysToHighlight: framesOfDaysToHighlight)) + } + + .onDaySelection { day in + DayRangeSelectionHelper.updateDayRange( + afterTapSelectionOf: day, + existingDayRange: &selectedDayRange) + } + + .onMultipleDaySelectionDrag( + began: { day in + DayRangeSelectionHelper.updateDayRange( + afterDragSelectionOf: day, + existingDayRange: &selectedDayRange, + initialDayRange: &selectedDayRangeAtStartOfDrag, + state: .began, + calendar: calendar) + }, + changed: { day in + DayRangeSelectionHelper.updateDayRange( + afterDragSelectionOf: day, + existingDayRange: &selectedDayRange, + initialDayRange: &selectedDayRangeAtStartOfDrag, + state: .changed, + calendar: calendar) + }, + ended: { day in + DayRangeSelectionHelper.updateDayRange( + afterDragSelectionOf: day, + existingDayRange: &selectedDayRange, + initialDayRange: &selectedDayRangeAtStartOfDrag, + state: .ended, + calendar: calendar) + }) + + .onAppear { + calendarViewProxy.scrollToDay( + containing: calendar.date(from: DateComponents(year: 2023, month: 07, day: 19))!, + scrollPosition: .centered, + animated: false) + } + + .frame(maxWidth: 375, maxHeight: .infinity) + } + + // MARK: Private + + private let calendar: Calendar + private let monthsLayout: MonthsLayout + private let visibleDateRange: ClosedRange + + private let monthDateFormatter: DateFormatter + + @StateObject private var calendarViewProxy = CalendarViewProxy() + + @State private var selectedDayRange: DayComponentsRange? + @State private var selectedDayRangeAtStartOfDrag: DayComponentsRange? + + private var selectedDateRanges: Set> { + guard let selectedDayRange else { return [] } + let selectedStartDate = calendar.date(from: selectedDayRange.lowerBound.components)! + let selectedEndDate = calendar.date(from: selectedDayRange.upperBound.components)! + return [selectedStartDate...selectedEndDate] + } + + private func isDaySelected(_ day: Day) -> Bool { + if let selectedDayRange { + return day == selectedDayRange.lowerBound || day == selectedDayRange.upperBound + } else { + return false + } + } + +} + +// MARK: - SwiftUIScreenDemo_Previews + +struct WeekdayOnlyDemoViewController_Previews: PreviewProvider { + static var previews: some View { + WeekdayOnlyDemoView(calendar: Calendar.current, monthsLayout: .vertical) + WeekdayOnlyDemoView(calendar: Calendar.current, monthsLayout: .horizontal) + } +} diff --git a/Example/HorizonCalendarExample/HorizonCalendarExample/DemoPickerViewController.swift b/Example/HorizonCalendarExample/HorizonCalendarExample/DemoPickerViewController.swift index 23c0ff50..dc01b995 100644 --- a/Example/HorizonCalendarExample/HorizonCalendarExample/DemoPickerViewController.swift +++ b/Example/HorizonCalendarExample/HorizonCalendarExample/DemoPickerViewController.swift @@ -68,6 +68,13 @@ final class DemoPickerViewController: UIViewController { ("Month Grid Background", MonthBackgroundDemoViewController.self), ("SwiftUI Day and Month View", SwiftUIItemModelsDemoViewController.self), ("SwiftUI Screen", SwiftUIScreenDemoViewController.self), + ("WeekdayView", WeekNumberDemoViewController.self), + ("Weekday Only Calendar", WeekdayOnlyDemoViewController.self), + ("SwiftUI Disabled Day", SwiftUIDisabledDayDemoViewController.self), + ("SwiftUI Week View", SwiftUIWeekViewViewController.self), + ("SwiftUI Flexible Week View", SwiftUIFlexWeekViewController.self), + ("Holidays", HolidayCalendarViewController.self), + ] private let horizontalDemoDestinations: [(name: String, destinationType: DemoViewController.Type)] = @@ -80,6 +87,10 @@ final class DemoPickerViewController: UIViewController { ("Month Grid Background", MonthBackgroundDemoViewController.self), ("SwiftUI Day and Month View", SwiftUIItemModelsDemoViewController.self), ("SwiftUI Screen", SwiftUIScreenDemoViewController.self), + ("WeekdayView", WeekNumberDemoViewController.self), + ("Weekday Only Calendar", WeekdayOnlyDemoViewController.self), + ("Holidays", HolidayCalendarViewController.self), + ] private lazy var tableView: UITableView = { diff --git a/Example/HorizonCalendarExample/HorizonCalendarExample/SelectedDayView.swift b/Example/HorizonCalendarExample/HorizonCalendarExample/SelectedDayView.swift new file mode 100644 index 00000000..1f30c25f --- /dev/null +++ b/Example/HorizonCalendarExample/HorizonCalendarExample/SelectedDayView.swift @@ -0,0 +1,178 @@ +// Created by Bryan Keller on 6/15/20. +// Edited by Kyle Parker on 03/02/25 +// Copyright © 2020 Airbnb Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import HorizonCalendar +import SwiftUICore +import UIKit + +// MARK: - SelectedDayView + +final class SelectedDayView: UIView, UITextFieldDelegate { + // MARK: Lifecycle + + fileprivate init(invariantViewProperties: InvariantViewProperties) { + backgroundView = UIView() + backgroundView.backgroundColor = invariantViewProperties.backgroundColor + backgroundView.layer.borderColor = invariantViewProperties.borderColor.cgColor + backgroundView.layer.borderWidth = 1 + backgroundView.layer.cornerRadius = 6 + + dateLabel = UILabel() + dateLabel.font = invariantViewProperties.font + dateLabel.textAlignment = invariantViewProperties.textAlignment + dateLabel.lineBreakMode = .byTruncatingTail + dateLabel.textColor = invariantViewProperties.textColor + + notes = UITextField() + notes.font = invariantViewProperties.font + notes.textColor = invariantViewProperties.textColor + notes.textAlignment = invariantViewProperties.textAlignment + notes.borderStyle = .roundedRect + notes.placeholder = "Enter your notes here..." + notes.layer.borderColor = invariantViewProperties.borderColor.cgColor + notes.layer.borderWidth = 1 + notes.layer.cornerRadius = 6 + notes.backgroundColor = invariantViewProperties.borderColor.withAlphaComponent(0.1 + ) + + super.init(frame: .zero) + + notes.delegate = self + addSubview(backgroundView) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: Internal + + override func layoutSubviews() { + super.layoutSubviews() + + guard let frameOfTooltippedItem else { return } + + dateLabel.sizeToFit() + + let backgroundSize = CGSize(width: frameOfTooltippedItem.width, + height: frameOfTooltippedItem.height) + + let dateLabelHeight: CGFloat = 20 + + let buffer: CGFloat = 5 + + let proposedFrame = CGRect( + x: frameOfTooltippedItem.minX, + y: frameOfTooltippedItem.minY - backgroundSize.height * 1.1, + width: backgroundSize.width - 60, + height: backgroundSize.height + ) + + backgroundView.frame = proposedFrame + + dateLabel.frame = CGRect(x: buffer, + y: buffer, + width: backgroundView.frame.width - 10, + height: dateLabelHeight) + + notes.frame = CGRect(x: 5, + y: buffer * 2 + dateLabelHeight, + width: dateLabel.frame.width, + height: backgroundView.frame.height - dateLabelHeight - buffer * 3) + + backgroundView.addSubview(dateLabel) + backgroundView.addSubview(notes) + } + + override func touchesBegan(_: Set, with _: UIEvent?) { + resignFirstResponder() + } + + func textFieldDidBeginEditing(_: UITextField) { + scrollToSelectedDate?() + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + return true + } + + // MARK: Fileprivate + + fileprivate var frameOfTooltippedItem: CGRect? { + didSet { + guard frameOfTooltippedItem != oldValue else { return } + setNeedsLayout() + } + } + + fileprivate var dateText: String { + get { dateLabel.text ?? "" } + set { dateLabel.text = newValue } + } + + fileprivate var fieldTextContent: String { + get { notes.text ?? "" } + set { notes.text = newValue } + } + + // MARK: Private + + private let backgroundView: UIView + private let dateLabel: UILabel + private let notes: UITextField + private var scrollToSelectedDate: (() -> Void)? +} + +// MARK: CalendarItemViewRepresentable + +extension SelectedDayView: CalendarItemViewRepresentable { + struct InvariantViewProperties: Hashable { + var backgroundColor = UIColor.white + var borderColor = UIColor.black + var font = UIFont.systemFont(ofSize: 16) + var textAlignment = NSTextAlignment.center + var textColor = UIColor.black + } + + struct Content: Equatable { + static func == (lhs: SelectedDayView.Content, rhs: SelectedDayView.Content) -> Bool { + lhs.frameOfTooltippedItem == rhs.frameOfTooltippedItem && + lhs.text == rhs.text && + lhs.notes == rhs.notes + } + + let frameOfTooltippedItem: CGRect? + let text: String + let notes: String? + let scrollToSelectedDate: (() -> Void)? + } + + static func makeView( + withInvariantViewProperties invariantViewProperties: InvariantViewProperties) + -> SelectedDayView + { + SelectedDayView(invariantViewProperties: invariantViewProperties) + } + + static func setContent(_ content: Content, on view: SelectedDayView) { + view.frameOfTooltippedItem = content.frameOfTooltippedItem + view.dateText = content.text + view.fieldTextContent = content.notes ?? "" + view.scrollToSelectedDate = content.scrollToSelectedDate + } +} diff --git a/Example/HorizonCalendarExample/HorizonCalendarExample/SwiftUIDayView.swift b/Example/HorizonCalendarExample/HorizonCalendarExample/SwiftUIDayView.swift index e68d367f..d55be59b 100644 --- a/Example/HorizonCalendarExample/HorizonCalendarExample/SwiftUIDayView.swift +++ b/Example/HorizonCalendarExample/HorizonCalendarExample/SwiftUIDayView.swift @@ -20,22 +20,46 @@ import SwiftUI struct SwiftUIDayView: View { - let dayNumber: Int - let isSelected: Bool - - var body: some View { - ZStack(alignment: .center) { - Circle() - .strokeBorder(isSelected ? Color.accentColor : .clear, lineWidth: 2) - .background { - Circle() - .foregroundColor(isSelected ? Color(UIColor.systemBackground) : .clear) + let dayNumber: Int + let isSelected: Bool + let isEnabled: Bool + + // MARK: - Lifecycle + init(day: Day, isSelected: Bool) { + self.dayNumber = day.day + self.isSelected = isSelected + self.isEnabled = day.isEnabled + } + + /// Backwards compatible + init(dayNumber: Int, isSelected: Bool, isEnabled: Bool = true) { + self.dayNumber = dayNumber + self.isSelected = isSelected + self.isEnabled = isEnabled + } + + var body: some View { + ZStack(alignment: .center) { + Circle() + .strokeBorder(isSelected ? Color.accentColor : .clear, lineWidth: 2) + .background { + if isEnabled { + Circle() + .foregroundColor(isSelected ? Color(UIColor.systemBackground) : .clear) + } else { + Image(systemName: "xmark") + .resizable() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .foregroundColor(.secondary) + .opacity(0.3) + .padding(6) + } + } + .aspectRatio(1, contentMode: .fill) + Text("\(dayNumber)").foregroundColor(isEnabled ? Color(UIColor.label) : Color(UIColor.lightText)) } - .aspectRatio(1, contentMode: .fill) - Text("\(dayNumber)").foregroundColor(Color(UIColor.label)) + .accessibilityAddTraits(.isButton) } - .accessibilityAddTraits(.isButton) - } } @@ -47,9 +71,9 @@ struct SwiftUIDayView_Previews: PreviewProvider { static var previews: some View { Group { - SwiftUIDayView(dayNumber: 1, isSelected: false) - SwiftUIDayView(dayNumber: 19, isSelected: false) - SwiftUIDayView(dayNumber: 27, isSelected: true) + SwiftUIDayView(dayNumber: 1, isSelected: false, isEnabled: true) + SwiftUIDayView(dayNumber: 19, isSelected: false, isEnabled: false) + SwiftUIDayView(dayNumber: 27, isSelected: true, isEnabled: true) } .frame(width: 50, height: 50) } diff --git a/Example/HorizonCalendarExample/HorizonCalendarExample/TooltipView.swift b/Example/HorizonCalendarExample/HorizonCalendarExample/TooltipView.swift index fcf6fe8c..35cb2bac 100644 --- a/Example/HorizonCalendarExample/HorizonCalendarExample/TooltipView.swift +++ b/Example/HorizonCalendarExample/HorizonCalendarExample/TooltipView.swift @@ -14,119 +14,118 @@ // limitations under the License. import HorizonCalendar +import SwiftUICore import UIKit // MARK: - TooltipView final class TooltipView: UIView { + // MARK: Lifecycle + + fileprivate init(invariantViewProperties: InvariantViewProperties) { + backgroundView = UIView() + backgroundView.backgroundColor = invariantViewProperties.backgroundColor + backgroundView.layer.borderColor = invariantViewProperties.borderColor.cgColor + backgroundView.layer.borderWidth = 1 + backgroundView.layer.cornerRadius = 6 + + label = UILabel() + label.font = invariantViewProperties.font + label.textAlignment = invariantViewProperties.textAlignment + label.lineBreakMode = .byTruncatingTail + label.textColor = invariantViewProperties.textColor + + super.init(frame: .zero) + + isUserInteractionEnabled = false + addSubview(backgroundView) + addSubview(label) + } - // MARK: Lifecycle - - fileprivate init(invariantViewProperties: InvariantViewProperties) { - backgroundView = UIView() - backgroundView.backgroundColor = invariantViewProperties.backgroundColor - backgroundView.layer.borderColor = invariantViewProperties.borderColor.cgColor - backgroundView.layer.borderWidth = 1 - backgroundView.layer.cornerRadius = 6 - - label = UILabel() - label.font = invariantViewProperties.font - label.textAlignment = invariantViewProperties.textAlignment - label.lineBreakMode = .byTruncatingTail - label.textColor = invariantViewProperties.textColor - - super.init(frame: .zero) - - isUserInteractionEnabled = false - addSubview(backgroundView) - addSubview(label) - } + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } - required init?(coder _: NSCoder) { - fatalError("init(coder:) has not been implemented") - } + // MARK: Internal - // MARK: Internal + override func layoutSubviews() { + super.layoutSubviews() - override func layoutSubviews() { - super.layoutSubviews() + guard let frameOfTooltippedItem else { return } - guard let frameOfTooltippedItem else { return } + label.sizeToFit() + let labelSize = CGSize( + width: min(label.bounds.size.width, bounds.width), + height: label.bounds.size.height + ) - label.sizeToFit() - let labelSize = CGSize( - width: min(label.bounds.size.width, bounds.width), - height: label.bounds.size.height) + let backgroundSize = CGSize(width: labelSize.width + 16, height: labelSize.height + 16) - let backgroundSize = CGSize(width: labelSize.width + 16, height: labelSize.height + 16) + let proposedFrame = CGRect( + x: frameOfTooltippedItem.midX - (backgroundSize.width / 2), + y: frameOfTooltippedItem.minY - backgroundSize.height - 4, + width: backgroundSize.width, + height: backgroundSize.height + ) - let proposedFrame = CGRect( - x: frameOfTooltippedItem.midX - (backgroundSize.width / 2), - y: frameOfTooltippedItem.minY - backgroundSize.height - 4, - width: backgroundSize.width, - height: backgroundSize.height) + let frame: CGRect = if proposedFrame.maxX > bounds.width { + proposedFrame.applying(.init(translationX: bounds.width - proposedFrame.maxX, y: 0)) + } else if proposedFrame.minX < 0 { + proposedFrame.applying(.init(translationX: -proposedFrame.minX, y: 0)) + } else { + proposedFrame + } - let frame: CGRect - if proposedFrame.maxX > bounds.width { - frame = proposedFrame.applying(.init(translationX: bounds.width - proposedFrame.maxX, y: 0)) - } else if proposedFrame.minX < 0 { - frame = proposedFrame.applying(.init(translationX: -proposedFrame.minX, y: 0)) - } else { - frame = proposedFrame + backgroundView.frame = frame + label.center = backgroundView.center } - backgroundView.frame = frame - label.center = backgroundView.center - } - - // MARK: Fileprivate + // MARK: Fileprivate - fileprivate var frameOfTooltippedItem: CGRect? { - didSet { - guard frameOfTooltippedItem != oldValue else { return } - setNeedsLayout() + fileprivate var frameOfTooltippedItem: CGRect? { + didSet { + guard frameOfTooltippedItem != oldValue else { return } + setNeedsLayout() + } } - } - fileprivate var text: String { - get { label.text ?? "" } - set { label.text = newValue } - } - - // MARK: Private + fileprivate var text: String { + get { label.text ?? "" } + set { label.text = newValue } + } - private let backgroundView: UIView - private let label: UILabel + // MARK: Private + private let backgroundView: UIView + private let label: UILabel } // MARK: CalendarItemViewRepresentable extension TooltipView: CalendarItemViewRepresentable { + struct InvariantViewProperties: Hashable { + var backgroundColor = UIColor.white + var borderColor = UIColor.black + var font = UIFont.systemFont(ofSize: 16) + var textAlignment = NSTextAlignment.center + var textColor = UIColor.black + } - struct InvariantViewProperties: Hashable { - var backgroundColor = UIColor.white - var borderColor = UIColor.black - var font = UIFont.systemFont(ofSize: 16) - var textAlignment = NSTextAlignment.center - var textColor = UIColor.black - } - - struct Content: Equatable { - let frameOfTooltippedItem: CGRect? - let text: String - } - - static func makeView( - withInvariantViewProperties invariantViewProperties: InvariantViewProperties) - -> TooltipView - { - TooltipView(invariantViewProperties: invariantViewProperties) - } - - static func setContent(_ content: Content, on view: TooltipView) { - view.frameOfTooltippedItem = content.frameOfTooltippedItem - view.text = content.text - } + struct Content: Equatable { + let frameOfTooltippedItem: CGRect? + let text: String + } + static func makeView( + withInvariantViewProperties invariantViewProperties: InvariantViewProperties) + -> TooltipView + { + TooltipView(invariantViewProperties: invariantViewProperties) + } + + static func setContent(_ content: Content, on view: TooltipView) { + view.frameOfTooltippedItem = content.frameOfTooltippedItem + view.text = content.text + } } diff --git a/HorizonCalendar.xcodeproj/project.pbxproj b/HorizonCalendar.xcodeproj/project.pbxproj index 59b19f79..beedf951 100644 --- a/HorizonCalendar.xcodeproj/project.pbxproj +++ b/HorizonCalendar.xcodeproj/project.pbxproj @@ -3,23 +3,20 @@ archiveVersion = 1; classes = { }; - objectVersion = 50; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ - 086333B02AB8D36900CC6125 /* CalendarContentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 086333AE2AB8D34700CC6125 /* CalendarContentTests.swift */; }; + 645FFEFE2D73AF53002EE721 /* WeekNumberView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 645FFEFD2D73AF53002EE721 /* WeekNumberView.swift */; }; 9321957E26EEB44C0001C7E9 /* DayOfWeekView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9321957D26EEB44C0001C7E9 /* DayOfWeekView.swift */; }; 9321958026EEB6330001C7E9 /* Shape.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9321957F26EEB6330001C7E9 /* Shape.swift */; }; 9321958226EEB6AB0001C7E9 /* DrawingConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9321958126EEB6AB0001C7E9 /* DrawingConfig.swift */; }; 9321958426EEB97F0001C7E9 /* MonthHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9321958326EEB97F0001C7E9 /* MonthHeaderView.swift */; }; - 9325F43825C1054600E3BFB8 /* PaginationHelpersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9325F43725C1054600E3BFB8 /* PaginationHelpersTests.swift */; }; - 932E24142558DF6E001648D2 /* HorizontalMonthsLayoutOptionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 932E24132558DF6E001648D2 /* HorizontalMonthsLayoutOptionsTests.swift */; }; 932FC00E26ED5191005E39A6 /* DayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 932FC00D26ED5191005E39A6 /* DayView.swift */; }; 933992992736562D00C80380 /* DoubleLayoutPassHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 933992982736562C00C80380 /* DoubleLayoutPassHelpers.swift */; }; 938DA40F24BEEB1A008A3B47 /* CalendarItemViewRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 938DA40E24BEEB1A008A3B47 /* CalendarItemViewRepresentable.swift */; }; 938DA41124BEFFCB008A3B47 /* AnyCalendarItemModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 938DA41024BEFFCB008A3B47 /* AnyCalendarItemModel.swift */; }; 9391F15625C097DF001D14A2 /* PaginationHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9391F15525C097DF001D14A2 /* PaginationHelpers.swift */; }; - 9396F3CC2483261B008AD306 /* HorizonCalendar.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9396F3C22483261B008AD306 /* HorizonCalendar.framework */; }; 9396F3DD24832715008AD306 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 9396F3DC24832715008AD306 /* README.md */; }; 9396F3DF248327C2008AD306 /* LICENSE in Resources */ = {isa = PBXBuildFile; fileRef = 9396F3DE248327C2008AD306 /* LICENSE */; }; 939E692424837E0300A8BCC7 /* CalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 939E691724837E0200A8BCC7 /* CalendarView.swift */; }; @@ -43,22 +40,13 @@ 939E694224846EB400A8BCC7 /* DayRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 939E694124846EB400A8BCC7 /* DayRange.swift */; }; 939E69442484784D00A8BCC7 /* Calendar+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 939E69432484784D00A8BCC7 /* Calendar+Helpers.swift */; }; 939E694624847BA300A8BCC7 /* DayOfWeekPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 939E694524847BA300A8BCC7 /* DayOfWeekPosition.swift */; }; - 939E69582484B21400A8BCC7 /* FrameProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 939E694F2484B14500A8BCC7 /* FrameProviderTests.swift */; }; - 939E69592484B21700A8BCC7 /* LayoutItemTypeEnumeratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 939E694C2484B14400A8BCC7 /* LayoutItemTypeEnumeratorTests.swift */; }; - 939E695A2484B21E00A8BCC7 /* ScreenPixelAlignmentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 939E694B2484B14400A8BCC7 /* ScreenPixelAlignmentTests.swift */; }; - 939E695B2484B22200A8BCC7 /* ScrollMetricsMutatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 939E694E2484B14500A8BCC7 /* ScrollMetricsMutatorTests.swift */; }; - 939E695C2484B22600A8BCC7 /* VisibleItemsProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 939E694D2484B14400A8BCC7 /* VisibleItemsProviderTests.swift */; }; - 939E695D2484B22A00A8BCC7 /* MonthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 939E69492484B14400A8BCC7 /* MonthTests.swift */; }; - 93A0062C24F206BE00F667A3 /* ItemViewReuseManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93A0062B24F206BE00F667A3 /* ItemViewReuseManagerTests.swift */; }; 93A361F4248332AE00E6544A /* ScreenPixelAlignment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93A361F3248332AE00E6544A /* ScreenPixelAlignment.swift */; }; 93E24A70249D915900B856F7 /* CONTRIBUTING.md in Resources */ = {isa = PBXBuildFile; fileRef = 93E24A56249D915900B856F7 /* CONTRIBUTING.md */; }; 93E24A71249D915900B856F7 /* TECHNICAL_DETAILS.md in Resources */ = {isa = PBXBuildFile; fileRef = 93E24A57249D915900B856F7 /* TECHNICAL_DETAILS.md */; }; - 93FA64EC248CDD3E00A8B7B1 /* MonthHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93FA64EB248CDD3E00A8B7B1 /* MonthHelperTests.swift */; }; - 93FA64EE248D7B0100A8B7B1 /* DayHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93FA64ED248D7B0100A8B7B1 /* DayHelperTests.swift */; }; - 93FA64F0248D84FE00A8B7B1 /* DayOfWeekPositionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93FA64EF248D84FE00A8B7B1 /* DayOfWeekPositionTests.swift */; }; - 93FA64F2248D93EA00A8B7B1 /* MonthRowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93FA64F1248D93EA00A8B7B1 /* MonthRowTests.swift */; }; + AA79A7F52D7508B600C07DD0 /* HorizonCalendar.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9396F3C22483261B008AD306 /* HorizonCalendar.framework */; }; + AA92BD622D73A7FF0076E283 /* DayComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA92BD612D73A7FF0076E283 /* DayComponents.swift */; }; + AA92BDA32D7418470076E283 /* DayRangeSelectionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA92BDA22D7418470076E283 /* DayRangeSelectionHelper.swift */; }; FD05F0D52A1F1AB300E6ACB0 /* CalendarViewProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD05F0D42A1F1AB300E6ACB0 /* CalendarViewProxy.swift */; }; - FD264F87294B260B00B13C97 /* SubviewsManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD264F86294B260B00B13C97 /* SubviewsManagerTests.swift */; }; FD2C7DC52922C75800B54C1D /* CHANGELOG.md in Resources */ = {isa = PBXBuildFile; fileRef = FD2C7DC42922C75700B54C1D /* CHANGELOG.md */; }; FD33BADE2A7249D600C1DA27 /* CGFloat+MaxLayoutValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD33BADD2A7249D600C1DA27 /* CGFloat+MaxLayoutValue.swift */; }; FD3D9B6128ED2C1500CC6D62 /* UIView+NoAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3D9B6028ED2C1500CC6D62 /* UIView+NoAnimation.swift */; }; @@ -77,7 +65,7 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ - 9396F3CD2483261B008AD306 /* PBXContainerItemProxy */ = { + AA79A7F62D7508B600C07DD0 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 9396F3B92483261B008AD306 /* Project object */; proxyType = 1; @@ -87,13 +75,11 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ - 086333AE2AB8D34700CC6125 /* CalendarContentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarContentTests.swift; sourceTree = ""; }; + 645FFEFD2D73AF53002EE721 /* WeekNumberView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekNumberView.swift; sourceTree = ""; }; 9321957D26EEB44C0001C7E9 /* DayOfWeekView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayOfWeekView.swift; sourceTree = ""; }; 9321957F26EEB6330001C7E9 /* Shape.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Shape.swift; sourceTree = ""; }; 9321958126EEB6AB0001C7E9 /* DrawingConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DrawingConfig.swift; sourceTree = ""; }; 9321958326EEB97F0001C7E9 /* MonthHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MonthHeaderView.swift; sourceTree = ""; }; - 9325F43725C1054600E3BFB8 /* PaginationHelpersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationHelpersTests.swift; sourceTree = ""; }; - 932E24132558DF6E001648D2 /* HorizontalMonthsLayoutOptionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HorizontalMonthsLayoutOptionsTests.swift; sourceTree = ""; }; 932FC00D26ED5191005E39A6 /* DayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayView.swift; sourceTree = ""; }; 933992982736562C00C80380 /* DoubleLayoutPassHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoubleLayoutPassHelpers.swift; sourceTree = ""; }; 938DA40E24BEEB1A008A3B47 /* CalendarItemViewRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarItemViewRepresentable.swift; sourceTree = ""; }; @@ -101,8 +87,6 @@ 9391F15525C097DF001D14A2 /* PaginationHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationHelpers.swift; sourceTree = ""; }; 9396F3C22483261B008AD306 /* HorizonCalendar.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = HorizonCalendar.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 9396F3C62483261B008AD306 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 9396F3CB2483261B008AD306 /* HorizonCalendarTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = HorizonCalendarTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 9396F3D22483261B008AD306 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 9396F3DC24832715008AD306 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 9396F3DE248327C2008AD306 /* LICENSE */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = LICENSE; sourceTree = ""; }; 939E691724837E0200A8BCC7 /* CalendarView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CalendarView.swift; sourceTree = ""; }; @@ -126,22 +110,13 @@ 939E694124846EB400A8BCC7 /* DayRange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayRange.swift; sourceTree = ""; }; 939E69432484784D00A8BCC7 /* Calendar+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Calendar+Helpers.swift"; sourceTree = ""; }; 939E694524847BA300A8BCC7 /* DayOfWeekPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayOfWeekPosition.swift; sourceTree = ""; }; - 939E69492484B14400A8BCC7 /* MonthTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MonthTests.swift; sourceTree = ""; }; - 939E694B2484B14400A8BCC7 /* ScreenPixelAlignmentTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScreenPixelAlignmentTests.swift; sourceTree = ""; }; - 939E694C2484B14400A8BCC7 /* LayoutItemTypeEnumeratorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LayoutItemTypeEnumeratorTests.swift; sourceTree = ""; }; - 939E694D2484B14400A8BCC7 /* VisibleItemsProviderTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VisibleItemsProviderTests.swift; sourceTree = ""; }; - 939E694E2484B14500A8BCC7 /* ScrollMetricsMutatorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScrollMetricsMutatorTests.swift; sourceTree = ""; }; - 939E694F2484B14500A8BCC7 /* FrameProviderTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FrameProviderTests.swift; sourceTree = ""; }; - 93A0062B24F206BE00F667A3 /* ItemViewReuseManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemViewReuseManagerTests.swift; sourceTree = ""; }; 93A361F3248332AE00E6544A /* ScreenPixelAlignment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScreenPixelAlignment.swift; sourceTree = ""; }; 93E24A56249D915900B856F7 /* CONTRIBUTING.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = CONTRIBUTING.md; sourceTree = ""; }; 93E24A57249D915900B856F7 /* TECHNICAL_DETAILS.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = TECHNICAL_DETAILS.md; sourceTree = ""; }; - 93FA64EB248CDD3E00A8B7B1 /* MonthHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MonthHelperTests.swift; sourceTree = ""; }; - 93FA64ED248D7B0100A8B7B1 /* DayHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayHelperTests.swift; sourceTree = ""; }; - 93FA64EF248D84FE00A8B7B1 /* DayOfWeekPositionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayOfWeekPositionTests.swift; sourceTree = ""; }; - 93FA64F1248D93EA00A8B7B1 /* MonthRowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MonthRowTests.swift; sourceTree = ""; }; + AA79A7F12D7508B600C07DD0 /* HorizonCalendarTests [Rec].xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "HorizonCalendarTests [Rec].xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; + AA92BD612D73A7FF0076E283 /* DayComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayComponents.swift; sourceTree = ""; }; + AA92BDA22D7418470076E283 /* DayRangeSelectionHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayRangeSelectionHelper.swift; sourceTree = ""; }; FD05F0D42A1F1AB300E6ACB0 /* CalendarViewProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarViewProxy.swift; sourceTree = ""; }; - FD264F86294B260B00B13C97 /* SubviewsManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubviewsManagerTests.swift; sourceTree = ""; }; FD2C7DC42922C75700B54C1D /* CHANGELOG.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = CHANGELOG.md; sourceTree = ""; }; FD33BADD2A7249D600C1DA27 /* CGFloat+MaxLayoutValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGFloat+MaxLayoutValue.swift"; sourceTree = ""; }; FD3D9B6028ED2C1500CC6D62 /* UIView+NoAnimation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+NoAnimation.swift"; sourceTree = ""; }; @@ -159,6 +134,10 @@ FDE2893B28F8A6D50020EBF1 /* SwiftUIWrapperView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftUIWrapperView.swift; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedRootGroup section */ + AA79A7F22D7508B600C07DD0 /* HorizonCalendarTests [Rec] */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = "HorizonCalendarTests [Rec]"; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ 9396F3BF2483261B008AD306 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -167,11 +146,11 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 9396F3C82483261B008AD306 /* Frameworks */ = { + AA79A7EE2D7508B600C07DD0 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 9396F3CC2483261B008AD306 /* HorizonCalendar.framework in Frameworks */, + AA79A7F52D7508B600C07DD0 /* HorizonCalendar.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -188,6 +167,7 @@ 9321958326EEB97F0001C7E9 /* MonthHeaderView.swift */, 9321957F26EEB6330001C7E9 /* Shape.swift */, FDE2893B28F8A6D50020EBF1 /* SwiftUIWrapperView.swift */, + 645FFEFD2D73AF53002EE721 /* WeekNumberView.swift */, ); path = ItemViews; sourceTree = ""; @@ -201,7 +181,7 @@ 9396F3DE248327C2008AD306 /* LICENSE */, 9396F3C62483261B008AD306 /* Info.plist */, 9396F3C42483261B008AD306 /* Sources */, - 9396F3CF2483261B008AD306 /* Tests */, + AA79A7F22D7508B600C07DD0 /* HorizonCalendarTests [Rec] */, 9396F3C32483261B008AD306 /* Products */, ); sourceTree = ""; @@ -210,7 +190,7 @@ isa = PBXGroup; children = ( 9396F3C22483261B008AD306 /* HorizonCalendar.framework */, - 9396F3CB2483261B008AD306 /* HorizonCalendarTests.xctest */, + AA79A7F12D7508B600C07DD0 /* HorizonCalendarTests [Rec].xctest */, ); name = Products; sourceTree = ""; @@ -224,29 +204,6 @@ path = Sources; sourceTree = ""; }; - 9396F3CF2483261B008AD306 /* Tests */ = { - isa = PBXGroup; - children = ( - 93FA64ED248D7B0100A8B7B1 /* DayHelperTests.swift */, - 93FA64EF248D84FE00A8B7B1 /* DayOfWeekPositionTests.swift */, - 939E694F2484B14500A8BCC7 /* FrameProviderTests.swift */, - 932E24132558DF6E001648D2 /* HorizontalMonthsLayoutOptionsTests.swift */, - 93A0062B24F206BE00F667A3 /* ItemViewReuseManagerTests.swift */, - 939E694C2484B14400A8BCC7 /* LayoutItemTypeEnumeratorTests.swift */, - 93FA64EB248CDD3E00A8B7B1 /* MonthHelperTests.swift */, - 93FA64F1248D93EA00A8B7B1 /* MonthRowTests.swift */, - 939E69492484B14400A8BCC7 /* MonthTests.swift */, - 9325F43725C1054600E3BFB8 /* PaginationHelpersTests.swift */, - 939E694B2484B14400A8BCC7 /* ScreenPixelAlignmentTests.swift */, - 939E694E2484B14500A8BCC7 /* ScrollMetricsMutatorTests.swift */, - FD264F86294B260B00B13C97 /* SubviewsManagerTests.swift */, - 939E694D2484B14400A8BCC7 /* VisibleItemsProviderTests.swift */, - 9396F3D22483261B008AD306 /* Info.plist */, - 086333AE2AB8D34700CC6125 /* CalendarContentTests.swift */, - ); - path = Tests; - sourceTree = ""; - }; 9396F3E024832857008AD306 /* Public */ = { isa = PBXGroup; children = ( @@ -260,10 +217,12 @@ FD6E1D192991C50100B480A6 /* CalendarViewRepresentable.swift */, 939E693324837E8700A8BCC7 /* CalendarViewScrollPosition.swift */, 939E693F24846A8C00A8BCC7 /* Day.swift */, + AA92BD612D73A7FF0076E283 /* DayComponents.swift */, FD6E1CF5298D94DC00B480A6 /* DaysOfTheWeekRowSeparatorOptions.swift */, 939E694524847BA300A8BCC7 /* DayOfWeekPosition.swift */, 939E694124846EB400A8BCC7 /* DayRange.swift */, FD6E1CF9298D94DC00B480A6 /* DayRangeLayoutContext.swift */, + AA92BDA22D7418470076E283 /* DayRangeSelectionHelper.swift */, 939E693D24846A6600A8BCC7 /* Month.swift */, 939E693424837E8700A8BCC7 /* MonthsLayout.swift */, FD6E1CF7298D94DC00B480A6 /* MonthLayoutContext.swift */, @@ -341,22 +300,27 @@ productReference = 9396F3C22483261B008AD306 /* HorizonCalendar.framework */; productType = "com.apple.product-type.framework"; }; - 9396F3CA2483261B008AD306 /* HorizonCalendarTests */ = { + AA79A7F02D7508B600C07DD0 /* HorizonCalendarTests [Rec] */ = { isa = PBXNativeTarget; - buildConfigurationList = 9396F3D92483261B008AD306 /* Build configuration list for PBXNativeTarget "HorizonCalendarTests" */; + buildConfigurationList = AA79A7F82D7508B600C07DD0 /* Build configuration list for PBXNativeTarget "HorizonCalendarTests [Rec]" */; buildPhases = ( - 9396F3C72483261B008AD306 /* Sources */, - 9396F3C82483261B008AD306 /* Frameworks */, - 9396F3C92483261B008AD306 /* Resources */, + AA79A7ED2D7508B600C07DD0 /* Sources */, + AA79A7EE2D7508B600C07DD0 /* Frameworks */, + AA79A7EF2D7508B600C07DD0 /* Resources */, ); buildRules = ( ); dependencies = ( - 9396F3CE2483261B008AD306 /* PBXTargetDependency */, + AA79A7F72D7508B600C07DD0 /* PBXTargetDependency */, ); - name = HorizonCalendarTests; - productName = HorizonCalendarTests; - productReference = 9396F3CB2483261B008AD306 /* HorizonCalendarTests.xctest */; + fileSystemSynchronizedGroups = ( + AA79A7F22D7508B600C07DD0 /* HorizonCalendarTests [Rec] */, + ); + name = "HorizonCalendarTests [Rec]"; + packageProductDependencies = ( + ); + productName = "HorizonCalendarTests [Rec]"; + productReference = AA79A7F12D7508B600C07DD0 /* HorizonCalendarTests [Rec].xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; /* End PBXNativeTarget section */ @@ -365,7 +329,7 @@ 9396F3B92483261B008AD306 /* Project object */ = { isa = PBXProject; attributes = { - LastSwiftUpdateCheck = 1150; + LastSwiftUpdateCheck = 1620; LastUpgradeCheck = 1200; ORGANIZATIONNAME = Airbnb; TargetAttributes = { @@ -373,8 +337,8 @@ CreatedOnToolsVersion = 11.5; LastSwiftMigration = 1150; }; - 9396F3CA2483261B008AD306 = { - CreatedOnToolsVersion = 11.5; + AA79A7F02D7508B600C07DD0 = { + CreatedOnToolsVersion = 16.2; }; }; }; @@ -392,7 +356,7 @@ projectRoot = ""; targets = ( 9396F3C12483261B008AD306 /* HorizonCalendar */, - 9396F3CA2483261B008AD306 /* HorizonCalendarTests */, + AA79A7F02D7508B600C07DD0 /* HorizonCalendarTests [Rec] */, ); }; /* End PBXProject section */ @@ -410,7 +374,7 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 9396F3C92483261B008AD306 /* Resources */ = { + AA79A7EF2D7508B600C07DD0 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( @@ -433,6 +397,8 @@ 939E692724837E0300A8BCC7 /* VisibleItemsProvider.swift in Sources */, 939E693024837E0300A8BCC7 /* FrameProvider.swift in Sources */, 939E693724837E8700A8BCC7 /* CalendarItemModel.swift in Sources */, + 645FFEFE2D73AF53002EE721 /* WeekNumberView.swift in Sources */, + AA92BDA32D7418470076E283 /* DayRangeSelectionHelper.swift in Sources */, 938DA40F24BEEB1A008A3B47 /* CalendarItemViewRepresentable.swift in Sources */, 9321958026EEB6330001C7E9 /* Shape.swift in Sources */, 939E692924837E0300A8BCC7 /* VisibleItem.swift in Sources */, @@ -463,6 +429,7 @@ 939E692624837E0300A8BCC7 /* ScrollMetricsMutator.swift in Sources */, 939E694224846EB400A8BCC7 /* DayRange.swift in Sources */, 939E69442484784D00A8BCC7 /* Calendar+Helpers.swift in Sources */, + AA92BD622D73A7FF0076E283 /* DayComponents.swift in Sources */, FD05F0D52A1F1AB300E6ACB0 /* CalendarViewProxy.swift in Sources */, FD6E1CFC298D94DC00B480A6 /* MonthLayoutContext.swift in Sources */, 939E692824837E0300A8BCC7 /* ItemView.swift in Sources */, @@ -473,35 +440,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 9396F3C72483261B008AD306 /* Sources */ = { + AA79A7ED2D7508B600C07DD0 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 939E69582484B21400A8BCC7 /* FrameProviderTests.swift in Sources */, - 93FA64EC248CDD3E00A8B7B1 /* MonthHelperTests.swift in Sources */, - 93FA64F0248D84FE00A8B7B1 /* DayOfWeekPositionTests.swift in Sources */, - 939E695D2484B22A00A8BCC7 /* MonthTests.swift in Sources */, - 939E695A2484B21E00A8BCC7 /* ScreenPixelAlignmentTests.swift in Sources */, - 93FA64EE248D7B0100A8B7B1 /* DayHelperTests.swift in Sources */, - 939E695B2484B22200A8BCC7 /* ScrollMetricsMutatorTests.swift in Sources */, - 939E695C2484B22600A8BCC7 /* VisibleItemsProviderTests.swift in Sources */, - 93A0062C24F206BE00F667A3 /* ItemViewReuseManagerTests.swift in Sources */, - 086333B02AB8D36900CC6125 /* CalendarContentTests.swift in Sources */, - 939E69592484B21700A8BCC7 /* LayoutItemTypeEnumeratorTests.swift in Sources */, - 93FA64F2248D93EA00A8B7B1 /* MonthRowTests.swift in Sources */, - 932E24142558DF6E001648D2 /* HorizontalMonthsLayoutOptionsTests.swift in Sources */, - FD264F87294B260B00B13C97 /* SubviewsManagerTests.swift in Sources */, - 9325F43825C1054600E3BFB8 /* PaginationHelpersTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ - 9396F3CE2483261B008AD306 /* PBXTargetDependency */ = { + AA79A7F72D7508B600C07DD0 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 9396F3C12483261B008AD306 /* HorizonCalendar */; - targetProxy = 9396F3CD2483261B008AD306 /* PBXContainerItemProxy */; + targetProxy = AA79A7F62D7508B600C07DD0 /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ @@ -697,41 +649,44 @@ }; name = Release; }; - 9396F3DA2483261B008AD306 /* Debug */ = { + AA79A7F92D7508B600C07DD0 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = 5Q5SGQT2R4; - INFOPLIST_FILE = Tests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.airbnb.HorizonCalendarTests; + CURRENT_PROJECT_VERSION = 1; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.airbnb.HorizonCalendar.HorizonCalendarTests--Rec-"; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; - 9396F3DB2483261B008AD306 /* Release */ = { + AA79A7FA2D7508B600C07DD0 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = 5Q5SGQT2R4; - INFOPLIST_FILE = Tests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.airbnb.HorizonCalendarTests; + CURRENT_PROJECT_VERSION = 1; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.airbnb.HorizonCalendar.HorizonCalendarTests--Rec-"; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -758,11 +713,11 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 9396F3D92483261B008AD306 /* Build configuration list for PBXNativeTarget "HorizonCalendarTests" */ = { + AA79A7F82D7508B600C07DD0 /* Build configuration list for PBXNativeTarget "HorizonCalendarTests [Rec]" */ = { isa = XCConfigurationList; buildConfigurations = ( - 9396F3DA2483261B008AD306 /* Debug */, - 9396F3DB2483261B008AD306 /* Release */, + AA79A7F92D7508B600C07DD0 /* Debug */, + AA79A7FA2D7508B600C07DD0 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; diff --git a/HorizonCalendarTests [Rec]/CalendarContentTests.swift b/HorizonCalendarTests [Rec]/CalendarContentTests.swift new file mode 100644 index 00000000..44ce0165 --- /dev/null +++ b/HorizonCalendarTests [Rec]/CalendarContentTests.swift @@ -0,0 +1,99 @@ +// Created by Cal Stephens on 9/18/23. +// Copyright © 2023 Airbnb Inc. All rights reserved. + +import XCTest +@testable import HorizonCalendar + +// MARK: - CalendarContentTests + +final class CalendarContentTests: XCTestCase { + + func testCanReturnNilFromCalendarContentClosures() { + _ = CalendarViewContent( + visibleDateRange: Date.distantPast...Date.distantFuture, + monthsLayout: .vertical) + .monthHeaderItemProvider { _ in + nil + } + .dayOfWeekItemProvider { _, _ in + nil + } + .dayItemProvider { _ in + nil + } + .dayBackgroundItemProvider { _ in + nil + } + } + + func testNilDayItemUsesDefaultValue() { + let content = CalendarViewContent( + visibleDateRange: Date.distantPast...Date.distantFuture, + monthsLayout: .vertical) + + let day = Day(month: Month(era: 1, year: 2023, month: 1, isInGregorianCalendar: true), day: 1) + let defaultDayItem = content.dayItemProvider(day) + + let contentWithNilDayItem = content.dayItemProvider { _ in nil } + let updatedDayItem = contentWithNilDayItem.dayItemProvider(day) + + XCTAssert(defaultDayItem._isContentEqual(toContentOf: updatedDayItem)) + } + + func testNilDayOfWeekItemUsesDefaultValue() { + let content = CalendarViewContent( + visibleDateRange: Date.distantPast...Date.distantFuture, + monthsLayout: .vertical) + + let month = Month(era: 1, year: 2023, month: 1, isInGregorianCalendar: true) + let defaultDayOfWeekItem = content.dayOfWeekItemProvider(month, 1) + + let contentWithNilDayOfWeekItem = content.dayOfWeekItemProvider { _, _ in nil } + let updatedDayOfWeekItem = contentWithNilDayOfWeekItem.dayOfWeekItemProvider(month, 1) + + XCTAssert(defaultDayOfWeekItem._isContentEqual(toContentOf: updatedDayOfWeekItem)) + } + + func testNilMonthHeaderItemUsesDefaultValue() { + let content = CalendarViewContent( + visibleDateRange: Date.distantPast...Date.distantFuture, + monthsLayout: .vertical) + + let month = Month(era: 1, year: 2023, month: 1, isInGregorianCalendar: true) + let defaultMonthHeaderItem = content.monthHeaderItemProvider(month) + + let contentWithNilMonthHeaderItem = content.monthHeaderItemProvider { _ in nil } + let updatedMonthHeaderItem = contentWithNilMonthHeaderItem.monthHeaderItemProvider(month) + + XCTAssert(defaultMonthHeaderItem._isContentEqual(toContentOf: updatedMonthHeaderItem)) + } + +} + +// MARK: - CalendarContentConfigurable + +/// Test case demonstrating that `CalendarViewContent` and `CalendarViewRepresentable` both have the same APIs +/// and can be abstracted behind a single protocol +protocol CalendarContentConfigurable { + func monthHeaderItemProvider( + _ monthHeaderItemProvider: @escaping (_ month: Month) -> AnyCalendarItemModel?) + -> Self + + func dayOfWeekItemProvider( + _ dayOfWeekItemProvider: @escaping ( + _ month: Month?, + _ weekdayIndex: Int) + -> AnyCalendarItemModel?) + -> Self + + func dayItemProvider(_ dayItemProvider: @escaping (_ day: Day) -> AnyCalendarItemModel?) -> Self +} + +// MARK: - CalendarViewContent + CalendarContentConfigurable + +extension CalendarViewContent: CalendarContentConfigurable { } + +// MARK: - CalendarViewRepresentable + CalendarContentConfigurable + +@available(iOS 13.0, *) +extension CalendarViewRepresentable: CalendarContentConfigurable { } diff --git a/HorizonCalendarTests [Rec]/DayComponentsTests.swift b/HorizonCalendarTests [Rec]/DayComponentsTests.swift new file mode 100644 index 00000000..1f163faf --- /dev/null +++ b/HorizonCalendarTests [Rec]/DayComponentsTests.swift @@ -0,0 +1,62 @@ +// +// DayComponentTests.swift +// HorizonCalendarTests +// +// Created by Kyle Parker on 3/1/25. +// Copyright © 2025 Airbnb. All rights reserved. +// + +import Testing +import HorizonCalendar +import Foundation + +struct DayComponentTests { + + @available(iOS 13.0.0, *) + @Test func testGreaterThanOperatorSameMonthTrue() async throws { + let early = DayComponents(date: createDate(year: 2025, month: 01, day: 01)) + let late = DayComponents(date: createDate(year: 2025, month: 01, day: 02)) + + #expect(early < late) + } + + @available(iOS 13.0.0, *) + @Test func testGreaterThanOperatorSameMonthFalse() async throws { + let early = DayComponents(date: createDate(year: 2025, month: 01, day: 01)) + let late = DayComponents(date: createDate(year: 2025, month: 01, day: 02)) + + #expect((late < early) == false) + } + + @available(iOS 13.0.0, *) + @Test func testGreaterThanOperatorSameMonthEqualFalse() async throws { + let early = DayComponents(date: createDate(year: 2025, month: 01, day: 01)) + let late = DayComponents(date: createDate(year: 2025, month: 01, day: 01)) + + #expect((late < early) == false) + } + + @available(iOS 13.0.0, *) + @Test func testGreaterThanOperatorDifferentMonthsTrue() async throws { + let early = DayComponents(date: createDate(year: 2024, month: 12, day: 31)) + let late = DayComponents(date: createDate(year: 2025, month: 01, day: 01)) + + #expect(early < late) + } + + @available(iOS 13.0.0, *) + @Test func testGreaterThanOperatorDifferentMonthsFalse() async throws { + let early = DayComponents(date: createDate(year: 2024, month: 12, day: 31)) + let late = DayComponents(date: createDate(year: 2025, month: 01, day: 01)) + + #expect((late < early) == false) + } + + private func createDate(year: Int, month: Int, day: Int) -> Date { + var dateComponents = DateComponents() + dateComponents.year = year + dateComponents.month = month + dateComponents.day = day + return Calendar.current.date(from: dateComponents)! + } +} diff --git a/HorizonCalendarTests [Rec]/DayHelperTests.swift b/HorizonCalendarTests [Rec]/DayHelperTests.swift new file mode 100644 index 00000000..4bb0a4af --- /dev/null +++ b/HorizonCalendarTests [Rec]/DayHelperTests.swift @@ -0,0 +1,146 @@ +// Created by Bryan Keller on 6/7/20. +// Copyright © 2020 Airbnb Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import XCTest +@testable import HorizonCalendar + +// MARK: - DayHelperTests + +final class DayHelperTests: XCTestCase { + + // MARK: Internal + + func testDayComparable() { + let january2020Day = Day( + month: Month(era: 1, year: 2020, month: 01, isInGregorianCalendar: true), + day: 19) + let december2020Day = Day( + month: Month(era: 1, year: 2020, month: 12, isInGregorianCalendar: true), + day: 05) + XCTAssert(january2020Day < december2020Day, "Expected January 19, 2020 < December 5, 2020.") + + let june0006Day = Day( + month: Month(era: 0, year: 0006, month: 06, isInGregorianCalendar: true), + day: 10) + let january0005Day = Day( + month: Month(era: 1, year: 0005, month: 01, isInGregorianCalendar: true), + day: 09) + XCTAssert(june0006Day < january0005Day, "Expected June 10, 0006 BCE < January 9, 0005 CE.") + + let june30Day = Day( + month: Month(era: 235, year: 30, month: 06, isInGregorianCalendar: false), + day: 25) + let august01Day = Day( + month: Month(era: 236, year: 01, month: 08, isInGregorianCalendar: false), + day: 30) + XCTAssert(june30Day < august01Day, "Expected June 30, 30 era 235 < August 30, 02 era 236.") + } + + func testDayContainingDate() { + let january2020Date = gregorianCalendar.date( + from: DateComponents(year: 2020, month: 01, day: 19))! + let january2020Day = Day( + month: Month(era: 1, year: 2020, month: 01, isInGregorianCalendar: true), + day: 19) + XCTAssert( + gregorianCalendar.day(containing: january2020Date) == january2020Day, + "Expected the day to be January 19, 2020.") + + let december0005Date = gregorianCalendar.date( + from: DateComponents(era: 0, year: 0005, month: 12, day: 08))! + let december0005Day = Day( + month: Month(era: 0, year: 0005, month: 12, isInGregorianCalendar: true), + day: 08) + XCTAssert( + gregorianCalendar.day(containing: december0005Date) == december0005Day, + "Expected the day to be December 8, 0005.") + + let september02Date = japaneseCalendar.date( + from: DateComponents(era: 236, year: 02, month: 09, day: 21))! + let september02Day = Day( + month: Month(era: 236, year: 02, month: 09, isInGregorianCalendar: false), + day: 21) + XCTAssert( + japaneseCalendar.day(containing: september02Date) == september02Day, + "Expected the day to be September 21, 02.") + } + + func testStartDateOfDay() { + let november2020Day = Day( + month: Month(era: 1, year: 2020, month: 11, isInGregorianCalendar: true), + day: 17) + let november2020Date = gregorianCalendar.date( + from: DateComponents(year: 2020, month: 11, day: 17))! + XCTAssert( + gregorianCalendar.startDate(of: november2020Day) == november2020Date, + "Expected the date to be the earliest possible time for November 17, 2020.") + + let january0100Day = Day( + month: Month(era: 0, year: 0100, month: 01, isInGregorianCalendar: true), + day: 14) + let january0100Date = gregorianCalendar.date( + from: DateComponents(era: 0, year: 0100, month: 01, day: 14))! + XCTAssert( + gregorianCalendar.startDate(of: january0100Day) == january0100Date, + "Expected the date to be the earliest possible time for January 14, 0100 BCE.") + + let june02Day = Day( + month: Month(era: 236, year: 02, month: 06, isInGregorianCalendar: false), + day: 11) + let june02Date = japaneseCalendar.date( + from: DateComponents(era: 236, year: 02, month: 06, day: 11))! + XCTAssert( + japaneseCalendar.startDate(of: june02Day) == june02Date, + "Expected the date to be the earliest possible time for June 11, 02.") + } + + func testDayByAddingDays() { + let january2021Day = Day( + month: Month(era: 1, year: 2021, month: 01, isInGregorianCalendar: true), + day: 19) + let june2020Day = Day( + month: Month(era: 1, year: 2020, month: 11, isInGregorianCalendar: true), + day: 16) + XCTAssert( + gregorianCalendar.day(byAddingDays: -64, to: january2021Day) == june2020Day, + "Expected January 19, 2021 - 100 = June 16, 2020.") + + let april0069Day = Day( + month: Month(era: 0, year: 0069, month: 04, isInGregorianCalendar: true), + day: 20) + let may0069Day = Day( + month: Month(era: 0, year: 0069, month: 05, isInGregorianCalendar: true), + day: 01) + XCTAssert( + gregorianCalendar.day(byAddingDays: 11, to: april0069Day) == may0069Day, + "Expected April 20, 0069 BCE + 12 = May 01, 0069 BCE.") + + let january02Day = Day( + month: Month(era: 236, year: 02, month: 01, isInGregorianCalendar: false), + day: 01) + let december01Day = Day( + month: Month(era: 236, year: 01, month: 12, isInGregorianCalendar: false), + day: 31) + XCTAssert( + japaneseCalendar.day(byAddingDays: -1, to: january02Day) == december01Day, + "Expected January 1, 02 - 1 = December 31, 01.") + } + + // MARK: Private + + private lazy var gregorianCalendar = Calendar(identifier: .gregorian) + private lazy var japaneseCalendar = Calendar(identifier: .japanese) + +} diff --git a/HorizonCalendarTests [Rec]/DayOfWeekPositionTests.swift b/HorizonCalendarTests [Rec]/DayOfWeekPositionTests.swift new file mode 100644 index 00000000..7d116bcf --- /dev/null +++ b/HorizonCalendarTests [Rec]/DayOfWeekPositionTests.swift @@ -0,0 +1,101 @@ +// Created by Bryan Keller on 6/7/20. +// Copyright © 2020 Airbnb Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import XCTest +@testable import HorizonCalendar + +// MARK: - DayOfWeekPositionTests + +final class DayOfWeekPositionTests: XCTestCase { + + // MARK: Internal + + func testWeekdayIndex() throws { + let allPositions = DayOfWeekPosition.allCases + for (position, expectedWeekdayIndex) in zip(allPositions, 0...6) { + XCTAssert( + gregorianCalendar.weekdayIndex(for: position) == expectedWeekdayIndex, + "Expected \(position) of the week to have weekday index = \(expectedWeekdayIndex).") + } + + for (position, expectedWeekdayIndex) in zip(allPositions, [1, 2, 3, 4, 5, 6, 0]) { + XCTAssert( + gregorianUKCalendar.weekdayIndex(for: position) == expectedWeekdayIndex, + "Expected \(position) of the week to have weekday index = \(expectedWeekdayIndex).") + } + } + + func testDayOfWeekComparable() { + for dayOfWeekPositionRawValue in DayOfWeekPosition.allCases.map({ $0.rawValue }) { + if dayOfWeekPositionRawValue > 1 { + let dayOfWeekPosition = DayOfWeekPosition(rawValue: dayOfWeekPositionRawValue)! + let previousDayOfWeekPosition = DayOfWeekPosition(rawValue: dayOfWeekPositionRawValue - 1)! + XCTAssert( + previousDayOfWeekPosition < dayOfWeekPosition, + "Expected \(previousDayOfWeekPosition) < \(dayOfWeekPosition).") + } + } + } + + func testDayOfWeekPositionForDate() { + let date0 = gregorianCalendar.date(from: DateComponents(year: 2020, month: 01, day: 19))! + XCTAssert( + gregorianCalendar.dayOfWeekPosition(for: date0) == .first, + "Expected January 19, 2020 to fall on the first day of the week.") + + let date1 = gregorianCalendar.date(from: DateComponents(year: 2015, month: 05, day: 01))! + XCTAssert( + gregorianCalendar.dayOfWeekPosition(for: date1) == .sixth, + "Expected May 1, 2015 to fall on the sixth day of the week.") + + let date2 = japaneseCalendar.date(from: DateComponents(era: 236, year: 01, month: 12, day: 25))! + XCTAssert( + japaneseCalendar.dayOfWeekPosition(for: date2) == .fourth, + "Expected December 25, 01 era 236 to fall on the fourth day of the week.") + + let date3 = japaneseCalendar.date(from: DateComponents(era: 235, year: 30, month: 07, day: 10))! + XCTAssert( + japaneseCalendar.dayOfWeekPosition(for: date3) == .third, + "Expected July 10, 30 era 235 to fall on the third day of the week.") + + let date4 = gregorianCalendar.date(from: DateComponents(year: 2100, month: 04, day: 22))! + XCTAssert( + gregorianCalendar.dayOfWeekPosition(for: date4) == .fifth, + "Expected April 22, 2100 to fall on the fifth day of the week.") + + let date5 = gregorianCalendar.date( + from: DateComponents(era: 0, year: 0018, month: 01, day: 03))! + XCTAssert( + gregorianCalendar.dayOfWeekPosition(for: date5) == .last, + "Expected March 1, 0016 BCE to fall on the last day of the week.") + + let date6 = gregorianCalendar.date( + from: DateComponents(era: 0, year: 1492, month: 09, day: 22))! + XCTAssert( + gregorianCalendar.dayOfWeekPosition(for: date6) == .second, + "Expected September 19, 1492 to fall on the second day of the week.") + } + + // MARK: Private + + private lazy var gregorianCalendar = Calendar(identifier: .gregorian) + private lazy var japaneseCalendar = Calendar(identifier: .japanese) + private lazy var gregorianUKCalendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.locale = Locale(identifier: "en_GB") + return calendar + }() + +} diff --git a/HorizonCalendarTests [Rec]/FrameProviderTests.swift b/HorizonCalendarTests [Rec]/FrameProviderTests.swift new file mode 100644 index 00000000..96869554 --- /dev/null +++ b/HorizonCalendarTests [Rec]/FrameProviderTests.swift @@ -0,0 +1,860 @@ +// Created by Bryan Keller on 3/31/20. +// Copyright © 2020 Airbnb Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import XCTest +@testable import HorizonCalendar + +// MARK: - FrameProviderTests + +final class FrameProviderTests: XCTestCase { + + // MARK: Internal + + override func setUp() { + let size = CGSize(width: 320, height: 480) + + let lowerBoundDate = calendar.date(from: DateComponents(year: 2020, month: 05, day: 15))! + let upperBoundDate = calendar.date(from: DateComponents(year: 2020, month: 07, day: 20))! + + verticalFrameProvider = FrameProvider( + content: CalendarViewContent( + calendar: calendar, + visibleDateRange: Date.distantPast...Date.distantFuture, + monthsLayout: .vertical(options: VerticalMonthsLayoutOptions())) + .monthDayInsets(NSDirectionalEdgeInsets(top: 5, leading: 8, bottom: 5, trailing: 8)) + .interMonthSpacing(20) + .verticalDayMargin(20) + .horizontalDayMargin(10), + size: size, + layoutMargins: .zero, + scale: 3) + verticalPinnedDaysOfWeekFrameProvider = FrameProvider( + content: CalendarViewContent( + calendar: calendar, + visibleDateRange: Date.distantPast...Date.distantFuture, + monthsLayout: .vertical(options: VerticalMonthsLayoutOptions(pinDaysOfWeekToTop: true))) + .monthDayInsets(NSDirectionalEdgeInsets(top: 5, leading: 8, bottom: 5, trailing: 8)) + .interMonthSpacing(20) + .verticalDayMargin(20) + .horizontalDayMargin(10), + size: size, + layoutMargins: .zero, + scale: 3) + verticalPartialMonthFrameProvider = FrameProvider( + content: CalendarViewContent( + calendar: calendar, + visibleDateRange: lowerBoundDate...upperBoundDate, + monthsLayout: .vertical( + options: VerticalMonthsLayoutOptions(alwaysShowCompleteBoundaryMonths: false))) + .monthDayInsets(NSDirectionalEdgeInsets(top: 5, leading: 8, bottom: 5, trailing: 8)) + .interMonthSpacing(20) + .verticalDayMargin(20) + .horizontalDayMargin(10), + size: size, + layoutMargins: .zero, + scale: 3) + horizontalFrameProvider = FrameProvider( + content: CalendarViewContent( + calendar: calendar, + visibleDateRange: Date.distantPast...Date.distantFuture, + monthsLayout: .horizontal( + options: HorizontalMonthsLayoutOptions(maximumFullyVisibleMonths: 1))) + .monthDayInsets(NSDirectionalEdgeInsets(top: 5, leading: 8, bottom: 5, trailing: 8)) + .interMonthSpacing(20) + .verticalDayMargin(20) + .horizontalDayMargin(10), + size: size, + layoutMargins: .zero, + scale: 3) + rectangularDayFrameProvider = FrameProvider( + content: CalendarViewContent( + calendar: calendar, + visibleDateRange: Date.distantPast...Date.distantFuture, + monthsLayout: .vertical(options: VerticalMonthsLayoutOptions())) + .monthDayInsets(NSDirectionalEdgeInsets(top: 5, leading: 8, bottom: 5, trailing: 8)) + .dayAspectRatio(1.5) + .dayOfWeekAspectRatio(1.5) + .interMonthSpacing(20) + .verticalDayMargin(20) + .horizontalDayMargin(10), + size: size, + layoutMargins: .zero, + scale: 3) + } + + func testMaxMonthHeight() { + let maxHeight1 = verticalFrameProvider.maxMonthHeight(monthHeaderHeight: 50) + .alignedToPixel(forScreenWithScale: 3) + let expectedMaxHeight1 = CGFloat(424).alignedToPixel(forScreenWithScale: 3) + XCTAssert(maxHeight1 == expectedMaxHeight1, "Incorrect max month height.") + + let maxHeight2 = verticalPinnedDaysOfWeekFrameProvider.maxMonthHeight(monthHeaderHeight: 50) + .alignedToPixel(forScreenWithScale: 3) + let expectedMaxHeight2 = CGFloat(369.1428571428571).alignedToPixel(forScreenWithScale: 3) + XCTAssert(maxHeight2 == expectedMaxHeight2, "Incorrect max month height.") + + let maxHeight3 = verticalPartialMonthFrameProvider.maxMonthHeight(monthHeaderHeight: 50) + .alignedToPixel(forScreenWithScale: 3) + let expectedMaxHeight3 = CGFloat(424).alignedToPixel(forScreenWithScale: 3) + XCTAssert(maxHeight3 == expectedMaxHeight3, "Incorrect max month height.") + + let maxHeight4 = horizontalFrameProvider.maxMonthHeight(monthHeaderHeight: 50) + .alignedToPixel(forScreenWithScale: 3) + let expectedMaxHeight4 = CGFloat(404.0).alignedToPixel(forScreenWithScale: 3) + XCTAssert(maxHeight4 == expectedMaxHeight4, "Incorrect max month height.") + } + + func testDaySize() { + let size1 = verticalFrameProvider.daySize.alignedToPixels(forScreenWithScale: 3) + let expectedSize1 = CGSize(width: 34.857142857142854, height: 34.857142857142854) + .alignedToPixels(forScreenWithScale: 3) + XCTAssert(size1 == expectedSize1, "Incorrect day size.") + + let size2 = verticalPinnedDaysOfWeekFrameProvider.daySize.alignedToPixels(forScreenWithScale: 3) + let expectedSize2 = CGSize(width: 34.857142857142854, height: 34.857142857142854) + .alignedToPixels(forScreenWithScale: 3) + XCTAssert(size2 == expectedSize2, "Incorrect day size.") + + let size3 = horizontalFrameProvider.daySize.alignedToPixels(forScreenWithScale: 3) + let expectedSize3 = CGSize(width: 32, height: 32).alignedToPixels(forScreenWithScale: 3) + XCTAssert(size3 == expectedSize3, "Incorrect day size.") + + let size4 = rectangularDayFrameProvider.daySize.alignedToPixels(forScreenWithScale: 3) + let expectedSize4 = CGSize(width: 34.857142857142854, height: 52.28571428571428) + .alignedToPixels(forScreenWithScale: 3) + XCTAssert(size4 == expectedSize4, "Incorrect day size.") + } + + // MARK: Test initial month calculations + + func testInitialMonthHeaderFrame() { + let frame1 = verticalFrameProvider.frameOfMonthHeader( + inMonthWithOrigin: CGPoint(x: 0, y: 100), + monthHeaderHeight: 50) + .alignedToPixels(forScreenWithScale: 3) + let expectedFrame1 = CGRect(x: 0, y: 100, width: 320, height: 50) + .alignedToPixels(forScreenWithScale: 3) + XCTAssert(frame1 == expectedFrame1, "Incorrect initial month header frame.") + + let frame2 = verticalPinnedDaysOfWeekFrameProvider.frameOfMonthHeader( + inMonthWithOrigin: CGPoint(x: 0, y: 100), + monthHeaderHeight: 50) + .alignedToPixels(forScreenWithScale: 3) + let expectedFrame2 = CGRect(x: 0, y: 100, width: 320, height: 50) + .alignedToPixels(forScreenWithScale: 3) + XCTAssert(frame2 == expectedFrame2, "Incorrect initial month header frame.") + + let frame3 = horizontalFrameProvider.frameOfMonthHeader( + inMonthWithOrigin: CGPoint(x: 100, y: 0), + monthHeaderHeight: 50) + .alignedToPixels(forScreenWithScale: 3) + let expectedFrame3 = CGRect(x: 100, y: 0, width: 300, height: 50) + XCTAssert(frame3 == expectedFrame3, "Incorrect initial month header frame.") + } + + func testMonthOriginContainingItem() { + let expectedOrigins1 = [ + CGPoint(x: 0, y: 100).alignedToPixels(forScreenWithScale: 3), + CGPoint(x: 0, y: 100).alignedToPixels(forScreenWithScale: 3), + CGPoint(x: 0, y: 100).alignedToPixels(forScreenWithScale: 3), + CGPoint(x: 0, y: 100).alignedToPixels(forScreenWithScale: 3), + ] + let expectedOrigins2 = [ + CGPoint(x: -12.571428571428555, y: 145).alignedToPixels(forScreenWithScale: 3), + CGPoint(x: -12.571428571428555, y: 145).alignedToPixels(forScreenWithScale: 3), + CGPoint(x: -12.571428571428555, y: 145).alignedToPixels(forScreenWithScale: 3), + CGPoint(x: -4, y: 145).alignedToPixels(forScreenWithScale: 3), + ] + let expectedOrigins3 = [ + CGPoint(x: 17.666666666666668, y: 70.66666666666667).alignedToPixels(forScreenWithScale: 3), + CGPoint(x: 17.666666666666668, y: 125.66666666666667).alignedToPixels(forScreenWithScale: 3), + CGPoint(x: 17.666666666666668, y: 180.33333333333334).alignedToPixels(forScreenWithScale: 3), + CGPoint(x: 32, y: 85).alignedToPixels(forScreenWithScale: 3), + ] + + let expectedOrigins4 = [ + CGPoint(x: 147.14285714285714, y: 25.571428571428584).alignedToPixels(forScreenWithScale: 3), + CGPoint(x: 147.14285714285714, y: 80.42857142857144).alignedToPixels(forScreenWithScale: 3), + CGPoint(x: 147.14285714285714, y: 135.28571428571428).alignedToPixels(forScreenWithScale: 3), + CGPoint(x: 150, y: 37).alignedToPixels(forScreenWithScale: 3), + ] + + let allFrameProviders: [FrameProvider] = [ + verticalFrameProvider, + verticalPinnedDaysOfWeekFrameProvider, + verticalPartialMonthFrameProvider, + horizontalFrameProvider, + ] + + for (index, frameProvider) in zip(allFrameProviders.indices, allFrameProviders) { + let monthHeaderLayoutItem = LayoutItem( + itemType: .monthHeader(Month(era: 1, year: 2020, month: 01, isInGregorianCalendar: true)), + frame: CGRect(x: 0, y: 100, width: 320, height: 50)) + let origin1 = frameProvider.originOfMonth( + containing: monthHeaderLayoutItem, + monthHeaderHeight: 50) + .alignedToPixels(forScreenWithScale: 3) + XCTAssert( + origin1 == expectedOrigins1[index], + "Incorrect month origin containing month header.") + + let dayOfWeekLayoutItem = LayoutItem( + itemType: .dayOfWeekInMonth( + position: .fourth, + month: Month(era: 1, year: 2020, month: 01, isInGregorianCalendar: true)), + frame: CGRect(origin: CGPoint(x: 130, y: 200), size: frameProvider.daySize)) + let origin2 = frameProvider.originOfMonth( + containing: dayOfWeekLayoutItem, + monthHeaderHeight: 50) + .alignedToPixels(forScreenWithScale: 3) + XCTAssert( + origin2 == expectedOrigins2[index], + "Incorrect month origin containing day of week.") + + let dayLayoutItem1 = LayoutItem( + itemType: .day( + Day(month: Month(era: 1, year: 2020, month: 05, isInGregorianCalendar: true), day: 29)), + frame: CGRect(origin: CGPoint(x: 250, y: 400), size: frameProvider.daySize)) + let origin3 = frameProvider.originOfMonth( + containing: dayLayoutItem1, + monthHeaderHeight: 50) + .alignedToPixels(forScreenWithScale: 3) + XCTAssert(origin3 == expectedOrigins3[index], "Incorrect month origin containing day.") + + let dayLayoutItem2 = LayoutItem( + itemType: .day( + Day(month: Month(era: 1, year: 2020, month: 05, isInGregorianCalendar: true), day: 18)), + frame: CGRect(origin: CGPoint(x: 200, y: 300), size: frameProvider.daySize)) + let origin4 = frameProvider.originOfMonth( + containing: dayLayoutItem2, + monthHeaderHeight: 50) + .alignedToPixels(forScreenWithScale: 3) + XCTAssert(origin4 == expectedOrigins4[index], "Incorrect month origin containing day.") + } + } + + func testPrecedingMonthOrigin() { + let origin1 = verticalFrameProvider.originOfMonth( + Month(era: 1, year: 2020, month: 01, isInGregorianCalendar: true), + beforeMonthWithOrigin: CGPoint(x: 0, y: 200), + subsequentMonthHeaderHeight: 50, + monthHeaderHeight: 50) + .alignedToPixels(forScreenWithScale: 3) + let expectedOrigin1 = CGPoint(x: 0, y: -189.1428571428571) + .alignedToPixels(forScreenWithScale: 3) + XCTAssert(origin1 == expectedOrigin1, "Incorrect origin for preceding month.") + + let origin2 = verticalPinnedDaysOfWeekFrameProvider.originOfMonth( + Month(era: 1, year: 2020, month: 01, isInGregorianCalendar: true), + beforeMonthWithOrigin: CGPoint(x: 0, y: 400), + subsequentMonthHeaderHeight: 50, + monthHeaderHeight: 50) + .alignedToPixels(forScreenWithScale: 3) + let expectedOrigin2 = CGPoint(x: 0, y: 65.71428571428572).alignedToPixels(forScreenWithScale: 3) + XCTAssert(origin2 == expectedOrigin2, "Incorrect origin for preceding month.") + + let origin3 = verticalPartialMonthFrameProvider.originOfMonth( + Month(era: 1, year: 2020, month: 05, isInGregorianCalendar: true), + beforeMonthWithOrigin: CGPoint(x: 0, y: 200), + subsequentMonthHeaderHeight: 50, + monthHeaderHeight: 50) + .alignedToPixels(forScreenWithScale: 3) + let expectedOrigin3 = CGPoint(x: 0, y: -134.28571428571428) + .alignedToPixels(forScreenWithScale: 3) + XCTAssert(origin3 == expectedOrigin3, "Incorrect origin for preceding month.") + + let origin4 = horizontalFrameProvider.originOfMonth( + Month(era: 1, year: 2020, month: 01, isInGregorianCalendar: true), + beforeMonthWithOrigin: CGPoint(x: 200, y: 0), + subsequentMonthHeaderHeight: 50, + monthHeaderHeight: 50) + .alignedToPixels(forScreenWithScale: 3) + let expectedOrigin4 = CGPoint(x: -120, y: 0).alignedToPixels(forScreenWithScale: 3) + XCTAssert(origin4 == expectedOrigin4, "Incorrect origin for preceding month.") + } + + func testSucceedingMonthOrigin() { + let origin1 = verticalFrameProvider.originOfMonth( + Month(era: 1, year: 2020, month: 01, isInGregorianCalendar: true), + afterMonthWithOrigin: CGPoint(x: 0, y: 200), + previousMonthHeaderHeight: 50, + monthHeaderHeight: 50) + .alignedToPixels(forScreenWithScale: 3) + let expectedOrigin1 = CGPoint(x: 0, y: 589.1428571428571) + .alignedToPixels(forScreenWithScale: 3) + XCTAssert(origin1 == expectedOrigin1, "Incorrect origin for succeeding month.") + + let origin2 = verticalPinnedDaysOfWeekFrameProvider.originOfMonth( + Month(era: 1, year: 2020, month: 01, isInGregorianCalendar: true), + afterMonthWithOrigin: CGPoint(x: 0, y: 400), + previousMonthHeaderHeight: 50, + monthHeaderHeight: 50) + .alignedToPixels(forScreenWithScale: 3) + let expectedOrigin2 = CGPoint(x: 0, y: 734.2857142857142).alignedToPixels(forScreenWithScale: 3) + XCTAssert(origin2 == expectedOrigin2, "Incorrect origin for succeeding month.") + + let origin3 = verticalPartialMonthFrameProvider.originOfMonth( + Month(era: 1, year: 2020, month: 06, isInGregorianCalendar: true), + afterMonthWithOrigin: CGPoint(x: 0, y: 400), + previousMonthHeaderHeight: 50, + monthHeaderHeight: 50) + .alignedToPixels(forScreenWithScale: 3) + let expectedOrigin3 = CGPoint(x: 0, y: 734.2857142857142).alignedToPixels(forScreenWithScale: 3) + XCTAssert(origin3 == expectedOrigin3, "Incorrect origin for succeeding month.") + + let origin4 = horizontalFrameProvider.originOfMonth( + Month(era: 1, year: 2020, month: 01, isInGregorianCalendar: true), + afterMonthWithOrigin: CGPoint(x: 200, y: 0), + previousMonthHeaderHeight: 50, + monthHeaderHeight: 50) + .alignedToPixels(forScreenWithScale: 3) + let expectedOrigin4 = CGPoint(x: 520, y: 0).alignedToPixels(forScreenWithScale: 3) + XCTAssert(origin4 == expectedOrigin4, "Incorrect origin for succeeding month.") + } + + func testFrameOfMonth() { + let frame1 = verticalFrameProvider.frameOfMonth( + Month(era: 1, year: 2020, month: 04, isInGregorianCalendar: true), + withOrigin: CGPoint(x: 0, y: 200), + monthHeaderHeight: 50) + .alignedToPixels(forScreenWithScale: 3) + let expectedFrame1 = CGRect(x: 0.0, y: 200.0, width: 320.0, height: 369.1428571428571) + .alignedToPixels(forScreenWithScale: 3) + XCTAssert(frame1 == expectedFrame1, "Incorrect frame for month.") + + let frame2 = verticalPinnedDaysOfWeekFrameProvider.frameOfMonth( + Month(era: 1, year: 2020, month: 04, isInGregorianCalendar: true), + withOrigin: CGPoint(x: 0, y: 200), + monthHeaderHeight: 50) + .alignedToPixels(forScreenWithScale: 3) + let expectedFrame2 = CGRect(x: 0.0, y: 200.0, width: 320.0, height: 314.2857142857143) + .alignedToPixels(forScreenWithScale: 3) + XCTAssert(frame2 == expectedFrame2, "Incorrect frame for month.") + + let frame3 = verticalPartialMonthFrameProvider.frameOfMonth( + Month(era: 1, year: 2020, month: 07, isInGregorianCalendar: true), + withOrigin: CGPoint(x: 0, y: 200), + monthHeaderHeight: 50) + .alignedToPixels(forScreenWithScale: 3) + let expectedFrame3 = CGRect(x: 0.0, y: 200.0, width: 320.0, height: 314.2857142857143) + .alignedToPixels(forScreenWithScale: 3) + XCTAssert(frame3 == expectedFrame3, "Incorrect frame for month.") + + let frame4 = horizontalFrameProvider.frameOfMonth( + Month(era: 1, year: 2020, month: 04, isInGregorianCalendar: true), + withOrigin: CGPoint(x: 500, y: 0), + monthHeaderHeight: 50) + .alignedToPixels(forScreenWithScale: 3) + let expectedFrame4 = CGRect(x: 500.0, y: 0.0, width: 300.0, height: 352.0) + .alignedToPixels(forScreenWithScale: 3) + XCTAssert(frame4 == expectedFrame4, "Incorrect frame for month.") + } + + // MARK: Core item frame calculations + + func testMonthHeaderFrame() { + let frame1 = verticalFrameProvider.frameOfMonthHeader( + inMonthWithOrigin: CGPoint(x: 0, y: 300), + monthHeaderHeight: 50) + .alignedToPixels(forScreenWithScale: 3) + let expectedFrame1 = CGRect(x: 0, y: 300, width: 320, height: 50) + .alignedToPixels(forScreenWithScale: 3) + XCTAssert(frame1 == expectedFrame1, "Incorrect frame for month header.") + + let frame2 = verticalPinnedDaysOfWeekFrameProvider.frameOfMonthHeader( + inMonthWithOrigin: CGPoint(x: 0, y: 300), + monthHeaderHeight: 50) + .alignedToPixels(forScreenWithScale: 3) + let expectedFrame2 = CGRect(x: 0, y: 300, width: 320, height: 50) + .alignedToPixels(forScreenWithScale: 3) + XCTAssert(frame2 == expectedFrame2, "Incorrect frame for month header.") + + let frame3 = horizontalFrameProvider.frameOfMonthHeader( + inMonthWithOrigin: CGPoint(x: 300, y: 0), + monthHeaderHeight: 50) + .alignedToPixels(forScreenWithScale: 3) + let expectedFrame3 = CGRect(x: 300, y: 0, width: 300, height: 50) + .alignedToPixels(forScreenWithScale: 3) + XCTAssert(frame3 == expectedFrame3, "Incorrect frame for month header.") + } + + func testDayOfWeekFrame() { + let frame1 = verticalFrameProvider.frameOfDayOfWeek( + at: .fifth, + inMonthWithOrigin: CGPoint(x: 0, y: 200), + monthHeaderHeight: 50) + .alignedToPixels(forScreenWithScale: 3) + let expectedFrame1 = CGRect( + x: 187.42857142857142, + y: 255, + width: 34.857142857142854, + height: 34.857142857142854) + .alignedToPixels(forScreenWithScale: 3) + XCTAssert(frame1 == expectedFrame1, "Incorrect frame for day of week.") + + let frame2 = verticalPinnedDaysOfWeekFrameProvider.frameOfDayOfWeek( + at: .first, + inMonthWithOrigin: CGPoint(x: 0, y: 200), + monthHeaderHeight: 50) + .alignedToPixels(forScreenWithScale: 3) + let expectedFrame2 = CGRect(x: 8, y: 255, width: 34.857142857142854, height: 34.857142857142854) + .alignedToPixels(forScreenWithScale: 3) + XCTAssert(frame2 == expectedFrame2, "Incorrect frame for day of week.") + + let frame3 = horizontalFrameProvider.frameOfDayOfWeek( + at: .last, + inMonthWithOrigin: CGPoint(x: 200, y: 0), + monthHeaderHeight: 50) + .alignedToPixels(forScreenWithScale: 3) + let expectedFrame3 = CGRect(x: 460, y: 55, width: 32, height: 32) + .alignedToPixels(forScreenWithScale: 3) + XCTAssert(frame3 == expectedFrame3, "Incorrect frame for day of week.") + + let frame4 = rectangularDayFrameProvider.frameOfDayOfWeek( + at: .fifth, + inMonthWithOrigin: CGPoint(x: 0, y: 200), + monthHeaderHeight: 50) + .alignedToPixels(forScreenWithScale: 3) + let expectedFrame4 = CGRect( + x: 187.42857142857142, + y: 255, + width: 34.857142857142854, + height: 52.28571428571428) + .alignedToPixels(forScreenWithScale: 3) + XCTAssert(frame4 == expectedFrame4, "Incorrect frame for day of week.") + } + + func testDayFrameInMonth() { + let frame1 = verticalFrameProvider.frameOfDay( + Day(month: Month(era: 1, year: 2020, month: 04, isInGregorianCalendar: true), day: 20), + inMonthWithOrigin: CGPoint(x: 0, y: 69), + monthHeaderHeight: 50) + .alignedToPixels(forScreenWithScale: 3) + let expectedFrame1 = CGRect( + x: 52.857142857142854, + y: 343.42857142857144, + width: 34.857142857142854, + height: 34.857142857142854) + .alignedToPixels(forScreenWithScale: 3) + XCTAssert(frame1 == expectedFrame1, "Incorrect frame for day.") + + let frame2 = verticalPinnedDaysOfWeekFrameProvider.frameOfDay( + Day(month: Month(era: 1, year: 1994, month: 01, isInGregorianCalendar: true), day: 01), + inMonthWithOrigin: CGPoint(x: 0, y: 130), + monthHeaderHeight: 50) + .alignedToPixels(forScreenWithScale: 3) + let expectedFrame2 = CGRect( + x: 277.1428571428571, + y: 185, + width: 34.857142857142854, + height: 34.857142857142854) + .alignedToPixels(forScreenWithScale: 3) + XCTAssert(frame2 == expectedFrame2, "Incorrect frame for day of week.") + + let frame3 = verticalPartialMonthFrameProvider.frameOfDay( + Day(month: Month(era: 1, year: 2020, month: 05, isInGregorianCalendar: true), day: 29), + inMonthWithOrigin: CGPoint(x: 0, y: 69), + monthHeaderHeight: 50) + .alignedToPixels(forScreenWithScale: 3) + let expectedFrame3 = CGRect( + x: 232.28571428571428, + y: 288.57142857142856, + width: 34.857142857142854, + height: 34.857142857142854) + .alignedToPixels(forScreenWithScale: 3) + XCTAssert(frame3 == expectedFrame3, "Incorrect frame for day.") + + let frame4 = horizontalFrameProvider.frameOfDay( + Day(month: Month(era: 1, year: 2072, month: 06, isInGregorianCalendar: true), day: 30), + inMonthWithOrigin: CGPoint(x: 300, y: 0), + monthHeaderHeight: 50) + .alignedToPixels(forScreenWithScale: 3) + let expectedFrame4 = CGRect(x: 476, y: 315, width: 32, height: 32) + .alignedToPixels(forScreenWithScale: 3) + XCTAssert(frame4 == expectedFrame4, "Incorrect frame for day of week.") + + let frame5 = rectangularDayFrameProvider.frameOfDay( + Day(month: Month(era: 1, year: 2020, month: 04, isInGregorianCalendar: true), day: 20), + inMonthWithOrigin: CGPoint(x: 0, y: 69), + monthHeaderHeight: 50) + .alignedToPixels(forScreenWithScale: 3) + let expectedFrame5 = CGRect( + x: 52.857142857142854, + y: 413.1428571428571, + width: 34.857142857142854, + height: 52.28571428571428) + .alignedToPixels(forScreenWithScale: 3) + XCTAssert(frame5 == expectedFrame5, "Incorrect frame for day.") + } + + func testAdjacentDayFrame() { + let middleDay = Day( + month: Month(era: 1, year: 2020, month: 04, isInGregorianCalendar: true), + day: 22) + let middleDayMonthOrigin = CGPoint(x: 0, y: 100) + let middleDayFrame = verticalFrameProvider.frameOfDay( + middleDay, + inMonthWithOrigin: middleDayMonthOrigin, + monthHeaderHeight: 50) + .alignedToPixels(forScreenWithScale: 3) + let frameBeforeMiddleDay = verticalFrameProvider.frameOfDay( + calendar.day(byAddingDays: -1, to: middleDay), + adjacentTo: middleDay, + withFrame: middleDayFrame, + inMonthWithOrigin: middleDayMonthOrigin) + .alignedToPixels(forScreenWithScale: 3) + let frameAfterMiddleDay = verticalFrameProvider.frameOfDay( + calendar.day(byAddingDays: 1, to: middleDay), + adjacentTo: middleDay, + withFrame: middleDayFrame, + inMonthWithOrigin: middleDayMonthOrigin) + .alignedToPixels(forScreenWithScale: 3) + let expectedFrameBeforeMiddleDay = CGRect( + x: 97.8095238095238, + y: 374.3333333333333, + width: 34.857142857142854, + height: 34.857142857142854) + .alignedToPixels(forScreenWithScale: 3) + let expectedFrameAfterMiddleDay = CGRect( + x: 187.66666666666666, + y: 374.3333333333333, + width: 35, + height: 35) + .alignedToPixels(forScreenWithScale: 3) + XCTAssert(frameBeforeMiddleDay == expectedFrameBeforeMiddleDay, "Incorrect frame for day.") + XCTAssert(frameAfterMiddleDay == expectedFrameAfterMiddleDay, "Incorrect frame for day.") + + let leftDay = Day( + month: Month(era: 1, year: 2020, month: 04, isInGregorianCalendar: true), + day: 19) + let leftDayMonthOrigin = CGPoint(x: 0, y: 200) + let leftDayFrame = verticalPinnedDaysOfWeekFrameProvider.frameOfDay( + leftDay, + inMonthWithOrigin: leftDayMonthOrigin, + monthHeaderHeight: 50) + .alignedToPixels(forScreenWithScale: 3) + let frameBeforeLeftDay = verticalPinnedDaysOfWeekFrameProvider.frameOfDay( + calendar.day(byAddingDays: -1, to: leftDay), + adjacentTo: leftDay, + withFrame: leftDayFrame, + inMonthWithOrigin: leftDayMonthOrigin) + .alignedToPixels(forScreenWithScale: 3) + let frameAfterLeftDay = verticalPinnedDaysOfWeekFrameProvider.frameOfDay( + calendar.day(byAddingDays: 1, to: leftDay), + adjacentTo: leftDay, + withFrame: leftDayFrame, + inMonthWithOrigin: leftDayMonthOrigin) + .alignedToPixels(forScreenWithScale: 3) + let expectedFrameBeforeLeftDay = CGRect( + x: 277.14285714285717, + y: 364.80952380952385, + width: 34.857142857142854, + height: 34.857142857142854) + .alignedToPixels(forScreenWithScale: 3) + let expectedFrameAfterLeftDay = CGRect( + x: 53.0, + y: 419.6666666666667, + width: 34.857142857142854, + height: 34.857142857142854) + .alignedToPixels(forScreenWithScale: 3) + XCTAssert(frameBeforeLeftDay == expectedFrameBeforeLeftDay, "Incorrect frame for day.") + XCTAssert(frameAfterLeftDay == expectedFrameAfterLeftDay, "Incorrect frame for day.") + + let rightDay = Day( + month: Month(era: 1, year: 2020, month: 04, isInGregorianCalendar: true), + day: 25) + let rightDayMonthOrigin = CGPoint(x: 1000, y: 0) + let rightDayFrame = horizontalFrameProvider.frameOfDay( + rightDay, + inMonthWithOrigin: rightDayMonthOrigin, + monthHeaderHeight: 50) + .alignedToPixels(forScreenWithScale: 3) + let frameBeforeRightDay = horizontalFrameProvider.frameOfDay( + calendar.day(byAddingDays: -1, to: rightDay), + adjacentTo: rightDay, + withFrame: rightDayFrame, + inMonthWithOrigin: rightDayMonthOrigin) + .alignedToPixels(forScreenWithScale: 3) + let frameAfterRightDay = horizontalFrameProvider.frameOfDay( + calendar.day(byAddingDays: 1, to: rightDay), + adjacentTo: rightDay, + withFrame: rightDayFrame, + inMonthWithOrigin: rightDayMonthOrigin) + .alignedToPixels(forScreenWithScale: 3) + let expectedFrameBeforeRightDay = CGRect( + x: 1218.0, + y: 263.0, + width: 32.0, + height: 32.0) + .alignedToPixels(forScreenWithScale: 3) + let expectedFrameAfterRightDay = CGRect( + x: 1008.0, + y: 315.0, + width: 32.0, + height: 32.0) + .alignedToPixels(forScreenWithScale: 3) + XCTAssert(frameBeforeRightDay == expectedFrameBeforeRightDay, "Incorrect frame for day.") + XCTAssert(frameAfterRightDay == expectedFrameAfterRightDay, "Incorrect frame for day.") + } + + func testAdjacentDayFrameFloatingPointPrecisionEdgeCase() { + let frameProvider = FrameProvider( + content: CalendarViewContent( + calendar: calendar, + visibleDateRange: Date.distantPast...Date.distantFuture, + monthsLayout: .horizontal(options: .init(maximumFullyVisibleMonths: 2.3))) + .interMonthSpacing(24), + size: CGSize(width: 375, height: 275), + layoutMargins: .init(top: 8, leading: 8, bottom: 8, trailing: 8), + scale: 3) + + let adjacentDayFrame = CGRect( + x: 10218.857142857141, + y: 104.4047619047619, + width: 23.357142857142858, + height: 23.357142857142858) + let frameOfPreviousDay = frameProvider.frameOfDay( + Day(month: Month(era: 1, year: 1500, month: 2, isInGregorianCalendar: true), day: 9), + adjacentTo: Day( + month: Month(era: 1, year: 1500, month: 2, isInGregorianCalendar: true), + day: 10), + withFrame: adjacentDayFrame, + inMonthWithOrigin: CGPoint(x: 10195.5, y: 7.9999999999999964)) + + XCTAssert( + frameOfPreviousDay.minY == adjacentDayFrame.minY, + "1500-02-09 and 1500-02-10 should have the same minY because they're in the same week.") + } + + // MARK: Misc item frame calculations + + func testPinnedDayOfWeekFrame() { + let frame1 = verticalPinnedDaysOfWeekFrameProvider.frameOfPinnedDayOfWeek( + at: .second, + yContentOffset: 275) + .alignedToPixels(forScreenWithScale: 3) + let expectedFrame1 = CGRect( + x: 52.857142857142854, + y: 275, + width: 34.857142857142854, + height: 34.857142857142854) + .alignedToPixels(forScreenWithScale: 3) + XCTAssert(frame1 == expectedFrame1, "Incorrect frame for pinned day of week.") + + let frame2 = rectangularDayFrameProvider.frameOfPinnedDayOfWeek( + at: .second, + yContentOffset: 275) + .alignedToPixels(forScreenWithScale: 3) + let expectedFrame2 = CGRect( + x: 52.857142857142854, + y: 275, + width: 34.857142857142854, + height: 52.28571428571428) + .alignedToPixels(forScreenWithScale: 3) + XCTAssert(frame2 == expectedFrame2, "Incorrect frame for pinned day of week.") + } + + func testPinnedDaysOfWeekBackgroundFrame() { + let frame1 = verticalPinnedDaysOfWeekFrameProvider.frameOfPinnedDaysOfWeekRowBackground( + yContentOffset: 140) + .alignedToPixels(forScreenWithScale: 3) + let expectedFrame1 = CGRect(x: 0, y: 140, width: 320, height: 34.857142857142854) + .alignedToPixels(forScreenWithScale: 3) + XCTAssert(frame1 == expectedFrame1, "Incorrect frame for pinned days-of-week row background.") + + let frame2 = rectangularDayFrameProvider.frameOfPinnedDaysOfWeekRowBackground( + yContentOffset: 140) + .alignedToPixels(forScreenWithScale: 3) + let expectedFrame2 = CGRect(x: 0, y: 140, width: 320, height: 52.28571428571428) + .alignedToPixels(forScreenWithScale: 3) + XCTAssert(frame2 == expectedFrame2, "Incorrect frame for pinned days-of-week row background.") + } + + func testPinnedDaysOfWeekSeparatorFrame() { + let frame1 = verticalPinnedDaysOfWeekFrameProvider.frameOfPinnedDaysOfWeekRowSeparator( + yContentOffset: 120, + separatorHeight: 2) + .alignedToPixels(forScreenWithScale: 3) + let expectedFrame1 = CGRect(x: 0, y: 152.85714285714286, width: 320, height: 2) + .alignedToPixels(forScreenWithScale: 3) + XCTAssert(frame1 == expectedFrame1, "Incorrect frame for pinned day-of-week row separator.") + + let frame2 = verticalFrameProvider.frameOfDaysOfWeekRowSeparator( + inMonthWithOrigin: CGPoint(x: 0, y: 120), + separatorHeight: 1, + monthHeaderHeight: 50) + .alignedToPixels(forScreenWithScale: 3) + let expectedFrame2 = CGRect(x: 0, y: 208.85714285714286, width: 320, height: 1) + .alignedToPixels(forScreenWithScale: 3) + XCTAssert(frame2 == expectedFrame2, "Incorrect frame for day-of-week row separator.") + + let frame3 = horizontalFrameProvider.frameOfDaysOfWeekRowSeparator( + inMonthWithOrigin: CGPoint(x: 421, y: 0), + separatorHeight: 10, + monthHeaderHeight: 50) + .alignedToPixels(forScreenWithScale: 3) + let expectedFrame3 = CGRect(x: 421, y: 77, width: 300, height: 10) + .alignedToPixels(forScreenWithScale: 3) + XCTAssert(frame3 == expectedFrame3, "Incorrect frame for day-of-week row separator.") + + let frame4 = rectangularDayFrameProvider.frameOfDaysOfWeekRowSeparator( + inMonthWithOrigin: CGPoint(x: 0, y: 120), + separatorHeight: 3, + monthHeaderHeight: 50) + .alignedToPixels(forScreenWithScale: 3) + let expectedFrame4 = CGRect(x: 0, y: 224.28571428571428, width: 320, height: 3) + .alignedToPixels(forScreenWithScale: 3) + XCTAssert(frame4 == expectedFrame4, "Incorrect frame for day-of-week row separator.") + } + + // MARK: Scroll-to-item Frame Calculations + + func testScrollToItemFirstFullyVisiblePosition() { + let verticalItemFrame = CGRect(x: 50, y: 200, width: 100, height: 100) + let horizontalFrame = CGRect(x: 200, y: 50, width: 100, height: 100) + + let frame1 = verticalFrameProvider.frameOfItem( + withOriginalFrame: verticalItemFrame, + at: .firstFullyVisiblePosition, + offset: CGPoint(x: 0, y: 100)) + let expectedFrame1 = CGRect(x: 50, y: 100, width: 100, height: 100) + XCTAssert(frame1 == expectedFrame1, "Incorrect frame for scroll-to-item.") + + let frame2 = verticalFrameProvider.frameOfItem( + withOriginalFrame: verticalItemFrame, + at: .firstFullyVisiblePosition(padding: 20), + offset: CGPoint(x: 0, y: 100)) + let expectedFrame2 = CGRect(x: 50, y: 120, width: 100, height: 100) + XCTAssert(frame2 == expectedFrame2, "Incorrect frame for scroll-to-item.") + + let frame3 = verticalPinnedDaysOfWeekFrameProvider.frameOfItem( + withOriginalFrame: verticalItemFrame, + at: .firstFullyVisiblePosition(padding: 20), + offset: CGPoint(x: 0, y: 100)) + let expectedFrame3 = CGRect(x: 50, y: 154.85714285714286, width: 100, height: 100) + XCTAssert( + frame3.alignedToPixels(forScreenWithScale: 3) == expectedFrame3.alignedToPixels(forScreenWithScale: 3), + "Incorrect frame for scroll-to-item.") + + let frame4 = horizontalFrameProvider.frameOfItem( + withOriginalFrame: horizontalFrame, + at: .firstFullyVisiblePosition, + offset: CGPoint(x: 100, y: 0)) + let expectedFrame4 = CGRect(x: 100, y: 50, width: 100, height: 100) + XCTAssert(frame4 == expectedFrame4, "Incorrect frame for scroll-to-item.") + + let frame5 = horizontalFrameProvider.frameOfItem( + withOriginalFrame: horizontalFrame, + at: .firstFullyVisiblePosition(padding: 20), + offset: CGPoint(x: 100, y: 0)) + let expectedFrame5 = CGRect(x: 120, y: 50, width: 100, height: 100) + XCTAssert(frame5 == expectedFrame5, "Incorrect frame for scroll-to-item.") + } + + func testScrollToItemCentered() { + let verticalItemFrame = CGRect(x: 50, y: 200, width: 100, height: 100) + let horizontalFrame = CGRect(x: 200, y: 50, width: 100, height: 100) + + let frame1 = verticalFrameProvider.frameOfItem( + withOriginalFrame: verticalItemFrame, + at: .centered, + offset: CGPoint(x: 0, y: 100)) + let expectedFrame1 = CGRect(x: 50, y: 290, width: 100, height: 100) + XCTAssert(frame1 == expectedFrame1, "Incorrect frame for scroll-to-item.") + + let frame2 = verticalPinnedDaysOfWeekFrameProvider.frameOfItem( + withOriginalFrame: verticalItemFrame, + at: .centered, + offset: CGPoint(x: 0, y: 100)) + let expectedFrame2 = CGRect(x: 50, y: 307.42857142857144, width: 100, height: 100) + XCTAssert( + frame2.alignedToPixels(forScreenWithScale: 3) == expectedFrame2.alignedToPixels(forScreenWithScale: 3), + "Incorrect frame for scroll-to-item.") + + let frame3 = horizontalFrameProvider.frameOfItem( + withOriginalFrame: horizontalFrame, + at: .centered, + offset: CGPoint(x: 100, y: 0)) + let expectedFrame3 = CGRect(x: 210, y: 50, width: 100, height: 100) + XCTAssert(frame3 == expectedFrame3, "Incorrect frame for scroll-to-item.") + } + + func testScrollToItemLastFullyVisiblePosition() { + let verticalItemFrame = CGRect(x: 50, y: 200, width: 100, height: 100) + let horizontalFrame = CGRect(x: 200, y: 50, width: 100, height: 100) + + let frame1 = verticalFrameProvider.frameOfItem( + withOriginalFrame: verticalItemFrame, + at: .lastFullyVisiblePosition, + offset: CGPoint(x: 0, y: 100)) + let expectedFrame1 = CGRect(x: 50, y: 480, width: 100, height: 100) + XCTAssert(frame1 == expectedFrame1, "Incorrect frame for scroll-to-item.") + + let frame2 = verticalFrameProvider.frameOfItem( + withOriginalFrame: verticalItemFrame, + at: .lastFullyVisiblePosition(padding: 20), + offset: CGPoint(x: 0, y: 100)) + let expectedFrame2 = CGRect(x: 50, y: 460, width: 100, height: 100) + XCTAssert(frame2 == expectedFrame2, "Incorrect frame for scroll-to-item.") + + let frame3 = verticalPinnedDaysOfWeekFrameProvider.frameOfItem( + withOriginalFrame: verticalItemFrame, + at: .lastFullyVisiblePosition(padding: 20), + offset: CGPoint(x: 0, y: 100)) + let expectedFrame3 = CGRect(x: 50, y: 460, width: 100, height: 100) + XCTAssert(frame3 == expectedFrame3, "Incorrect frame for scroll-to-item.") + + let frame4 = horizontalFrameProvider.frameOfItem( + withOriginalFrame: horizontalFrame, + at: .lastFullyVisiblePosition, + offset: CGPoint(x: 100, y: 0)) + let expectedFrame4 = CGRect(x: 320, y: 50, width: 100, height: 100) + XCTAssert(frame4 == expectedFrame4, "Incorrect frame for scroll-to-item.") + + let frame5 = horizontalFrameProvider.frameOfItem( + withOriginalFrame: horizontalFrame, + at: .lastFullyVisiblePosition(padding: 20), + offset: CGPoint(x: 100, y: 0)) + let expectedFrame5 = CGRect(x: 300, y: 50, width: 100, height: 100) + XCTAssert(frame5 == expectedFrame5, "Incorrect frame for scroll-to-item.") + } + + // MARK: Private + + private let calendar = Calendar(identifier: .gregorian) + + // swiftlint:disable implicitly_unwrapped_optional + + private var verticalFrameProvider: FrameProvider! + private var verticalPinnedDaysOfWeekFrameProvider: FrameProvider! + private var verticalPartialMonthFrameProvider: FrameProvider! + private var horizontalFrameProvider: FrameProvider! + private var rectangularDayFrameProvider: FrameProvider! + +} + +// MARK: CGSize Pixel Alignment + +extension CGSize { + + // Rounds a `CGSize`'s origin `width` and `height` values so that they're aligned on pixel + // boundaries for a screen with the provided scale. + fileprivate func alignedToPixels(forScreenWithScale scale: CGFloat) -> CGSize { + CGSize( + width: width.alignedToPixel(forScreenWithScale: scale), + height: height.alignedToPixel(forScreenWithScale: scale)) + } + +} diff --git a/HorizonCalendarTests [Rec]/HorizonCalendarTests__Rec_.swift b/HorizonCalendarTests [Rec]/HorizonCalendarTests__Rec_.swift new file mode 100644 index 00000000..cce5f470 --- /dev/null +++ b/HorizonCalendarTests [Rec]/HorizonCalendarTests__Rec_.swift @@ -0,0 +1,36 @@ +// +// HorizonCalendarTests__Rec_.swift +// HorizonCalendarTests [Rec] +// +// Created by main on 3/2/25. +// Copyright © 2025 Airbnb. All rights reserved. +// + +import XCTest + +final class HorizonCalendarTests__Rec_: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() throws { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + // Any test you write for XCTest can be annotated as throws and async. + // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. + // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. + } + + func testPerformanceExample() throws { + // This is an example of a performance test case. + measure { + // Put the code you want to measure the time of here. + } + } + +} diff --git a/HorizonCalendarTests [Rec]/HorizontalMonthsLayoutOptionsTests.swift b/HorizonCalendarTests [Rec]/HorizontalMonthsLayoutOptionsTests.swift new file mode 100644 index 00000000..073ec918 --- /dev/null +++ b/HorizonCalendarTests [Rec]/HorizontalMonthsLayoutOptionsTests.swift @@ -0,0 +1,57 @@ +// Created by Bryan Keller on 11/8/20. +// Copyright © 2020 Airbnb Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import XCTest +@testable import HorizonCalendar + +final class HorizontalMonthsLayoutOptionsTests: XCTestCase { + + func testMonthWidthOneVisibleMonth() { + let options = HorizontalMonthsLayoutOptions(maximumFullyVisibleMonths: 1) + + XCTAssert( + options.monthWidth(calendarWidth: 100, interMonthSpacing: 0) == 100, + "Incorrect month width") + + XCTAssert( + options.monthWidth(calendarWidth: 100, interMonthSpacing: 10) == 90, + "Incorrect month width") + } + + func testMonthWidthOneAndAHalfVisibleMonths() { + let options = HorizontalMonthsLayoutOptions(maximumFullyVisibleMonths: 1.5) + + XCTAssert( + options.monthWidth(calendarWidth: 120, interMonthSpacing: 0) == 80, + "Incorrect month width") + + XCTAssert( + options.monthWidth(calendarWidth: 120, interMonthSpacing: 10) == 70, + "Incorrect month width") + } + + func testMonthWidthFourVisibleMonths() { + let options = HorizontalMonthsLayoutOptions(maximumFullyVisibleMonths: 4) + + XCTAssert( + options.monthWidth(calendarWidth: 100, interMonthSpacing: 0) == 25, + "Incorrect month width") + + XCTAssert( + options.monthWidth(calendarWidth: 100, interMonthSpacing: 10) == 15, + "Incorrect month width") + } + +} diff --git a/HorizonCalendarTests [Rec]/ItemViewReuseManagerTests.swift b/HorizonCalendarTests [Rec]/ItemViewReuseManagerTests.swift new file mode 100644 index 00000000..50f61f27 --- /dev/null +++ b/HorizonCalendarTests [Rec]/ItemViewReuseManagerTests.swift @@ -0,0 +1,649 @@ +// Created by Bryan Keller on 3/26/20. +// Copyright © 2020 Airbnb Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import XCTest +@testable import HorizonCalendar + +// MARK: - ItemViewReuseManagerTests + +final class ItemViewReuseManagerTests: XCTestCase { + + // MARK: Internal + + override func setUp() { + reuseManager = ItemViewReuseManager() + } + + func testInitialViewCreationWithNoReuse() { + let visibleItems: Set = [ + .init( + calendarItemModel: MockCalendarItemModel.variant0, + itemType: .layoutItemType( + .monthHeader(Month(era: 1, year: 2020, month: 01, isInGregorianCalendar: true))), + frame: .zero), + .init( + calendarItemModel: MockCalendarItemModel.variant0, + itemType: .layoutItemType( + .monthHeader(Month(era: 1, year: 2020, month: 02, isInGregorianCalendar: true))), + frame: .zero), + .init( + calendarItemModel: MockCalendarItemModel.variant1, + itemType: .layoutItemType( + .day( + Day( + month: Month(era: 1, year: 2020, month: 01, isInGregorianCalendar: true), + day: 01))), + frame: .zero), + .init( + calendarItemModel: MockCalendarItemModel.variant1, + itemType: .layoutItemType( + .day( + Day( + month: Month(era: 1, year: 2020, month: 02, isInGregorianCalendar: true), + day: 01))), + frame: .zero), + ] + + let contexts = reuseManager.reusedViewContexts( + visibleItems: visibleItems, + reuseUnusedViews: true) + for context in contexts { + XCTAssert( + !context.isViewReused, + "isViewReused should be false since there are no views to reuse.") + XCTAssert( + !context.isReusedViewSameAsPreviousView, + "isReusedViewSameAsPreviousView should be false when no view was reused.") + } + } + + func testReusingIdenticalViews() { + let initialVisibleItems: Set = [ + .init( + calendarItemModel: MockCalendarItemModel.variant0, + itemType: .layoutItemType( + .monthHeader(Month(era: 1, year: 2020, month: 01, isInGregorianCalendar: true))), + frame: .zero), + .init( + calendarItemModel: MockCalendarItemModel.variant0, + itemType: .layoutItemType( + .monthHeader(Month(era: 1, year: 2020, month: 02, isInGregorianCalendar: true))), + frame: .zero), + .init( + calendarItemModel: MockCalendarItemModel.variant1, + itemType: .layoutItemType( + .day( + Day( + month: Month(era: 1, year: 2020, month: 01, isInGregorianCalendar: true), + day: 01))), + frame: .zero), + .init( + calendarItemModel: MockCalendarItemModel.variant1, + itemType: .layoutItemType( + .day( + Day( + month: Month(era: 1, year: 2020, month: 02, isInGregorianCalendar: true), + day: 01))), + frame: .zero), + ] + + let subsequentVisibleItems = initialVisibleItems + + // Populate the reuse manager with the initial visible items + let _ = reuseManager.reusedViewContexts( + visibleItems: initialVisibleItems, + reuseUnusedViews: true) + + // Ensure all views are reused by using the exact same previous views + let contexts = reuseManager.reusedViewContexts( + visibleItems: subsequentVisibleItems, + reuseUnusedViews: true) + for context in contexts { + XCTAssert( + context.isViewReused, + """ + Expected every view to be reused, since the subsequent visible items are identical to the + initial visible items. + """) + XCTAssert( + context.isReusedViewSameAsPreviousView, + "isReusedViewSameAsPreviousView should be true when the same view was reused.") + } + } + + func testReusingAllViews() { + let initialVisibleItems: Set = [ + .init( + calendarItemModel: MockCalendarItemModel.variant0, + itemType: .layoutItemType( + .monthHeader(Month(era: 1, year: 2020, month: 01, isInGregorianCalendar: true))), + frame: .zero), + .init( + calendarItemModel: MockCalendarItemModel.variant0, + itemType: .layoutItemType( + .monthHeader(Month(era: 1, year: 2020, month: 02, isInGregorianCalendar: true))), + frame: .zero), + .init( + calendarItemModel: MockCalendarItemModel.variant1, + itemType: .layoutItemType( + .day( + Day( + month: Month(era: 1, year: 2020, month: 01, isInGregorianCalendar: true), + day: 01))), + frame: .zero), + .init( + calendarItemModel: MockCalendarItemModel.variant1, + itemType: .layoutItemType( + .day( + Day( + month: Month(era: 1, year: 2020, month: 02, isInGregorianCalendar: true), + day: 01))), + frame: .zero), + ] + + let subsequentVisibleItems: Set = [ + .init( + calendarItemModel: MockCalendarItemModel.variant0, + itemType: .layoutItemType( + .monthHeader(Month(era: 1, year: 2020, month: 02, isInGregorianCalendar: true))), + frame: .zero), + .init( + calendarItemModel: MockCalendarItemModel.variant0, + itemType: .layoutItemType( + .monthHeader(Month(era: 1, year: 2020, month: 03, isInGregorianCalendar: true))), + frame: .zero), + .init( + calendarItemModel: MockCalendarItemModel.variant1, + itemType: .layoutItemType( + .day( + Day( + month: Month(era: 1, year: 2020, month: 03, isInGregorianCalendar: true), + day: 01))), + frame: .zero), + .init( + calendarItemModel: MockCalendarItemModel.variant1, + itemType: .layoutItemType( + .day( + Day( + month: Month(era: 1, year: 2020, month: 04, isInGregorianCalendar: true), + day: 01))), + frame: .zero), + ] + + // Populate the reuse manager with the initial visible items + let _ = reuseManager.reusedViewContexts( + visibleItems: initialVisibleItems, + reuseUnusedViews: true) + + // Allow the reuse manager to figure out which items are reusable + let _ = reuseManager.reusedViewContexts( + visibleItems: [], + reuseUnusedViews: true) + + // Ensure all views are reused given the subsequent visible items + let contexts = reuseManager.reusedViewContexts( + visibleItems: subsequentVisibleItems, + reuseUnusedViews: true) + for context in contexts { + XCTAssert(context.isViewReused, "Expected every view to be reused") + } + } + + func testReusingSomeViews() { + let initialVisibleItems: Set = [ + .init( + calendarItemModel: MockCalendarItemModel.variant0, + itemType: .layoutItemType( + .monthHeader(Month(era: 1, year: 2020, month: 01, isInGregorianCalendar: true))), + frame: .zero), + .init( + calendarItemModel: MockCalendarItemModel.variant0, + itemType: .layoutItemType( + .monthHeader(Month(era: 1, year: 2020, month: 02, isInGregorianCalendar: true))), + frame: .zero), + .init( + calendarItemModel: MockCalendarItemModel.variant1, + itemType: .layoutItemType( + .day( + Day( + month: Month(era: 1, year: 2020, month: 01, isInGregorianCalendar: true), + day: 01))), + frame: .zero), + .init( + calendarItemModel: MockCalendarItemModel.variant1, + itemType: .layoutItemType( + .day( + Day( + month: Month(era: 1, year: 2020, month: 02, isInGregorianCalendar: true), + day: 01))), + frame: .zero), + .init( + calendarItemModel: MockCalendarItemModel.variant2, + itemType: .layoutItemType( + .day( + Day( + month: Month(era: 1, year: 2020, month: 02, isInGregorianCalendar: true), + day: 01))), + frame: .zero), + .init( + calendarItemModel: MockCalendarItemModel.variant3, + itemType: .layoutItemType( + .day( + Day( + month: Month(era: 1, year: 2020, month: 02, isInGregorianCalendar: true), + day: 01))), + frame: .zero), + ] + + let subsequentVisibleItems: Set = [ + .init( + calendarItemModel: MockCalendarItemModel.variant1, + itemType: .layoutItemType( + .day( + Day( + month: Month(era: 1, year: 2020, month: 05, isInGregorianCalendar: true), + day: 01))), + frame: .zero), + .init( + calendarItemModel: MockCalendarItemModel.variant3, + itemType: .layoutItemType( + .day( + Day( + month: Month(era: 1, year: 2020, month: 05, isInGregorianCalendar: true), + day: 01))), + frame: .zero), + .init( + calendarItemModel: MockCalendarItemModel.variant4, + itemType: .layoutItemType( + .monthHeader(Month(era: 1, year: 2020, month: 04, isInGregorianCalendar: true))), + frame: .zero), + .init( + calendarItemModel: MockCalendarItemModel.variant5, + itemType: .layoutItemType( + .monthHeader(Month(era: 1, year: 2020, month: 05, isInGregorianCalendar: true))), + frame: .zero), + ] + + // Populate the reuse manager with the initial visible items + let _ = reuseManager.reusedViewContexts( + visibleItems: initialVisibleItems, + reuseUnusedViews: true) + + // Allow the reuse manager to figure out which items are reusable + let _ = reuseManager.reusedViewContexts( + visibleItems: [], + reuseUnusedViews: true) + + // Ensure the correct subset of views are reused given the subsequent visible items + let contexts = reuseManager.reusedViewContexts( + visibleItems: subsequentVisibleItems, + reuseUnusedViews: true) + for context in contexts { + guard let itemModel = context.visibleItem.calendarItemModel as? MockCalendarItemModel else { + preconditionFailure( + "Failed to convert the calendar item model to an instance of MockCalendarItemModel.") + } + + switch itemModel { + case .variant1, .variant3: + XCTAssert( + context.isViewReused, + "isViewReused should be true since it was reused.") + XCTAssert( + !context.isReusedViewSameAsPreviousView, + "isReusedViewSameAsPreviousView should be false when a different view was reused.") + default: + XCTAssert( + !context.isViewReused, + "isViewReused should be false since there are no views to reuse.") + XCTAssert( + !context.isReusedViewSameAsPreviousView, + "isReusedViewSameAsPreviousView should be false when a different view was reused.") + } + } + } + + func testDepletingAvailableReusableViews() { + let initialVisibleItems: Set = [ + .init( + calendarItemModel: MockCalendarItemModel.variant0, + itemType: .layoutItemType( + .monthHeader(Month(era: 1, year: 2020, month: 01, isInGregorianCalendar: true))), + frame: .zero), + .init( + calendarItemModel: MockCalendarItemModel.variant0, + itemType: .layoutItemType( + .monthHeader(Month(era: 1, year: 2020, month: 02, isInGregorianCalendar: true))), + frame: .zero), + .init( + calendarItemModel: MockCalendarItemModel.variant1, + itemType: .layoutItemType( + .day( + Day( + month: Month(era: 1, year: 2020, month: 01, isInGregorianCalendar: true), + day: 01))), + frame: .zero), + .init( + calendarItemModel: MockCalendarItemModel.variant1, + itemType: .layoutItemType( + .day( + Day( + month: Month(era: 1, year: 2020, month: 02, isInGregorianCalendar: true), + day: 01))), + frame: .zero), + .init( + calendarItemModel: MockCalendarItemModel.variant1, + itemType: .layoutItemType( + .day( + Day( + month: Month(era: 1, year: 2020, month: 03, isInGregorianCalendar: true), + day: 01))), + frame: .zero), + ] + + let subsequentVisibleItems: Set = [ + .init( + calendarItemModel: MockCalendarItemModel.variant0, + itemType: .layoutItemType( + .monthHeader(Month(era: 1, year: 2020, month: 03, isInGregorianCalendar: true))), + frame: .zero), + .init( + calendarItemModel: MockCalendarItemModel.variant0, + itemType: .layoutItemType( + .monthHeader(Month(era: 1, year: 2020, month: 04, isInGregorianCalendar: true))), + frame: .zero), + .init( + calendarItemModel: MockCalendarItemModel.variant0, + itemType: .layoutItemType( + .monthHeader(Month(era: 1, year: 2020, month: 05, isInGregorianCalendar: true))), + frame: .zero), + .init( + calendarItemModel: MockCalendarItemModel.variant1, + itemType: .layoutItemType( + .day( + Day( + month: Month(era: 1, year: 2020, month: 03, isInGregorianCalendar: true), + day: 01))), + frame: .zero), + .init( + calendarItemModel: MockCalendarItemModel.variant1, + itemType: .layoutItemType( + .day( + Day( + month: Month(era: 1, year: 2020, month: 04, isInGregorianCalendar: true), + day: 01))), + frame: .zero), + .init( + calendarItemModel: MockCalendarItemModel.variant1, + itemType: .layoutItemType( + .day( + Day( + month: Month(era: 1, year: 2020, month: 05, isInGregorianCalendar: true), + day: 01))), + frame: .zero), + .init( + calendarItemModel: MockCalendarItemModel.variant1, + itemType: .layoutItemType( + .day( + Day( + month: Month(era: 1, year: 2020, month: 06, isInGregorianCalendar: true), + day: 01))), + frame: .zero), + .init( + calendarItemModel: MockCalendarItemModel.variant1, + itemType: .layoutItemType( + .day( + Day( + month: Month(era: 1, year: 2020, month: 07, isInGregorianCalendar: true), + day: 01))), + frame: .zero), + .init( + calendarItemModel: MockCalendarItemModel.variant2, + itemType: .layoutItemType( + .day( + Day( + month: Month(era: 1, year: 2020, month: 07, isInGregorianCalendar: true), + day: 01))), + frame: .zero), + ] + + // Populate the reuse manager with the initial visible items + let _ = reuseManager.reusedViewContexts( + visibleItems: initialVisibleItems, + reuseUnusedViews: true) + + // Allow the reuse manager to figure out which items are reusable + let _ = reuseManager.reusedViewContexts( + visibleItems: [], + reuseUnusedViews: true) + + // Ensure the correct subset of views are reused given the subsequent visible items + var reuseCountsForDifferentiators = [_CalendarItemViewDifferentiator: Int]() + var newViewCountsForDifferentiators = [_CalendarItemViewDifferentiator: Int]() + let contexts = reuseManager.reusedViewContexts( + visibleItems: subsequentVisibleItems, + reuseUnusedViews: true) + for context in contexts { + let item = context.visibleItem + if context.isViewReused { + let reuseCount = (reuseCountsForDifferentiators[item.calendarItemModel._itemViewDifferentiator] ?? 0) + 1 + reuseCountsForDifferentiators[item.calendarItemModel._itemViewDifferentiator] = reuseCount + } else { + let newViewCount = (newViewCountsForDifferentiators[item.calendarItemModel._itemViewDifferentiator] ?? 0) + 1 + newViewCountsForDifferentiators[item.calendarItemModel._itemViewDifferentiator] = newViewCount + } + } + + let expectedReuseCountsForDifferentiators: [_CalendarItemViewDifferentiator: Int] = [ + MockCalendarItemModel.variant0._itemViewDifferentiator: 2, + MockCalendarItemModel.variant1._itemViewDifferentiator: 3, + ] + let expectedNewViewCountsForDifferentiators: [_CalendarItemViewDifferentiator: Int] = [ + MockCalendarItemModel.variant0._itemViewDifferentiator: 1, + MockCalendarItemModel.variant1._itemViewDifferentiator: 2, + MockCalendarItemModel.variant2._itemViewDifferentiator: 1, + ] + + XCTAssert( + reuseCountsForDifferentiators == expectedReuseCountsForDifferentiators, + "The number of reuses does not match the expected number of reuses.") + + XCTAssert( + newViewCountsForDifferentiators == expectedNewViewCountsForDifferentiators, + "The number of new view creations does not match the expected number of new view creations.") + } + + func testDisablingViewRecycling() { + let initialVisibleItems: Set = [ + .init( + calendarItemModel: MockCalendarItemModel.variant0, + itemType: .layoutItemType( + .monthHeader(Month(era: 1, year: 2020, month: 01, isInGregorianCalendar: true))), + frame: .zero), + .init( + calendarItemModel: MockCalendarItemModel.variant0, + itemType: .layoutItemType( + .monthHeader(Month(era: 1, year: 2020, month: 02, isInGregorianCalendar: true))), + frame: .zero), + .init( + calendarItemModel: MockCalendarItemModel.variant1, + itemType: .layoutItemType( + .day( + Day( + month: Month(era: 1, year: 2020, month: 01, isInGregorianCalendar: true), + day: 01))), + frame: .zero), + .init( + calendarItemModel: MockCalendarItemModel.variant1, + itemType: .layoutItemType( + .day( + Day( + month: Month(era: 1, year: 2020, month: 02, isInGregorianCalendar: true), + day: 01))), + frame: .zero), + .init( + calendarItemModel: MockCalendarItemModel.variant2, + itemType: .layoutItemType( + .day( + Day( + month: Month(era: 1, year: 2020, month: 02, isInGregorianCalendar: true), + day: 01))), + frame: .zero), + .init( + calendarItemModel: MockCalendarItemModel.variant3, + itemType: .layoutItemType( + .day( + Day( + month: Month(era: 1, year: 2020, month: 02, isInGregorianCalendar: true), + day: 01))), + frame: .zero), + ] + + let subsequentVisibleItems: Set = [ + .init( + calendarItemModel: MockCalendarItemModel.variant1, + itemType: .layoutItemType( + .day( + Day( + month: Month(era: 1, year: 2020, month: 05, isInGregorianCalendar: true), + day: 01))), + frame: .zero), + .init( + calendarItemModel: MockCalendarItemModel.variant3, + itemType: .layoutItemType( + .day( + Day( + month: Month(era: 1, year: 2020, month: 05, isInGregorianCalendar: true), + day: 01))), + frame: .zero), + .init( + calendarItemModel: MockCalendarItemModel.variant4, + itemType: .layoutItemType( + .monthHeader(Month(era: 1, year: 2020, month: 04, isInGregorianCalendar: true))), + frame: .zero), + .init( + calendarItemModel: MockCalendarItemModel.variant5, + itemType: .layoutItemType( + .monthHeader(Month(era: 1, year: 2020, month: 05, isInGregorianCalendar: true))), + frame: .zero), + ] + + // Populate the reuse manager with the initial visible items + let _ = reuseManager.reusedViewContexts( + visibleItems: initialVisibleItems, + reuseUnusedViews: false) + + // Ensure the correct subset of views are reused given the subsequent visible items + let contexts = reuseManager.reusedViewContexts( + visibleItems: subsequentVisibleItems, + reuseUnusedViews: false) + for context in contexts { + guard let itemModel = context.visibleItem.calendarItemModel as? MockCalendarItemModel else { + preconditionFailure( + "Failed to convert the calendar item model to an instance of MockCalendarItemModel.") + } + + switch itemModel { + case .variant1, .variant3: + XCTAssert( + !context.isViewReused, + "isViewReused should be false since view recycling is disabled.") + XCTAssert( + !context.isReusedViewSameAsPreviousView, + "isReusedViewSameAsPreviousView should be false when a different view was reused.") + default: + XCTAssert( + !context.isViewReused, + "isViewReused should be false since there are no views to reuse.") + XCTAssert( + !context.isReusedViewSameAsPreviousView, + "isReusedViewSameAsPreviousView should be false when a different view was reused.") + } + } + } + + // MARK: Private + + // swiftlint:disable:next implicitly_unwrapped_optional + private var reuseManager: ItemViewReuseManager! + +} + +// MARK: - MockCalendarItemModel + +private struct MockCalendarItemModel: AnyCalendarItemModel, Equatable { + + // MARK: Lifecycle + + init( + viewType: ObjectIdentifier, + invariantViewProperties: AnyHashable) + { + _itemViewDifferentiator = _CalendarItemViewDifferentiator( + viewType: viewType, + invariantViewProperties: invariantViewProperties) + } + + // MARK: Internal + + struct InvariantViewProperties: Hashable { + let font: UIFont + let color: UIColor + } + + struct InvariantLabelPropertiesA: Hashable { + let font: UIFont + let color: UIColor + } + + struct InvariantLabelPropertiesB: Hashable { + let font: UIFont + let color: UIColor + } + + static let variant0 = MockCalendarItemModel( + viewType: ObjectIdentifier(UIView.self), + invariantViewProperties: InvariantViewProperties(font: .systemFont(ofSize: 12), color: .white)) + static let variant1 = MockCalendarItemModel( + viewType: ObjectIdentifier(UIView.self), + invariantViewProperties: InvariantViewProperties(font: .systemFont(ofSize: 14), color: .white)) + static let variant2 = MockCalendarItemModel( + viewType: ObjectIdentifier(UIView.self), + invariantViewProperties: InvariantViewProperties(font: .systemFont(ofSize: 14), color: .black)) + static let variant3 = MockCalendarItemModel( + viewType: ObjectIdentifier(UILabel.self), + invariantViewProperties: InvariantLabelPropertiesA(font: .systemFont(ofSize: 14), color: .red)) + static let variant4 = MockCalendarItemModel( + viewType: ObjectIdentifier(UILabel.self), + invariantViewProperties: InvariantLabelPropertiesB(font: .systemFont(ofSize: 16), color: .red)) + static let variant5 = MockCalendarItemModel( + viewType: ObjectIdentifier(UILabel.self), + invariantViewProperties: InvariantLabelPropertiesB(font: .systemFont(ofSize: 16), color: .blue)) + + var _itemViewDifferentiator: _CalendarItemViewDifferentiator + + func _makeView() -> UIView { + UIView() + } + + func _setContent(onViewOfSameType _: UIView) { } + + func _isContentEqual(toContentOf _: AnyCalendarItemModel) -> Bool { + false + } + + mutating func _setSwiftUIWrapperViewContentIDIfNeeded(_: AnyHashable) { } + +} diff --git a/HorizonCalendarTests [Rec]/LayoutItemTypeEnumeratorTests.swift b/HorizonCalendarTests [Rec]/LayoutItemTypeEnumeratorTests.swift new file mode 100644 index 00000000..5c9f87c9 --- /dev/null +++ b/HorizonCalendarTests [Rec]/LayoutItemTypeEnumeratorTests.swift @@ -0,0 +1,260 @@ +// Created by Bryan Keller on 4/4/20. +// Copyright © 2020 Airbnb Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import XCTest +@testable import HorizonCalendar + +// MARK: - LayoutItemTypeEnumeratorTests + +final class LayoutItemTypeEnumeratorTests: XCTestCase { + + // MARK: Internal + + override func setUp() { + let lowerBoundMonth = Month(era: 1, year: 2020, month: 11, isInGregorianCalendar: true) + let upperBoundMonth = Month(era: 1, year: 2021, month: 1, isInGregorianCalendar: true) + let monthRange = lowerBoundMonth...upperBoundMonth + + let lowerBoundDay = Day(month: lowerBoundMonth, day: 12) + let upperBoundDay = Day(month: upperBoundMonth, day: 20) + let dayRange = lowerBoundDay...upperBoundDay + + verticalItemTypeEnumerator = LayoutItemTypeEnumerator( + calendar: calendar, + monthsLayout: .vertical(options: VerticalMonthsLayoutOptions()), + monthRange: monthRange, + dayRange: dayRange) + verticalPinnedDaysOfWeekItemTypeEnumerator = LayoutItemTypeEnumerator( + calendar: calendar, + monthsLayout: .vertical(options: VerticalMonthsLayoutOptions(pinDaysOfWeekToTop: true)), + monthRange: monthRange, + dayRange: dayRange) + horizontalItemTypeEnumerator = LayoutItemTypeEnumerator( + calendar: calendar, + monthsLayout: .horizontal( + options: HorizontalMonthsLayoutOptions(maximumFullyVisibleMonths: 305 / 300)), + monthRange: monthRange, + dayRange: dayRange) + + expectedItemTypeStackBackwards = [ + .dayOfWeekInMonth( + position: .last, + month: Month(era: 1, year: 2020, month: 12, isInGregorianCalendar: true)), + .dayOfWeekInMonth( + position: .sixth, month: Month(era: 1, year: 2020, month: 12, isInGregorianCalendar: true)), + .dayOfWeekInMonth( + position: .fifth, + month: Month(era: 1, year: 2020, month: 12, isInGregorianCalendar: true)), + .dayOfWeekInMonth( + position: .fourth, + month: Month(era: 1, year: 2020, month: 12, isInGregorianCalendar: true)), + .dayOfWeekInMonth( + position: .third, + month: Month(era: 1, year: 2020, month: 12, isInGregorianCalendar: true)), + .dayOfWeekInMonth( + position: .second, + month: Month(era: 1, year: 2020, month: 12, isInGregorianCalendar: true)), + .dayOfWeekInMonth( + position: .first, + month: Month(era: 1, year: 2020, month: 12, isInGregorianCalendar: true)), + .monthHeader(Month(era: 1, year: 2020, month: 12, isInGregorianCalendar: true)), + .day(calendar.day(byAddingDays: -1, to: startDay)), + .day(calendar.day(byAddingDays: -2, to: startDay)), + .day(calendar.day(byAddingDays: -3, to: startDay)), + .day(calendar.day(byAddingDays: -4, to: startDay)), + .day(calendar.day(byAddingDays: -5, to: startDay)), + .day(calendar.day(byAddingDays: -6, to: startDay)), + .day(calendar.day(byAddingDays: -7, to: startDay)), + .day(calendar.day(byAddingDays: -8, to: startDay)), + .day(calendar.day(byAddingDays: -9, to: startDay)), + .day(calendar.day(byAddingDays: -10, to: startDay)), + .day(calendar.day(byAddingDays: -11, to: startDay)), + .day(calendar.day(byAddingDays: -12, to: startDay)), + .day(calendar.day(byAddingDays: -13, to: startDay)), + .day(calendar.day(byAddingDays: -14, to: startDay)), + .day(calendar.day(byAddingDays: -15, to: startDay)), + .day(calendar.day(byAddingDays: -16, to: startDay)), + .day(calendar.day(byAddingDays: -17, to: startDay)), + .day(calendar.day(byAddingDays: -18, to: startDay)), + .day(calendar.day(byAddingDays: -19, to: startDay)), + ] + + expectedItemTypeStackForwards = [ + .day(startDay), + .day(calendar.day(byAddingDays: 1, to: startDay)), + .day(calendar.day(byAddingDays: 2, to: startDay)), + .day(calendar.day(byAddingDays: 3, to: startDay)), + .day(calendar.day(byAddingDays: 4, to: startDay)), + .day(calendar.day(byAddingDays: 5, to: startDay)), + .day(calendar.day(byAddingDays: 6, to: startDay)), + .day(calendar.day(byAddingDays: 7, to: startDay)), + .day(calendar.day(byAddingDays: 8, to: startDay)), + .day(calendar.day(byAddingDays: 9, to: startDay)), + .day(calendar.day(byAddingDays: 10, to: startDay)), + .day(calendar.day(byAddingDays: 11, to: startDay)), + .day(calendar.day(byAddingDays: 12, to: startDay)), + .day(calendar.day(byAddingDays: 13, to: startDay)), + .day(calendar.day(byAddingDays: 14, to: startDay)), + .day(calendar.day(byAddingDays: 15, to: startDay)), + .day(calendar.day(byAddingDays: 16, to: startDay)), + .day(calendar.day(byAddingDays: 17, to: startDay)), + .day(calendar.day(byAddingDays: 18, to: startDay)), + .day(calendar.day(byAddingDays: 19, to: startDay)), + .day(calendar.day(byAddingDays: 20, to: startDay)), + .day(calendar.day(byAddingDays: 21, to: startDay)), + .day(calendar.day(byAddingDays: 22, to: startDay)), + .day(calendar.day(byAddingDays: 23, to: startDay)), + .day(calendar.day(byAddingDays: 24, to: startDay)), + .day(calendar.day(byAddingDays: 25, to: startDay)), + .day(calendar.day(byAddingDays: 26, to: startDay)), + .day(calendar.day(byAddingDays: 27, to: startDay)), + .day(calendar.day(byAddingDays: 28, to: startDay)), + .day(calendar.day(byAddingDays: 29, to: startDay)), + .day(calendar.day(byAddingDays: 30, to: startDay)), + .monthHeader(Month(era: 1, year: 2021, month: 01, isInGregorianCalendar: true)), + .dayOfWeekInMonth( + position: .first, + month: Month(era: 1, year: 2021, month: 01, isInGregorianCalendar: true)), + .dayOfWeekInMonth( + position: .second, + month: Month(era: 1, year: 2021, month: 01, isInGregorianCalendar: true)), + .dayOfWeekInMonth( + position: .third, + month: Month(era: 1, year: 2021, month: 01, isInGregorianCalendar: true)), + .dayOfWeekInMonth( + position: .fourth, + month: Month(era: 1, year: 2021, month: 01, isInGregorianCalendar: true)), + .dayOfWeekInMonth( + position: .fifth, + month: Month(era: 1, year: 2021, month: 01, isInGregorianCalendar: true)), + .dayOfWeekInMonth( + position: .sixth, + month: Month(era: 1, year: 2021, month: 01, isInGregorianCalendar: true)), + .dayOfWeekInMonth( + position: .last, + month: Month(era: 1, year: 2021, month: 01, isInGregorianCalendar: true)), + .day(calendar.day(byAddingDays: 31, to: startDay)), + .day(calendar.day(byAddingDays: 32, to: startDay)), + .day(calendar.day(byAddingDays: 33, to: startDay)), + .day(calendar.day(byAddingDays: 34, to: startDay)), + .day(calendar.day(byAddingDays: 35, to: startDay)), + .day(calendar.day(byAddingDays: 36, to: startDay)), + .day(calendar.day(byAddingDays: 37, to: startDay)), + .day(calendar.day(byAddingDays: 38, to: startDay)), + .day(calendar.day(byAddingDays: 39, to: startDay)), + .day(calendar.day(byAddingDays: 40, to: startDay)), + .day(calendar.day(byAddingDays: 41, to: startDay)), + .day(calendar.day(byAddingDays: 42, to: startDay)), + .day(calendar.day(byAddingDays: 43, to: startDay)), + .day(calendar.day(byAddingDays: 44, to: startDay)), + .day(calendar.day(byAddingDays: 45, to: startDay)), + .day(calendar.day(byAddingDays: 46, to: startDay)), + .day(calendar.day(byAddingDays: 47, to: startDay)), + .day(calendar.day(byAddingDays: 48, to: startDay)), + .day(calendar.day(byAddingDays: 49, to: startDay)), + .day(calendar.day(byAddingDays: 50, to: startDay)), + ] + } + + func testEnumeratingVerticalItems() { + verticalItemTypeEnumerator.enumerateItemTypes( + startingAt: .day(startDay), + itemTypeHandlerLookingBackwards: { itemType, shouldStop in + let expectedItemType = expectedItemTypeStackBackwards.remove(at: 0) + XCTAssert( + itemType == expectedItemType, + "Unexpected item type encountered while enumerating.") + + shouldStop = expectedItemTypeStackBackwards.isEmpty + }, + itemTypeHandlerLookingForwards: { itemType, shouldStop in + let expectedItemType = expectedItemTypeStackForwards.remove(at: 0) + XCTAssert( + itemType == expectedItemType, + "Unexpected item type encountered while enumerating.") + + shouldStop = expectedItemTypeStackForwards.isEmpty + }) + } + + func testEnumeratingVerticalPinnedDaysOfWeekItemsBackwards() { + verticalPinnedDaysOfWeekItemTypeEnumerator.enumerateItemTypes( + startingAt: .day(startDay), + itemTypeHandlerLookingBackwards: { itemType, shouldStop in + var expectedItemType = expectedItemTypeStackBackwards.remove(at: 0) + // Skip days of the week since they're pinned to the top / outside of individual months + while case .dayOfWeekInMonth = expectedItemType { + expectedItemType = expectedItemTypeStackBackwards.remove(at: 0) + } + + XCTAssert( + itemType == expectedItemType, + "Unexpected item type encountered while enumerating.") + + shouldStop = expectedItemTypeStackBackwards.isEmpty + }, + itemTypeHandlerLookingForwards: { itemType, shouldStop in + var expectedItemType = expectedItemTypeStackForwards.remove(at: 0) + // Skip days of the week since they're pinned to the top / outside of individual months + while case .dayOfWeekInMonth = expectedItemType { + expectedItemType = expectedItemTypeStackForwards.remove(at: 0) + } + + XCTAssert( + itemType == expectedItemType, + "Unexpected item type encountered while enumerating.") + + shouldStop = expectedItemTypeStackForwards.isEmpty + }) + } + + func testEnumeratingHorizontalItemsBackwards() { + verticalItemTypeEnumerator.enumerateItemTypes( + startingAt: .day(startDay), + itemTypeHandlerLookingBackwards: { itemType, shouldStop in + let expectedItemType = expectedItemTypeStackBackwards.remove(at: 0) + XCTAssert( + itemType == expectedItemType, + "Unexpected item type encountered while enumerating.") + + shouldStop = expectedItemTypeStackBackwards.isEmpty + }, + itemTypeHandlerLookingForwards: { itemType, shouldStop in + let expectedItemType = expectedItemTypeStackForwards.remove(at: 0) + XCTAssert( + itemType == expectedItemType, + "Unexpected item type encountered while enumerating.") + + shouldStop = expectedItemTypeStackForwards.isEmpty + }) + } + + // MARK: Private + + private let calendar = Calendar(identifier: .gregorian) + private let startDay = Day( + month: Month(era: 1, year: 2020, month: 12, isInGregorianCalendar: true), + day: 1) + + // swiftlint:disable implicitly_unwrapped_optional + + private var verticalItemTypeEnumerator: LayoutItemTypeEnumerator! + private var verticalPinnedDaysOfWeekItemTypeEnumerator: LayoutItemTypeEnumerator! + private var horizontalItemTypeEnumerator: LayoutItemTypeEnumerator! + + private var expectedItemTypeStackBackwards: [LayoutItem.ItemType]! + private var expectedItemTypeStackForwards: [LayoutItem.ItemType]! + +} diff --git a/HorizonCalendarTests [Rec]/MonthHelperTests.swift b/HorizonCalendarTests [Rec]/MonthHelperTests.swift new file mode 100644 index 00000000..c96ba332 --- /dev/null +++ b/HorizonCalendarTests [Rec]/MonthHelperTests.swift @@ -0,0 +1,145 @@ +// Created by Bryan Keller on 6/7/20. +// Copyright © 2020 Airbnb Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import XCTest +@testable import HorizonCalendar + +// MARK: - MonthHelperTests + +final class MonthHelperTests: XCTestCase { + + // MARK: Internal + + func testMonthComparable() { + let december0001BCE = Month(era: 0, year: 0001, month: 12, isInGregorianCalendar: true) + let january0001CE = Month(era: 1, year: 0001, month: 01, isInGregorianCalendar: true) + XCTAssert( + december0001BCE < january0001CE, + "Expected December 0001 BCE < January 0001 CE.") + + let february2020 = Month(era: 1, year: 2020, month: 02, isInGregorianCalendar: true) + let january2019 = Month(era: 1, year: 2019, month: 01, isInGregorianCalendar: true) + XCTAssert( + february2020 > january2019, + "Expected February 2020 > 2019.") + + let march02 = Month(era: 236, year: 02, month: 03, isInGregorianCalendar: false) + let may29 = Month(era: 235, year: 29, month: 05, isInGregorianCalendar: false) + XCTAssert( + march02 > may29, + "Expected March 02 era 236 > May 29 era 235.") + } + + func testMonthContainingDate() { + let january2020Date = gregorianCalendar.date( + from: DateComponents(year: 2020, month: 01, day: 19))! + let january2020 = gregorianCalendar.month(containing: january2020Date) + XCTAssert( + january2020 == Month(era: 1, year: 2020, month: 01, isInGregorianCalendar: true), + "Expected the month to be January 2020.") + + let december0050Date = gregorianCalendar.date( + from: DateComponents(era: 0, year: 0050, month: 12, day: 31))! + let december0050 = gregorianCalendar.month(containing: december0050Date) + XCTAssert( + december0050 == Month(era: 0, year: 0050, month: 12, isInGregorianCalendar: true), + "Expected the month to be December 0050 BCE.") + + let september02Date = japaneseCalendar.date( + from: DateComponents(era: 236, year: 02, month: 09, day: 01))! + let september02 = japaneseCalendar.month(containing: september02Date) + XCTAssert( + september02 == Month(era: 236, year: 02, month: 09, isInGregorianCalendar: false), + "Expected the month to be September 02.") + } + + func testFirstDateOfMonth() { + let january2020Date = gregorianCalendar.firstDate( + of: Month(era: 1, year: 2020, month: 01, isInGregorianCalendar: true)) + let january2020ExpectedDate = gregorianCalendar.date( + from: DateComponents(era: 1, year: 2020, month: 01, day: 01))! + XCTAssert( + january2020Date == january2020ExpectedDate, + "Expected the date to be the earliest possible time for January 2020.") + + let april0050Date = gregorianCalendar.firstDate( + of: Month(era: 0, year: 0050, month: 04, isInGregorianCalendar: true)) + let april0050ExpectedDate = gregorianCalendar.date( + from: DateComponents(era: 0, year: 0050, month: 04, day: 01))! + XCTAssert( + april0050Date == april0050ExpectedDate, + "Expected the date to be the earliest possible time for April 0050 BCE.") + + let may05Date = japaneseCalendar.firstDate( + of: Month(era: 236, year: 05, month: 05, isInGregorianCalendar: false)) + let may05ExpectedDate = japaneseCalendar.date( + from: DateComponents(era: 236, year: 05, month: 05, day: 01))! + XCTAssert( + may05Date == may05ExpectedDate, + "Expected the date to be the earliest possible time for May 05.") + } + + func testLastDateOfMonth() { + let february2020Date = gregorianCalendar.lastDate( + of: Month(era: 1, year: 2020, month: 02, isInGregorianCalendar: true)) + let february2020ExpectedDate = gregorianCalendar.date( + from: DateComponents(era: 1, year: 2020, month: 02, day: 29))! + XCTAssert( + february2020Date == february2020ExpectedDate, + "Expected the date to be the earliest possible time for the last day of February 2020.") + + let october0050Date = gregorianCalendar.lastDate( + of: Month(era: 0, year: 0050, month: 10, isInGregorianCalendar: true)) + let october0050ExpectedDate = gregorianCalendar.date( + from: DateComponents(era: 0, year: 0050, month: 10, day: 31))! + XCTAssert( + october0050Date == october0050ExpectedDate, + "Expected the date to be the earliest possible time for the last day of October 0050 BCE.") + + let july06Date = japaneseCalendar.lastDate( + of: Month(era: 236, year: 06, month: 07, isInGregorianCalendar: false)) + let july06ExpectedDate = japaneseCalendar.date( + from: DateComponents(era: 236, year: 06, month: 07, day: 31))! + XCTAssert( + july06Date == july06ExpectedDate, + "Expected the date to be the earliest possible time for the last day of July 06.") + } + + func testMonthByAddingMonths() { + let june0001BCE = Month(era: 0, year: 0001, month: 06, isInGregorianCalendar: true) + let january0001CE = Month(era: 1, year: 0001, month: 01, isInGregorianCalendar: true) + XCTAssert( + gregorianCalendar.month(byAddingMonths: 7, to: june0001BCE) == january0001CE, + "Expected June 0001 BCE + 7 = January 0001 CE.") + + let february2020 = Month(era: 1, year: 2020, month: 02, isInGregorianCalendar: true) + let january2019 = Month(era: 1, year: 2019, month: 01, isInGregorianCalendar: true) + XCTAssert( + gregorianCalendar.month(byAddingMonths: -13, to: february2020) == january2019, + "Expected February 2020 - 13 = January 2019.") + + let march02 = Month(era: 236, year: 02, month: 03, isInGregorianCalendar: false) + let july02 = Month(era: 236, year: 02, month: 07, isInGregorianCalendar: false) + XCTAssert( + japaneseCalendar.month(byAddingMonths: 4, to: march02) == july02, + "Expected March 02 + 4 = July 02.") + } + + // MARK: Private + + private lazy var gregorianCalendar = Calendar(identifier: .gregorian) + private lazy var japaneseCalendar = Calendar(identifier: .japanese) + +} diff --git a/HorizonCalendarTests [Rec]/MonthRowTests.swift b/HorizonCalendarTests [Rec]/MonthRowTests.swift new file mode 100644 index 00000000..6509613a --- /dev/null +++ b/HorizonCalendarTests [Rec]/MonthRowTests.swift @@ -0,0 +1,72 @@ +// Created by Bryan Keller on 6/7/20. +// Copyright © 2020 Airbnb Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import XCTest +@testable import HorizonCalendar + +// MARK: - MonthRowTests + +final class MonthRowTests: XCTestCase { + + // MARK: Internal + + func testRowInMonthForDate() { + let date0 = gregorianCalendar.date(from: DateComponents(year: 2020, month: 06, day: 02))! + XCTAssert( + gregorianCalendar.rowInMonth(for: date0) == 0, + "Expected June 2, 2020 to be in the first row of the month.") + + let date1 = gregorianCalendar.date(from: DateComponents(year: 2020, month: 02, day: 05))! + XCTAssert( + gregorianCalendar.rowInMonth(for: date1) == 1, + "Expected February 5, 2020 to be in the second row of the month.") + + let date2 = gregorianCalendar.date(from: DateComponents(year: 1500, month: 12, day: 15))! + XCTAssert( + gregorianCalendar.rowInMonth(for: date2) == 2, + "Expected December 15, 1500 to be in the third row of the month.") + + let date3 = gregorianCalendar.date(from: DateComponents(year: 0001, month: 02, day: 21))! + XCTAssert( + gregorianCalendar.rowInMonth(for: date3) == 3, + "Expected February 21, 0001 CE to be in the fourth row of the month.") + + let date4 = gregorianCalendar.date(from: DateComponents(year: 2020, month: 06, day: 30))! + XCTAssert( + gregorianCalendar.rowInMonth(for: date4) == 4, + "Expected June 30, 2020 to be in the fifth row of the month.") + + let date5 = gregorianCalendar.date(from: DateComponents(year: 2020, month: 05, day: 31))! + XCTAssert( + gregorianCalendar.rowInMonth(for: date5) == 5, + "Expected May 31, 2020 to be in the sixth row of the month.") + + let date6 = japaneseCalendar.date(from: DateComponents(era: 236, year: 01, month: 03, day: 01))! + XCTAssert( + japaneseCalendar.rowInMonth(for: date6) == 0, + "Expected March 1, 01 era 236 to be in the first row of the month.") + + let date7 = japaneseCalendar.date(from: DateComponents(era: 236, year: 01, month: 03, day: 31))! + XCTAssert( + japaneseCalendar.rowInMonth(for: date7) == 5, + "Expected March 31, 01 era 236 to be in the sixth row of the month.") + } + + // MARK: Private + + private lazy var gregorianCalendar = Calendar(identifier: .gregorian) + private lazy var japaneseCalendar = Calendar(identifier: .japanese) + +} diff --git a/HorizonCalendarTests [Rec]/MonthTests.swift b/HorizonCalendarTests [Rec]/MonthTests.swift new file mode 100644 index 00000000..9dea1ae6 --- /dev/null +++ b/HorizonCalendarTests [Rec]/MonthTests.swift @@ -0,0 +1,62 @@ +// Created by Bryan Keller on 4/20/20. +// Copyright © 2020 Airbnb Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import XCTest +@testable import HorizonCalendar + +// MARK: - MonthTests + +final class MonthTests: XCTestCase { + + // MARK: Internal + + // MARK: - Advancing Months Tests + + func testAdvancingByNothing() { + let month = calendar.month( + byAddingMonths: 0, + to: Month(era: 1, year: 2020, month: 01, isInGregorianCalendar: true)) + XCTAssert(month == Month(era: 1, year: 2020, month: 01, isInGregorianCalendar: true), "Expected 2020-01.") + } + + func testAdvancingByLessThanOneYear() { + let month1 = calendar.month( + byAddingMonths: 4, + to: Month(era: 1, year: 2020, month: 06, isInGregorianCalendar: true)) + XCTAssert(month1 == Month(era: 1, year: 2020, month: 10, isInGregorianCalendar: true), "Expected 2020-10.") + + let month2 = calendar.month( + byAddingMonths: -4, + to: Month(era: 1, year: 2020, month: 06, isInGregorianCalendar: true)) + XCTAssert(month2 == Month(era: 1, year: 2020, month: 02, isInGregorianCalendar: true), "Expected 2020-02.") + } + + func testAdvancingByMoreThanOneYear() { + let month1 = calendar.month( + byAddingMonths: 16, + to: Month(era: 1, year: 2020, month: 08, isInGregorianCalendar: true)) + XCTAssert(month1 == Month(era: 1, year: 2021, month: 12, isInGregorianCalendar: true), "Expected 2021-12.") + + let month2 = calendar.month( + byAddingMonths: -25, + to: Month(era: 1, year: 2020, month: 06, isInGregorianCalendar: true)) + XCTAssert(month2 == Month(era: 1, year: 2018, month: 05, isInGregorianCalendar: true), "Expected 2018-05.") + } + + // MARK: Private + + private let calendar = Calendar(identifier: .gregorian) + +} diff --git a/HorizonCalendarTests [Rec]/PaginationHelpersTests.swift b/HorizonCalendarTests [Rec]/PaginationHelpersTests.swift new file mode 100644 index 00000000..7d78d212 --- /dev/null +++ b/HorizonCalendarTests [Rec]/PaginationHelpersTests.swift @@ -0,0 +1,331 @@ +// Created by Bryan Keller on 1/26/21. +// Copyright © 2021 Airbnb Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import XCTest +@testable import HorizonCalendar + +final class PaginationHelpersTests: XCTestCase { + + func testClosestPageIndex() throws { + XCTAssert(PaginationHelpers.closestPageIndex(forOffset: 0, pageSize: 100) == 0) + XCTAssert(PaginationHelpers.closestPageIndex(forOffset: -100, pageSize: 100) == -1) + XCTAssert(PaginationHelpers.closestPageIndex(forOffset: 100, pageSize: 100) == 1) + XCTAssert(PaginationHelpers.closestPageIndex(forOffset: -49, pageSize: 100) == 0) + XCTAssert(PaginationHelpers.closestPageIndex(forOffset: -51, pageSize: 100) == -1) + XCTAssert(PaginationHelpers.closestPageIndex(forOffset: 49, pageSize: 100) == 0) + XCTAssert(PaginationHelpers.closestPageIndex(forOffset: 51, pageSize: 100) == 1) + XCTAssert(PaginationHelpers.closestPageIndex(forOffset: 799, pageSize: 100) == 8) + XCTAssert(PaginationHelpers.closestPageIndex(forOffset: 801, pageSize: 100) == 8) + XCTAssert(PaginationHelpers.closestPageIndex(forOffset: -799, pageSize: 100) == -8) + XCTAssert(PaginationHelpers.closestPageIndex(forOffset: -801, pageSize: 100) == -8) + } + + // MARK: Closest Page Offset Tests + + func testClosestPageOffsetsNoOffsetNoVelocity() { + let offset1 = PaginationHelpers.closestPageOffset( + toTargetOffset: 75, + touchUpOffset: 75, + velocity: 0, + pageSize: 100) + XCTAssert(offset1 == 100) + + let offset2 = PaginationHelpers.closestPageOffset( + toTargetOffset: -75, + touchUpOffset: -75, + velocity: 0, + pageSize: 100) + XCTAssert(offset2 == -100) + + let offset3 = PaginationHelpers.closestPageOffset( + toTargetOffset: 25, + touchUpOffset: 25, + velocity: 0, + pageSize: 100) + XCTAssert(offset3 == 0) + + let offset4 = PaginationHelpers.closestPageOffset( + toTargetOffset: -25, + touchUpOffset: -25, + velocity: 0, + pageSize: 100) + XCTAssert(offset4 == 0) + + let offset5 = PaginationHelpers.closestPageOffset( + toTargetOffset: 150, + touchUpOffset: 150, + velocity: 0, + pageSize: 100) + XCTAssert(offset5 == 200) + + let offset6 = PaginationHelpers.closestPageOffset( + toTargetOffset: -150, + touchUpOffset: -150, + velocity: 0, + pageSize: 100) + XCTAssert(offset6 == -200) + } + + func testClosestPageOffsetsSmallOffsetSmallVelocity() { + let offset1 = PaginationHelpers.closestPageOffset( + toTargetOffset: 20, + touchUpOffset: 10, + velocity: 1, + pageSize: 100) + XCTAssert(offset1 == 100) + + let offset2 = PaginationHelpers.closestPageOffset( + toTargetOffset: -20, + touchUpOffset: -10, + velocity: -1, + pageSize: 100) + XCTAssert(offset2 == -100) + + let offset3 = PaginationHelpers.closestPageOffset( + toTargetOffset: 90, + touchUpOffset: 80, + velocity: 1, + pageSize: 100) + XCTAssert(offset3 == 100) + + let offset4 = PaginationHelpers.closestPageOffset( + toTargetOffset: -80, + touchUpOffset: -80, + velocity: -1, + pageSize: 100) + XCTAssert(offset4 == -100) + + let offset5 = PaginationHelpers.closestPageOffset( + toTargetOffset: 145, + touchUpOffset: 135, + velocity: 1, + pageSize: 100) + XCTAssert(offset5 == 200) + + let offset6 = PaginationHelpers.closestPageOffset( + toTargetOffset: -145, + touchUpOffset: -135, + velocity: -1, + pageSize: 100) + XCTAssert(offset6 == -200) + } + + func testClosestPageOffsetsNormalOffsetNormalVelocity() { + let offset1 = PaginationHelpers.closestPageOffset( + toTargetOffset: 90, + touchUpOffset: 40, + velocity: 5, + pageSize: 100) + XCTAssert(offset1 == 100) + + let offset2 = PaginationHelpers.closestPageOffset( + toTargetOffset: -90, + touchUpOffset: -50, + velocity: -5, + pageSize: 100) + XCTAssert(offset2 == -100) + + let offset3 = PaginationHelpers.closestPageOffset( + toTargetOffset: 260, + touchUpOffset: 110, + velocity: 10, + pageSize: 100) + XCTAssert(offset3 == 300) + + let offset4 = PaginationHelpers.closestPageOffset( + toTargetOffset: -260, + touchUpOffset: -110, + velocity: -10, + pageSize: 100) + XCTAssert(offset4 == -300) + + let offset5 = PaginationHelpers.closestPageOffset( + toTargetOffset: 969, + touchUpOffset: 420, + velocity: 13, + pageSize: 100) + XCTAssert(offset5 == 1000) + + let offset6 = PaginationHelpers.closestPageOffset( + toTargetOffset: -969, + touchUpOffset: -420, + velocity: -13, + pageSize: 100) + XCTAssert(offset6 == -1000) + + let offset7 = PaginationHelpers.closestPageOffset( + toTargetOffset: -175, + touchUpOffset: 100, + velocity: -12, + pageSize: 100) + XCTAssert(offset7 == -200) + + let offset8 = PaginationHelpers.closestPageOffset( + toTargetOffset: 175, + touchUpOffset: -100, + velocity: 12, + pageSize: 100) + XCTAssert(offset8 == 200) + } + + // MARK: Adjacent Page Offset Tests + + func testAdjacentPageOffsetsNoOffsetNoVelocity() { + let offset1 = PaginationHelpers.adjacentPageOffset( + toPreviousPageIndex: 0, + targetOffset: 75, + velocity: 0, + pageSize: 100) + XCTAssert(offset1 == 100) + + let offset2 = PaginationHelpers.adjacentPageOffset( + toPreviousPageIndex: 0, + targetOffset: -75, + velocity: 0, + pageSize: 100) + XCTAssert(offset2 == -100) + + let offset3 = PaginationHelpers.adjacentPageOffset( + toPreviousPageIndex: 0, + targetOffset: 25, + velocity: 0, + pageSize: 100) + XCTAssert(offset3 == 0) + + let offset4 = PaginationHelpers.adjacentPageOffset( + toPreviousPageIndex: 0, + targetOffset: -25, + velocity: 0, + pageSize: 100) + XCTAssert(offset4 == 0) + + let offset5 = PaginationHelpers.adjacentPageOffset( + toPreviousPageIndex: 0, + targetOffset: 150, + velocity: 0, + pageSize: 100) + XCTAssert(offset5 == 100) + + let offset6 = PaginationHelpers.adjacentPageOffset( + toPreviousPageIndex: 0, + targetOffset: -150, + velocity: 0, + pageSize: 100) + XCTAssert(offset6 == -100) + + let offset7 = PaginationHelpers.adjacentPageOffset( + toPreviousPageIndex: 5, + targetOffset: 700, + velocity: 0, + pageSize: 100) + XCTAssert(offset7 == 600) + + let offset8 = PaginationHelpers.adjacentPageOffset( + toPreviousPageIndex: -5, + targetOffset: -700, + velocity: 0, + pageSize: 100) + XCTAssert(offset8 == -600) + } + + func testAdjacentPageOffsetsSmallOffsetSmallVelocity() { + let offset1 = PaginationHelpers.adjacentPageOffset( + toPreviousPageIndex: 0, + targetOffset: 20, + velocity: 1, + pageSize: 100) + XCTAssert(offset1 == 100) + + let offset2 = PaginationHelpers.adjacentPageOffset( + toPreviousPageIndex: 0, + targetOffset: -20, + velocity: -1, + pageSize: 100) + XCTAssert(offset2 == -100) + + let offset3 = PaginationHelpers.adjacentPageOffset( + toPreviousPageIndex: 1, + targetOffset: 110, + velocity: 1, + pageSize: 100) + XCTAssert(offset3 == 200) + + let offset4 = PaginationHelpers.adjacentPageOffset( + toPreviousPageIndex: -1, + targetOffset: -110, + velocity: -1, + pageSize: 100) + XCTAssert(offset4 == -200) + + let offset5 = PaginationHelpers.adjacentPageOffset( + toPreviousPageIndex: 5, + targetOffset: 501, + velocity: 1, + pageSize: 100) + XCTAssert(offset5 == 600) + + let offset6 = PaginationHelpers.adjacentPageOffset( + toPreviousPageIndex: -5, + targetOffset: -501, + velocity: -1, + pageSize: 100) + XCTAssert(offset6 == -600) + } + + func testAdjacentPageOffsetsLargelOffsetNormalVelocity() { + let offset1 = PaginationHelpers.adjacentPageOffset( + toPreviousPageIndex: 0, + targetOffset: 310, + velocity: 5, + pageSize: 100) + XCTAssert(offset1 == 100) + + let offset2 = PaginationHelpers.adjacentPageOffset( + toPreviousPageIndex: 0, + targetOffset: -310, + velocity: -5, + pageSize: 100) + XCTAssert(offset2 == -100) + + let offset3 = PaginationHelpers.adjacentPageOffset( + toPreviousPageIndex: 10, + targetOffset: 1600, + velocity: 10, + pageSize: 100) + XCTAssert(offset3 == 1100) + + let offset4 = PaginationHelpers.adjacentPageOffset( + toPreviousPageIndex: -10, + targetOffset: -1600, + velocity: -10, + pageSize: 100) + XCTAssert(offset4 == -1100) + + let offset5 = PaginationHelpers.adjacentPageOffset( + toPreviousPageIndex: -1, + targetOffset: -10, + velocity: 5, + pageSize: 100) + XCTAssert(offset5 == 0) + + let offset6 = PaginationHelpers.adjacentPageOffset( + toPreviousPageIndex: 1, + targetOffset: 10, + velocity: -5, + pageSize: 100) + XCTAssert(offset6 == 0) + } + +} diff --git a/HorizonCalendarTests [Rec]/ScreenPixelAlignmentTests.swift b/HorizonCalendarTests [Rec]/ScreenPixelAlignmentTests.swift new file mode 100644 index 00000000..4eaed189 --- /dev/null +++ b/HorizonCalendarTests [Rec]/ScreenPixelAlignmentTests.swift @@ -0,0 +1,135 @@ +// Created by Bryan Keller on 3/31/20. +// Copyright © 2020 Airbnb Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import XCTest +@testable import HorizonCalendar + +// MARK: - ScreenPixelAlignmentTests + +final class ScreenPixelAlignmentTests: XCTestCase { + + // MARK: Value alignment tests + + func test1xScaleValueAlignment() { + XCTAssert( + CGFloat(1).alignedToPixel(forScreenWithScale: 1) == CGFloat(1), + "Incorrect screen pixel alignment") + XCTAssert( + CGFloat(1.5).alignedToPixel(forScreenWithScale: 1) == CGFloat(2), + "Incorrect screen pixel alignment") + XCTAssert( + CGFloat(500.8232134315).alignedToPixel(forScreenWithScale: 1) == CGFloat(501), + "Incorrect screen pixel alignment") + } + + func test2xScaleValueAlignment() { + XCTAssert( + CGFloat(1).alignedToPixel(forScreenWithScale: 2) == CGFloat(1), + "Incorrect screen pixel alignment") + XCTAssert( + CGFloat(1.5).alignedToPixel(forScreenWithScale: 2) == CGFloat(1.5), + "Incorrect screen pixel alignment") + XCTAssert( + CGFloat(500.8232134315).alignedToPixel(forScreenWithScale: 2) == CGFloat(501), + "Incorrect screen pixel alignment") + } + + func test3xScaleValueAlignment() { + XCTAssert( + CGFloat(1).alignedToPixel(forScreenWithScale: 3) == CGFloat(1), + "Incorrect screen pixel alignment") + XCTAssert( + CGFloat(1.5).alignedToPixel(forScreenWithScale: 3) == CGFloat(1.6666666666666667), + "Incorrect screen pixel alignment") + XCTAssert( + CGFloat(500.8232134315).alignedToPixel(forScreenWithScale: 3) == CGFloat(500.6666666666667), + "Incorrect screen pixel alignment") + } + + // MARK: Point alignment tests + + func test1xScalePointAlignment() { + let point1 = CGPoint(x: 1, y: 2.3) + XCTAssert( + point1.alignedToPixels(forScreenWithScale: 1) == CGPoint(x: 1, y: 2), + "Incorrect screen pixel alignment") + + let point2 = CGPoint(x: 100.05, y: -50.51) + XCTAssert( + point2.alignedToPixels(forScreenWithScale: 1) == CGPoint(x: 100, y: -51), + "Incorrect screen pixel alignment") + } + + func test2xScalePointAlignment() { + let point1 = CGPoint(x: -0.6, y: 199) + XCTAssert( + point1.alignedToPixels(forScreenWithScale: 2) == CGPoint(x: -0.5, y: 199), + "Incorrect screen pixel alignment") + + let point2 = CGPoint(x: 52.33333333, y: 52.249999) + XCTAssert( + point2.alignedToPixels(forScreenWithScale: 2) == CGPoint(x: 52.5, y: 52), + "Incorrect screen pixel alignment") + } + + func test3xScalePointAlignment() { + let point1 = CGPoint(x: -5.6, y: 0.85) + XCTAssert( + point1.alignedToPixels(forScreenWithScale: 3) == CGPoint(x: -5.666666666666667, y: 1), + "Incorrect screen pixel alignment") + + let point2 = CGPoint(x: 99.91, y: 13.25) + XCTAssert( + point2.alignedToPixels(forScreenWithScale: 3) == CGPoint(x: 100, y: 13.333333333333334), + "Incorrect screen pixel alignment") + } + + // MARK: Rectangle alignment tests + + func test1xScaleRectAlignment() { + let rect = CGRect(x: 0, y: 1.24, width: 10.25, height: 11.76) + let expectedRect = CGRect(x: 0, y: 1, width: 10, height: 12) + XCTAssert( + rect.alignedToPixels(forScreenWithScale: 1) == expectedRect, + "Incorrect screen pixel alignment") + } + + func test2xScaleRectAlignment() { + let rect = CGRect(x: 5.299999, y: -19.1994, width: 20.25, height: 0.76) + let expectedRect = CGRect(x: 5.5, y: -19, width: 20.5, height: 1) + XCTAssert( + rect.alignedToPixels(forScreenWithScale: 2) == expectedRect, + "Incorrect screen pixel alignment") + } + + func test3xScaleRectAlignment() { + let rect = CGRect(x: 71.13, y: 71.19, width: 20.25, height: 2) + let expectedRect = CGRect(x: 71, y: 71.33333333333333, width: 20.333333333333332, height: 2) + XCTAssert( + rect.alignedToPixels(forScreenWithScale: 3) == expectedRect, + "Incorrect screen pixel alignment") + } + + // MARK: CGFloat Approximate Comparison Tests + + func testApproximateEquality() { + XCTAssert(CGFloat(1.48).isEqual(to: 1.52, screenScale: 2)) + XCTAssert(!CGFloat(1).isEqual(to: 10, screenScale: 9)) + XCTAssert(!CGFloat(1).isEqual(to: 10, screenScale: 9)) + XCTAssert(!CGFloat(1).isEqual(to: 9, screenScale: 9)) + XCTAssert(!CGFloat(1.333).isEqual(to: 1.666, screenScale: 3)) + } + +} diff --git a/HorizonCalendarTests [Rec]/ScrollMetricsMutatorTests.swift b/HorizonCalendarTests [Rec]/ScrollMetricsMutatorTests.swift new file mode 100644 index 00000000..36a05f5a --- /dev/null +++ b/HorizonCalendarTests [Rec]/ScrollMetricsMutatorTests.swift @@ -0,0 +1,327 @@ +// Created by Bryan Keller on 3/26/20. +// Copyright © 2020 Airbnb Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import XCTest +@testable import HorizonCalendar + +// MARK: - ScrollMetricsMutatorTests + +final class ScrollMetricsMutatorTests: XCTestCase { + + // MARK: Internal + + override func setUp() { + verticalScrollMetricsProvider = Self.mockScrollMetricsProvider() + horizontalScrollMetricsProvider = Self.mockScrollMetricsProvider() + + let initialSize = CGSize(width: 320, height: 480) + + verticalScrollMetricsMutator = ScrollMetricsMutator( + scrollMetricsProvider: verticalScrollMetricsProvider, + scrollAxis: .vertical) + verticalScrollMetricsMutator.setUpInitialMetricsIfNeeded() + verticalScrollMetricsMutator.updateContentSizePerpendicularToScrollAxis( + viewportSize: initialSize) + + horizontalScrollMetricsMutator = ScrollMetricsMutator( + scrollMetricsProvider: horizontalScrollMetricsProvider, + scrollAxis: .horizontal) + horizontalScrollMetricsMutator.setUpInitialMetricsIfNeeded() + horizontalScrollMetricsMutator.updateContentSizePerpendicularToScrollAxis( + viewportSize: initialSize) + } + + // MARK: Boundary inset setting + + func testNoVerticalBoundariesVisible() { + let initialOffset = verticalScrollMetricsProvider.offset(for: .vertical) + let initialTopInset = verticalScrollMetricsProvider.startInset(for: .vertical) + let initialBottomInset = verticalScrollMetricsProvider.endInset(for: .vertical) + + verticalScrollMetricsMutator.updateScrollBoundaries( + minimumScrollOffset: nil, + maximumScrollOffset: nil) + + let finalOffset = verticalScrollMetricsProvider.offset(for: .vertical) + let finalTopInset = verticalScrollMetricsProvider.startInset(for: .vertical) + let finalBottomInset = verticalScrollMetricsProvider.endInset(for: .vertical) + + XCTAssert(initialOffset == finalOffset, "Offset changed despite no boundaries being visible.") + XCTAssert( + initialTopInset == finalTopInset, + "Top inset changed despite no boundaries being visible.") + XCTAssert( + initialBottomInset == finalBottomInset, + "Bottom inset changed despite no boundaries being visible.") + } + + func testTopBoundaryBecomingVisible() { + let initialOffset = verticalScrollMetricsProvider.offset(for: .vertical) + let initialBottomInset = verticalScrollMetricsProvider.endInset(for: .vertical) + + let minimumScrollOffset = CGFloat(1000) + verticalScrollMetricsMutator.updateScrollBoundaries( + minimumScrollOffset: minimumScrollOffset, + maximumScrollOffset: nil) + + let finalOffset = verticalScrollMetricsProvider.offset(for: .vertical) + let finalTopInset = verticalScrollMetricsProvider.startInset(for: .vertical) + let finalBottomInset = verticalScrollMetricsProvider.endInset(for: .vertical) + + XCTAssert( + initialOffset == finalOffset, + "Offset should not change when updating scroll boundaries.") + XCTAssert( + finalTopInset == -minimumScrollOffset, + "Top inset does not equal the negated minimum scroll offset.") + XCTAssert( + initialBottomInset == finalBottomInset, + "Bottom inset should not change unless the maximum scroll offset is set.") + } + + func testBottomBoundaryBecomingVisible() { + let initialOffset = verticalScrollMetricsProvider.offset(for: .vertical) + let initialTopInset = verticalScrollMetricsProvider.startInset(for: .vertical) + + let maximumScrollOffset = CGFloat(3000) + verticalScrollMetricsMutator.updateScrollBoundaries( + minimumScrollOffset: nil, + maximumScrollOffset: maximumScrollOffset) + + let finalOffset = verticalScrollMetricsProvider.offset(for: .vertical) + let finalTopInset = verticalScrollMetricsProvider.startInset(for: .vertical) + let finalBottomInset = verticalScrollMetricsProvider.endInset(for: .vertical) + + let size = verticalScrollMetricsProvider.size(for: .vertical) + let expectedBottomInset = -(size - maximumScrollOffset) + + XCTAssert( + initialOffset == finalOffset, + "Offset should not change when updating scroll boundaries.") + XCTAssert( + initialTopInset == finalTopInset, + "Top inset should not change unless the minimum scroll offset is set.") + XCTAssert( + finalBottomInset == expectedBottomInset, + "Bottom inset does not equal the negated size minus maximum scroll offset.") + } + + func testTopAndBottomBoundaryBecomingVisible() { + let initialOffset = verticalScrollMetricsProvider.offset(for: .vertical) + + let minimumScrollOffset = CGFloat(1000) + let maximumScrollOffset = CGFloat(3000) + verticalScrollMetricsMutator.updateScrollBoundaries( + minimumScrollOffset: minimumScrollOffset, + maximumScrollOffset: maximumScrollOffset) + + let finalOffset = verticalScrollMetricsProvider.offset(for: .vertical) + let finalTopInset = verticalScrollMetricsProvider.startInset(for: .vertical) + let finalBottomInset = verticalScrollMetricsProvider.endInset(for: .vertical) + + let size = verticalScrollMetricsProvider.size(for: .vertical) + let expectedBottomInset = -(size - maximumScrollOffset) + + XCTAssert( + initialOffset == finalOffset, + "Offset should not change when updating scroll boundaries.") + XCTAssert( + finalTopInset == -minimumScrollOffset, + "Top inset does not equal the negated minimum scroll offset.") + XCTAssert( + finalBottomInset == expectedBottomInset, + "Bottom inset does not equal the negated size minus maximum scroll offset.") + } + + func testNoHorizontalBoundariesVisible() { + let initialOffset = horizontalScrollMetricsProvider.offset(for: .horizontal) + let initialLeftInset = horizontalScrollMetricsProvider.startInset(for: .horizontal) + let initialRightInset = horizontalScrollMetricsProvider.endInset(for: .horizontal) + + horizontalScrollMetricsMutator.updateScrollBoundaries( + minimumScrollOffset: nil, + maximumScrollOffset: nil) + + let finalOffset = horizontalScrollMetricsProvider.offset(for: .horizontal) + let finalLeftInset = horizontalScrollMetricsProvider.startInset(for: .horizontal) + let finalRightInset = horizontalScrollMetricsProvider.endInset(for: .horizontal) + + XCTAssert(initialOffset == finalOffset, "Offset changed despite no boundaries being visible.") + XCTAssert( + initialLeftInset == finalLeftInset, + "Left inset changed despite no boundaries being visible.") + XCTAssert( + initialRightInset == finalRightInset, + "Right inset changed despite no boundaries being visible.") + } + + func testLeftBoundaryBecomingVisible() { + let initialOffset = horizontalScrollMetricsProvider.offset(for: .horizontal) + let initialRightInset = horizontalScrollMetricsProvider.endInset(for: .horizontal) + + let minimumScrollOffset = CGFloat(1000) + horizontalScrollMetricsMutator.updateScrollBoundaries( + minimumScrollOffset: minimumScrollOffset, + maximumScrollOffset: nil) + + let finalOffset = horizontalScrollMetricsProvider.offset(for: .horizontal) + let finalLeftInset = horizontalScrollMetricsProvider.startInset(for: .horizontal) + let finalRightInset = horizontalScrollMetricsProvider.endInset(for: .horizontal) + + XCTAssert( + initialOffset == finalOffset, + "Offset should not change when updating scroll boundaries.") + XCTAssert( + finalLeftInset == -minimumScrollOffset, + "Left inset does not equal the negated minimum scroll offset.") + XCTAssert( + initialRightInset == finalRightInset, + "Right inset should not change unless the maximum scroll offset is set.") + } + + func testRightBoundaryBecomingVisible() { + let initialOffset = horizontalScrollMetricsProvider.offset(for: .horizontal) + let initialLeftInset = horizontalScrollMetricsProvider.startInset(for: .horizontal) + + let maximumScrollOffset = CGFloat(3000) + horizontalScrollMetricsMutator.updateScrollBoundaries( + minimumScrollOffset: nil, + maximumScrollOffset: maximumScrollOffset) + + let finalOffset = horizontalScrollMetricsProvider.offset(for: .horizontal) + let finalLeftInset = horizontalScrollMetricsProvider.startInset(for: .horizontal) + let finalRightInset = horizontalScrollMetricsProvider.endInset(for: .horizontal) + + let size = horizontalScrollMetricsProvider.size(for: .horizontal) + let expectedRightInset = -(size - maximumScrollOffset) + + XCTAssert( + initialOffset == finalOffset, + "Offset should not change when updating scroll boundaries.") + XCTAssert( + initialLeftInset == finalLeftInset, + "Left inset should not change unless the minimum scroll offset is set.") + XCTAssert( + finalRightInset == expectedRightInset, + "Right inset does not equal the negated size minus maximum scroll offset.") + } + + func testLeftAndRightBoundaryBecomingVisible() { + let initialOffset = horizontalScrollMetricsProvider.offset(for: .horizontal) + + let minimumScrollOffset = CGFloat(1000) + let maximumScrollOffset = CGFloat(3000) + horizontalScrollMetricsMutator.updateScrollBoundaries( + minimumScrollOffset: minimumScrollOffset, + maximumScrollOffset: maximumScrollOffset) + + let finalOffset = horizontalScrollMetricsProvider.offset(for: .horizontal) + let finalLeftInset = horizontalScrollMetricsProvider.startInset(for: .horizontal) + let finalRightInset = horizontalScrollMetricsProvider.endInset(for: .horizontal) + + let size = horizontalScrollMetricsProvider.size(for: .horizontal) + let expectedRightInset = -(size - maximumScrollOffset) + + XCTAssert( + initialOffset == finalOffset, + "Offset should not change when updating scroll boundaries.") + XCTAssert( + finalLeftInset == -minimumScrollOffset, + "Left inset does not equal the negated minimum scroll offset.") + XCTAssert( + finalRightInset == expectedRightInset, + "Right inset does not equal the negated size minus maximum scroll offset.") + } + + // MARK: Scroll position offsetting + + func testVerticalOffsetAdjustments() { + let initialOffset = verticalScrollMetricsProvider.offset(for: .vertical) + verticalScrollMetricsMutator.applyOffset(500) + XCTAssert( + initialOffset + 500 == verticalScrollMetricsProvider.offset(for: .vertical), + "Scroll offset does not equal the initial offset + 500.") + + let initialOffset2 = verticalScrollMetricsProvider.offset(for: .vertical) + verticalScrollMetricsMutator.applyOffset(-30) + XCTAssert( + initialOffset2 - 30 == verticalScrollMetricsProvider.offset(for: .vertical), + "Scroll offset does not equal the initial offset - 30.") + } + + func testHorizontalOffsetAdjustments() { + let initialOffset = horizontalScrollMetricsProvider.offset(for: .horizontal) + horizontalScrollMetricsMutator.applyOffset(25) + XCTAssert( + initialOffset + 25 == horizontalScrollMetricsProvider.offset(for: .horizontal), + "Scroll offset does not equal the initial offset + 25.") + + let initialOffset2 = horizontalScrollMetricsProvider.offset(for: .horizontal) + horizontalScrollMetricsMutator.applyOffset(-1000) + XCTAssert( + initialOffset2 - 1000 == horizontalScrollMetricsProvider.offset(for: .horizontal), + "Scroll offset does not equal the initial offset - 1000.") + } + + // MARK: Scroll Boundary Offsets + + func testVerticalMinimumScrollOffset() { + verticalScrollMetricsProvider.setStartInset(to: 100, for: .vertical) + XCTAssert( + verticalScrollMetricsProvider.minimumOffset(for: .vertical) == -100, + "The minimum offset should equal the negated top inset.") + } + + func testHorizontalMinimumScrollOffset() { + horizontalScrollMetricsProvider.setStartInset(to: 300, for: .horizontal) + XCTAssert( + horizontalScrollMetricsProvider.minimumOffset(for: .horizontal) == -300, + "The minimum offset should equal the negated left inset.") + } + + func testVerticalMaximumScrollOffset() { + verticalScrollMetricsProvider.setEndInset(to: -50, for: .vertical) + XCTAssert( + verticalScrollMetricsProvider.maximumOffset(for: .vertical) == 9999999999470.0, + "The maximum offset should equal the content height plus bottom inset minus bounds height.") + } + + func testHorizontalMaximumScrollOffset() { + horizontalScrollMetricsProvider.setEndInset(to: -80, for: .horizontal) + XCTAssert( + horizontalScrollMetricsProvider.maximumOffset(for: .horizontal) == 9999999999600.0, + "The maximum offset should equal the content width plus right inset minus bounds width.") + } + + // MARK: Private + + // swiftlint:disable implicitly_unwrapped_optional + + private var verticalScrollMetricsProvider: ScrollMetricsProvider! + private var horizontalScrollMetricsProvider: ScrollMetricsProvider! + + private var verticalScrollMetricsMutator: ScrollMetricsMutator! + private var horizontalScrollMetricsMutator: ScrollMetricsMutator! + + private static func mockScrollMetricsProvider() -> ScrollMetricsProvider { + let scrollView = UIScrollView(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + scrollView.contentInsetAdjustmentBehavior = .never + scrollView.contentSize = CGSize(width: 10, height: 10) + scrollView.contentOffset = CGPoint(x: 1, y: -1) + scrollView.contentInset = UIEdgeInsets(top: 1, left: 2, bottom: 3, right: 4) + return scrollView + } + +} diff --git a/HorizonCalendarTests [Rec]/SubviewsManagerTests.swift b/HorizonCalendarTests [Rec]/SubviewsManagerTests.swift new file mode 100644 index 00000000..54139fb8 --- /dev/null +++ b/HorizonCalendarTests [Rec]/SubviewsManagerTests.swift @@ -0,0 +1,152 @@ +// Created by Bryan Keller on 12/15/22. +// Copyright © 2022 Airbnb Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import XCTest +@testable import HorizonCalendar + +// MARK: - SubviewInsertionIndexTrackerTests + +final class SubviewInsertionIndexTrackerTests: XCTestCase { + + // MARK: Internal + + func testCorrectSubviewsOrderFewItems() throws { + let itemTypesToInsert: [VisibleItem.ItemType] = [ + .pinnedDayOfWeek(.first), + .pinnedDaysOfWeekRowSeparator, + .pinnedDaysOfWeekRowBackground, + .overlayItem(.monthHeader(monthContainingDate: Date())), + .daysOfWeekRowSeparator( + Month(era: 1, year: 2022, month: 1, isInGregorianCalendar: true)), + .layoutItemType( + .monthHeader(Month(era: 1, year: 2022, month: 1, isInGregorianCalendar: true))), + .dayRange(.init(containing: Date()...Date(), in: .current)), + ] + + var itemTypes = [VisibleItem.ItemType]() + for itemTypeToInsert in itemTypesToInsert { + let insertionIndex = subviewInsertionIndexTracker.insertionIndex( + forSubviewWithCorrespondingItemType: itemTypeToInsert) + itemTypes.insert(itemTypeToInsert, at: insertionIndex) + } + + XCTAssert(itemTypes == itemTypesToInsert.sorted(), "Incorrect subviews order.") + } + + func testCorrectSubviewsOrderManyItems() throws { + let itemTypesToInsert: [VisibleItem.ItemType] = [ + .layoutItemType( + .monthHeader(Month(era: 1, year: 2022, month: 1, isInGregorianCalendar: true))), + .dayRange(.init(containing: Date()...Date(), in: .current)), + .dayRange(.init(containing: Date()...Date(), in: .current)), + .dayRange(.init(containing: Date()...Date(), in: .current)), + .layoutItemType( + .monthHeader(Month(era: 1, year: 2022, month: 2, isInGregorianCalendar: true))), + .layoutItemType( + .monthHeader(Month(era: 1, year: 2022, month: 3, isInGregorianCalendar: true))), + .layoutItemType( + .monthHeader(Month(era: 1, year: 2022, month: 4, isInGregorianCalendar: true))), + .layoutItemType( + .monthHeader(Month(era: 1, year: 2022, month: 5, isInGregorianCalendar: true))), + .layoutItemType( + .monthHeader(Month(era: 1, year: 2022, month: 6, isInGregorianCalendar: true))), + .layoutItemType( + .monthHeader(Month(era: 1, year: 2022, month: 7, isInGregorianCalendar: true))), + .layoutItemType( + .monthHeader(Month(era: 1, year: 2022, month: 8, isInGregorianCalendar: true))), + .overlayItem(.monthHeader(monthContainingDate: Date())), + .pinnedDaysOfWeekRowBackground, + .dayRange(.init(containing: Date()...Date(), in: .current)), + .layoutItemType( + .monthHeader(Month(era: 1, year: 2022, month: 9, isInGregorianCalendar: true))), + .pinnedDayOfWeek(.first), + .pinnedDayOfWeek(.second), + .daysOfWeekRowSeparator( + Month(era: 1, year: 2022, month: 1, isInGregorianCalendar: true)), + .pinnedDayOfWeek(.third), + .pinnedDaysOfWeekRowSeparator, + .pinnedDayOfWeek(.fourth), + .pinnedDayOfWeek(.fifth), + .layoutItemType( + .monthHeader(Month(era: 1, year: 2022, month: 10, isInGregorianCalendar: true))), + .pinnedDayOfWeek(.sixth), + .layoutItemType( + .monthHeader(Month(era: 1, year: 2022, month: 11, isInGregorianCalendar: true))), + .layoutItemType( + .monthHeader(Month(era: 1, year: 2022, month: 12, isInGregorianCalendar: true))), + .layoutItemType( + .monthHeader(Month(era: 1, year: 2022, month: 1, isInGregorianCalendar: true))), + .dayRange(.init(containing: Date()...Date(), in: .current)), + .layoutItemType( + .monthHeader(Month(era: 1, year: 2023, month: 1, isInGregorianCalendar: true))), + .layoutItemType( + .monthHeader(Month(era: 1, year: 2023, month: 2, isInGregorianCalendar: true))), + .daysOfWeekRowSeparator( + Month(era: 1, year: 2023, month: 1, isInGregorianCalendar: true)), + .layoutItemType( + .monthHeader(Month(era: 1, year: 2023, month: 3, isInGregorianCalendar: true))), + .layoutItemType( + .monthHeader(Month(era: 1, year: 2023, month: 4, isInGregorianCalendar: true))), + .pinnedDayOfWeek(.last), + ] + + var itemTypes = [VisibleItem.ItemType]() + for itemTypeToInsert in itemTypesToInsert { + let insertionIndex = subviewInsertionIndexTracker.insertionIndex( + forSubviewWithCorrespondingItemType: itemTypeToInsert) + itemTypes.insert(itemTypeToInsert, at: insertionIndex) + } + + XCTAssert(itemTypes == itemTypesToInsert.sorted(), "Incorrect subviews order.") + } + + // MARK: Private + + private let subviewInsertionIndexTracker = SubviewInsertionIndexTracker() + +} + +// MARK: - VisibleItem.ItemType + Comparable + +extension VisibleItem.ItemType: Comparable { + + // MARK: Public + + public static func < ( + lhs: HorizonCalendar.VisibleItem.ItemType, + rhs: HorizonCalendar.VisibleItem.ItemType) + -> Bool + { + lhs.relativeDistanceFromBack < rhs.relativeDistanceFromBack + } + + // MARK: Private + + private var relativeDistanceFromBack: Int { + switch self { + case .monthBackground: return 0 + case .dayBackground: return 1 + case .dayRange: return 2 + case .layoutItemType: return 3 + case .daysOfWeekRowSeparator: return 4 + case .overlayItem: return 5 + case .weekNumber: return 6 + case .pinnedDaysOfWeekRowBackground: return 7 + case .pinnedDayOfWeek: return 8 + case .pinnedDaysOfWeekRowSeparator: return 9 + } + } + +} diff --git a/HorizonCalendarTests [Rec]/VisibleItemsProviderTests.swift b/HorizonCalendarTests [Rec]/VisibleItemsProviderTests.swift new file mode 100644 index 00000000..a5692992 --- /dev/null +++ b/HorizonCalendarTests [Rec]/VisibleItemsProviderTests.swift @@ -0,0 +1,1727 @@ +// Created by Bryan Keller on 4/5/20. +// Copyright © 2020 Airbnb Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import XCTest +@testable import HorizonCalendar + +// MARK: - VisibleItemsProviderTests + +/// To print out a set of expected item descriptions, copy and paste the output of: +/// `po print(details.visibleItems.map { "\"\($0.description)\",\n" }.sorted().joined())` +final class VisibleItemsProviderTests: XCTestCase { + + // MARK: Internal + + // MARK: Initial anchor layout item tests + + func testVerticalInitialVisibleMonthHeader() { + let monthHeaderItem1 = verticalVisibleItemsProvider.anchorMonthHeaderItem( + for: Month(era: 1, year: 2020, month: 01, isInGregorianCalendar: true), + offset: CGPoint(x: 0, y: 100), + scrollPosition: .firstFullyVisiblePosition) + XCTAssert( + monthHeaderItem1.description == "[itemType: .layoutItemType(.monthHeader(2020-01)), frame: (0.0, 100.0, 320.0, 50.0)]", + "Unexpected initial month header item.") + + let monthHeaderItem2 = verticalVisibleItemsProvider.anchorMonthHeaderItem( + for: Month(era: 1, year: 2020, month: 03, isInGregorianCalendar: true), + offset: CGPoint(x: 0, y: 250), + scrollPosition: .lastFullyVisiblePosition) + XCTAssert( + monthHeaderItem2.description == "[itemType: .layoutItemType(.monthHeader(2020-03)), frame: (0.0, 335.5, 320.0, 50.0)]", + "Unexpected initial month header item.") + + let monthHeaderItem3 = verticalVisibleItemsProvider.anchorMonthHeaderItem( + for: Month(era: 1, year: 2020, month: 06, isInGregorianCalendar: true), + offset: CGPoint(x: 0, y: 400), + scrollPosition: .centered) + XCTAssert( + monthHeaderItem3.description == "[itemType: .layoutItemType(.monthHeader(2020-06)), frame: (0.0, 443.0, 320.0, 50.0)]", + "Unexpected initial month header item.") + } + + func testVerticalPinnedDaysOfWeekInitialVisibleMonthHeader() { + let monthHeaderItem1 = verticalPinnedDaysOfWeekVisibleItemsProvider.anchorMonthHeaderItem( + for: Month(era: 1, year: 2020, month: 02, isInGregorianCalendar: true), + offset: CGPoint(x: 0, y: 190), + scrollPosition: .firstFullyVisiblePosition) + XCTAssert( + monthHeaderItem1.description == "[itemType: .layoutItemType(.monthHeader(2020-02)), frame: (0.0, 225.5, 320.0, 50.0)]", + "Unexpected initial month header item.") + + let monthHeaderItem2 = verticalPinnedDaysOfWeekVisibleItemsProvider.anchorMonthHeaderItem( + for: Month(era: 1, year: 2020, month: 03, isInGregorianCalendar: true), + offset: CGPoint(x: 0, y: 200), + scrollPosition: .lastFullyVisiblePosition) + XCTAssert( + monthHeaderItem2.description == "[itemType: .layoutItemType(.monthHeader(2020-03)), frame: (0.0, 341.5, 320.0, 50.0)]", + "Unexpected initial month header item.") + + let monthHeaderItem3 = verticalPinnedDaysOfWeekVisibleItemsProvider.anchorMonthHeaderItem( + for: Month(era: 1, year: 2020, month: 04, isInGregorianCalendar: true), + offset: CGPoint(x: 0, y: 250), + scrollPosition: .centered) + XCTAssert( + monthHeaderItem3.description == "[itemType: .layoutItemType(.monthHeader(2020-04)), frame: (0.0, 313.5, 320.0, 100.0)]", + "Unexpected initial month header item.") + } + + func testVerticalPartialMonthVisibleMonthHeader() { + let monthHeaderItem1 = verticalPartialMonthVisibleItemsProvider.anchorMonthHeaderItem( + for: Month(era: 1, year: 2020, month: 01, isInGregorianCalendar: true), + offset: CGPoint(x: 0, y: 100), + scrollPosition: .firstFullyVisiblePosition) + XCTAssert( + monthHeaderItem1.description == "[itemType: .layoutItemType(.monthHeader(2020-01)), frame: (0.0, 100.0, 320.0, 50.0)]", + "Unexpected initial month header item.") + + let monthHeaderItem2 = verticalPartialMonthVisibleItemsProvider.anchorMonthHeaderItem( + for: Month(era: 1, year: 2020, month: 03, isInGregorianCalendar: true), + offset: CGPoint(x: 0, y: 250), + scrollPosition: .lastFullyVisiblePosition) + XCTAssert( + monthHeaderItem2.description == "[itemType: .layoutItemType(.monthHeader(2020-03)), frame: (0.0, 335.5, 320.0, 50.0)]", + "Unexpected initial month header item.") + + let monthHeaderItem3 = verticalPartialMonthVisibleItemsProvider.anchorMonthHeaderItem( + for: Month(era: 1, year: 2020, month: 06, isInGregorianCalendar: true), + offset: CGPoint(x: 0, y: 400), + scrollPosition: .centered) + XCTAssert( + monthHeaderItem3.description == "[itemType: .layoutItemType(.monthHeader(2020-06)), frame: (0.0, 443.0, 320.0, 50.0)]", + "Unexpected initial month header item.") + } + + func testHorizontalInitialVisibleMonthHeader() { + let monthHeaderItem1 = horizontalVisibleItemsProvider.anchorMonthHeaderItem( + for: Month(era: 1, year: 2020, month: 11, isInGregorianCalendar: true), + offset: CGPoint(x: 1000, y: 0), + scrollPosition: .firstFullyVisiblePosition) + XCTAssert( + monthHeaderItem1.description == "[itemType: .layoutItemType(.monthHeader(2020-11)), frame: (1000.0, 50.0, 300.0, 50.0)]", + "Unexpected initial month header item.") + + let monthHeaderItem2 = horizontalVisibleItemsProvider.anchorMonthHeaderItem( + for: Month(era: 1, year: 2020, month: 09, isInGregorianCalendar: true), + offset: CGPoint(x: 800, y: 0), + scrollPosition: .lastFullyVisiblePosition) + XCTAssert( + monthHeaderItem2.description == "[itemType: .layoutItemType(.monthHeader(2020-09)), frame: (820.0, 50.0, 300.0, 50.0)]", + "Unexpected initial month header item.") + + let monthHeaderItem3 = horizontalVisibleItemsProvider.anchorMonthHeaderItem( + for: Month(era: 1, year: 2020, month: 10, isInGregorianCalendar: true), + offset: CGPoint(x: 500, y: 0), + scrollPosition: .centered) + XCTAssert( + monthHeaderItem3.description == "[itemType: .layoutItemType(.monthHeader(2020-10)), frame: (510.0, 50.0, 300.0, 50.0)]", + "Unexpected initial month header item.") + } + + func testVerticalInitialVisibleDay() { + let day = Day(month: Month(era: 1, year: 2020, month: 04, isInGregorianCalendar: true), day: 20) + + let dayItem1 = verticalVisibleItemsProvider.anchorDayItem( + for: day, + offset: CGPoint(x: 0, y: 400), + scrollPosition: .firstFullyVisiblePosition) + XCTAssert( + dayItem1.description == "[itemType: .layoutItemType(.day(2020-04-20)), frame: (50.5, 400.0, 35.5, 35.5)]", + "Unexpected initial day item.") + + let dayItem2 = verticalVisibleItemsProvider.anchorDayItem( + for: day, + offset: CGPoint(x: 0, y: 200), + scrollPosition: .lastFullyVisiblePosition) + XCTAssert( + dayItem2.description == "[itemType: .layoutItemType(.day(2020-04-20)), frame: (50.5, 644.5, 35.5, 35.5)]", + "Unexpected initial day item.") + + let dayItem3 = verticalVisibleItemsProvider.anchorDayItem( + for: day, + offset: CGPoint(x: 0, y: 600), + scrollPosition: .centered) + XCTAssert( + dayItem3.description == "[itemType: .layoutItemType(.day(2020-04-20)), frame: (50.5, 822.0, 35.5, 35.5)]", + "Unexpected initial day item.") + } + + func testInitialVisiblePositionsNeedingCorrection() { + let dayItem1 = verticalVisibleItemsProvider.anchorDayItem( + for: Day(month: Month(era: 1, year: 2020, month: 01, isInGregorianCalendar: true), day: 01), + offset: CGPoint(x: 0, y: 400), + scrollPosition: .lastFullyVisiblePosition) + XCTAssert( + dayItem1.description == "[itemType: .layoutItemType(.day(2020-01-01)), frame: (142.0, 535.5, 35.5, 35.5)]", + "Unexpected initial day item.") + + let dayItem2 = verticalVisibleItemsProvider.anchorDayItem( + for: Day(month: Month(era: 1, year: 2020, month: 12, isInGregorianCalendar: true), day: 31), + offset: CGPoint(x: 0, y: 200), + scrollPosition: .firstFullyVisiblePosition) + XCTAssert( + dayItem2.description == "[itemType: .layoutItemType(.day(2020-12-31)), frame: (188.0, 644.5, 35.5, 35.5)]", + "Unexpected initial day item.") + + let dayItem3 = verticalPinnedDaysOfWeekVisibleItemsProvider.anchorDayItem( + for: Day(month: Month(era: 1, year: 2020, month: 12, isInGregorianCalendar: true), day: 31), + offset: CGPoint(x: 0, y: 200), + scrollPosition: .centered) + XCTAssert( + dayItem3.description == "[itemType: .layoutItemType(.day(2020-12-31)), frame: (188.0, 644.5, 35.5, 35.5)]", + "Unexpected initial day item.") + + let dayItem4 = horizontalVisibleItemsProvider.anchorDayItem( + for: Day(month: Month(era: 1, year: 2020, month: 01, isInGregorianCalendar: true), day: 01), + offset: CGPoint(x: 600, y: 0), + scrollPosition: .lastFullyVisiblePosition) + XCTAssert( + dayItem4.description == "[itemType: .layoutItemType(.day(2020-01-01)), frame: (733.5, 183.0, 33.0, 33.0)]", + "Unexpected initial day item.") + + let dayItem5 = horizontalVisibleItemsProvider.anchorDayItem( + for: Day(month: Month(era: 1, year: 2020, month: 12, isInGregorianCalendar: true), day: 28), + offset: CGPoint(x: 100, y: 0), + scrollPosition: .firstFullyVisiblePosition) + XCTAssert( + dayItem5.description == "[itemType: .layoutItemType(.day(2020-12-28)), frame: (168.0, 394.5, 33.0, 33.0)]", + "Unexpected initial day item.") + } + + func testVerticalPinnedDaysOfWeekInitialVisibleDay() { + let day = Day(month: Month(era: 1, year: 2020, month: 04, isInGregorianCalendar: true), day: 20) + + let dayItem1 = verticalPinnedDaysOfWeekVisibleItemsProvider.anchorDayItem( + for: day, + offset: CGPoint(x: 0, y: 0), + scrollPosition: .firstFullyVisiblePosition) + XCTAssert( + dayItem1.description == "[itemType: .layoutItemType(.day(2020-04-20)), frame: (50.5, 35.5, 35.5, 35.5)]", + "Unexpected initial day item.") + + let dayItem2 = verticalPinnedDaysOfWeekVisibleItemsProvider.anchorDayItem( + for: day, + offset: CGPoint(x: 0, y: 100), + scrollPosition: .lastFullyVisiblePosition) + XCTAssert( + dayItem2.description == "[itemType: .layoutItemType(.day(2020-04-20)), frame: (50.5, 544.5, 35.5, 35.5)]", + "Unexpected initial day item.") + + let dayItem3 = verticalPinnedDaysOfWeekVisibleItemsProvider.anchorDayItem( + for: day, + offset: CGPoint(x: 0, y: 1000), + scrollPosition: .centered) + XCTAssert( + dayItem3.description == "[itemType: .layoutItemType(.day(2020-04-20)), frame: (50.5, 1240.0, 35.5, 35.5)]", + "Unexpected initial day item.") + } + + func testVerticalPartialMonthInitialVisibleDay() { + let day = Day(month: Month(era: 1, year: 2020, month: 01, isInGregorianCalendar: true), day: 28) + + let dayItem1 = verticalPartialMonthVisibleItemsProvider.anchorDayItem( + for: day, + offset: CGPoint(x: 0, y: 400), + scrollPosition: .firstFullyVisiblePosition) + XCTAssert( + dayItem1.description == "[itemType: .layoutItemType(.day(2020-01-28)), frame: (96.5, 400.0, 35.5, 35.5)]", + "Unexpected initial day item.") + + let dayItem2 = verticalPartialMonthVisibleItemsProvider.anchorDayItem( + for: day, + offset: CGPoint(x: 0, y: 200), + scrollPosition: .lastFullyVisiblePosition) + XCTAssert( + dayItem2.description == "[itemType: .layoutItemType(.day(2020-01-28)), frame: (96.5, 391.5, 35.5, 35.5)]", + "Unexpected initial day item.") + + let dayItem3 = verticalPartialMonthVisibleItemsProvider.anchorDayItem( + for: day, + offset: CGPoint(x: 0, y: 600), + scrollPosition: .centered) + XCTAssert( + dayItem3.description == "[itemType: .layoutItemType(.day(2020-01-28)), frame: (96.5, 791.5, 35.5, 35.5)]", + "Unexpected initial day item.") + } + + func testHorizontalInitialVisibleDay() { + let day = Day(month: Month(era: 1, year: 2020, month: 04, isInGregorianCalendar: true), day: 20) + + let dayItem1 = horizontalVisibleItemsProvider.anchorDayItem( + for: day, + offset: CGPoint(x: 300, y: 0), + scrollPosition: .firstFullyVisiblePosition) + XCTAssert( + dayItem1.description == "[itemType: .layoutItemType(.day(2020-04-20)), frame: (300.0, 341.5, 33.0, 33.0)]", + "Unexpected initial day item.") + + let dayItem2 = horizontalVisibleItemsProvider.anchorDayItem( + for: day, + offset: CGPoint(x: 500, y: 0), + scrollPosition: .lastFullyVisiblePosition) + XCTAssert( + dayItem2.description == "[itemType: .layoutItemType(.day(2020-04-20)), frame: (787.0, 341.5, 33.0, 33.0)]", + "Unexpected initial day item.") + + let dayItem3 = horizontalVisibleItemsProvider.anchorDayItem( + for: day, + offset: CGPoint(x: 0, y: 0), + scrollPosition: .centered) + XCTAssert( + dayItem3.description == "[itemType: .layoutItemType(.day(2020-04-20)), frame: (143.5, 341.5, 33.0, 33.0)]", + "Unexpected initial day item.") + } + + // MARK: Scrolled to middle of content tests + + func testVisibleItemsContextAfterMetricsChange() { + let anchorLayoutItem = verticalVisibleItemsProvider.detailsForVisibleItems( + surroundingPreviouslyVisibleLayoutItem: LayoutItem( + itemType: .monthHeader(Month(era: 1, year: 2020, month: 03, isInGregorianCalendar: true)), + frame: CGRect(x: 0, y: 200, width: 320, height: 50)), + offset: CGPoint(x: 0, y: 150), + extendLayoutRegion: false) + .centermostLayoutItem + let details = verticalShortDayAspectRatioVisibleItemsProvider.detailsForVisibleItems( + surroundingPreviouslyVisibleLayoutItem: anchorLayoutItem, + offset: CGPoint(x: 0, y: 150), + extendLayoutRegion: false) + + let expectedVisibleItemDescriptions: Set = [ + "[itemType: .dayBackground(2020-02-16), frame: (5.0, 165.0, 35.5, 18.0)]", + "[itemType: .dayBackground(2020-02-17), frame: (50.5, 165.0, 35.5, 18.0)]", + "[itemType: .dayBackground(2020-02-18), frame: (96.5, 165.0, 35.5, 18.0)]", + "[itemType: .dayBackground(2020-02-19), frame: (142.0, 165.0, 35.5, 18.0)]", + "[itemType: .dayBackground(2020-03-11), frame: (142.0, 391.5, 35.5, 18.0)]", + "[itemType: .dayBackground(2020-03-12), frame: (188.0, 391.5, 35.5, 18.0)]", + "[itemType: .dayBackground(2020-03-13), frame: (233.5, 391.5, 35.5, 18.0)]", + "[itemType: .dayBackground(2020-03-14), frame: (279.5, 391.5, 35.5, 18.0)]", + "[itemType: .dayBackground(2020-03-15), frame: (5.0, 429.5, 35.5, 18.0)]", + "[itemType: .dayBackground(2020-03-16), frame: (50.5, 429.5, 35.5, 18.0)]", + "[itemType: .dayBackground(2020-03-17), frame: (96.5, 429.5, 35.5, 18.0)]", + "[itemType: .dayBackground(2020-03-18), frame: (142.0, 429.5, 35.5, 18.0)]", + "[itemType: .dayBackground(2020-03-19), frame: (188.0, 429.5, 35.5, 18.0)]", + "[itemType: .dayRange(2020-03-11, 2020-04-05), frame: (5.0, 391.5, 310.0, 370.0)]", + "[itemType: .daysOfWeekRowSeparator(2020-3), frame: (-0.0, 332.5, 320.0, 1.0)]", + "[itemType: .daysOfWeekRowSeparator(2020-4), frame: (0.0, 684.5, 320.0, 1.0)]", + "[itemType: .layoutItemType(.day(2020-02-16)), frame: (5.0, 165.0, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-02-17)), frame: (50.5, 165.0, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-02-18)), frame: (96.5, 165.0, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-02-19)), frame: (142.0, 165.0, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-02-20)), frame: (188.0, 165.0, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-02-21)), frame: (233.5, 165.0, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-02-22)), frame: (279.5, 165.0, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-02-23)), frame: (5.0, 203.0, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-02-24)), frame: (50.5, 203.0, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-02-25)), frame: (96.5, 203.0, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-02-26)), frame: (142.0, 203.0, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-02-27)), frame: (188.0, 203.0, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-02-28)), frame: (233.5, 203.0, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-02-29)), frame: (279.5, 203.0, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-03-01)), frame: (5.0, 353.5, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-03-02)), frame: (50.5, 353.5, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-03-03)), frame: (96.5, 353.5, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-03-04)), frame: (142.0, 353.5, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-03-05)), frame: (188.0, 353.5, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-03-06)), frame: (233.5, 353.5, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-03-07)), frame: (279.5, 353.5, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-03-08)), frame: (5.0, 391.5, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-03-09)), frame: (50.5, 391.5, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-03-10)), frame: (96.5, 391.5, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-03-11)), frame: (142.0, 391.5, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-03-12)), frame: (188.0, 391.5, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-03-13)), frame: (233.5, 391.5, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-03-14)), frame: (279.5, 391.5, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-03-15)), frame: (5.0, 429.5, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-03-16)), frame: (50.5, 429.5, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-03-17)), frame: (96.5, 429.5, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-03-18)), frame: (142.0, 429.5, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-03-19)), frame: (188.0, 429.5, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-03-20)), frame: (233.5, 429.5, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-03-21)), frame: (279.5, 429.5, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-03-22)), frame: (5.0, 467.0, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-03-23)), frame: (50.5, 467.0, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-03-24)), frame: (96.5, 467.0, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-03-25)), frame: (142.0, 467.0, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-03-26)), frame: (188.0, 467.0, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-03-27)), frame: (233.5, 467.0, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-03-28)), frame: (279.5, 467.0, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-03-29)), frame: (5.0, 505.0, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-03-30)), frame: (50.5, 505.0, 35.5, 18.0)]", + "[itemType: .layoutItemType(.day(2020-03-31)), frame: (96.5, 505.0, 35.5, 18.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.fifth, 2020-03)), frame: (188.0, 315.5, 35.5, 18.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.first, 2020-03)), frame: (5.0, 315.5, 35.5, 18.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.fourth, 2020-03)), frame: (142.0, 315.5, 35.5, 18.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.last, 2020-03)), frame: (279.5, 315.5, 35.5, 18.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.second, 2020-03)), frame: (50.5, 315.5, 35.5, 18.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.sixth, 2020-03)), frame: (233.5, 315.5, 35.5, 18.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.third, 2020-03)), frame: (96.5, 315.5, 35.5, 18.0)]", + "[itemType: .layoutItemType(.monthHeader(2020-03)), frame: (0.0, 235.5, 320.0, 50.0)]", + "[itemType: .layoutItemType(.monthHeader(2020-04)), frame: (0.0, 538.0, 320.0, 100.0)]", + "[itemType: .monthBackground(2020-02), frame: (0.0, -74.0, 320.0, 302.0)]", + "[itemType: .monthBackground(2020-03), frame: (-0.0, 228.0, 320.0, 302.0)]", + "[itemType: .monthBackground(2020-04), frame: (0.0, 530.5, 320.0, 352.0)]", + ] + + XCTAssert( + Set(details.visibleItems.map { $0.description }) == expectedVisibleItemDescriptions, + "Unexpected visible items.") + + XCTAssert( + details.centermostLayoutItem + .description == "[itemType: .layoutItemType(.day(2020-03-11)), frame: (142.0, 391.5, 35.5, 18.0)]", + "Unexpected centermost layout item.") + } + + func testVerticalVisibleItemsContext() { + let details = verticalVisibleItemsProvider.detailsForVisibleItems( + surroundingPreviouslyVisibleLayoutItem: LayoutItem( + itemType: .monthHeader(Month(era: 1, year: 2020, month: 03, isInGregorianCalendar: true)), + frame: CGRect(x: 0, y: 200, width: 320, height: 50)), + offset: CGPoint(x: 0, y: 150), + extendLayoutRegion: false) + + let expectedVisibleItemDescriptions: Set = [ + "[itemType: .dayBackground(2020-03-11), frame: (142.0, 391.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-03-12), frame: (188.0, 391.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-03-13), frame: (233.5, 391.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-03-14), frame: (279.5, 391.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-03-15), frame: (5.0, 447.0, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-03-16), frame: (50.5, 447.0, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-03-17), frame: (96.5, 447.0, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-03-18), frame: (142.0, 447.0, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-03-19), frame: (188.0, 447.0, 35.5, 35.5)]", + "[itemType: .dayRange(2020-03-11, 2020-04-05), frame: (5.0, 391.5, 310.0, 495.0)]", + "[itemType: .daysOfWeekRowSeparator(2020-3), frame: (0.0, 314.5, 320.0, 1.0)]", + "[itemType: .daysOfWeekRowSeparator(2020-4), frame: (0.0, 774.0, 320.0, 1.0)]", + "[itemType: .layoutItemType(.day(2020-02-23)), frame: (5.0, 149.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-02-24)), frame: (50.5, 149.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-02-25)), frame: (96.5, 149.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-02-26)), frame: (142.0, 149.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-02-27)), frame: (188.0, 149.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-02-28)), frame: (233.5, 149.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-02-29)), frame: (279.5, 149.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-03-01)), frame: (5.0, 335.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-03-02)), frame: (50.5, 335.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-03-03)), frame: (96.5, 335.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-03-04)), frame: (142.0, 335.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-03-05)), frame: (188.0, 335.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-03-06)), frame: (233.5, 335.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-03-07)), frame: (279.5, 335.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-03-08)), frame: (5.0, 391.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-03-09)), frame: (50.5, 391.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-03-10)), frame: (96.5, 391.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-03-11)), frame: (142.0, 391.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-03-12)), frame: (188.0, 391.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-03-13)), frame: (233.5, 391.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-03-14)), frame: (279.5, 391.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-03-15)), frame: (5.0, 447.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-03-16)), frame: (50.5, 447.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-03-17)), frame: (96.5, 447.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-03-18)), frame: (142.0, 447.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-03-19)), frame: (188.0, 447.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-03-20)), frame: (233.5, 447.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-03-21)), frame: (279.5, 447.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-03-22)), frame: (5.0, 503.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-03-23)), frame: (50.5, 503.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-03-24)), frame: (96.5, 503.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-03-25)), frame: (142.0, 503.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-03-26)), frame: (188.0, 503.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-03-27)), frame: (233.5, 503.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-03-28)), frame: (279.5, 503.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-03-29)), frame: (5.0, 558.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-03-30)), frame: (50.5, 558.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-03-31)), frame: (96.5, 558.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.fifth, 2020-03)), frame: (188.0, 280.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.first, 2020-03)), frame: (5.0, 280.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.fourth, 2020-03)), frame: (142.0, 280.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.last, 2020-03)), frame: (279.5, 280.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.second, 2020-03)), frame: (50.5, 280.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.sixth, 2020-03)), frame: (233.5, 280.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.third, 2020-03)), frame: (96.5, 280.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.monthHeader(2020-03)), frame: (0.0, 200.0, 320.0, 50.0)]", + "[itemType: .layoutItemType(.monthHeader(2020-04)), frame: (0.0, 609.5, 320.0, 100.0)]", + "[itemType: .monthBackground(2020-02), frame: (0.0, -217.0, 320.0, 409.5)]", + "[itemType: .monthBackground(2020-03), frame: (0.0, 192.5, 320.0, 409.5)]", + "[itemType: .monthBackground(2020-04), frame: (0.0, 602.0, 320.0, 459.5)]", + ] + + XCTAssert( + Set(details.visibleItems.map { $0.description }) == expectedVisibleItemDescriptions, + "Unexpected visible items.") + + XCTAssert( + details.centermostLayoutItem + .description == "[itemType: .layoutItemType(.day(2020-03-11)), frame: (142.0, 391.5, 35.5, 35.5)]", + "Unexpected centermost layout item.") + + XCTAssert(details.contentStartBoundary == nil, "Unexpected content start offset.") + XCTAssert(details.contentEndBoundary == nil, "Unexpected content end offset.") + } + + func testVerticalPinnedDaysOfWeekVisibleItemsContext() { + let details = verticalPinnedDaysOfWeekVisibleItemsProvider.detailsForVisibleItems( + surroundingPreviouslyVisibleLayoutItem: LayoutItem( + itemType: .monthHeader(Month(era: 1, year: 2020, month: 06, isInGregorianCalendar: true)), + frame: CGRect(x: 0, y: 450, width: 320, height: 40)), + offset: CGPoint(x: 0, y: 450), + extendLayoutRegion: false) + + let expectedVisibleItemDescriptions: Set = [ + "[itemType: .dayBackground(2020-06-11), frame: (188.0, 585.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-06-12), frame: (233.5, 585.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-06-13), frame: (279.5, 585.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-06-14), frame: (5.0, 641.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-06-15), frame: (50.5, 641.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-06-16), frame: (96.5, 641.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-06-17), frame: (142.0, 641.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-06-18), frame: (188.0, 641.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-06-19), frame: (233.5, 641.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-01)), frame: (50.5, 530.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-02)), frame: (96.5, 530.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-03)), frame: (142.0, 530.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-04)), frame: (188.0, 530.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-05)), frame: (233.5, 530.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-06)), frame: (279.5, 530.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-07)), frame: (5.0, 585.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-08)), frame: (50.5, 585.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-09)), frame: (96.5, 585.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-10)), frame: (142.0, 585.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-11)), frame: (188.0, 585.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-12)), frame: (233.5, 585.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-13)), frame: (279.5, 585.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-14)), frame: (5.0, 641.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-15)), frame: (50.5, 641.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-16)), frame: (96.5, 641.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-17)), frame: (142.0, 641.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-18)), frame: (188.0, 641.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-19)), frame: (233.5, 641.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-20)), frame: (279.5, 641.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-21)), frame: (5.0, 697.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-22)), frame: (50.5, 697.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-23)), frame: (96.5, 697.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-24)), frame: (142.0, 697.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-25)), frame: (188.0, 697.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-26)), frame: (233.5, 697.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-27)), frame: (279.5, 697.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-28)), frame: (5.0, 753.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-29)), frame: (50.5, 753.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-30)), frame: (96.5, 753.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-07-01)), frame: (142.0, 883.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-07-02)), frame: (188.0, 883.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-07-03)), frame: (233.5, 883.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-07-04)), frame: (279.5, 883.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.monthHeader(2020-06)), frame: (0.0, 450.0, 320.0, 50.0)]", + "[itemType: .layoutItemType(.monthHeader(2020-07)), frame: (0.0, 803.5, 320.0, 50.0)]", + "[itemType: .monthBackground(2020-06), frame: (0.0, 442.5, 320.0, 353.5)]", + "[itemType: .monthBackground(2020-07), frame: (0.0, 796.0, 320.0, 353.5)]", + "[itemType: .pinnedDayOfWeek(.fifth), frame: (188.0, 450.0, 35.5, 35.5)]", + "[itemType: .pinnedDayOfWeek(.first), frame: (5.0, 450.0, 35.5, 35.5)]", + "[itemType: .pinnedDayOfWeek(.fourth), frame: (142.0, 450.0, 35.5, 35.5)]", + "[itemType: .pinnedDayOfWeek(.last), frame: (279.5, 450.0, 35.5, 35.5)]", + "[itemType: .pinnedDayOfWeek(.second), frame: (50.5, 450.0, 35.5, 35.5)]", + "[itemType: .pinnedDayOfWeek(.sixth), frame: (233.5, 450.0, 35.5, 35.5)]", + "[itemType: .pinnedDayOfWeek(.third), frame: (96.5, 450.0, 35.5, 35.5)]", + "[itemType: .pinnedDaysOfWeekRowBackground, frame: (0.0, 450.0, 320.0, 35.5)]", + "[itemType: .pinnedDaysOfWeekRowSeparator, frame: (0.0, 484.5, 320.0, 1.0)]", + ] + + XCTAssert( + Set(details.visibleItems.map { $0.description }) == expectedVisibleItemDescriptions, + "Unexpected visible items.") + + XCTAssert( + details.centermostLayoutItem + .description == "[itemType: .layoutItemType(.day(2020-06-24)), frame: (142.0, 697.0, 35.5, 35.5)]", + "Unexpected centermost layout item.") + + XCTAssert(details.contentStartBoundary == nil, "Unexpected content start offset.") + XCTAssert(details.contentEndBoundary == nil, "Unexpected content end offset.") + } + + func testVerticalPartialMonthVisibleItemsContext() { + let details = verticalPartialMonthVisibleItemsProvider.detailsForVisibleItems( + surroundingPreviouslyVisibleLayoutItem: LayoutItem( + itemType: .monthHeader(Month(era: 1, year: 2020, month: 01, isInGregorianCalendar: true)), + frame: CGRect(x: 0, y: 200, width: 320, height: 50)), + offset: CGPoint(x: 0, y: 150), + extendLayoutRegion: false) + + let expectedVisibleItemDescriptions: Set = [ + "[itemType: .daysOfWeekRowSeparator(2020-1), frame: (0.0, 314.5, 320.0, 1.0)]", + "[itemType: .daysOfWeekRowSeparator(2020-2), frame: (0.0, 557.0, 320.0, 1.0)]", + "[itemType: .layoutItemType(.day(2020-01-25)), frame: (279.5, 335.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-26)), frame: (5.0, 391.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-27)), frame: (50.5, 391.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-28)), frame: (96.5, 391.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-29)), frame: (142.0, 391.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-30)), frame: (188.0, 391.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-31)), frame: (233.5, 391.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-02-01)), frame: (279.5, 578.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.fifth, 2020-01)), frame: (188.0, 280.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.fifth, 2020-02)), frame: (188.0, 522.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.first, 2020-01)), frame: (5.0, 280.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.first, 2020-02)), frame: (5.0, 522.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.fourth, 2020-01)), frame: (142.0, 280.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.fourth, 2020-02)), frame: (142.0, 522.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.last, 2020-01)), frame: (279.5, 280.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.last, 2020-02)), frame: (279.5, 522.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.second, 2020-01)), frame: (50.5, 280.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.second, 2020-02)), frame: (50.5, 522.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.sixth, 2020-01)), frame: (233.5, 280.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.sixth, 2020-02)), frame: (233.5, 522.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.third, 2020-01)), frame: (96.5, 280.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.third, 2020-02)), frame: (96.5, 522.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.monthHeader(2020-01)), frame: (0.0, 200.0, 320.0, 50.0)]", + "[itemType: .layoutItemType(.monthHeader(2020-02)), frame: (0.0, 442.0, 320.0, 50.0)]", + "[itemType: .monthBackground(2020-01), frame: (0.0, 192.5, 320.0, 242.0)]", + "[itemType: .monthBackground(2020-02), frame: (0.0, 434.5, 320.0, 409.5)]", + ] + + XCTAssert( + Set(details.visibleItems.map { $0.description }) == expectedVisibleItemDescriptions, + "Unexpected visible items.") + + XCTAssert( + details.centermostLayoutItem + .description == "[itemType: .layoutItemType(.day(2020-01-29)), frame: (142.0, 391.5, 35.5, 35.5)]", + "Unexpected centermost layout item.") + + XCTAssert(details.contentStartBoundary == 200, "Unexpected content start offset.") + XCTAssert(details.contentEndBoundary == nil, "Unexpected content end offset.") + } + + func testHorizontalVisibleItemsContext() { + let details = horizontalVisibleItemsProvider.detailsForVisibleItems( + surroundingPreviouslyVisibleLayoutItem: LayoutItem( + itemType: .monthHeader(Month(era: 1, year: 2020, month: 05, isInGregorianCalendar: true)), + frame: CGRect(x: 250, y: 0, width: 300, height: 50)), + offset: CGPoint(x: 100, y: 0), + extendLayoutRegion: false) + + let expectedVisibleItemDescriptions: Set = [ + "[itemType: .dayBackground(2020-04-11), frame: (197.0, 185.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-04-15), frame: (68.5, 238.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-04-16), frame: (111.5, 238.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-04-17), frame: (154.5, 238.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-04-18), frame: (197.0, 238.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-05-11), frame: (298.0, 238.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-05-12), frame: (340.5, 238.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-05-13), frame: (383.5, 238.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-05-17), frame: (255.0, 291.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-05-18), frame: (298.0, 291.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-05-19), frame: (340.5, 291.5, 33.0, 33.0)]", + "[itemType: .dayRange(2020-03-11, 2020-04-05), frame: (-375.0, 133.0, 605.0, 244.5)]", + "[itemType: .dayRange(2020-04-30, 2020-05-14), frame: (111.5, 133.0, 433.5, 244.5)]", + "[itemType: .daysOfWeekRowSeparator(2020-4), frame: (-65.0, 112.0, 300.0, 1.0)]", + "[itemType: .daysOfWeekRowSeparator(2020-5), frame: (250.0, 112.0, 300.0, 1.0)]", + "[itemType: .layoutItemType(.day(2020-04-01)), frame: (68.5, 133.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-02)), frame: (111.5, 133.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-03)), frame: (154.5, 133.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-04)), frame: (197.0, 133.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-08)), frame: (68.5, 185.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-09)), frame: (111.5, 185.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-10)), frame: (154.5, 185.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-11)), frame: (197.0, 185.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-15)), frame: (68.5, 238.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-16)), frame: (111.5, 238.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-17)), frame: (154.5, 238.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-18)), frame: (197.0, 238.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-22)), frame: (68.5, 291.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-23)), frame: (111.5, 291.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-24)), frame: (154.5, 291.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-25)), frame: (197.0, 291.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-29)), frame: (68.5, 344.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-30)), frame: (111.5, 344.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-03)), frame: (255.0, 185.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-04)), frame: (298.0, 185.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-05)), frame: (340.5, 185.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-06)), frame: (383.5, 185.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-10)), frame: (255.0, 238.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-11)), frame: (298.0, 238.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-12)), frame: (340.5, 238.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-13)), frame: (383.5, 238.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-17)), frame: (255.0, 291.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-18)), frame: (298.0, 291.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-19)), frame: (340.5, 291.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-20)), frame: (383.5, 291.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-24)), frame: (255.0, 344.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-25)), frame: (298.0, 344.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-26)), frame: (340.5, 344.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-27)), frame: (383.5, 344.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-31)), frame: (255.0, 397.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.fifth, 2020-04)), frame: (111.5, 80.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.first, 2020-05)), frame: (255.0, 80.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.fourth, 2020-04)), frame: (68.5, 80.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.fourth, 2020-05)), frame: (383.5, 80.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.last, 2020-04)), frame: (197.0, 80.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.second, 2020-05)), frame: (298.0, 80.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.sixth, 2020-04)), frame: (154.5, 80.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.third, 2020-05)), frame: (340.5, 80.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.monthHeader(2020-04)), frame: (-65.0, -50.0, 300.0, 100.0)]", + "[itemType: .layoutItemType(.monthHeader(2020-05)), frame: (250.0, 0.0, 300.0, 50.0)]", + "[itemType: .monthBackground(2020-04), frame: (-72.5, -76.5, 315.0, 480.0)]", + "[itemType: .monthBackground(2020-05), frame: (242.5, -25.0, 315.0, 480.0)]", + ] + + XCTAssert( + Set(details.visibleItems.map { $0.description }) == expectedVisibleItemDescriptions, + "Unexpected visible items.") + + XCTAssert( + details.centermostLayoutItem + .description == "[itemType: .layoutItemType(.day(2020-05-10)), frame: (255.0, 238.5, 33.0, 33.0)]", + "Unexpected centermost layout item.") + + XCTAssert(details.contentStartBoundary == nil, "Unexpected content start offset.") + XCTAssert(details.contentEndBoundary == nil, "Unexpected content end offset.") + } + + func testLargeScrollOffsetSincePreviouslyVisibleItem() { + let details = verticalVisibleItemsProvider.detailsForVisibleItems( + surroundingPreviouslyVisibleLayoutItem: LayoutItem( + itemType: .monthHeader(Month(era: 1, year: 2020, month: 12, isInGregorianCalendar: true)), + frame: CGRect(x: 0, y: 3000, width: 320, height: 50)), + offset: CGPoint(x: 0, y: 150), + extendLayoutRegion: false) + + let expectedVisibleItemDescriptions: Set = [ + "[itemType: .dayBackground(2020-05-11), frame: (50.5, 220.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-05-12), frame: (96.5, 220.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-05-13), frame: (142.0, 220.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-05-14), frame: (188.0, 220.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-05-15), frame: (233.5, 220.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-05-16), frame: (279.5, 220.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-05-17), frame: (5.0, 276.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-05-18), frame: (50.5, 276.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-05-19), frame: (96.5, 276.5, 35.5, 35.5)]", + "[itemType: .dayRange(2020-04-30, 2020-05-14), frame: (5.0, -77.0, 310.0, 333.5)]", + "[itemType: .daysOfWeekRowSeparator(2020-6), frame: (0.0, 553.5, 320.0, 1.0)]", + "[itemType: .layoutItemType(.day(2020-05-03)), frame: (5.0, 165.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-04)), frame: (50.5, 165.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-05)), frame: (96.5, 165.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-06)), frame: (142.0, 165.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-07)), frame: (188.0, 165.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-08)), frame: (233.5, 165.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-09)), frame: (279.5, 165.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-10)), frame: (5.0, 220.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-11)), frame: (50.5, 220.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-12)), frame: (96.5, 220.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-13)), frame: (142.0, 220.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-14)), frame: (188.0, 220.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-15)), frame: (233.5, 220.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-16)), frame: (279.5, 220.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-17)), frame: (5.0, 276.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-18)), frame: (50.5, 276.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-19)), frame: (96.5, 276.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-20)), frame: (142.0, 276.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-21)), frame: (188.0, 276.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-22)), frame: (233.5, 276.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-23)), frame: (279.5, 276.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-24)), frame: (5.0, 332.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-25)), frame: (50.5, 332.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-26)), frame: (96.5, 332.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-27)), frame: (142.0, 332.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-28)), frame: (188.0, 332.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-29)), frame: (233.5, 332.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-30)), frame: (279.5, 332.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-31)), frame: (5.0, 388.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-01)), frame: (50.5, 574.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-02)), frame: (96.5, 574.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-03)), frame: (142.0, 574.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-04)), frame: (188.0, 574.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-05)), frame: (233.5, 574.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-06)), frame: (279.5, 574.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.fifth, 2020-06)), frame: (188.0, 518.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.first, 2020-06)), frame: (5.0, 518.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.fourth, 2020-06)), frame: (142.0, 518.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.last, 2020-06)), frame: (279.5, 518.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.second, 2020-06)), frame: (50.5, 518.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.sixth, 2020-06)), frame: (233.5, 518.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.third, 2020-06)), frame: (96.5, 518.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.monthHeader(2020-06)), frame: (0.0, 438.5, 320.0, 50.0)]", + "[itemType: .monthBackground(2020-05), frame: (0.0, -34.0, 320.0, 465.0)]", + "[itemType: .monthBackground(2020-06), frame: (0.0, 431.0, 320.0, 409.5)]", + ] + + XCTAssert( + Set(details.visibleItems.map { $0.description }) == expectedVisibleItemDescriptions, + "Unexpected visible items.") + + XCTAssert( + details.centermostLayoutItem + .description == "[itemType: .layoutItemType(.day(2020-05-27)), frame: (142.0, 332.0, 35.5, 35.5)]", + "Unexpected centermost layout item.") + + XCTAssert(details.contentStartBoundary == nil, "Unexpected content start offset.") + XCTAssert( + details.contentEndBoundary?.alignedToPixel(forScreenWithScale: 2) == 3444.5, + "Unexpected content end offset.") + } + + func testHorizontalLeadingMonthPartiallyClipped() { + let details = horizontalVisibleItemsProvider.detailsForVisibleItems( + surroundingPreviouslyVisibleLayoutItem: LayoutItem( + itemType: .monthHeader(Month(era: 1, year: 2020, month: 2, isInGregorianCalendar: true)), + frame: CGRect(x: 315, y: 0, width: 300, height: 50)), + offset: CGPoint(x: 295, y: 0), + extendLayoutRegion: false) + + let expectedVisibleItemDescriptions: Set = [ + "[itemType: .dayBackground(2020-02-11), frame: (405.5, 238.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-02-12), frame: (448.5, 238.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-02-13), frame: (491.5, 238.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-02-14), frame: (534.5, 238.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-02-15), frame: (577.0, 238.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-02-16), frame: (320.0, 291.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-02-17), frame: (363.0, 291.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-02-18), frame: (405.5, 291.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-02-19), frame: (448.5, 291.5, 33.0, 33.0)]", + "[itemType: .daysOfWeekRowSeparator(2020-1), frame: (0.0, 112.0, 300.0, 1.0)]", + "[itemType: .daysOfWeekRowSeparator(2020-2), frame: (315.0, 112.0, 300.0, 1.0)]", + "[itemType: .layoutItemType(.day(2020-02-01)), frame: (577.0, 133.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-02-02)), frame: (320.0, 185.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-02-03)), frame: (363.0, 185.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-02-04)), frame: (405.5, 185.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-02-05)), frame: (448.5, 185.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-02-06)), frame: (491.5, 185.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-02-07)), frame: (534.5, 185.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-02-08)), frame: (577.0, 185.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-02-09)), frame: (320.0, 238.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-02-10)), frame: (363.0, 238.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-02-11)), frame: (405.5, 238.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-02-12)), frame: (448.5, 238.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-02-13)), frame: (491.5, 238.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-02-14)), frame: (534.5, 238.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-02-15)), frame: (577.0, 238.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-02-16)), frame: (320.0, 291.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-02-17)), frame: (363.0, 291.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-02-18)), frame: (405.5, 291.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-02-19)), frame: (448.5, 291.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-02-20)), frame: (491.5, 291.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-02-21)), frame: (534.5, 291.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-02-22)), frame: (577.0, 291.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-02-23)), frame: (320.0, 344.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-02-24)), frame: (363.0, 344.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-02-25)), frame: (405.5, 344.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-02-26)), frame: (448.5, 344.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-02-27)), frame: (491.5, 344.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-02-28)), frame: (534.5, 344.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-02-29)), frame: (577.0, 344.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.fifth, 2020-02)), frame: (491.5, 80.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.first, 2020-02)), frame: (320.0, 80.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.fourth, 2020-02)), frame: (448.5, 80.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.last, 2020-02)), frame: (577.0, 80.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.second, 2020-02)), frame: (363.0, 80.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.sixth, 2020-02)), frame: (534.5, 80.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.third, 2020-02)), frame: (405.5, 80.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.monthHeader(2020-01)), frame: (0.0, 0.0, 300.0, 50.0)]", + "[itemType: .layoutItemType(.monthHeader(2020-02)), frame: (315.0, 0.0, 300.0, 50.0)]", + "[itemType: .monthBackground(2020-01), frame: (-7.5, -51.5, 315.0, 480.0)]", + "[itemType: .monthBackground(2020-02), frame: (307.5, -51.5, 315.0, 480.0)]", + ] + + XCTAssert( + Set(details.visibleItems.map { $0.description }) == expectedVisibleItemDescriptions, + "Unexpected visible items.") + } + + // MARK: Scrolled to content boundary tests + + func testBoundaryVerticalVisibleItemsContext() { + let details = verticalVisibleItemsProvider.detailsForVisibleItems( + surroundingPreviouslyVisibleLayoutItem: LayoutItem( + itemType: .monthHeader(Month(era: 1, year: 2020, month: 01, isInGregorianCalendar: true)), + frame: CGRect(x: 0, y: 0, width: 320, height: 50)), + offset: CGPoint(x: 0, y: -50), + extendLayoutRegion: false) + + let expectedVisibleItemDescriptions: Set = [ + "[itemType: .dayBackground(2020-01-11), frame: (279.5, 191.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-01-12), frame: (5.0, 247.0, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-01-13), frame: (50.5, 247.0, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-01-14), frame: (96.5, 247.0, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-01-15), frame: (142.0, 247.0, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-01-16), frame: (188.0, 247.0, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-01-17), frame: (233.5, 247.0, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-01-18), frame: (279.5, 247.0, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-01-19), frame: (5.0, 303.0, 35.5, 35.5)]", + "[itemType: .daysOfWeekRowSeparator(2020-1), frame: (0.0, 114.5, 320.0, 1.0)]", + "[itemType: .daysOfWeekRowSeparator(2020-2), frame: (0.0, 524.0, 320.0, 1.0)]", + "[itemType: .layoutItemType(.day(2020-01-01)), frame: (142.0, 135.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-02)), frame: (188.0, 135.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-03)), frame: (233.5, 135.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-04)), frame: (279.5, 135.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-05)), frame: (5.0, 191.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-06)), frame: (50.5, 191.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-07)), frame: (96.5, 191.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-08)), frame: (142.0, 191.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-09)), frame: (188.0, 191.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-10)), frame: (233.5, 191.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-11)), frame: (279.5, 191.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-12)), frame: (5.0, 247.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-13)), frame: (50.5, 247.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-14)), frame: (96.5, 247.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-15)), frame: (142.0, 247.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-16)), frame: (188.0, 247.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-17)), frame: (233.5, 247.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-18)), frame: (279.5, 247.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-19)), frame: (5.0, 303.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-20)), frame: (50.5, 303.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-21)), frame: (96.5, 303.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-22)), frame: (142.0, 303.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-23)), frame: (188.0, 303.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-24)), frame: (233.5, 303.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-25)), frame: (279.5, 303.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-26)), frame: (5.0, 358.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-27)), frame: (50.5, 358.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-28)), frame: (96.5, 358.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-29)), frame: (142.0, 358.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-30)), frame: (188.0, 358.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-31)), frame: (233.5, 358.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.fifth, 2020-01)), frame: (188.0, 80.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.first, 2020-01)), frame: (5.0, 80.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.fourth, 2020-01)), frame: (142.0, 80.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.last, 2020-01)), frame: (279.5, 80.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.second, 2020-01)), frame: (50.5, 80.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.sixth, 2020-01)), frame: (233.5, 80.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.third, 2020-01)), frame: (96.5, 80.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.monthHeader(2020-01)), frame: (0.0, 0.0, 320.0, 50.0)]", + "[itemType: .layoutItemType(.monthHeader(2020-02)), frame: (0.0, 409.5, 320.0, 50.0)]", + "[itemType: .monthBackground(2020-01), frame: (0.0, -7.5, 320.0, 409.5)]", + "[itemType: .monthBackground(2020-02), frame: (0.0, 402.0, 320.0, 409.5)]", + "[itemType: .overlayItem(.day(2020-1-19)), frame: (0.0, -50.0, 320.0, 480.0)]", + ] + + XCTAssert( + Set(details.visibleItems.map { $0.description }) == expectedVisibleItemDescriptions, + "Unexpected visible items.") + + XCTAssert( + details.centermostLayoutItem + .description == "[itemType: .layoutItemType(.day(2020-01-08)), frame: (142.0, 191.5, 35.5, 35.5)]", + "Unexpected centermost layout item.") + + XCTAssert(details.contentStartBoundary == 0, "Unexpected content start offset.") + XCTAssert(details.contentEndBoundary == nil, "Unexpected content end offset.") + } + + func testBoundaryVerticalPinnedDaysOfWeekVisibleItemsContext() { + let details = verticalPinnedDaysOfWeekVisibleItemsProvider.detailsForVisibleItems( + surroundingPreviouslyVisibleLayoutItem: LayoutItem( + itemType: .monthHeader(Month(era: 1, year: 2020, month: 01, isInGregorianCalendar: true)), + frame: CGRect(x: 0, y: 45, width: 320, height: 40)), + offset: CGPoint(x: 0, y: 50), + extendLayoutRegion: false) + + let expectedVisibleItemDescriptions: Set = [ + "[itemType: .dayBackground(2020-01-11), frame: (279.5, 180.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-01-12), frame: (5.0, 236.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-01-13), frame: (50.5, 236.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-01-14), frame: (96.5, 236.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-01-15), frame: (142.0, 236.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-01-16), frame: (188.0, 236.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-01-17), frame: (233.5, 236.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-01-18), frame: (279.5, 236.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-01-19), frame: (5.0, 292.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-01)), frame: (142.0, 125.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-02)), frame: (188.0, 125.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-03)), frame: (233.5, 125.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-04)), frame: (279.5, 125.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-05)), frame: (5.0, 180.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-06)), frame: (50.5, 180.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-07)), frame: (96.5, 180.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-08)), frame: (142.0, 180.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-09)), frame: (188.0, 180.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-10)), frame: (233.5, 180.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-11)), frame: (279.5, 180.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-12)), frame: (5.0, 236.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-13)), frame: (50.5, 236.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-14)), frame: (96.5, 236.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-15)), frame: (142.0, 236.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-16)), frame: (188.0, 236.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-17)), frame: (233.5, 236.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-18)), frame: (279.5, 236.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-19)), frame: (5.0, 292.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-20)), frame: (50.5, 292.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-21)), frame: (96.5, 292.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-22)), frame: (142.0, 292.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-23)), frame: (188.0, 292.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-24)), frame: (233.5, 292.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-25)), frame: (279.5, 292.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-26)), frame: (5.0, 348.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-27)), frame: (50.5, 348.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-28)), frame: (96.5, 348.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-29)), frame: (142.0, 348.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-30)), frame: (188.0, 348.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-01-31)), frame: (233.5, 348.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-02-01)), frame: (279.5, 478.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.monthHeader(2020-01)), frame: (0.0, 45.0, 320.0, 50.0)]", + "[itemType: .layoutItemType(.monthHeader(2020-02)), frame: (0.0, 398.5, 320.0, 50.0)]", + "[itemType: .monthBackground(2020-01), frame: (0.0, 37.5, 320.0, 353.5)]", + "[itemType: .monthBackground(2020-02), frame: (0.0, 391.0, 320.0, 353.5)]", + "[itemType: .overlayItem(.day(2020-1-19)), frame: (0.0, 50.0, 320.0, 480.0)]", + "[itemType: .pinnedDayOfWeek(.fifth), frame: (188.0, 50.0, 35.5, 35.5)]", + "[itemType: .pinnedDayOfWeek(.first), frame: (5.0, 50.0, 35.5, 35.5)]", + "[itemType: .pinnedDayOfWeek(.fourth), frame: (142.0, 50.0, 35.5, 35.5)]", + "[itemType: .pinnedDayOfWeek(.last), frame: (279.5, 50.0, 35.5, 35.5)]", + "[itemType: .pinnedDayOfWeek(.second), frame: (50.5, 50.0, 35.5, 35.5)]", + "[itemType: .pinnedDayOfWeek(.sixth), frame: (233.5, 50.0, 35.5, 35.5)]", + "[itemType: .pinnedDayOfWeek(.third), frame: (96.5, 50.0, 35.5, 35.5)]", + "[itemType: .pinnedDaysOfWeekRowBackground, frame: (0.0, 50.0, 320.0, 35.5)]", + "[itemType: .pinnedDaysOfWeekRowSeparator, frame: (0.0, 84.5, 320.0, 1.0)]", + ] + + XCTAssert( + Set(details.visibleItems.map { $0.description }) == expectedVisibleItemDescriptions, + "Unexpected visible items.") + + XCTAssert( + details.centermostLayoutItem + .description == "[itemType: .layoutItemType(.day(2020-01-22)), frame: (142.0, 292.0, 35.5, 35.5)]", + "Unexpected centermost layout item.") + + XCTAssert( + details.contentStartBoundary?.alignedToPixel(forScreenWithScale: 2) == 9.5, + "Unexpected content start offset.") + XCTAssert(details.contentEndBoundary == nil, "Unexpected content end offset.") + } + + func testBoundaryVerticalPartialMonthVisibleItemsContext() { + let details = verticalPartialMonthVisibleItemsProvider.detailsForVisibleItems( + surroundingPreviouslyVisibleLayoutItem: LayoutItem( + itemType: .monthHeader(Month(era: 1, year: 2020, month: 12, isInGregorianCalendar: true)), + frame: CGRect(x: 0, y: 690, width: 320, height: 50)), + offset: CGPoint(x: 0, y: 690), + extendLayoutRegion: false) + + let expectedVisibleItemDescriptions: Set = [ + "[itemType: .daysOfWeekRowSeparator(2020-12), frame: (0.0, 854.5, 320.0, 1.0)]", + "[itemType: .layoutItemType(.day(2020-12-01)), frame: (96.5, 875.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.fifth, 2020-12)), frame: (188.0, 820.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.first, 2020-12)), frame: (5.0, 820.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.fourth, 2020-12)), frame: (142.0, 820.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.last, 2020-12)), frame: (279.5, 820.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.second, 2020-12)), frame: (50.5, 820.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.sixth, 2020-12)), frame: (233.5, 820.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.third, 2020-12)), frame: (96.5, 820.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.monthHeader(2020-12)), frame: (0.0, 690.0, 320.0, 100.0)]", + "[itemType: .monthBackground(2020-12), frame: (0.0, 682.5, 320.0, 236.5)]", + ] + + XCTAssert( + Set(details.visibleItems.map { $0.description }) == expectedVisibleItemDescriptions, + "Unexpected visible items.") + + XCTAssert( + details.centermostLayoutItem + .description == "[itemType: .layoutItemType(.day(2020-12-01)), frame: (96.5, 875.5, 35.5, 35.5)]", + "Unexpected centermost layout item.") + + XCTAssert(details.contentStartBoundary == nil, "Unexpected content start offset.") + XCTAssert( + details.contentEndBoundary?.alignedToPixel(forScreenWithScale: 3) == CGFloat(911.4285714285714) + .alignedToPixel(forScreenWithScale: 3), + "Unexpected content end offset.") + } + + func testBoundaryHorizontalVisibleItemsContext() { + let details = horizontalVisibleItemsProvider.detailsForVisibleItems( + surroundingPreviouslyVisibleLayoutItem: LayoutItem( + itemType: .monthHeader(Month(era: 1, year: 2020, month: 12, isInGregorianCalendar: true)), + frame: CGRect(x: 1200, y: 0, width: 300, height: 50)), + offset: CGPoint(x: 1000, y: 0), + extendLayoutRegion: false) + + let expectedVisibleItemDescriptions: Set = [ + "[itemType: .dayBackground(2020-11-11), frame: (1018.5, 235.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-11-12), frame: (1061.5, 235.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-11-13), frame: (1104.5, 235.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-11-14), frame: (1147.0, 235.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-11-17), frame: (975.5, 288.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-11-18), frame: (1018.5, 288.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-11-19), frame: (1061.5, 288.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-12-13), frame: (1205.0, 288.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-12-14), frame: (1248.0, 288.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-12-15), frame: (1290.5, 288.5, 33.0, 33.0)]", + "[itemType: .daysOfWeekRowSeparator(2020-11), frame: (885.0, 162.0, 300.0, 1.0)]", + "[itemType: .daysOfWeekRowSeparator(2020-12), frame: (1200.0, 162.0, 300.0, 1.0)]", + "[itemType: .layoutItemType(.day(2020-11-03)), frame: (975.5, 183.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-11-04)), frame: (1018.5, 183.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-11-05)), frame: (1061.5, 183.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-11-06)), frame: (1104.5, 183.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-11-07)), frame: (1147.0, 183.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-11-10)), frame: (975.5, 235.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-11-11)), frame: (1018.5, 235.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-11-12)), frame: (1061.5, 235.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-11-13)), frame: (1104.5, 235.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-11-14)), frame: (1147.0, 235.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-11-17)), frame: (975.5, 288.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-11-18)), frame: (1018.5, 288.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-11-19)), frame: (1061.5, 288.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-11-20)), frame: (1104.5, 288.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-11-21)), frame: (1147.0, 288.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-11-24)), frame: (975.5, 341.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-11-25)), frame: (1018.5, 341.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-11-26)), frame: (1061.5, 341.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-11-27)), frame: (1104.5, 341.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-11-28)), frame: (1147.0, 341.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-12-01)), frame: (1290.5, 183.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-12-06)), frame: (1205.0, 235.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-12-07)), frame: (1248.0, 235.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-12-08)), frame: (1290.5, 235.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-12-13)), frame: (1205.0, 288.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-12-14)), frame: (1248.0, 288.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-12-15)), frame: (1290.5, 288.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-12-20)), frame: (1205.0, 341.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-12-21)), frame: (1248.0, 341.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-12-22)), frame: (1290.5, 341.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-12-27)), frame: (1205.0, 394.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-12-28)), frame: (1248.0, 394.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-12-29)), frame: (1290.5, 394.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.fifth, 2020-11)), frame: (1061.5, 130.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.first, 2020-12)), frame: (1205.0, 130.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.fourth, 2020-11)), frame: (1018.5, 130.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.last, 2020-11)), frame: (1147.0, 130.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.second, 2020-12)), frame: (1248.0, 130.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.sixth, 2020-11)), frame: (1104.5, 130.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.third, 2020-11)), frame: (975.5, 130.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.third, 2020-12)), frame: (1290.5, 130.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.monthHeader(2020-11)), frame: (885.0, 50.0, 300.0, 50.0)]", + "[itemType: .layoutItemType(.monthHeader(2020-12)), frame: (1200.0, 0.0, 300.0, 100.0)]", + "[itemType: .monthBackground(2020-11), frame: (877.5, -1.5, 315.0, 480.0)]", + "[itemType: .monthBackground(2020-12), frame: (1192.5, -26.5, 315.0, 480.0)]", + "[itemType: .overlayItem(.monthHeader(2020-11)), frame: (1000.0, 0.0, 320.0, 480.0)]", + ] + + XCTAssert( + Set(details.visibleItems.map { $0.description }) == expectedVisibleItemDescriptions, + "Unexpected visible items.") + + XCTAssert( + details.centermostLayoutItem + .description == "[itemType: .layoutItemType(.day(2020-11-14)), frame: (1147.0, 235.5, 33.0, 33.0)]", + "Unexpected centermost layout item.") + + XCTAssert(details.contentStartBoundary == nil, "Unexpected content start offset.") + XCTAssert(details.contentEndBoundary == 1500, "Unexpected content end offset.") + } + + // MARK: Animated update pass tests + + func testVerticalVisibleItemsForAnimatedUpdatePass() { + let details = verticalPinnedDaysOfWeekVisibleItemsProvider.detailsForVisibleItems( + surroundingPreviouslyVisibleLayoutItem: LayoutItem( + itemType: .monthHeader(Month(era: 1, year: 2020, month: 06, isInGregorianCalendar: true)), + frame: CGRect(x: 0, y: 450, width: 320, height: 40)), + offset: CGPoint(x: 0, y: 450), + extendLayoutRegion: true) + + let expectedVisibleItemDescriptions: Set = [ + "[itemType: .dayBackground(2020-04-19), frame: (5.0, -65.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-05-11), frame: (50.5, 232.0, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-05-12), frame: (96.5, 232.0, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-05-13), frame: (142.0, 232.0, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-05-14), frame: (188.0, 232.0, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-05-15), frame: (233.5, 232.0, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-05-16), frame: (279.5, 232.0, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-05-17), frame: (5.0, 288.0, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-05-18), frame: (50.5, 288.0, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-05-19), frame: (96.5, 288.0, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-06-11), frame: (188.0, 585.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-06-12), frame: (233.5, 585.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-06-13), frame: (279.5, 585.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-06-14), frame: (5.0, 641.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-06-15), frame: (50.5, 641.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-06-16), frame: (96.5, 641.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-06-17), frame: (142.0, 641.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-06-18), frame: (188.0, 641.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-06-19), frame: (233.5, 641.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-07-11), frame: (279.5, 939.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-07-12), frame: (5.0, 995.0, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-07-13), frame: (50.5, 995.0, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-07-14), frame: (96.5, 995.0, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-07-15), frame: (142.0, 995.0, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-07-16), frame: (188.0, 995.0, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-07-17), frame: (233.5, 995.0, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-07-18), frame: (279.5, 995.0, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-07-19), frame: (5.0, 1050.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-08-11), frame: (96.5, 1398.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-08-12), frame: (142.0, 1398.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-08-13), frame: (188.0, 1398.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-08-14), frame: (233.5, 1398.5, 35.5, 35.5)]", + "[itemType: .dayBackground(2020-08-15), frame: (279.5, 1398.5, 35.5, 35.5)]", + "[itemType: .dayRange(2020-04-30, 2020-05-14), frame: (5.0, -10.0, 310.0, 278.0)]", + "[itemType: .layoutItemType(.day(2020-04-19)), frame: (5.0, -65.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-04-20)), frame: (50.5, -65.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-04-21)), frame: (96.5, -65.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-04-22)), frame: (142.0, -65.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-04-23)), frame: (188.0, -65.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-04-24)), frame: (233.5, -65.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-04-25)), frame: (279.5, -65.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-04-26)), frame: (5.0, -10.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-04-27)), frame: (50.5, -10.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-04-28)), frame: (96.5, -10.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-04-29)), frame: (142.0, -10.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-04-30)), frame: (188.0, -10.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-01)), frame: (233.5, 120.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-02)), frame: (279.5, 120.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-03)), frame: (5.0, 176.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-04)), frame: (50.5, 176.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-05)), frame: (96.5, 176.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-06)), frame: (142.0, 176.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-07)), frame: (188.0, 176.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-08)), frame: (233.5, 176.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-09)), frame: (279.5, 176.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-10)), frame: (5.0, 232.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-11)), frame: (50.5, 232.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-12)), frame: (96.5, 232.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-13)), frame: (142.0, 232.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-14)), frame: (188.0, 232.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-15)), frame: (233.5, 232.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-16)), frame: (279.5, 232.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-17)), frame: (5.0, 288.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-18)), frame: (50.5, 288.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-19)), frame: (96.5, 288.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-20)), frame: (142.0, 288.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-21)), frame: (188.0, 288.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-22)), frame: (233.5, 288.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-23)), frame: (279.5, 288.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-24)), frame: (5.0, 343.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-25)), frame: (50.5, 343.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-26)), frame: (96.5, 343.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-27)), frame: (142.0, 343.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-28)), frame: (188.0, 343.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-29)), frame: (233.5, 343.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-30)), frame: (279.5, 343.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-05-31)), frame: (5.0, 399.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-01)), frame: (50.5, 530.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-02)), frame: (96.5, 530.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-03)), frame: (142.0, 530.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-04)), frame: (188.0, 530.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-05)), frame: (233.5, 530.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-06)), frame: (279.5, 530.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-07)), frame: (5.0, 585.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-08)), frame: (50.5, 585.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-09)), frame: (96.5, 585.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-10)), frame: (142.0, 585.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-11)), frame: (188.0, 585.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-12)), frame: (233.5, 585.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-13)), frame: (279.5, 585.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-14)), frame: (5.0, 641.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-15)), frame: (50.5, 641.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-16)), frame: (96.5, 641.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-17)), frame: (142.0, 641.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-18)), frame: (188.0, 641.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-19)), frame: (233.5, 641.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-20)), frame: (279.5, 641.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-21)), frame: (5.0, 697.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-22)), frame: (50.5, 697.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-23)), frame: (96.5, 697.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-24)), frame: (142.0, 697.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-25)), frame: (188.0, 697.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-26)), frame: (233.5, 697.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-27)), frame: (279.5, 697.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-28)), frame: (5.0, 753.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-29)), frame: (50.5, 753.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-06-30)), frame: (96.5, 753.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-07-01)), frame: (142.0, 883.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-07-02)), frame: (188.0, 883.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-07-03)), frame: (233.5, 883.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-07-04)), frame: (279.5, 883.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-07-05)), frame: (5.0, 939.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-07-06)), frame: (50.5, 939.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-07-07)), frame: (96.5, 939.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-07-08)), frame: (142.0, 939.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-07-09)), frame: (188.0, 939.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-07-10)), frame: (233.5, 939.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-07-11)), frame: (279.5, 939.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-07-12)), frame: (5.0, 995.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-07-13)), frame: (50.5, 995.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-07-14)), frame: (96.5, 995.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-07-15)), frame: (142.0, 995.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-07-16)), frame: (188.0, 995.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-07-17)), frame: (233.5, 995.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-07-18)), frame: (279.5, 995.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-07-19)), frame: (5.0, 1050.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-07-20)), frame: (50.5, 1050.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-07-21)), frame: (96.5, 1050.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-07-22)), frame: (142.0, 1050.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-07-23)), frame: (188.0, 1050.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-07-24)), frame: (233.5, 1050.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-07-25)), frame: (279.5, 1050.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-07-26)), frame: (5.0, 1106.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-07-27)), frame: (50.5, 1106.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-07-28)), frame: (96.5, 1106.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-07-29)), frame: (142.0, 1106.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-07-30)), frame: (188.0, 1106.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-07-31)), frame: (233.5, 1106.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-08-01)), frame: (279.5, 1287.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-08-02)), frame: (5.0, 1343.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-08-03)), frame: (50.5, 1343.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-08-04)), frame: (96.5, 1343.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-08-05)), frame: (142.0, 1343.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-08-06)), frame: (188.0, 1343.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-08-07)), frame: (233.5, 1343.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-08-08)), frame: (279.5, 1343.0, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-08-09)), frame: (5.0, 1398.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-08-10)), frame: (50.5, 1398.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-08-11)), frame: (96.5, 1398.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-08-12)), frame: (142.0, 1398.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-08-13)), frame: (188.0, 1398.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-08-14)), frame: (233.5, 1398.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.day(2020-08-15)), frame: (279.5, 1398.5, 35.5, 35.5)]", + "[itemType: .layoutItemType(.monthHeader(2020-05)), frame: (0.0, 40.5, 320.0, 50.0)]", + "[itemType: .layoutItemType(.monthHeader(2020-06)), frame: (0.0, 450.0, 320.0, 50.0)]", + "[itemType: .layoutItemType(.monthHeader(2020-07)), frame: (0.0, 803.5, 320.0, 50.0)]", + "[itemType: .layoutItemType(.monthHeader(2020-08)), frame: (0.0, 1157.0, 320.0, 100.0)]", + "[itemType: .monthBackground(2020-04), frame: (0.0, -370.5, 320.0, 403.5)]", + "[itemType: .monthBackground(2020-05), frame: (0.0, 33.0, 320.0, 409.5)]", + "[itemType: .monthBackground(2020-06), frame: (0.0, 442.5, 320.0, 353.5)]", + "[itemType: .monthBackground(2020-07), frame: (0.0, 796.0, 320.0, 353.5)]", + "[itemType: .monthBackground(2020-08), frame: (0.0, 1149.5, 320.0, 459.5)]", + "[itemType: .pinnedDayOfWeek(.fifth), frame: (188.0, 450.0, 35.5, 35.5)]", + "[itemType: .pinnedDayOfWeek(.first), frame: (5.0, 450.0, 35.5, 35.5)]", + "[itemType: .pinnedDayOfWeek(.fourth), frame: (142.0, 450.0, 35.5, 35.5)]", + "[itemType: .pinnedDayOfWeek(.last), frame: (279.5, 450.0, 35.5, 35.5)]", + "[itemType: .pinnedDayOfWeek(.second), frame: (50.5, 450.0, 35.5, 35.5)]", + "[itemType: .pinnedDayOfWeek(.sixth), frame: (233.5, 450.0, 35.5, 35.5)]", + "[itemType: .pinnedDayOfWeek(.third), frame: (96.5, 450.0, 35.5, 35.5)]", + "[itemType: .pinnedDaysOfWeekRowBackground, frame: (0.0, 450.0, 320.0, 35.5)]", + "[itemType: .pinnedDaysOfWeekRowSeparator, frame: (0.0, 484.5, 320.0, 1.0)]", + ] + + XCTAssert( + Set(details.visibleItems.map { $0.description }) == expectedVisibleItemDescriptions, + "Unexpected visible items.") + } + + func testHorizontalVisibleItemsForAnimatedUpdatePass() { + let details = horizontalVisibleItemsProvider.detailsForVisibleItems( + surroundingPreviouslyVisibleLayoutItem: LayoutItem( + itemType: .monthHeader(Month(era: 1, year: 2020, month: 05, isInGregorianCalendar: true)), + frame: CGRect(x: 250, y: 0, width: 300, height: 50)), + offset: CGPoint(x: 100, y: 0), + extendLayoutRegion: true) + + let expectedVisibleItemDescriptions: Set = [ + "[itemType: .dayBackground(2020-03-11), frame: (-246.5, 185.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-03-12), frame: (-203.5, 185.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-03-13), frame: (-160.5, 185.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-03-14), frame: (-118.0, 185.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-03-18), frame: (-246.5, 238.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-03-19), frame: (-203.5, 238.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-04-11), frame: (197.0, 185.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-04-12), frame: (-60.0, 238.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-04-13), frame: (-17.0, 238.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-04-14), frame: (25.5, 238.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-04-15), frame: (68.5, 238.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-04-16), frame: (111.5, 238.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-04-17), frame: (154.5, 238.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-04-18), frame: (197.0, 238.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-04-19), frame: (-60.0, 291.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-05-11), frame: (298.0, 238.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-05-12), frame: (340.5, 238.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-05-13), frame: (383.5, 238.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-05-14), frame: (426.5, 238.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-05-15), frame: (469.5, 238.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-05-16), frame: (512.0, 238.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-05-17), frame: (255.0, 291.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-05-18), frame: (298.0, 291.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-05-19), frame: (340.5, 291.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-06-14), frame: (570.0, 238.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-06-15), frame: (613.0, 238.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-06-16), frame: (655.5, 238.5, 33.0, 33.0)]", + "[itemType: .dayBackground(2020-06-17), frame: (698.5, 238.5, 33.0, 33.0)]", + "[itemType: .dayRange(2020-03-11, 2020-04-05), frame: (-375.0, 133.0, 605.0, 244.5)]", + "[itemType: .dayRange(2020-04-30, 2020-05-14), frame: (111.5, 133.0, 433.5, 244.5)]", + "[itemType: .daysOfWeekRowSeparator(2020-3), frame: (-380.0, 112.0, 300.0, 1.0)]", + "[itemType: .daysOfWeekRowSeparator(2020-4), frame: (-65.0, 112.0, 300.0, 1.0)]", + "[itemType: .daysOfWeekRowSeparator(2020-5), frame: (250.0, 112.0, 300.0, 1.0)]", + "[itemType: .daysOfWeekRowSeparator(2020-6), frame: (565.0, 112.0, 300.0, 1.0)]", + "[itemType: .layoutItemType(.day(2020-03-04)), frame: (-246.5, 133.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-03-05)), frame: (-203.5, 133.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-03-06)), frame: (-160.5, 133.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-03-07)), frame: (-118.0, 133.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-03-11)), frame: (-246.5, 185.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-03-12)), frame: (-203.5, 185.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-03-13)), frame: (-160.5, 185.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-03-14)), frame: (-118.0, 185.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-03-18)), frame: (-246.5, 238.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-03-19)), frame: (-203.5, 238.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-03-20)), frame: (-160.5, 238.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-03-21)), frame: (-118.0, 238.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-03-25)), frame: (-246.5, 291.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-03-26)), frame: (-203.5, 291.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-03-27)), frame: (-160.5, 291.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-03-28)), frame: (-118.0, 291.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-01)), frame: (68.5, 133.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-02)), frame: (111.5, 133.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-03)), frame: (154.5, 133.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-04)), frame: (197.0, 133.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-05)), frame: (-60.0, 185.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-06)), frame: (-17.0, 185.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-07)), frame: (25.5, 185.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-08)), frame: (68.5, 185.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-09)), frame: (111.5, 185.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-10)), frame: (154.5, 185.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-11)), frame: (197.0, 185.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-12)), frame: (-60.0, 238.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-13)), frame: (-17.0, 238.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-14)), frame: (25.5, 238.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-15)), frame: (68.5, 238.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-16)), frame: (111.5, 238.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-17)), frame: (154.5, 238.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-18)), frame: (197.0, 238.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-19)), frame: (-60.0, 291.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-20)), frame: (-17.0, 291.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-21)), frame: (25.5, 291.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-22)), frame: (68.5, 291.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-23)), frame: (111.5, 291.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-24)), frame: (154.5, 291.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-25)), frame: (197.0, 291.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-26)), frame: (-60.0, 344.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-27)), frame: (-17.0, 344.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-28)), frame: (25.5, 344.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-29)), frame: (68.5, 344.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-04-30)), frame: (111.5, 344.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-01)), frame: (469.5, 133.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-02)), frame: (512.0, 133.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-03)), frame: (255.0, 185.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-04)), frame: (298.0, 185.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-05)), frame: (340.5, 185.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-06)), frame: (383.5, 185.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-07)), frame: (426.5, 185.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-08)), frame: (469.5, 185.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-09)), frame: (512.0, 185.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-10)), frame: (255.0, 238.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-11)), frame: (298.0, 238.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-12)), frame: (340.5, 238.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-13)), frame: (383.5, 238.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-14)), frame: (426.5, 238.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-15)), frame: (469.5, 238.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-16)), frame: (512.0, 238.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-17)), frame: (255.0, 291.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-18)), frame: (298.0, 291.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-19)), frame: (340.5, 291.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-20)), frame: (383.5, 291.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-21)), frame: (426.5, 291.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-22)), frame: (469.5, 291.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-23)), frame: (512.0, 291.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-24)), frame: (255.0, 344.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-25)), frame: (298.0, 344.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-26)), frame: (340.5, 344.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-27)), frame: (383.5, 344.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-28)), frame: (426.5, 344.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-29)), frame: (469.5, 344.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-30)), frame: (512.0, 344.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-05-31)), frame: (255.0, 397.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-06-01)), frame: (613.0, 133.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-06-02)), frame: (655.5, 133.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-06-03)), frame: (698.5, 133.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-06-07)), frame: (570.0, 185.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-06-08)), frame: (613.0, 185.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-06-09)), frame: (655.5, 185.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-06-10)), frame: (698.5, 185.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-06-14)), frame: (570.0, 238.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-06-15)), frame: (613.0, 238.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-06-16)), frame: (655.5, 238.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-06-17)), frame: (698.5, 238.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-06-21)), frame: (570.0, 291.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-06-22)), frame: (613.0, 291.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-06-23)), frame: (655.5, 291.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-06-24)), frame: (698.5, 291.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-06-28)), frame: (570.0, 344.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-06-29)), frame: (613.0, 344.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.day(2020-06-30)), frame: (655.5, 344.5, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.fifth, 2020-03)), frame: (-203.5, 80.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.fifth, 2020-04)), frame: (111.5, 80.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.fifth, 2020-05)), frame: (426.5, 80.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.first, 2020-04)), frame: (-60.0, 80.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.first, 2020-05)), frame: (255.0, 80.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.first, 2020-06)), frame: (570.0, 80.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.fourth, 2020-03)), frame: (-246.5, 80.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.fourth, 2020-04)), frame: (68.5, 80.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.fourth, 2020-05)), frame: (383.5, 80.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.fourth, 2020-06)), frame: (698.5, 80.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.last, 2020-03)), frame: (-118.0, 80.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.last, 2020-04)), frame: (197.0, 80.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.last, 2020-05)), frame: (512.0, 80.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.second, 2020-04)), frame: (-17.0, 80.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.second, 2020-05)), frame: (298.0, 80.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.second, 2020-06)), frame: (613.0, 80.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.sixth, 2020-03)), frame: (-160.5, 80.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.sixth, 2020-04)), frame: (154.5, 80.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.sixth, 2020-05)), frame: (469.5, 80.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.third, 2020-04)), frame: (25.5, 80.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.third, 2020-05)), frame: (340.5, 80.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.dayOfWeekInMonth(.third, 2020-06)), frame: (655.5, 80.0, 33.0, 33.0)]", + "[itemType: .layoutItemType(.monthHeader(2020-03)), frame: (-380.0, 0.0, 300.0, 50.0)]", + "[itemType: .layoutItemType(.monthHeader(2020-04)), frame: (-65.0, -50.0, 300.0, 100.0)]", + "[itemType: .layoutItemType(.monthHeader(2020-05)), frame: (250.0, 0.0, 300.0, 50.0)]", + "[itemType: .layoutItemType(.monthHeader(2020-06)), frame: (565.0, 0.0, 300.0, 50.0)]", + "[itemType: .monthBackground(2020-03), frame: (-387.5, -51.5, 315.0, 480.0)]", + "[itemType: .monthBackground(2020-04), frame: (-72.5, -76.5, 315.0, 480.0)]", + "[itemType: .monthBackground(2020-05), frame: (242.5, -25.0, 315.0, 480.0)]", + "[itemType: .monthBackground(2020-06), frame: (557.5, -51.5, 315.0, 480.0)]", + ] + + XCTAssert( + Set(details.visibleItems.map { $0.description }) == expectedVisibleItemDescriptions, + "Unexpected visible items.") + } + + // MARK: Private + + private static let calendar = Calendar(identifier: .gregorian) + + private static let dateRange = ClosedRange( + uncheckedBounds: ( + lower: calendar.startDate( + of: Day(month: Month(era: 1, year: 2020, month: 01, isInGregorianCalendar: true), day: 25)), + upper: calendar.startDate( + of: Day( + month: Month(era: 1, year: 2020, month: 12, isInGregorianCalendar: true), + day: 01)))) + + private static let size = CGSize(width: 320, height: 480) + + private var verticalVisibleItemsProvider = VisibleItemsProvider( + calendar: calendar, + content: makeContent( + fromBaseContent: CalendarViewContent( + calendar: calendar, + visibleDateRange: dateRange, + monthsLayout: .vertical(options: VerticalMonthsLayoutOptions()))), + size: size, + layoutMargins: .zero, + scale: 2, + backgroundColor: nil) + + private var verticalShortDayAspectRatioVisibleItemsProvider = VisibleItemsProvider( + calendar: calendar, + content: makeContent( + fromBaseContent: CalendarViewContent( + calendar: calendar, + visibleDateRange: dateRange, + monthsLayout: .vertical(options: VerticalMonthsLayoutOptions()))) + .dayAspectRatio(0.5) + .dayOfWeekAspectRatio(0.5), + size: size, + layoutMargins: .zero, + scale: 2, + backgroundColor: nil) + + private var verticalPinnedDaysOfWeekVisibleItemsProvider = VisibleItemsProvider( + calendar: calendar, + content: makeContent( + fromBaseContent: CalendarViewContent( + calendar: calendar, + visibleDateRange: dateRange, + monthsLayout: .vertical(options: VerticalMonthsLayoutOptions(pinDaysOfWeekToTop: true)))), + size: size, + layoutMargins: .zero, + scale: 2, + backgroundColor: nil) + + private var verticalPartialMonthVisibleItemsProvider = VisibleItemsProvider( + calendar: calendar, + content: makeContent( + fromBaseContent: CalendarViewContent( + calendar: calendar, + visibleDateRange: dateRange, + monthsLayout: .vertical( + options: VerticalMonthsLayoutOptions(alwaysShowCompleteBoundaryMonths: false)))), + size: size, + layoutMargins: .zero, + scale: 2, + backgroundColor: nil) + + private var horizontalVisibleItemsProvider = VisibleItemsProvider( + calendar: calendar, + content: makeContent( + fromBaseContent: CalendarViewContent( + calendar: calendar, + visibleDateRange: dateRange, + monthsLayout: .horizontal( + options: HorizontalMonthsLayoutOptions(maximumFullyVisibleMonths: 64 / 63)))), + size: size, + layoutMargins: .zero, + scale: 2, + backgroundColor: nil) + + private static func mockCalendarItemModel(height: CGFloat = 50) -> AnyCalendarItemModel { + final class MockView: UIView, CalendarItemViewRepresentable { + + typealias Height = CGFloat + + static func makeView( + withInvariantViewProperties height: Height) + -> MockView + { + let view = MockView() + let heightConstraint = view.heightAnchor.constraint(equalToConstant: height) + heightConstraint.priority = .defaultLow + heightConstraint.isActive = true + return view + } + + } + + return MockView.calendarItemModel(invariantViewProperties: height) + } + + private static func makeContent( + fromBaseContent baseContent: CalendarViewContent) + -> CalendarViewContent + { + baseContent + .monthDayInsets(.init(top: 30, leading: 5, bottom: 0, trailing: 5)) + .interMonthSpacing(15) + .verticalDayMargin(20) + .horizontalDayMargin(10) + .daysOfTheWeekRowSeparator(options: .init(height: 1, color: .gray)) + .monthHeaderItemProvider { month in + if month.month % 4 == 0 { + return mockCalendarItemModel(height: 100) + } else { + return mockCalendarItemModel() + } + } + .monthBackgroundItemProvider { _ in mockCalendarItemModel() } + .dayOfWeekItemProvider { _, _ in mockCalendarItemModel() } + .dayItemProvider { _ in mockCalendarItemModel() } + .dayBackgroundItemProvider { day in + // Just test a few backgrounds to make sure they're working correctly + if day.day > 10, day.day < 20 { + return mockCalendarItemModel() + } else { + return nil + } + } + .dayRangeItemProvider( + for: [ + calendar.date(from: DateComponents(year: 2020, month: 03, day: 11))! + ... + calendar.date(from: DateComponents(year: 2020, month: 04, day: 05))!, + + calendar.date(from: DateComponents(year: 2020, month: 04, day: 30))! + ... + calendar.date(from: DateComponents(year: 2020, month: 05, day: 14))!, + ]) { _ in mockCalendarItemModel() } + .overlayItemProvider( + for: [ + .day( + containingDate: calendar.date(from: DateComponents(year: 2020, month: 01, day: 19))!), + .monthHeader( + monthContainingDate: calendar.date(from: DateComponents(year: 2020, month: 11))!), + ]) { _ in mockCalendarItemModel() } + } + +} + +// MARK: - VisibleItem + CustomStringConvertible + +extension VisibleItem: CustomStringConvertible { + + public var description: String { + let itemTypeText: String + switch itemType { + case .layoutItemType(let layoutItemType): + itemTypeText = layoutItemType.description + case .pinnedDayOfWeek(let position): + itemTypeText = ".pinnedDayOfWeek(\(position))" + case .pinnedDaysOfWeekRowBackground: + itemTypeText = ".pinnedDaysOfWeekRowBackground" + case .pinnedDaysOfWeekRowSeparator: + itemTypeText = ".pinnedDaysOfWeekRowSeparator" + case .daysOfWeekRowSeparator(let month): + itemTypeText = ".daysOfWeekRowSeparator(\(month.year)-\(month.month))" + case .dayRange(let dayRange): + itemTypeText = ".dayRange(\(dayRange.lowerBound), \(dayRange.upperBound))" + case .dayBackground(let day): + itemTypeText = ".dayBackground(\(day))" + case .monthBackground(let month): + itemTypeText = ".monthBackground(\(month.description))" + case .weekNumber(let weekNumber, let month): + itemTypeText = ".weekNumber(\(weekNumber), \(month.description))" + case .overlayItem(let overlaidItemLocation): + let calendar = Calendar(identifier: .gregorian) + let itemLocationText: String + switch overlaidItemLocation { + case .day(let date): + let year = calendar.component(.year, from: date) + let month = calendar.component(.month, from: date) + let day = calendar.component(.day, from: date) + itemLocationText = ".day(\(year)-\(month)-\(day))" + case .monthHeader(let date): + let year = calendar.component(.year, from: date) + let month = calendar.component(.month, from: date) + itemLocationText = ".monthHeader(\(year)-\(month))" + } + itemTypeText = ".overlayItem(\(itemLocationText))" + } + + let frameText = frame.alignedToPixels(forScreenWithScale: 2).debugDescription + return "[itemType: \(itemTypeText), frame: \(frameText)]" + } + +} + +// MARK: - LayoutItem + CustomStringConvertible + +extension LayoutItem: CustomStringConvertible { + + public var description: String { + let frameText = frame.alignedToPixels(forScreenWithScale: 2).debugDescription + return "[itemType: \(itemType.description), frame: \(frameText)]" + } + +} + +// MARK: - LayoutItem.ItemType + CustomStringConvertible + +extension LayoutItem.ItemType: CustomStringConvertible { + + public var description: String { + switch self { + case .monthHeader(let month): + return ".layoutItemType(.monthHeader(\(month.description)))" + case .dayOfWeekInMonth(let position, let month): + return ".layoutItemType(.dayOfWeekInMonth(\(position.description), \(month.description)))" + case .day(let day): + return ".layoutItemType(.day(\(day)))" + } + } + +} + +// MARK: - DayOfWeekPosition + CustomStringConvertible + +extension DayOfWeekPosition: CustomStringConvertible { + + public var description: String { + switch self { + case .first: return ".first" + case .second: return ".second" + case .third: return ".third" + case .fourth: return ".fourth" + case .fifth: return ".fifth" + case .sixth: return ".sixth" + case .last: return ".last" + } + } + +} diff --git a/HorizonCalendarTests [Rec]/WeekNumberTests.swift b/HorizonCalendarTests [Rec]/WeekNumberTests.swift new file mode 100644 index 00000000..7fbccffd --- /dev/null +++ b/HorizonCalendarTests [Rec]/WeekNumberTests.swift @@ -0,0 +1,198 @@ +// +// WeekNumberTests.swift +// HorizonCalendar +// +// Created by Cade Chaplin on 3/1/25. +// Copyright © 2025 Airbnb. All rights reserved. +// + + +import XCTest +@testable import HorizonCalendar + +final class WeekNumberTests: XCTestCase { + + // MARK: - Common Test Properties + + private let calendar = Calendar(identifier: .gregorian) + private let size = CGSize(width: 320, height: 500) + private let dateRange = Date().addingTimeInterval(-60 * 60 * 24 * 30 * 6)...Date().addingTimeInterval(60 * 60 * 24 * 30 * 6) + + // MARK: - Tests + + func testWeekNumbersAreVisibleWhenEnabled() { + // Create content with week numbers enabled + let content = CalendarViewContent( + calendar: calendar, + visibleDateRange: dateRange, + monthsLayout: .vertical(options: .init())) + .showWeekNumbers(true) + + let visibleItemsProvider = VisibleItemsProvider( + calendar: calendar, + content: content, + size: size, + layoutMargins: .zero, + scale: 2, + backgroundColor: nil) + + // Get visible items when positioned at January + let details = visibleItemsProvider.detailsForVisibleItems( + surroundingPreviouslyVisibleLayoutItem: LayoutItem( + itemType: .monthHeader(Month(era: 1, year: 2024, month: 01, isInGregorianCalendar: true)), + frame: CGRect(x: 0, y: 0, width: 320, height: 50)), + offset: CGPoint(x: 0, y: 0), + extendLayoutRegion: false) + + // Find items with weekNumber type + let weekNumberItems = details.visibleItems.filter { + if case .weekNumber = $0.itemType { + return true + } + return false + } + + // Verify that week numbers are visible + XCTAssertFalse(weekNumberItems.isEmpty, "Week numbers should be visible when enabled") + } + + func testWeekNumbersAreHiddenWhenDisabled() { + // Create content with week numbers disabled (default) + let content = CalendarViewContent( + calendar: calendar, + visibleDateRange: dateRange, + monthsLayout: .vertical(options: .init())) + + let visibleItemsProvider = VisibleItemsProvider( + calendar: calendar, + content: content, + size: size, + layoutMargins: .zero, + scale: 2, + backgroundColor: nil) + + // Get visible items when positioned at January + let details = visibleItemsProvider.detailsForVisibleItems( + surroundingPreviouslyVisibleLayoutItem: LayoutItem( + itemType: .monthHeader(Month(era: 1, year: 2024, month: 01, isInGregorianCalendar: true)), + frame: CGRect(x: 0, y: 0, width: 320, height: 50)), + offset: CGPoint(x: 0, y: 0), + extendLayoutRegion: false) + + // Find items with weekNumber type + let weekNumberItems = details.visibleItems.filter { + if case .weekNumber = $0.itemType { + return true + } + return false + } + + // Verify that week numbers are not visible + XCTAssertTrue(weekNumberItems.isEmpty, "Week numbers should not be visible when disabled") + } + + func testWeekNumbersHaveCorrectValue() { + // Create content with week numbers enabled + let content = CalendarViewContent( + calendar: calendar, + visibleDateRange: dateRange, + monthsLayout: .vertical(options: .init())) + .showWeekNumbers(true) + + let visibleItemsProvider = VisibleItemsProvider( + calendar: calendar, + content: content, + size: size, + layoutMargins: .zero, + scale: 2, + backgroundColor: nil) + + // Get visible items when positioned at January 2024 + let details = visibleItemsProvider.detailsForVisibleItems( + surroundingPreviouslyVisibleLayoutItem: LayoutItem( + itemType: .monthHeader(Month(era: 1, year: 2024, month: 01, isInGregorianCalendar: true)), + frame: CGRect(x: 0, y: 0, width: 320, height: 50)), + offset: CGPoint(x: 0, y: 0), + extendLayoutRegion: false) + + // Find items with weekNumber type + let weekNumberItems = details.visibleItems.compactMap { item -> (Int, Month)? in + if case .weekNumber(let weekNumber, let month) = item.itemType { + return (weekNumber, month) + } + return nil + } + + // Verify that we have some week numbers + XCTAssertFalse(weekNumberItems.isEmpty, "Week numbers should be visible") + + // Verify that week numbers are in ascending order + var lastWeekNumber = 0 + for (weekNumber, _) in weekNumberItems { + if lastWeekNumber > 0 { + // Allow jumping back to week 1 for new years + if !(lastWeekNumber > 50 && weekNumber < 10) { + XCTAssertGreaterThanOrEqual(weekNumber, lastWeekNumber, "Week numbers should be ascending") + } + } + lastWeekNumber = weekNumber + } + + // Verify first week number is valid (should be 1 or close to it for January) + if let firstWeekNumber = weekNumberItems.first?.0 { + XCTAssertTrue(firstWeekNumber < 5, "First week of January should be low week number") + } + } + + func testWeekNumbersPositioning() { + // Create content with week numbers enabled + let content = CalendarViewContent( + calendar: calendar, + visibleDateRange: dateRange, + monthsLayout: .vertical(options: .init())) + .showWeekNumbers(true) + + let visibleItemsProvider = VisibleItemsProvider( + calendar: calendar, + content: content, + size: size, + layoutMargins: .zero, + scale: 2, + backgroundColor: nil) + + // Get visible items when positioned at January + let details = visibleItemsProvider.detailsForVisibleItems( + surroundingPreviouslyVisibleLayoutItem: LayoutItem( + itemType: .monthHeader(Month(era: 1, year: 2024, month: 01, isInGregorianCalendar: true)), + frame: CGRect(x: 0, y: 0, width: 320, height: 50)), + offset: CGPoint(x: 0, y: 0), + extendLayoutRegion: false) + + // Get all day items + let dayItems = details.visibleItems.filter { + if case .layoutItemType(.day) = $0.itemType { + return true + } + return false + } + + // Get all week number items + let weekNumberItems = details.visibleItems.filter { + if case .weekNumber = $0.itemType { + return true + } + return false + } + + // Verify that week numbers are positioned to the left of days + XCTAssertFalse(dayItems.isEmpty, "Day items should be present") + XCTAssertFalse(weekNumberItems.isEmpty, "Week number items should be present") + + // Find the rightmost x position of week numbers + if let maxWeekNumberX = weekNumberItems.map({ $0.frame.maxX }).max(), + let minDayX = dayItems.map({ $0.frame.minX }).min() + { + XCTAssertLessThanOrEqual(maxWeekNumberX, minDayX, "Week numbers should be positioned to the left of days") + } + } +} diff --git a/README.md b/README.md index 94b0b3d2..27c2451f 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,11 @@ The example app has several demo view controllers to try, with both vertical and | ---- | ---- | | ![Scroll to Day with Animation Vertical](Docs/Images/scroll_to_day_with_animation_vertical.gif) | ![Scroll to Day with Animation Horizontal](Docs/Images/scroll_to_day_with_animation_horizontal.gif) | +#### Disable days of the week +| Vertical | Horizontal | +| ---- | ---- | +| ![Scroll to Day with Animation Vertical](Docs/Images/scroll_to_day_with_animation_vertical.gif) | ![Scroll to Day with Animation Horizontal](Docs/Images/scroll_to_day_with_animation_horizontal.gif) | + ## Integration Tutorial ### Requirements diff --git a/Sources/Internal/Calendar+Helpers.swift b/Sources/Internal/Calendar+Helpers.swift index 723fabbf..17a922ac 100644 --- a/Sources/Internal/Calendar+Helpers.swift +++ b/Sources/Internal/Calendar+Helpers.swift @@ -78,7 +78,7 @@ extension Calendar { year: component(.year, from: date), month: component(.month, from: date), isInGregorianCalendar: identifier == .gregorian) - return Day(month: month, day: component(.day, from: date)) + return Day(month: month, day: component(.day, from: date)) } func startDate(of day: Day) -> Date { diff --git a/Sources/Internal/SubviewInsertionIndexTracker.swift b/Sources/Internal/SubviewInsertionIndexTracker.swift index 1f5c6b66..b6fb6faf 100644 --- a/Sources/Internal/SubviewInsertionIndexTracker.swift +++ b/Sources/Internal/SubviewInsertionIndexTracker.swift @@ -43,7 +43,9 @@ final class SubviewInsertionIndexTracker { index = pinnedDayOfWeekItemsEndIndex case .pinnedDaysOfWeekRowSeparator: index = pinnedDaysOfWeekRowSeparatorEndIndex - } + case .weekNumber(weekNumber: _, month: _): + index = weekNumberItemsEndIndex + } addValue(1, toItemTypesAffectedBy: itemType) @@ -65,71 +67,87 @@ final class SubviewInsertionIndexTracker { private var pinnedDaysOfWeekRowBackgroundEndIndex = 0 private var pinnedDayOfWeekItemsEndIndex = 0 private var pinnedDaysOfWeekRowSeparatorEndIndex = 0 - - private func addValue(_ value: Int, toItemTypesAffectedBy itemType: VisibleItem.ItemType) { - switch itemType { - case .monthBackground: - monthBackgroundItemsEndIndex += value - dayRangeItemsEndIndex += value - mainItemsEndIndex += value - daysOfWeekRowSeparatorItemsEndIndex += value - overlayItemsEndIndex += value - pinnedDaysOfWeekRowBackgroundEndIndex += value - pinnedDayOfWeekItemsEndIndex += value - pinnedDaysOfWeekRowSeparatorEndIndex += value - - case .dayBackground: - dayBackgroundItemsEndIndex += value - dayRangeItemsEndIndex += value - mainItemsEndIndex += value - daysOfWeekRowSeparatorItemsEndIndex += value - overlayItemsEndIndex += value - pinnedDaysOfWeekRowBackgroundEndIndex += value - pinnedDayOfWeekItemsEndIndex += value - pinnedDaysOfWeekRowSeparatorEndIndex += value - - case .dayRange: - dayRangeItemsEndIndex += value - mainItemsEndIndex += value - daysOfWeekRowSeparatorItemsEndIndex += value - overlayItemsEndIndex += value - pinnedDaysOfWeekRowBackgroundEndIndex += value - pinnedDayOfWeekItemsEndIndex += value - pinnedDaysOfWeekRowSeparatorEndIndex += value - - case .layoutItemType: - mainItemsEndIndex += value - daysOfWeekRowSeparatorItemsEndIndex += value - overlayItemsEndIndex += value - pinnedDaysOfWeekRowBackgroundEndIndex += value - pinnedDayOfWeekItemsEndIndex += value - pinnedDaysOfWeekRowSeparatorEndIndex += value - - case .daysOfWeekRowSeparator: - daysOfWeekRowSeparatorItemsEndIndex += value - overlayItemsEndIndex += value - pinnedDaysOfWeekRowBackgroundEndIndex += value - pinnedDayOfWeekItemsEndIndex += value - pinnedDaysOfWeekRowSeparatorEndIndex += value - - case .overlayItem: - overlayItemsEndIndex += value - pinnedDaysOfWeekRowBackgroundEndIndex += value - pinnedDayOfWeekItemsEndIndex += value - pinnedDaysOfWeekRowSeparatorEndIndex += value - - case .pinnedDaysOfWeekRowBackground: - pinnedDaysOfWeekRowBackgroundEndIndex += value - pinnedDayOfWeekItemsEndIndex += value - pinnedDaysOfWeekRowSeparatorEndIndex += value - - case .pinnedDayOfWeek: - pinnedDayOfWeekItemsEndIndex += value - pinnedDaysOfWeekRowSeparatorEndIndex += value - - case .pinnedDaysOfWeekRowSeparator: - pinnedDaysOfWeekRowSeparatorEndIndex += value - } - } + private var weekNumberItemsEndIndex = 0 + + + + private func addValue(_ value: Int, toItemTypesAffectedBy itemType: VisibleItem.ItemType) { + switch itemType { + case .monthBackground: + monthBackgroundItemsEndIndex += value + dayBackgroundItemsEndIndex += value + dayRangeItemsEndIndex += value + mainItemsEndIndex += value + daysOfWeekRowSeparatorItemsEndIndex += value + overlayItemsEndIndex += value + weekNumberItemsEndIndex += value + pinnedDaysOfWeekRowBackgroundEndIndex += value + pinnedDayOfWeekItemsEndIndex += value + pinnedDaysOfWeekRowSeparatorEndIndex += value + + case .dayBackground: + dayBackgroundItemsEndIndex += value + dayRangeItemsEndIndex += value + mainItemsEndIndex += value + daysOfWeekRowSeparatorItemsEndIndex += value + overlayItemsEndIndex += value + weekNumberItemsEndIndex += value + pinnedDaysOfWeekRowBackgroundEndIndex += value + pinnedDayOfWeekItemsEndIndex += value + pinnedDaysOfWeekRowSeparatorEndIndex += value + + case .dayRange: + dayRangeItemsEndIndex += value + mainItemsEndIndex += value + daysOfWeekRowSeparatorItemsEndIndex += value + overlayItemsEndIndex += value + weekNumberItemsEndIndex += value + pinnedDaysOfWeekRowBackgroundEndIndex += value + pinnedDayOfWeekItemsEndIndex += value + pinnedDaysOfWeekRowSeparatorEndIndex += value + + case .layoutItemType: + mainItemsEndIndex += value + daysOfWeekRowSeparatorItemsEndIndex += value + overlayItemsEndIndex += value + weekNumberItemsEndIndex += value + pinnedDaysOfWeekRowBackgroundEndIndex += value + pinnedDayOfWeekItemsEndIndex += value + pinnedDaysOfWeekRowSeparatorEndIndex += value + + case .daysOfWeekRowSeparator: + daysOfWeekRowSeparatorItemsEndIndex += value + overlayItemsEndIndex += value + weekNumberItemsEndIndex += value + pinnedDaysOfWeekRowBackgroundEndIndex += value + pinnedDayOfWeekItemsEndIndex += value + pinnedDaysOfWeekRowSeparatorEndIndex += value + + case .overlayItem: + overlayItemsEndIndex += value + weekNumberItemsEndIndex += value + pinnedDaysOfWeekRowBackgroundEndIndex += value + pinnedDayOfWeekItemsEndIndex += value + pinnedDaysOfWeekRowSeparatorEndIndex += value + + case .weekNumber(weekNumber: _, month: _): + weekNumberItemsEndIndex += value + pinnedDaysOfWeekRowBackgroundEndIndex += value + pinnedDayOfWeekItemsEndIndex += value + pinnedDaysOfWeekRowSeparatorEndIndex += value + + case .pinnedDaysOfWeekRowBackground: + pinnedDaysOfWeekRowBackgroundEndIndex += value + pinnedDayOfWeekItemsEndIndex += value + pinnedDaysOfWeekRowSeparatorEndIndex += value + + case .pinnedDayOfWeek: + pinnedDayOfWeekItemsEndIndex += value + pinnedDaysOfWeekRowSeparatorEndIndex += value + + case .pinnedDaysOfWeekRowSeparator: + pinnedDaysOfWeekRowSeparatorEndIndex += value + } + } } diff --git a/Sources/Internal/VisibleItem.swift b/Sources/Internal/VisibleItem.swift index 6c9c3993..9b8b64ec 100644 --- a/Sources/Internal/VisibleItem.swift +++ b/Sources/Internal/VisibleItem.swift @@ -88,6 +88,9 @@ extension VisibleItem { case daysOfWeekRowSeparator(Month) case dayRange(DayRange) case overlayItem(OverlaidItemLocation) + case weekNumber(weekNumber: Int, month: MonthComponents) } } + + diff --git a/Sources/Internal/VisibleItemsProvider.swift b/Sources/Internal/VisibleItemsProvider.swift index 23316546..4de66cd8 100644 --- a/Sources/Internal/VisibleItemsProvider.swift +++ b/Sources/Internal/VisibleItemsProvider.swift @@ -280,6 +280,12 @@ final class VisibleItemsProvider { previousHeightsForVisibleMonthHeaders = context.heightsForVisibleMonthHeaders previousCalendarItemModelCache = context.calendarItemModelCache + + // Add week numbers if enabled + if content.showWeekNumbers { + addWeekNumbersIfNeeded(context: &context) + } + return VisibleItemsDetails( visibleItems: context.visibleItems, centermostLayoutItem: context.centermostLayoutItem, @@ -968,7 +974,59 @@ final class VisibleItemsProvider { frame: bounds)) } } - + + + + private func addWeekNumbersIfNeeded(context: inout VisibleItemsContext) { + guard content.showWeekNumbers else { return } + let calendar = content.calendar + + // Group days by week to find the first day of each week + var firstDaysOfWeek = [Day: CGRect]() + + // Loop through all visible days + for (day, frame) in context.framesForVisibleDays { + guard let date = calendar.date(from: day.components) else { continue } + + // Check if this is the first day of the week + if calendar.component(.weekday, from: date) == calendar.firstWeekday { + firstDaysOfWeek[day] = frame + } + } + + // Create week number items for each first day of week + for (day, frame) in firstDaysOfWeek { + guard let date = calendar.date(from: day.components) else { continue } + + // Calculate week number + let weekNumber = calendar.component(.weekOfYear, from: date) + + // Create a frame for the week number + let weekNumberFrame = CGRect( + x: frame.minX - content.weekNumberWidth - 5, + y: frame.minY, + width: content.weekNumberWidth, + height: frame.height) + + // Create week number item + let weekNumberItemType = VisibleItem.ItemType.weekNumber(weekNumber: weekNumber, month: day.month) + let weekNumberCalendarItemModel = context.calendarItemModelCache.value( + for: weekNumberItemType, + missingValueProvider: { + previousCalendarItemModelCache?[weekNumberItemType] ?? + WeekNumberView.calendarItemModel( + invariantViewProperties: .init(textColor: content.weekNumberTextColor), + content: .init(weekNumber: weekNumber)) + }) + + context.visibleItems.insert( + VisibleItem( + calendarItemModel: weekNumberCalendarItemModel, + itemType: weekNumberItemType, + frame: weekNumberFrame)) + } + } + private func handleMonthBackgroundItemsIfNeeded(context: inout VisibleItemsContext) { guard let monthBackgroundItemProvider = content.monthBackgroundItemProvider else { return } diff --git a/Sources/Public/CalendarView.swift b/Sources/Public/CalendarView.swift index 487a05c5..8d65cda7 100644 --- a/Sources/Public/CalendarView.swift +++ b/Sources/Public/CalendarView.swift @@ -47,6 +47,7 @@ public final class CalendarView: UIView { content = initialContent super.init(frame: .zero) commonInit() + } required init?(coder: NSCoder) { @@ -62,7 +63,7 @@ public final class CalendarView: UIView { /// A closure (that is retained) that is invoked whenever a day is selected. It is the responsibility of your feature code to decide what to /// do with each day. For example, you might store the most recent day in a selected day property, then read that property in your /// `dayItemProvider` closure to add specific "selected" styling to a particular day view. - public var daySelectionHandler: ((DayComponents) -> Void)? + public var daySelectionHandler: ((Day) -> Void)? /// A closure (that is retained) that is invoked inside `scrollViewDidScroll(_:)` public var didScroll: ((_ visibleDayRange: DayComponentsRange, _ isUserDragging: Bool) -> Void)? @@ -77,7 +78,7 @@ public final class CalendarView: UIView { /// followed by a drag / pan. As the gesture crosses over more days in the calendar, this handler will be invoked with each new day. It /// is the responsibility of your feature code to decide what to do with this stream of days. For example, you might convert them to /// `Date` instances and use them as input to the `dayRangeItemProvider`. - public var multiDaySelectionDragHandler: ((DayComponents, UIGestureRecognizer.State) -> Void)? { + public var multiDaySelectionDragHandler: ((Day, UIGestureRecognizer.State) -> Void)? { didSet { configureMultiDaySelectionPanGestureRecognizer() } @@ -114,7 +115,7 @@ public final class CalendarView: UIView { right: max(newValue.right, 0)) } } - + /// `CalendarView` only supports positive values for `directionalLayoutMargins`. Negative values will be changed to /// `0`. public override var directionalLayoutMargins: NSDirectionalEdgeInsets { @@ -184,6 +185,17 @@ public final class CalendarView: UIView { _layoutSubviews(extendLayoutRegion: extendLayoutRegion) } + /// Scrolls the calendar to show today's date. + /// + /// If the calendar has a non-zero frame, this function will scroll to today immediately. Otherwise the scroll-to-day + /// action will be queued and executed once the calendar has a non-zero frame. + /// + /// - Parameters: + /// - scrollPosition: The final position at which today should be situated in the scroll view. + /// - animated: Whether the scroll should be animated (from the current position). + public func scrollToToday(scrollPosition: CalendarViewScrollPosition = .centered, animated: Bool = true) { + scroll(toDayContaining: Date(), scrollPosition: scrollPosition, animated: animated) + } /// Sets the content of the `CalendarView`, causing it to re-render, with no animation. /// diff --git a/Sources/Public/CalendarViewContent.swift b/Sources/Public/CalendarViewContent.swift index 5a016e09..d8ab584c 100644 --- a/Sources/Public/CalendarViewContent.swift +++ b/Sources/Public/CalendarViewContent.swift @@ -23,6 +23,7 @@ import UIKit /// range to display, and a months layout to indicate whether months should be laid out vertically or horizontally. All other properties /// have default values. public final class CalendarViewContent { + public var visibleWeekdays: Set // MARK: Lifecycle @@ -37,8 +38,10 @@ public final class CalendarViewContent { public init( calendar: Calendar = Calendar.current, visibleDateRange: ClosedRange, - monthsLayout: MonthsLayout) + monthsLayout: MonthsLayout, + visibleWeekdays: Set? = nil) { + self.visibleWeekdays = visibleWeekdays ?? Set(1...7) self.calendar = calendar monthRange = MonthRange(containing: visibleDateRange, in: calendar) self.monthsLayout = monthsLayout @@ -65,6 +68,7 @@ public final class CalendarViewContent { dayOfWeekItemProvider = defaultDayOfWeekItemProvider dayItemProvider = defaultDayItemProvider } + // MARK: Public @@ -87,6 +91,28 @@ public final class CalendarViewContent { validAspectRatioRange.upperBound) return self } + /// Configures whether week numbers should be displayed. + /// + /// - Parameters: + /// - show: Whether to show week numbers to the left of each week. + /// - textColor: The text color of the week numbers. + /// - width: The width allocated for week numbers. + /// - Returns: A copy of this `CalendarViewContent` instance with week number settings updated. + public func showWeekNumbers(_ show: Bool, textColor: UIColor = .gray, width: CGFloat = 25) -> CalendarViewContent { + var content = self + content.showWeekNumbers = show + content.weekNumberTextColor = textColor + content.weekNumberWidth = width + + // If we're showing week numbers, make sure there's enough left margin + if show { + var updatedInsets = content.monthDayInsets + updatedInsets.leading = max(updatedInsets.leading, width + 5) + content.monthDayInsets = updatedInsets + } + + return content + } /// Configures the aspect ratio of each day of the week. /// @@ -242,29 +268,41 @@ public final class CalendarViewContent { /// or background, by including it in the view that your `CalendarItemModel` creates. /// /// If you don't configure your own day item provider via this function, or if the `dayItemProvider` closure - /// returns nil, then a default day item provider will be used. + /// returns nil, then a default day item provider will be used. If a day is invalid, an emptyDayView is provided. + /// /// /// - Parameters: /// - dayItemProvider: A closure (that is retained) that returns a `CalendarItemModel` representing a single day /// in the calendar. /// - day: The `Day` for which to provide a day item. /// - Returns: A mutated `CalendarViewContent` instance with a new day item provider. - public func dayItemProvider( - _ dayItemProvider: @escaping (_ day: DayComponents) -> AnyCalendarItemModel?) - -> CalendarViewContent - { - self.dayItemProvider = { [defaultDayItemProvider] day in - guard let itemModel = dayItemProvider(day) else { - // If the caller returned nil, fall back to the default item provider - return defaultDayItemProvider(day) + public func dayItemProvider( + _ dayItemProvider: @escaping (_ day: Day) -> AnyCalendarItemModel?) + -> CalendarViewContent + { + self.dayItemProvider = { [calendar, visibleWeekdays, defaultDayItemProvider] day in + // Get weekday for the current day + let date = calendar.date(from: day.components)! + let weekday = calendar.component(.weekday, from: date) + + // Return empty view for invisible weekdays + guard visibleWeekdays.contains(weekday) else { + return EmptyDayView.calendarItemModel( + invariantViewProperties: .standard, + content: .init()) + } + + // Use provided or default provider for visible days + guard let itemModel = dayItemProvider(day) else { + return defaultDayItemProvider(day) + } + + return itemModel + } + + return self } - return itemModel - } - - return self - } - /// Configures the day background item provider. /// /// `CalendarView` invokes the provided `dayBackgroundItemProvider` for each day being displayed. The @@ -279,7 +317,7 @@ public final class CalendarViewContent { /// - day: The `Day` for which to provide a day background item. /// - Returns: A mutated `CalendarViewContent` instance with a new day background item provider. public func dayBackgroundItemProvider( - _ dayBackgroundItemProvider: @escaping (_ day: DayComponents) -> AnyCalendarItemModel?) + _ dayBackgroundItemProvider: @escaping (_ day: Day) -> AnyCalendarItemModel?) -> CalendarViewContent { self.dayBackgroundItemProvider = dayBackgroundItemProvider @@ -374,6 +412,7 @@ public final class CalendarViewContent { } // MARK: Internal + let calendar: Calendar let dayRange: DayRange @@ -387,6 +426,7 @@ public final class CalendarViewContent { private(set) var verticalDayMargin: CGFloat = 0 private(set) var horizontalDayMargin: CGFloat = 0 private(set) var daysOfTheWeekRowSeparatorOptions: DaysOfTheWeekRowSeparatorOptions? + private(set) var monthHeaderItemProvider: (Month) -> AnyCalendarItemModel private(set) var dayOfWeekItemProvider: ( @@ -402,7 +442,12 @@ public final class CalendarViewContent { private(set) var overlaidItemLocationsAndItemProvider: ( overlaidItemLocations: Set, overlayItemProvider: (OverlayLayoutContext) -> AnyCalendarItemModel)? + private(set) var showWeekNumbers: Bool = false + private(set) var weekNumberTextColor: UIColor = .gray + private(set) var weekNumberWidth: CGFloat = 25 + + // MARK: Private /// The default `monthHeaderItemProvider` if no provider has been configured, @@ -466,3 +511,33 @@ public final class CalendarViewContent { }() } + + +final class EmptyDayView: UIView, CalendarItemViewRepresentable { + static func setContent(_ content: Content, on view: EmptyDayView) { + + } + + + struct InvariantViewProperties: Hashable { + static let standard = InvariantViewProperties() + } + + struct Content: Hashable { } + + static func makeView(withInvariantViewProperties: InvariantViewProperties) -> EmptyDayView { + EmptyDayView() + } + + func setContent(_ content: Content, animated: Bool) { } + + init() { + super.init(frame: .zero) + isUserInteractionEnabled = false + isHidden = true + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Sources/Public/CalendarViewProxy.swift b/Sources/Public/CalendarViewProxy.swift index 0831c6ba..69f23da8 100644 --- a/Sources/Public/CalendarViewProxy.swift +++ b/Sources/Public/CalendarViewProxy.swift @@ -79,6 +79,17 @@ public final class CalendarViewProxy: ObservableObject { scrollPosition: scrollPosition, animated: animated) } + /// Scrolls the calendar to show today's date. + /// + /// If the calendar has a non-zero frame, this function will scroll to today immediately. Otherwise the scroll-to-day + /// action will be queued and executed once the calendar has a non-zero frame. + /// + /// - Parameters: + /// - scrollPosition: The final position at which today should be situated in the scroll view. + /// - animated: Whether the scroll should be animated (from the current position). + public func scrollToToday(scrollPosition: CalendarViewScrollPosition = .centered, animated: Bool = true) { + calendarView.scrollToToday(scrollPosition: scrollPosition, animated: animated) + } // MARK: Internal diff --git a/Sources/Public/CalendarViewRepresentable.swift b/Sources/Public/CalendarViewRepresentable.swift index c989bcdf..9a8a7695 100644 --- a/Sources/Public/CalendarViewRepresentable.swift +++ b/Sources/Public/CalendarViewRepresentable.swift @@ -52,13 +52,16 @@ public struct CalendarViewRepresentable: UIViewRepresentable { visibleDateRange: ClosedRange, monthsLayout: MonthsLayout, dataDependency: Any?, - proxy: CalendarViewProxy? = nil) + proxy: CalendarViewProxy? = nil, + visibleWeekdays: Set? = nil) { self.calendar = calendar self.visibleDateRange = visibleDateRange self.monthsLayout = monthsLayout self.dataDependency = dataDependency self.proxy = proxy + self.visibleWeekdays = visibleWeekdays ?? Set(1...7) + } // MARK: Public @@ -124,6 +127,7 @@ public struct CalendarViewRepresentable: UIViewRepresentable { private let calendar: Calendar private let visibleDateRange: ClosedRange +private let visibleWeekdays: Set private let monthsLayout: MonthsLayout private let dataDependency: Any? private let proxy: CalendarViewProxy? @@ -132,7 +136,8 @@ public struct CalendarViewRepresentable: UIViewRepresentable { var content = CalendarViewContent( calendar: calendar, visibleDateRange: visibleDateRange, - monthsLayout: monthsLayout) + monthsLayout: monthsLayout, + visibleWeekdays : visibleWeekdays) if let dayAspectRatio { content = content.dayAspectRatio(dayAspectRatio) @@ -173,7 +178,7 @@ public struct CalendarViewRepresentable: UIViewRepresentable { } if let dayBackgroundItemProvider { - content = content.dayBackgroundItemProvider(dayBackgroundItemProvider) + content = (content.dayBackgroundItemProvider)(dayBackgroundItemProvider) } if let monthBackgroundItemProvider { @@ -425,7 +430,7 @@ extension CalendarViewRepresentable { /// - day: The `Day` for which to provide a day item. /// - Returns: A new `CalendarViewRepresentable` with a new day item provider. public func dayItemProvider( - _ dayItemProvider: @escaping (_ day: DayComponents) -> AnyCalendarItemModel?) + _ dayItemProvider: @escaping (_ day: Day) -> AnyCalendarItemModel?) -> Self { var view = self @@ -445,7 +450,7 @@ extension CalendarViewRepresentable { /// - day: The `Day` for which to provide a day view. /// - Returns: A new `CalendarViewRepresentable` with custom day views configured. public func days( - @ViewBuilder _ content: @escaping (_ day: DayComponents) -> some View) + @ViewBuilder _ content: @escaping (_ day: Day) -> some View) -> Self { dayItemProvider { day in @@ -468,7 +473,7 @@ extension CalendarViewRepresentable { /// - day: The `Day` for which to provide a day background item. /// - Returns: A new `CalendarViewRepresentable` with a new day background item provider. public func dayBackgroundItemProvider( - _ dayBackgroundItemProvider: @escaping (_ day: DayComponents) -> AnyCalendarItemModel?) + _ dayBackgroundItemProvider: @escaping (_ day: Day) -> AnyCalendarItemModel?) -> Self { var view = self @@ -488,7 +493,7 @@ extension CalendarViewRepresentable { /// - day: The `Day` for which to provide a day background view. /// - Returns: A new `CalendarViewRepresentable` with day background views configured. public func dayBackgrounds( - @ViewBuilder _ content: @escaping (_ day: DayComponents) -> some View) + @ViewBuilder _ content: @escaping (_ day: Day) -> some View) -> Self { dayBackgroundItemProvider { day in @@ -685,7 +690,7 @@ extension CalendarViewRepresentable { /// /// - Parameters: /// - daySelectionHandler: A closure (that is retained) that is invoked whenever a day is selected. - public func onDaySelection(_ daySelectionHandler: @escaping (DayComponents) -> Void) -> Self { + public func onDaySelection(_ daySelectionHandler: @escaping (Day) -> Void) -> Self { var view = self view.daySelectionHandler = daySelectionHandler return view @@ -704,9 +709,9 @@ extension CalendarViewRepresentable { /// - changed: A closure (that is retained) that is invoked when the multiple-day-selection drag gesture intersects a new day. /// - ended: A closure (that is retained) that is invoked when the multiple-day-selection drag gesture ends. public func onMultipleDaySelectionDrag( - began: @escaping (DayComponents) -> Void, - changed: @escaping (DayComponents) -> Void, - ended: @escaping (DayComponents) -> Void) + began: @escaping (Day) -> Void, + changed: @escaping (Day) -> Void, + ended: @escaping (Day) -> Void) -> Self { var view = self diff --git a/Sources/Public/Day.swift b/Sources/Public/Day.swift index f657f430..8113fea1 100644 --- a/Sources/Public/Day.swift +++ b/Sources/Public/Day.swift @@ -17,53 +17,85 @@ import Foundation // MARK: - Day -typealias Day = DayComponents +public protocol DayAvailabilityProvider { + func isEnabled(_ day: DayComponents) -> Bool + func isEnabled(_ day: Date) -> Bool +} + +public protocol DayProtcol: Hashable, Comparable { + static var availabilityProvider: DayAvailabilityProvider? { get set } -// MARK: - DayComponents + var components: DateComponents { get } -/// Represents the components of a day. This type is created internally, then vended to you via the public API. All `DayComponents` -/// instances that are vended to you are created using the `Calendar` instance that you provide when initializing your -/// `CalendarView`. -public struct DayComponents: Hashable { + var month: MonthComponents { get } - // MARK: Lifecycle + var day: Int { get } - init(month: MonthComponents, day: Int) { - self.month = month - self.day = day - } + var isEnabled: Bool { get } +} - // MARK: Public +/// Represents the day, including availability. Backwards compatible with prior versions of Day aliasing to +/// DayComponents. +public struct Day: DayProtcol { + // MARK: - Private - public let month: MonthComponents - public let day: Int + private let _dayComponents: DayComponents - public var components: DateComponents { - DateComponents(era: month.era, year: month.year, month: month.month, day: day) - } + // MARK: - Public -} + // Reference to the availability provider + public static var availabilityProvider: DayAvailabilityProvider? -// MARK: CustomStringConvertible + /// Forwarding to support existing codebase + public var components: DateComponents { + _dayComponents.components + } -extension DayComponents: CustomStringConvertible { + public var month: MonthComponents { + _dayComponents.month + } - public var description: String { - let yearDescription = String(format: "%04d", month.year) - let monthDescription = String(format: "%02d", month.month) - let dayDescription = String(format: "%02d", day) - return "\(yearDescription)-\(monthDescription)-\(dayDescription)" - } + public var day: Int { + _dayComponents.day + } + public var isEnabled: Bool + + init(month: MonthComponents, day: Int) { + _dayComponents = DayComponents(month: month, day: day) + isEnabled = Day.availabilityProvider?.isEnabled(_dayComponents) ?? true + } } -// MARK: Comparable +// Implement Comparable +public extension Day { + static func < (lhs: Day, rhs: Day) -> Bool { + lhs._dayComponents < rhs._dayComponents + } + + static func > (lhs: Day, rhs: Day) -> Bool { + lhs._dayComponents > rhs._dayComponents + } -extension DayComponents: Comparable { + static func == (lhs: Day, rhs: Day) -> Bool { + lhs._dayComponents == rhs._dayComponents + } - public static func < (lhs: DayComponents, rhs: DayComponents) -> Bool { - guard lhs.month == rhs.month else { return lhs.month < rhs.month } - return lhs.day < rhs.day - } + static func >= (lhs: Day, rhs: Day) -> Bool { + lhs == rhs || lhs > rhs + } + + static func <= (lhs: Day, rhs: Day) -> Bool { + lhs == rhs || lhs < rhs + } +} +/// Explicitly implement Hashable +public extension Day { + func hash(into hasher: inout Hasher) { + hasher.combine(day) + hasher.combine(month) + hasher.combine(isEnabled) + hasher.combine(components) + } } diff --git a/Sources/Public/DayComponents.swift b/Sources/Public/DayComponents.swift new file mode 100644 index 00000000..b9df8835 --- /dev/null +++ b/Sources/Public/DayComponents.swift @@ -0,0 +1,74 @@ +// Created by Bryan Keller on 5/31/20. +// Copyright © 2020 Airbnb Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +// MARK: - DayComponents + +public protocol DayComponentsProtocol: Hashable { + var month: MonthComponents { get } + var day: Int { get } +} + +/// Represents the components of a day. This type is created internally, then vended to you via the public API. All +/// `DayComponents` instances that are vended to you are created using the `Calendar` instance that you +/// provide when initializing your +/// `CalendarView`. +public struct DayComponents: DayComponentsProtocol { + // MARK: Lifecycle + + init(month: MonthComponents, day: Int) { + self.month = month + self.day = day + } + + public init(date: Date) { + let comps = Calendar.current.dateComponents([.era, .year, .month, .day], from: date) + month = Month(era: comps.era!, + year: comps.year!, + month: comps.month!, + isInGregorianCalendar: Calendar.current.identifier == .gregorian) + day = comps.day! + } + + // MARK: Public + + public let month: MonthComponents + public let day: Int + + public var components: DateComponents { + DateComponents(era: month.era, year: month.year, month: month.month, day: day) + } +} + +// MARK: CustomStringConvertible + +extension DayComponents: CustomStringConvertible { + public var description: String { + let yearDescription = String(format: "%04d", month.year) + let monthDescription = String(format: "%02d", month.month) + let dayDescription = String(format: "%02d", day) + return "\(yearDescription)-\(monthDescription)-\(dayDescription)" + } +} + +// MARK: Comparable + +extension DayComponents: Comparable { + public static func < (lhs: DayComponents, rhs: DayComponents) -> Bool { + guard lhs.month == rhs.month else { return lhs.month < rhs.month } + return lhs.day < rhs.day + } +} diff --git a/Sources/Public/DayRange.swift b/Sources/Public/DayRange.swift index 1597ac25..3542a79f 100644 --- a/Sources/Public/DayRange.swift +++ b/Sources/Public/DayRange.swift @@ -21,7 +21,7 @@ typealias DayRange = DayComponentsRange // MARK: - DayComponentsRange -public typealias DayComponentsRange = ClosedRange +public typealias DayComponentsRange = ClosedRange extension DayComponentsRange { diff --git a/Sources/Public/DayRangeLayoutContext.swift b/Sources/Public/DayRangeLayoutContext.swift index 27252fe9..64c3fd3c 100644 --- a/Sources/Public/DayRangeLayoutContext.swift +++ b/Sources/Public/DayRangeLayoutContext.swift @@ -26,7 +26,7 @@ public struct DayRangeLayoutContext: Hashable { /// Each frame represents the frame of an individual day in the day range in the coordinate system of /// `boundingUnionRectOfDayFrames`. If a day range extends beyond the `visibleDateRange`, this array will only /// contain the day-frame pairs for the visible portion of the day range. - public let daysAndFrames: [(day: DayComponents, frame: CGRect)] + public let daysAndFrames: [(day: Day, frame: CGRect)] /// A rectangle that perfectly contains all day frames in `daysAndFrames`. In other words, it is the union of all day frames in /// `daysAndFrames`. diff --git a/Sources/Public/DayRangeSelectionHelper.swift b/Sources/Public/DayRangeSelectionHelper.swift new file mode 100644 index 00000000..9dc71739 --- /dev/null +++ b/Sources/Public/DayRangeSelectionHelper.swift @@ -0,0 +1,138 @@ +// Created by Bryan Keller on 2/8/23. +// Copyright © 2023 Airbnb Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +public enum DayRangeSelectionHelper { + + /// - Description: Check all dates in the new range and update the range if there are no blacked out dates. + /// - Parameters: + /// - day: The day object selected by the user + /// - dayRange: Current range selected by user + /// - calendar: The calendar to calculate dates with + /// - Returns: A set of blacked out dates + @discardableResult + public static func getInvalidDateSet(_ day: Day, + _ dayRange: DayComponentsRange?, + _ calendar: Calendar) -> Set { + var invalidDates: Set = [] + + guard var dayRange else { return invalidDates } + + var newRange: ClosedRange? + + performUpdateRangeHelper(day, &newRange, &dayRange, calendar) + + var currentDate = calendar.date(from: newRange!.lowerBound.components)! + let endDate = calendar.date(from: newRange!.upperBound.components)! + + while currentDate <= endDate { + let isEnabled = Day.availabilityProvider?.isEnabled(currentDate) ?? true + + if !isEnabled { + invalidDates.insert(currentDate) + } + + currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate)! + } + + return invalidDates + } + + /// - Description: Update the day range, if it is a valid selection. If the day slected is blacked out, the + /// range will not change. If the day selected generates a range which contains blacked out days, the range + /// will be the selected day. + /// - Parameters: + /// - afterTapSelectionOf: The day selected by the user + /// - existingDayRange: The range to be updated + /// - Returns: The set of invalid dates. Empty represents no invalid dates. + @discardableResult + public static func updateDayRange(afterTapSelectionOf day: Day, + existingDayRange: inout DayComponentsRange?) -> Set { + if day.isEnabled { + if let dayRange = existingDayRange, + dayRange.lowerBound == dayRange.upperBound, + day > dayRange.lowerBound { + // Ensure date range is valid only if it will not create single-node range + let invalidRange = getInvalidDateSet(day, existingDayRange, Calendar.current) + guard invalidRange.isEmpty else { + existingDayRange = day...day + return invalidRange + } + + existingDayRange = dayRange.lowerBound...day + } else { + existingDayRange = day...day + } + } + + return [] + } + + /// - Description: An assistant to the performUpdateRange function. Will create a range from the lower or + /// upper bound to the selected day, based on the nearest distance. + /// - SeeAlso: performUpdateRange(\_:\_:\_:\_:) + /// - Parameters: + /// - day: The day selected by the user + /// - existingDayRange: The range to be returned + /// - initalDayRange: The range prior to user selection + /// - calendar: The calendar to calculate days on + private static func performUpdateRangeHelper(_ day: Day, + _ existingDayRange: inout ClosedRange?, + _ initialDayRange: inout ClosedRange, + _ calendar: Calendar) { + + let startingLowerDate = calendar.date(from: initialDayRange.lowerBound.components)! + let startingUpperDate = calendar.date(from: initialDayRange.upperBound.components)! + let selectedDate = calendar.date(from: day.components)! + + let numberOfDaysToLowerDate = calendar + .dateComponents([.day], + from: selectedDate, + to: startingLowerDate).day! + let numberOfDaysToUpperDate = calendar + .dateComponents([.day], + from: selectedDate, + to: startingUpperDate).day! + + if abs(numberOfDaysToLowerDate) < abs(numberOfDaysToUpperDate) || + day < initialDayRange.lowerBound { + + existingDayRange = day...initialDayRange.upperBound + + } else if abs(numberOfDaysToLowerDate) > abs(numberOfDaysToUpperDate) || + day > initialDayRange.upperBound { + + existingDayRange = initialDayRange.lowerBound...day + } else { + existingDayRange = day...day + } + } + + /// - Description: Will create a range from the lower or upper bound to the selected day, based on the nearest + /// distance. + /// - SeeAlso: performUpdateRangeHelper(\_:\_:\_:\_:) + /// - Parameters: + /// - day: The day selected by the user + /// - existingDayRange: The range to be returned + /// - initalDayRange: The range prior to user selection + /// - calendar: The calendar to calculate days on + public static func performUpdateRange(_ day: Day, + _ existingDayRange: inout ClosedRange?, + _ initialDayRange: inout ClosedRange?, + _ calendar: Calendar) { + performUpdateRangeHelper(day, &existingDayRange, &((initialDayRange)!), calendar) + } +} diff --git a/Sources/Public/ItemViews/WeekNumberView.swift b/Sources/Public/ItemViews/WeekNumberView.swift new file mode 100644 index 00000000..64420d3a --- /dev/null +++ b/Sources/Public/ItemViews/WeekNumberView.swift @@ -0,0 +1,75 @@ +// +// WeekNumberView.swift +// HorizonCalendar +// +// Created by Cade Chaplin on 3/1/25. +// Copyright © 2025 Airbnb. All rights reserved. +// + +import UIKit + +/// A view that displays a week number. +public final class WeekNumberView: UIView { + + // MARK: - Properties + + private let label: UILabel = UILabel() + + // MARK: - Initializers + + init(weekNumber: Int, textColor: UIColor) { + super.init(frame: .zero) + + label.text = "\(weekNumber)" + label.textColor = textColor + label.textAlignment = .center + label.font = .systemFont(ofSize: 12, weight: .medium) + + addSubview(label) + + // Make view non-interactive so it doesn't interfere with calendar interactions + isUserInteractionEnabled = false + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Layout + + public override func layoutSubviews() { + super.layoutSubviews() + label.frame = bounds + } +} + +// MARK: - CalendarItemViewRepresentable + +extension WeekNumberView: CalendarItemViewRepresentable { + + /// Properties that do not change based on the state of the calendar. + public struct InvariantViewProperties: Hashable { + let textColor: UIColor + + public init(textColor: UIColor) { + self.textColor = textColor + } + } + + /// Properties that will vary depending on the date being displayed. + public struct Content: Equatable { + let weekNumber: Int + + public init(weekNumber: Int) { + self.weekNumber = weekNumber + } + } + + public static func makeView(withInvariantViewProperties invariantViewProperties: InvariantViewProperties) -> WeekNumberView { + return WeekNumberView(weekNumber: 0, textColor: invariantViewProperties.textColor) + } + + public static func setContent(_ content: Content, on view: WeekNumberView) { + view.label.text = "\(content.weekNumber)" + } +} diff --git a/Sources/Public/Month.swift b/Sources/Public/Month.swift index 67155910..6f777902 100644 --- a/Sources/Public/Month.swift +++ b/Sources/Public/Month.swift @@ -25,56 +25,50 @@ typealias Month = MonthComponents /// `MonthComponents` instances that are vended to you are created using the `Calendar` instance that you provide when /// initializing your `CalendarView`. public struct MonthComponents: Hashable { + // MARK: Lifecycle - // MARK: Lifecycle + init(era: Int, year: Int, month: Int, isInGregorianCalendar: Bool) { + self.era = era + self.year = year + self.month = month + self.isInGregorianCalendar = isInGregorianCalendar + } - init(era: Int, year: Int, month: Int, isInGregorianCalendar: Bool) { - self.era = era - self.year = year - self.month = month - self.isInGregorianCalendar = isInGregorianCalendar - } + // MARK: Public - // MARK: Public + public let era: Int + public let year: Int + public let month: Int - public let era: Int - public let year: Int - public let month: Int + public var components: DateComponents { + DateComponents(era: era, year: year, month: month) + } - public var components: DateComponents { - DateComponents(era: era, year: year, month: month) - } - - // MARK: Internal - - // In the Gregorian calendar, BCE years (era 0) get larger in descending order (10 BCE < 5 BCE). - // This property exists to facilitate an accurate `Comparable` implementation. - let isInGregorianCalendar: Bool + // MARK: Internal + // In the Gregorian calendar, BCE years (era 0) get larger in descending order (10 BCE < 5 BCE). + // This property exists to facilitate an accurate `Comparable` implementation. + let isInGregorianCalendar: Bool } // MARK: CustomStringConvertible extension MonthComponents: CustomStringConvertible { - - public var description: String { - "\(String(format: "%04d", year))-\(String(format: "%02d", month))" - } - + public var description: String { + "\(String(format: "%04d", year))-\(String(format: "%02d", month))" + } } // MARK: Comparable extension MonthComponents: Comparable { + public static func < (lhs: MonthComponents, rhs: MonthComponents) -> Bool { + guard lhs.era == rhs.era else { return lhs.era < rhs.era } - public static func < (lhs: MonthComponents, rhs: MonthComponents) -> Bool { - guard lhs.era == rhs.era else { return lhs.era < rhs.era } - - let lhsCorrectedYear = lhs.isInGregorianCalendar && lhs.era == 0 ? -lhs.year : lhs.year - let rhsCorrectedYear = rhs.isInGregorianCalendar && rhs.era == 0 ? -rhs.year : rhs.year - guard lhsCorrectedYear == rhsCorrectedYear else { return lhsCorrectedYear < rhsCorrectedYear } - - return lhs.month < rhs.month - } + let lhsCorrectedYear = lhs.isInGregorianCalendar && lhs.era == 0 ? -lhs.year : lhs.year + let rhsCorrectedYear = rhs.isInGregorianCalendar && rhs.era == 0 ? -rhs.year : rhs.year + guard lhsCorrectedYear == rhsCorrectedYear else { return lhsCorrectedYear < rhsCorrectedYear } + return lhs.month < rhs.month + } } diff --git a/Sources/Public/MonthLayoutContext.swift b/Sources/Public/MonthLayoutContext.swift index ee1fcf7a..4d13d2ef 100644 --- a/Sources/Public/MonthLayoutContext.swift +++ b/Sources/Public/MonthLayoutContext.swift @@ -19,53 +19,53 @@ import CoreGraphics /// headers. Also included is the bounding rect (union) of those frames. This can be used in a custom month background view to draw /// the background around the month's foreground views. public struct MonthLayoutContext: Hashable { + /// The month that this layout context describes. + public let month: MonthComponents - /// The month that this layout context describes. - public let month: MonthComponents + /// The frame of the month header in the coordinate system of `bounds`. + public let monthHeaderFrame: CGRect - /// The frame of the month header in the coordinate system of `bounds`. - public let monthHeaderFrame: CGRect + /// An ordered list of tuples containing day-of-the-week positions and frames. + /// + /// Each frame corresponds to an individual day-of-the-week item (Sunday, Monday, etc.) in the month, in the coordinate system of + /// `bounds`. If `monthsLayout` is `.vertical`, and `pinDaysOfWeekToTop` is `true`, then this array will be empty + /// since day-of-the-week items appear outside of individual months. + public let dayOfWeekPositionsAndFrames: [(dayOfWeekPosition: DayOfWeekPosition, frame: CGRect)] - /// An ordered list of tuples containing day-of-the-week positions and frames. - /// - /// Each frame corresponds to an individual day-of-the-week item (Sunday, Monday, etc.) in the month, in the coordinate system of - /// `bounds`. If `monthsLayout` is `.vertical`, and `pinDaysOfWeekToTop` is `true`, then this array will be empty - /// since day-of-the-week items appear outside of individual months. - public let dayOfWeekPositionsAndFrames: [(dayOfWeekPosition: DayOfWeekPosition, frame: CGRect)] + /// An ordered list of tuples containing day and day frame pairs. + /// + /// Each frame represents the frame of an individual day in the month in the coordinate system of `bounds`. + public let daysAndFrames: [(day: Day, frame: CGRect)] - /// An ordered list of tuples containing day and day frame pairs. - /// - /// Each frame represents the frame of an individual day in the month in the coordinate system of `bounds`. - public let daysAndFrames: [(day: DayComponents, frame: CGRect)] + /// The bounds into which a background can be drawn without getting clipped. Additionally, all other frames in this type are in the + /// coordinate system of this. + public let bounds: CGRect - /// The bounds into which a background can be drawn without getting clipped. Additionally, all other frames in this type are in the - /// coordinate system of this. - public let bounds: CGRect - - public static func == (lhs: MonthLayoutContext, rhs: MonthLayoutContext) -> Bool { - lhs.month == rhs.month && - lhs.monthHeaderFrame == rhs.monthHeaderFrame && - lhs.dayOfWeekPositionsAndFrames.elementsEqual( - rhs.dayOfWeekPositionsAndFrames, - by: { $0.dayOfWeekPosition == $1.dayOfWeekPosition && $0.frame == $1.frame }) && - lhs.daysAndFrames.elementsEqual( - rhs.daysAndFrames, - by: { $0.day == $1.day && $0.frame == $0.frame }) && - lhs.bounds == rhs.bounds - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(month) - hasher.combine(monthHeaderFrame) - for (dayOfWeekPosition, frame) in dayOfWeekPositionsAndFrames { - hasher.combine(dayOfWeekPosition) - hasher.combine(frame) + public static func == (lhs: MonthLayoutContext, rhs: MonthLayoutContext) -> Bool { + lhs.month == rhs.month && + lhs.monthHeaderFrame == rhs.monthHeaderFrame && + lhs.dayOfWeekPositionsAndFrames.elementsEqual( + rhs.dayOfWeekPositionsAndFrames, + by: { $0.dayOfWeekPosition == $1.dayOfWeekPosition && $0.frame == $1.frame } + ) && + lhs.daysAndFrames.elementsEqual( + rhs.daysAndFrames, + by: { $0.day == $1.day && $0.frame == $0.frame } + ) && + lhs.bounds == rhs.bounds } - for (day, frame) in daysAndFrames { - hasher.combine(day) - hasher.combine(frame) - } - hasher.combine(bounds) - } + public func hash(into hasher: inout Hasher) { + hasher.combine(month) + hasher.combine(monthHeaderFrame) + for (dayOfWeekPosition, frame) in dayOfWeekPositionsAndFrames { + hasher.combine(dayOfWeekPosition) + hasher.combine(frame) + } + for (day, frame) in daysAndFrames { + hasher.combine(day) + hasher.combine(frame) + } + hasher.combine(bounds) + } } diff --git a/Sources/Public/MonthRange.swift b/Sources/Public/MonthRange.swift index 54f59c81..c72171e2 100644 --- a/Sources/Public/MonthRange.swift +++ b/Sources/Public/MonthRange.swift @@ -24,14 +24,13 @@ typealias MonthRange = MonthComponentsRange public typealias MonthComponentsRange = ClosedRange extension MonthRange { - - /// Instantiates a `MonthRange` that encapsulates the `dateRange` in the `calendar` as closely as possible. For example, - /// a date range of [2020-01-19, 2021-02-01] will result in a month range of [2020-01, 2021-02]. - init(containing dateRange: ClosedRange, in calendar: Calendar) { - self.init( - uncheckedBounds: ( - lower: calendar.month(containing: dateRange.lowerBound), - upper: calendar.month(containing: dateRange.upperBound))) - } - + /// Instantiates a `MonthRange` that encapsulates the `dateRange` in the `calendar` as closely as possible. For example, + /// a date range of [2020-01-19, 2021-02-01] will result in a month range of [2020-01, 2021-02]. + init(containing dateRange: ClosedRange, in calendar: Calendar) { + self.init( + uncheckedBounds: ( + lower: calendar.month(containing: dateRange.lowerBound), + upper: calendar.month(containing: dateRange.upperBound) + )) + } } diff --git a/Sources/Public/MonthsLayout.swift b/Sources/Public/MonthsLayout.swift index 121e9c15..75f06e01 100644 --- a/Sources/Public/MonthsLayout.swift +++ b/Sources/Public/MonthsLayout.swift @@ -19,239 +19,223 @@ import CoreGraphics /// The layout of months displayed in `CalendarView`. public enum MonthsLayout: Hashable { + /// Calendar months will be arranged in a single column, and scroll on the vertical axis. + /// + /// - `options`: Additional options to adjust the layout of the vertically-scrolling calendar. + case vertical(options: VerticalMonthsLayoutOptions) - /// Calendar months will be arranged in a single column, and scroll on the vertical axis. - /// - /// - `options`: Additional options to adjust the layout of the vertically-scrolling calendar. - case vertical(options: VerticalMonthsLayoutOptions) + /// Calendar months will be arranged in a single row, and scroll on the horizontal axis. + /// + /// - `options`: Additional options to adjust the layout of the horizontally-scrolling calendar. + case horizontal(options: HorizontalMonthsLayoutOptions) - /// Calendar months will be arranged in a single row, and scroll on the horizontal axis. - /// - /// - `options`: Additional options to adjust the layout of the horizontally-scrolling calendar. - case horizontal(options: HorizontalMonthsLayoutOptions) - - // MARK: Public + // MARK: Public - public static var vertical: MonthsLayout { - .vertical(options: .init()) - } + public static var vertical: MonthsLayout { + .vertical(options: .init()) + } - public static var horizontal: MonthsLayout { - .horizontal(options: .init()) - } + public static var horizontal: MonthsLayout { + .horizontal(options: .init()) + } - // MARK: Internal + // MARK: Internal - var isHorizontal: Bool { - switch self { - case .vertical: return false - case .horizontal: return true + var isHorizontal: Bool { + switch self { + case .vertical: false + case .horizontal: true + } } - } - var pinDaysOfWeekToTop: Bool { - switch self { - case .vertical(let options): return options.pinDaysOfWeekToTop - case .horizontal: return false + var pinDaysOfWeekToTop: Bool { + switch self { + case let .vertical(options): options.pinDaysOfWeekToTop + case .horizontal: false + } } - } - var alwaysShowCompleteBoundaryMonths: Bool { - switch self { - case .vertical(let options): return options.alwaysShowCompleteBoundaryMonths - case .horizontal: return true - } - } - - var isPaginationEnabled: Bool { - guard - case .horizontal(let options) = self, - case .paginatedScrolling = options.scrollingBehavior - else { - return false + var alwaysShowCompleteBoundaryMonths: Bool { + switch self { + case let .vertical(options): options.alwaysShowCompleteBoundaryMonths + case .horizontal: true + } } - return true - } + var isPaginationEnabled: Bool { + guard + case let .horizontal(options) = self, + case .paginatedScrolling = options.scrollingBehavior + else { + return false + } - var scrollsToFirstMonthOnStatusBarTap: Bool { - switch self { - case .vertical(let options): return options.scrollsToFirstMonthOnStatusBarTap - case .horizontal: return false + return true + } + + var scrollsToFirstMonthOnStatusBarTap: Bool { + switch self { + case let .vertical(options): options.scrollsToFirstMonthOnStatusBarTap + case .horizontal: false + } } - } } // MARK: - VerticalMonthsLayoutOptions /// Layout options for a vertically-scrolling calendar. public struct VerticalMonthsLayoutOptions: Hashable { + // MARK: Lifecycle + + /// Initializes a new instance of `VerticalMonthsLayoutOptions`. + /// + /// - Parameters: + /// - pinDaysOfWeekToTop: Whether the days of the week will appear once, pinned at the top, or repeatedly in each month. + /// The default value is `false`. + /// - alwaysShowCompleteBoundaryMonths: Whether the calendar will always show complete months, even if the visible + /// date range does not start on the first date or end on the last date of a month. The default value is `true`. + /// - scrollsToFirstMonthOnStatusBarTap: Whether the calendar should scroll to the first month when the system + /// status bar is tapped. The default value is `false`. + public init( + pinDaysOfWeekToTop: Bool = false, + alwaysShowCompleteBoundaryMonths: Bool = true, + scrollsToFirstMonthOnStatusBarTap: Bool = false + ) { + self.pinDaysOfWeekToTop = pinDaysOfWeekToTop + self.alwaysShowCompleteBoundaryMonths = alwaysShowCompleteBoundaryMonths + self.scrollsToFirstMonthOnStatusBarTap = scrollsToFirstMonthOnStatusBarTap + } + + // MARK: Public + + /// Whether the days of the week will appear once, pinned at the top, or repeatedly in each month. + public let pinDaysOfWeekToTop: Bool - // MARK: Lifecycle - - /// Initializes a new instance of `VerticalMonthsLayoutOptions`. - /// - /// - Parameters: - /// - pinDaysOfWeekToTop: Whether the days of the week will appear once, pinned at the top, or repeatedly in each month. - /// The default value is `false`. - /// - alwaysShowCompleteBoundaryMonths: Whether the calendar will always show complete months, even if the visible - /// date range does not start on the first date or end on the last date of a month. The default value is `true`. - /// - scrollsToFirstMonthOnStatusBarTap: Whether the calendar should scroll to the first month when the system - /// status bar is tapped. The default value is `false`. - public init( - pinDaysOfWeekToTop: Bool = false, - alwaysShowCompleteBoundaryMonths: Bool = true, - scrollsToFirstMonthOnStatusBarTap: Bool = false) - { - self.pinDaysOfWeekToTop = pinDaysOfWeekToTop - self.alwaysShowCompleteBoundaryMonths = alwaysShowCompleteBoundaryMonths - self.scrollsToFirstMonthOnStatusBarTap = scrollsToFirstMonthOnStatusBarTap - } - - // MARK: Public - - /// Whether the days of the week will appear once, pinned at the top, or repeatedly in each month. - public let pinDaysOfWeekToTop: Bool - - /// Whether the calendar will always show complete months at the calendar's boundaries, even if the visible date range does not start - /// on the first date or end on the last date of a month. - public let alwaysShowCompleteBoundaryMonths: Bool - - /// Whether the calendar should scroll to the first month when the system status bar is tapped. - public let scrollsToFirstMonthOnStatusBarTap: Bool + /// Whether the calendar will always show complete months at the calendar's boundaries, even if the visible date range does not start + /// on the first date or end on the last date of a month. + public let alwaysShowCompleteBoundaryMonths: Bool + /// Whether the calendar should scroll to the first month when the system status bar is tapped. + public let scrollsToFirstMonthOnStatusBarTap: Bool } // MARK: - HorizontalMonthsLayoutOptions /// Layout options for a horizontally-scrolling calendar. public struct HorizontalMonthsLayoutOptions: Hashable { + // MARK: Lifecycle - // MARK: Lifecycle - - /// Initializes a new instance of `HorizontalMonthsLayoutOptions`. - /// - /// - Parameters: - /// - maximumFullyVisibleMonths: The maximum number of fully visible months for any scroll offset. The default value is - /// `1`. - /// - scrollingBehavior: The scrolling behavior of the horizontally-scrolling calendar: either paginated-scrolling or - /// free-scrolling. The default value is paginated-scrolling by month. - public init( - maximumFullyVisibleMonths: Double = 1, - scrollingBehavior: ScrollingBehavior = .paginatedScrolling( - .init( - restingPosition: .atLeadingEdgeOfEachMonth, - restingAffinity: .atPositionsAdjacentToPrevious))) - { - assert(maximumFullyVisibleMonths >= 1, "`maximumFullyVisibleMonths` must be greater than 1.") - self.maximumFullyVisibleMonths = maximumFullyVisibleMonths - self.scrollingBehavior = scrollingBehavior - } - - // MARK: Public - - /// The maximum number of fully visible months for any scroll offset. - public let maximumFullyVisibleMonths: Double - - /// The scrolling behavior of the horizontally-scrolling calendar: either paginated-scrolling or free-scrolling. - public let scrollingBehavior: ScrollingBehavior - - // MARK: Internal - - func monthWidth(calendarWidth: CGFloat, interMonthSpacing: CGFloat) -> CGFloat { - let visibleInterMonthSpacing = CGFloat(maximumFullyVisibleMonths) * interMonthSpacing - return (calendarWidth - visibleInterMonthSpacing) / CGFloat(maximumFullyVisibleMonths) - } - - func pageSize(calendarWidth: CGFloat, interMonthSpacing: CGFloat) -> CGFloat { - guard case .paginatedScrolling(let configuration) = scrollingBehavior else { - preconditionFailure( - "Cannot get a page size for a calendar that does not have horizontal pagination enabled.") - } - - switch configuration.restingPosition { - case .atIncrementsOfCalendarWidth: - return calendarWidth - case .atLeadingEdgeOfEachMonth: - let monthWidth = monthWidth( - calendarWidth: calendarWidth, - interMonthSpacing: interMonthSpacing) - return monthWidth + interMonthSpacing + /// Initializes a new instance of `HorizontalMonthsLayoutOptions`. + /// + /// - Parameters: + /// - maximumFullyVisibleMonths: The maximum number of fully visible months for any scroll offset. The default value is + /// `1`. + /// - scrollingBehavior: The scrolling behavior of the horizontally-scrolling calendar: either paginated-scrolling or + /// free-scrolling. The default value is paginated-scrolling by month. + public init( + maximumFullyVisibleMonths: Double = 1, + scrollingBehavior: ScrollingBehavior = .paginatedScrolling( + .init( + restingPosition: .atLeadingEdgeOfEachMonth, + restingAffinity: .atPositionsAdjacentToPrevious + )) + ) { + assert(maximumFullyVisibleMonths >= 1, "`maximumFullyVisibleMonths` must be greater than 1.") + self.maximumFullyVisibleMonths = maximumFullyVisibleMonths + self.scrollingBehavior = scrollingBehavior } - } -} - -// MARK: HorizontalMonthsLayoutOptions.ScrollingBehavior + // MARK: Public -extension HorizontalMonthsLayoutOptions { + /// The maximum number of fully visible months for any scroll offset. + public let maximumFullyVisibleMonths: Double - /// The scrolling behavior of the horizontally-scrolling calendar: either paginated-scrolling or free-scrolling. - public enum ScrollingBehavior: Hashable { + /// The scrolling behavior of the horizontally-scrolling calendar: either paginated-scrolling or free-scrolling. + public let scrollingBehavior: ScrollingBehavior - /// The calendar will come to a rest at specific scroll positions, defined by the `PaginationConfiguration`. - case paginatedScrolling(PaginationConfiguration) + // MARK: Internal - /// The calendar will come to a rest at any scroll position. - case freeScrolling - } + func monthWidth(calendarWidth: CGFloat, interMonthSpacing: CGFloat) -> CGFloat { + let visibleInterMonthSpacing = CGFloat(maximumFullyVisibleMonths) * interMonthSpacing + return (calendarWidth - visibleInterMonthSpacing) / CGFloat(maximumFullyVisibleMonths) + } + func pageSize(calendarWidth: CGFloat, interMonthSpacing: CGFloat) -> CGFloat { + guard case let .paginatedScrolling(configuration) = scrollingBehavior else { + preconditionFailure( + "Cannot get a page size for a calendar that does not have horizontal pagination enabled.") + } + + switch configuration.restingPosition { + case .atIncrementsOfCalendarWidth: + return calendarWidth + case .atLeadingEdgeOfEachMonth: + let monthWidth = monthWidth( + calendarWidth: calendarWidth, + interMonthSpacing: interMonthSpacing + ) + return monthWidth + interMonthSpacing + } + } } -// MARK: HorizontalMonthsLayoutOptions.PaginationConfiguration - -extension HorizontalMonthsLayoutOptions { - - /// The pagination behavior's configurable options. - public struct PaginationConfiguration: Hashable { +// MARK: HorizontalMonthsLayoutOptions.ScrollingBehavior - // MARK: Lifecycle +public extension HorizontalMonthsLayoutOptions { + /// The scrolling behavior of the horizontally-scrolling calendar: either paginated-scrolling or free-scrolling. + enum ScrollingBehavior: Hashable { + /// The calendar will come to a rest at specific scroll positions, defined by the `PaginationConfiguration`. + case paginatedScrolling(PaginationConfiguration) - public init(restingPosition: RestingPosition, restingAffinity: RestingAffinity) { - self.restingPosition = restingPosition - self.restingAffinity = restingAffinity + /// The calendar will come to a rest at any scroll position. + case freeScrolling } - - // MARK: Public - - /// The position at which the calendar will come to a rest when paginating. - public let restingPosition: RestingPosition - - /// The calendar's affinity for stopping at a resting position. - public let restingAffinity: RestingAffinity - - } - } -extension HorizontalMonthsLayoutOptions.PaginationConfiguration { - - // MARK: - HorizontalMonthsLayoutOptions.PaginationConfiguration.RestingPosition +// MARK: HorizontalMonthsLayoutOptions.PaginationConfiguration - /// The position at which the calendar will come to a rest when paginating. - public enum RestingPosition: Hashable { +public extension HorizontalMonthsLayoutOptions { + /// The pagination behavior's configurable options. + struct PaginationConfiguration: Hashable { + // MARK: Lifecycle - /// The calendar will come to a rest at the leading edge of each month. - case atLeadingEdgeOfEachMonth + public init(restingPosition: RestingPosition, restingAffinity: RestingAffinity) { + self.restingPosition = restingPosition + self.restingAffinity = restingAffinity + } - /// The calendar will come to a rest at increments equal to the calendar's width. - case atIncrementsOfCalendarWidth + // MARK: Public - } + /// The position at which the calendar will come to a rest when paginating. + public let restingPosition: RestingPosition - // MARK: - HorizontalMonthsLayoutOptions.PaginationConfiguration.RestingAffinity + /// The calendar's affinity for stopping at a resting position. + public let restingAffinity: RestingAffinity + } +} - /// The calendar's affinity for stopping at a resting position. - public enum RestingAffinity: Hashable { +public extension HorizontalMonthsLayoutOptions.PaginationConfiguration { + // MARK: - HorizontalMonthsLayoutOptions.PaginationConfiguration.RestingPosition - /// The calendar will come to a rest at the position adjacent to the previous resting position, regardless of how fast the user - /// swipes. - case atPositionsAdjacentToPrevious + /// The position at which the calendar will come to a rest when paginating. + enum RestingPosition: Hashable { + /// The calendar will come to a rest at the leading edge of each month. + case atLeadingEdgeOfEachMonth - /// The calendar will come to a rest at the closest position to the target scroll offset, potentially skipping over many valid resting - /// positions depending on how fast the user swipes. - case atPositionsClosestToTargetOffset + /// The calendar will come to a rest at increments equal to the calendar's width. + case atIncrementsOfCalendarWidth + } - } + // MARK: - HorizontalMonthsLayoutOptions.PaginationConfiguration.RestingAffinity + /// The calendar's affinity for stopping at a resting position. + enum RestingAffinity: Hashable { + /// The calendar will come to a rest at the position adjacent to the previous resting position, regardless of how fast the user + /// swipes. + case atPositionsAdjacentToPrevious + + /// The calendar will come to a rest at the closest position to the target scroll offset, potentially skipping over many valid resting + /// positions depending on how fast the user swipes. + case atPositionsClosestToTargetOffset + } } diff --git a/Tests/DayComponentsTests.swift b/Tests/DayComponentsTests.swift new file mode 100644 index 00000000..1f163faf --- /dev/null +++ b/Tests/DayComponentsTests.swift @@ -0,0 +1,62 @@ +// +// DayComponentTests.swift +// HorizonCalendarTests +// +// Created by Kyle Parker on 3/1/25. +// Copyright © 2025 Airbnb. All rights reserved. +// + +import Testing +import HorizonCalendar +import Foundation + +struct DayComponentTests { + + @available(iOS 13.0.0, *) + @Test func testGreaterThanOperatorSameMonthTrue() async throws { + let early = DayComponents(date: createDate(year: 2025, month: 01, day: 01)) + let late = DayComponents(date: createDate(year: 2025, month: 01, day: 02)) + + #expect(early < late) + } + + @available(iOS 13.0.0, *) + @Test func testGreaterThanOperatorSameMonthFalse() async throws { + let early = DayComponents(date: createDate(year: 2025, month: 01, day: 01)) + let late = DayComponents(date: createDate(year: 2025, month: 01, day: 02)) + + #expect((late < early) == false) + } + + @available(iOS 13.0.0, *) + @Test func testGreaterThanOperatorSameMonthEqualFalse() async throws { + let early = DayComponents(date: createDate(year: 2025, month: 01, day: 01)) + let late = DayComponents(date: createDate(year: 2025, month: 01, day: 01)) + + #expect((late < early) == false) + } + + @available(iOS 13.0.0, *) + @Test func testGreaterThanOperatorDifferentMonthsTrue() async throws { + let early = DayComponents(date: createDate(year: 2024, month: 12, day: 31)) + let late = DayComponents(date: createDate(year: 2025, month: 01, day: 01)) + + #expect(early < late) + } + + @available(iOS 13.0.0, *) + @Test func testGreaterThanOperatorDifferentMonthsFalse() async throws { + let early = DayComponents(date: createDate(year: 2024, month: 12, day: 31)) + let late = DayComponents(date: createDate(year: 2025, month: 01, day: 01)) + + #expect((late < early) == false) + } + + private func createDate(year: Int, month: Int, day: Int) -> Date { + var dateComponents = DateComponents() + dateComponents.year = year + dateComponents.month = month + dateComponents.day = day + return Calendar.current.date(from: dateComponents)! + } +} diff --git a/Tests/SubviewsManagerTests.swift b/Tests/SubviewsManagerTests.swift index 8f228880..54139fb8 100644 --- a/Tests/SubviewsManagerTests.swift +++ b/Tests/SubviewsManagerTests.swift @@ -142,9 +142,10 @@ extension VisibleItem.ItemType: Comparable { case .layoutItemType: return 3 case .daysOfWeekRowSeparator: return 4 case .overlayItem: return 5 - case .pinnedDaysOfWeekRowBackground: return 6 - case .pinnedDayOfWeek: return 7 - case .pinnedDaysOfWeekRowSeparator: return 8 + case .weekNumber: return 6 + case .pinnedDaysOfWeekRowBackground: return 7 + case .pinnedDayOfWeek: return 8 + case .pinnedDaysOfWeekRowSeparator: return 9 } } diff --git a/Tests/VisibleItemsProviderTests.swift b/Tests/VisibleItemsProviderTests.swift index efaa28b9..a5692992 100644 --- a/Tests/VisibleItemsProviderTests.swift +++ b/Tests/VisibleItemsProviderTests.swift @@ -1655,6 +1655,8 @@ extension VisibleItem: CustomStringConvertible { itemTypeText = ".dayBackground(\(day))" case .monthBackground(let month): itemTypeText = ".monthBackground(\(month.description))" + case .weekNumber(let weekNumber, let month): + itemTypeText = ".weekNumber(\(weekNumber), \(month.description))" case .overlayItem(let overlaidItemLocation): let calendar = Calendar(identifier: .gregorian) let itemLocationText: String diff --git a/Tests/WeekNumberTests.swift b/Tests/WeekNumberTests.swift new file mode 100644 index 00000000..7fbccffd --- /dev/null +++ b/Tests/WeekNumberTests.swift @@ -0,0 +1,198 @@ +// +// WeekNumberTests.swift +// HorizonCalendar +// +// Created by Cade Chaplin on 3/1/25. +// Copyright © 2025 Airbnb. All rights reserved. +// + + +import XCTest +@testable import HorizonCalendar + +final class WeekNumberTests: XCTestCase { + + // MARK: - Common Test Properties + + private let calendar = Calendar(identifier: .gregorian) + private let size = CGSize(width: 320, height: 500) + private let dateRange = Date().addingTimeInterval(-60 * 60 * 24 * 30 * 6)...Date().addingTimeInterval(60 * 60 * 24 * 30 * 6) + + // MARK: - Tests + + func testWeekNumbersAreVisibleWhenEnabled() { + // Create content with week numbers enabled + let content = CalendarViewContent( + calendar: calendar, + visibleDateRange: dateRange, + monthsLayout: .vertical(options: .init())) + .showWeekNumbers(true) + + let visibleItemsProvider = VisibleItemsProvider( + calendar: calendar, + content: content, + size: size, + layoutMargins: .zero, + scale: 2, + backgroundColor: nil) + + // Get visible items when positioned at January + let details = visibleItemsProvider.detailsForVisibleItems( + surroundingPreviouslyVisibleLayoutItem: LayoutItem( + itemType: .monthHeader(Month(era: 1, year: 2024, month: 01, isInGregorianCalendar: true)), + frame: CGRect(x: 0, y: 0, width: 320, height: 50)), + offset: CGPoint(x: 0, y: 0), + extendLayoutRegion: false) + + // Find items with weekNumber type + let weekNumberItems = details.visibleItems.filter { + if case .weekNumber = $0.itemType { + return true + } + return false + } + + // Verify that week numbers are visible + XCTAssertFalse(weekNumberItems.isEmpty, "Week numbers should be visible when enabled") + } + + func testWeekNumbersAreHiddenWhenDisabled() { + // Create content with week numbers disabled (default) + let content = CalendarViewContent( + calendar: calendar, + visibleDateRange: dateRange, + monthsLayout: .vertical(options: .init())) + + let visibleItemsProvider = VisibleItemsProvider( + calendar: calendar, + content: content, + size: size, + layoutMargins: .zero, + scale: 2, + backgroundColor: nil) + + // Get visible items when positioned at January + let details = visibleItemsProvider.detailsForVisibleItems( + surroundingPreviouslyVisibleLayoutItem: LayoutItem( + itemType: .monthHeader(Month(era: 1, year: 2024, month: 01, isInGregorianCalendar: true)), + frame: CGRect(x: 0, y: 0, width: 320, height: 50)), + offset: CGPoint(x: 0, y: 0), + extendLayoutRegion: false) + + // Find items with weekNumber type + let weekNumberItems = details.visibleItems.filter { + if case .weekNumber = $0.itemType { + return true + } + return false + } + + // Verify that week numbers are not visible + XCTAssertTrue(weekNumberItems.isEmpty, "Week numbers should not be visible when disabled") + } + + func testWeekNumbersHaveCorrectValue() { + // Create content with week numbers enabled + let content = CalendarViewContent( + calendar: calendar, + visibleDateRange: dateRange, + monthsLayout: .vertical(options: .init())) + .showWeekNumbers(true) + + let visibleItemsProvider = VisibleItemsProvider( + calendar: calendar, + content: content, + size: size, + layoutMargins: .zero, + scale: 2, + backgroundColor: nil) + + // Get visible items when positioned at January 2024 + let details = visibleItemsProvider.detailsForVisibleItems( + surroundingPreviouslyVisibleLayoutItem: LayoutItem( + itemType: .monthHeader(Month(era: 1, year: 2024, month: 01, isInGregorianCalendar: true)), + frame: CGRect(x: 0, y: 0, width: 320, height: 50)), + offset: CGPoint(x: 0, y: 0), + extendLayoutRegion: false) + + // Find items with weekNumber type + let weekNumberItems = details.visibleItems.compactMap { item -> (Int, Month)? in + if case .weekNumber(let weekNumber, let month) = item.itemType { + return (weekNumber, month) + } + return nil + } + + // Verify that we have some week numbers + XCTAssertFalse(weekNumberItems.isEmpty, "Week numbers should be visible") + + // Verify that week numbers are in ascending order + var lastWeekNumber = 0 + for (weekNumber, _) in weekNumberItems { + if lastWeekNumber > 0 { + // Allow jumping back to week 1 for new years + if !(lastWeekNumber > 50 && weekNumber < 10) { + XCTAssertGreaterThanOrEqual(weekNumber, lastWeekNumber, "Week numbers should be ascending") + } + } + lastWeekNumber = weekNumber + } + + // Verify first week number is valid (should be 1 or close to it for January) + if let firstWeekNumber = weekNumberItems.first?.0 { + XCTAssertTrue(firstWeekNumber < 5, "First week of January should be low week number") + } + } + + func testWeekNumbersPositioning() { + // Create content with week numbers enabled + let content = CalendarViewContent( + calendar: calendar, + visibleDateRange: dateRange, + monthsLayout: .vertical(options: .init())) + .showWeekNumbers(true) + + let visibleItemsProvider = VisibleItemsProvider( + calendar: calendar, + content: content, + size: size, + layoutMargins: .zero, + scale: 2, + backgroundColor: nil) + + // Get visible items when positioned at January + let details = visibleItemsProvider.detailsForVisibleItems( + surroundingPreviouslyVisibleLayoutItem: LayoutItem( + itemType: .monthHeader(Month(era: 1, year: 2024, month: 01, isInGregorianCalendar: true)), + frame: CGRect(x: 0, y: 0, width: 320, height: 50)), + offset: CGPoint(x: 0, y: 0), + extendLayoutRegion: false) + + // Get all day items + let dayItems = details.visibleItems.filter { + if case .layoutItemType(.day) = $0.itemType { + return true + } + return false + } + + // Get all week number items + let weekNumberItems = details.visibleItems.filter { + if case .weekNumber = $0.itemType { + return true + } + return false + } + + // Verify that week numbers are positioned to the left of days + XCTAssertFalse(dayItems.isEmpty, "Day items should be present") + XCTAssertFalse(weekNumberItems.isEmpty, "Week number items should be present") + + // Find the rightmost x position of week numbers + if let maxWeekNumberX = weekNumberItems.map({ $0.frame.maxX }).max(), + let minDayX = dayItems.map({ $0.frame.minX }).min() + { + XCTAssertLessThanOrEqual(maxWeekNumberX, minDayX, "Week numbers should be positioned to the left of days") + } + } +}