diff --git a/HorizonCalendar.xcodeproj/project.pbxproj b/HorizonCalendar.xcodeproj/project.pbxproj index 15738c7..c560904 100644 --- a/HorizonCalendar.xcodeproj/project.pbxproj +++ b/HorizonCalendar.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 086333B02AB8D36900CC6125 /* CalendarContentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 086333AE2AB8D34700CC6125 /* CalendarContentTests.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 */; }; @@ -86,6 +87,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 086333AE2AB8D34700CC6125 /* CalendarContentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarContentTests.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 = ""; }; @@ -240,6 +242,7 @@ FD264F86294B260B00B13C97 /* SubviewsManagerTests.swift */, 939E694D2484B14400A8BCC7 /* VisibleItemsProviderTests.swift */, 9396F3D22483261B008AD306 /* Info.plist */, + 086333AE2AB8D34700CC6125 /* CalendarContentTests.swift */, ); path = Tests; sourceTree = ""; @@ -483,6 +486,7 @@ 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 */, diff --git a/Sources/Public/CalendarViewContent.swift b/Sources/Public/CalendarViewContent.swift index 4b2e91e..176cd9b 100644 --- a/Sources/Public/CalendarViewContent.swift +++ b/Sources/Public/CalendarViewContent.swift @@ -54,49 +54,16 @@ public final class CalendarViewContent { dayRange = exactDayRange } - let monthHeaderDateFormatter = DateFormatter() - monthHeaderDateFormatter.calendar = calendar - monthHeaderDateFormatter.locale = calendar.locale - monthHeaderDateFormatter.dateFormat = DateFormatter.dateFormat( - fromTemplate: "MMMM yyyy", - options: 0, - locale: calendar.locale ?? Locale.current) - - monthHeaderItemProvider = { month in - let firstDateInMonth = calendar.firstDate(of: month) - let monthText = monthHeaderDateFormatter.string(from: firstDateInMonth) - let itemModel = MonthHeaderView.calendarItemModel( - invariantViewProperties: .base, - content: .init(monthText: monthText, accessibilityLabel: monthText)) - return itemModel - } - - dayOfWeekItemProvider = { _, weekdayIndex in - let dayOfWeekText = monthHeaderDateFormatter.veryShortStandaloneWeekdaySymbols[weekdayIndex] - let itemModel = DayOfWeekView.calendarItemModel( - invariantViewProperties: .base, - content: .init(dayOfWeekText: dayOfWeekText, accessibilityLabel: dayOfWeekText)) - return itemModel - } - - let dayDateFormatter = DateFormatter() - dayDateFormatter.calendar = calendar - dayDateFormatter.locale = calendar.locale - dayDateFormatter.dateFormat = DateFormatter.dateFormat( - fromTemplate: "EEEE, MMM d, yyyy", - options: 0, - locale: calendar.locale ?? Locale.current) - - dayItemProvider = { day in - let date = calendar.startDate(of: day) - let itemModel = DayView.calendarItemModel( - invariantViewProperties: .baseNonInteractive, - content: .init( - dayText: "\(day.day)", - accessibilityLabel: dayDateFormatter.string(from: date), - accessibilityHint: nil)) - return itemModel - } + // We have to initialize the providers to some value before we can access + // the default implementations (which are lazy instance variables). + monthHeaderItemProvider = { _ in fatalError("This closure must never be called.") } + dayOfWeekItemProvider = { _, _ in fatalError("This closure must never be called.") } + dayItemProvider = { _ in fatalError("This closure must never be called.") } + + // Now that `self` is fully initialized, we can update the providers to the real default implementations + monthHeaderItemProvider = defaultMonthHeaderItemProvider + dayOfWeekItemProvider = defaultDayOfWeekItemProvider + dayItemProvider = defaultDayItemProvider } // MARK: Public @@ -208,8 +175,8 @@ public final class CalendarViewContent { /// displayed. The `CalendarItemModel`s that you return will be used to create the views for each month header in /// `CalendarView`. /// - /// If you don't configure your own month header item provider via this function, then a default month header item provider will be - /// used. + /// If you don't configure your own month header item provider via this function, or if the `monthHeaderItemProvider` closure + /// returns nil, then a default month header item provider will be used. /// /// - Parameters: /// - monthHeaderItemProvider: A closure (that is retained) that returns a `CalendarItemModel` representing a @@ -217,10 +184,18 @@ public final class CalendarViewContent { /// - month: The `Month` for which to provide a month header item. /// - Returns: A mutated `CalendarViewContent` instance with a new month header item provider. public func monthHeaderItemProvider( - _ monthHeaderItemProvider: @escaping (_ month: Month) -> AnyCalendarItemModel) + _ monthHeaderItemProvider: @escaping (_ month: Month) -> AnyCalendarItemModel?) -> CalendarViewContent { - self.monthHeaderItemProvider = monthHeaderItemProvider + self.monthHeaderItemProvider = { [defaultMonthHeaderItemProvider] month in + guard let itemModel = monthHeaderItemProvider(month) else { + // If the caller returned nil, fall back to the default item provider + return defaultMonthHeaderItemProvider(month) + } + + return itemModel + } + return self } @@ -230,7 +205,8 @@ public final class CalendarViewContent { /// For example, for the en_US locale, 0 is Sunday, 1 is Monday, and 6 is Saturday. This will be different in some other locales. The /// `CalendarItemModel`s that you return will be used to create the views for each day-of-week view in `CalendarView`. /// - /// If you don't configure your own day-of-week item provider via this function, then a default day-of-week item provider will be used. + /// If you don't configure your own day-of-week item provider via this function, or if the `dayOfWeekItemProvider` closure + /// returns nil, then a default day-of-week item provider will be used. /// /// - Parameters: /// - dayOfWeekItemProvider: A closure (that is retained) that returns a `CalendarItemModel` representing a @@ -243,10 +219,18 @@ public final class CalendarViewContent { _ dayOfWeekItemProvider: @escaping ( _ month: Month?, _ weekdayIndex: Int) - -> AnyCalendarItemModel) + -> AnyCalendarItemModel?) -> CalendarViewContent { - self.dayOfWeekItemProvider = dayOfWeekItemProvider + self.dayOfWeekItemProvider = { [defaultDayOfWeekItemProvider] month, weekdayIndex in + guard let itemModel = dayOfWeekItemProvider(month, weekdayIndex) else { + // If the caller returned nil, fall back to the default item provider + return defaultDayOfWeekItemProvider(month, weekdayIndex) + } + + return itemModel + } + return self } @@ -257,7 +241,8 @@ public final class CalendarViewContent { /// view should be some kind of label that tells the user the day number of the month. You can also add other decoration, like a badge /// 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, then a default day item provider will be used. + /// 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. /// /// - Parameters: /// - dayItemProvider: A closure (that is retained) that returns a `CalendarItemModel` representing a single day @@ -265,10 +250,18 @@ public final class CalendarViewContent { /// - 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: Day) -> AnyCalendarItemModel) + _ dayItemProvider: @escaping (_ day: Day) -> AnyCalendarItemModel?) -> CalendarViewContent { - self.dayItemProvider = dayItemProvider + 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) + } + + return itemModel + } + return self } @@ -410,4 +403,62 @@ public final class CalendarViewContent { overlaidItemLocations: Set, overlayItemProvider: (OverlayLayoutContext) -> AnyCalendarItemModel)? + /// The default `monthHeaderItemProvider` if no provider has been configured, + /// or if the existing provider returns nil. + private lazy var defaultMonthHeaderItemProvider: (Month) -> AnyCalendarItemModel = { [calendar, monthHeaderDateFormatter] month in + let firstDateInMonth = calendar.firstDate(of: month) + let monthText = monthHeaderDateFormatter.string(from: firstDateInMonth) + let itemModel = MonthHeaderView.calendarItemModel( + invariantViewProperties: .base, + content: .init(monthText: monthText, accessibilityLabel: monthText)) + return itemModel + } + + /// The default `dayHeaderItemProvider` if no provider has been configured, + /// or if the existing provider returns nil. + private lazy var defaultDayOfWeekItemProvider: (Month?, Int) -> AnyCalendarItemModel = { [monthHeaderDateFormatter] _, weekdayIndex in + let dayOfWeekText = monthHeaderDateFormatter.veryShortStandaloneWeekdaySymbols[weekdayIndex] + let itemModel = DayOfWeekView.calendarItemModel( + invariantViewProperties: .base, + content: .init(dayOfWeekText: dayOfWeekText, accessibilityLabel: dayOfWeekText)) + return itemModel + } + + /// The default `dayItemProvider` if no provider has been configured, + /// or if the existing provider returns nil. + private lazy var defaultDayItemProvider: (Day) -> AnyCalendarItemModel = { [calendar, dayDateFormatter] day in + let date = calendar.startDate(of: day) + let itemModel = DayView.calendarItemModel( + invariantViewProperties: .baseNonInteractive, + content: .init( + dayText: "\(day.day)", + accessibilityLabel: dayDateFormatter.string(from: date), + accessibilityHint: nil)) + return itemModel + } + + private lazy var monthHeaderDateFormatter: DateFormatter = { + let monthHeaderDateFormatter = DateFormatter() + monthHeaderDateFormatter.calendar = calendar + monthHeaderDateFormatter.locale = calendar.locale + monthHeaderDateFormatter.dateFormat = DateFormatter.dateFormat( + fromTemplate: "MMMM yyyy", + options: 0, + locale: calendar.locale ?? Locale.current) + return monthHeaderDateFormatter + }() + + private lazy var dayDateFormatter: DateFormatter = { + let dayDateFormatter = DateFormatter() + dayDateFormatter.calendar = calendar + dayDateFormatter.locale = calendar.locale + dayDateFormatter.dateFormat = DateFormatter.dateFormat( + fromTemplate: "EEEE, MMM d, yyyy", + options: 0, + locale: calendar.locale ?? Locale.current) + return dayDateFormatter + }() + + + } diff --git a/Tests/CalendarContentTests.swift b/Tests/CalendarContentTests.swift new file mode 100644 index 0000000..abf6864 --- /dev/null +++ b/Tests/CalendarContentTests.swift @@ -0,0 +1,69 @@ +// Created by Cal Stephens on 9/18/23. +// Copyright © 2023 Airbnb Inc. All rights reserved. + +@testable import HorizonCalendar +import XCTest + +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)) + } + +}