Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update CalendarViewContent closures to accept nil return values #269

Merged
merged 2 commits into from
Sep 19, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions HorizonCalendar.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -86,6 +87,7 @@
/* End PBXContainerItemProxy section */

/* Begin PBXFileReference section */
086333AE2AB8D34700CC6125 /* CalendarContentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarContentTests.swift; sourceTree = "<group>"; };
9321957D26EEB44C0001C7E9 /* DayOfWeekView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayOfWeekView.swift; sourceTree = "<group>"; };
9321957F26EEB6330001C7E9 /* Shape.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Shape.swift; sourceTree = "<group>"; };
9321958126EEB6AB0001C7E9 /* DrawingConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DrawingConfig.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -240,6 +242,7 @@
FD264F86294B260B00B13C97 /* SubviewsManagerTests.swift */,
939E694D2484B14400A8BCC7 /* VisibleItemsProviderTests.swift */,
9396F3D22483261B008AD306 /* Info.plist */,
086333AE2AB8D34700CC6125 /* CalendarContentTests.swift */,
);
path = Tests;
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
Expand Down
17 changes: 10 additions & 7 deletions Sources/Internal/VisibleItemsProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -950,11 +950,13 @@ final class VisibleItemsProvider {
daysAndFrames: dayRangeLayoutContext.daysAndFrames,
boundingUnionRectOfDayFrames: dayRangeLayoutContext.boundingUnionRectOfDayFrames)

context.visibleItems.insert(
VisibleItem(
calendarItemModel: dayRangeItemProvider(dayRangeLayoutContext),
itemType: .dayRange(dayRange),
frame: frame))
if let dayRangeItemModel = dayRangeItemProvider(dayRangeLayoutContext) {
calda marked this conversation as resolved.
Show resolved Hide resolved
context.visibleItems.insert(
VisibleItem(
calendarItemModel: dayRangeItemModel,
itemType: .dayRange(dayRange),
frame: frame))
}
}

private func handlePinnedDaysOfWeekIfNeeded(
Expand Down Expand Up @@ -1029,15 +1031,16 @@ final class VisibleItemsProvider {
let layoutContext = overlayLayoutContext(
for: overlaidItemLocation,
inBounds: bounds,
context: &context)
context: &context),
let itemModel = itemModelProvider(layoutContext)
else
{
continue
}

context.visibleItems.insert(
VisibleItem(
calendarItemModel: itemModelProvider(layoutContext),
calendarItemModel: itemModel,
itemType: .overlayItem(overlaidItemLocation),
frame: bounds))
}
Expand Down
165 changes: 108 additions & 57 deletions Sources/Public/CalendarViewContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -208,19 +175,27 @@ 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
/// month header.
/// - 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)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To avoid having to change any of the existing call sites, we can have the instance properties still always return a non-nil value, even if the user-provided closure returns nil. An easy way to do this is to just fall back to the existing default item provider, which always returns a valid value.

}

return itemModel
}

return self
}

Expand All @@ -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
Expand All @@ -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
}

Expand All @@ -257,18 +241,27 @@ 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
/// 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: 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
}

Expand Down Expand Up @@ -345,7 +338,7 @@ public final class CalendarViewContent {
for dateRanges: Set<ClosedRange<Date>>,
_ dayRangeItemProvider: @escaping (
_ dayRangeLayoutContext: DayRangeLayoutContext)
-> AnyCalendarItemModel)
-> AnyCalendarItemModel?)
-> CalendarViewContent
{
let dayRanges = Set(dateRanges.map { DayRange(containing: $0, in: calendar) })
Expand Down Expand Up @@ -373,7 +366,7 @@ public final class CalendarViewContent {
for overlaidItemLocations: Set<OverlaidItemLocation>,
_ overlayItemProvider: @escaping (
_ overlayLayoutContext: OverlayLayoutContext)
-> AnyCalendarItemModel)
-> AnyCalendarItemModel?)
-> CalendarViewContent
{
overlaidItemLocationsAndItemProvider = (overlaidItemLocations, overlayItemProvider)
Expand Down Expand Up @@ -405,9 +398,67 @@ public final class CalendarViewContent {
private(set) var monthBackgroundItemProvider: ((MonthLayoutContext) -> AnyCalendarItemModel?)?
private(set) var dayRangesAndItemProvider: (
dayRanges: Set<DayRange>,
dayRangeItemProvider: (DayRangeLayoutContext) -> AnyCalendarItemModel)?
dayRangeItemProvider: (DayRangeLayoutContext) -> AnyCalendarItemModel?)?
private(set) var overlaidItemLocationsAndItemProvider: (
overlaidItemLocations: Set<OverlaidItemLocation>,
overlayItemProvider: (OverlayLayoutContext) -> AnyCalendarItemModel)?
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
}()



}
75 changes: 75 additions & 0 deletions Tests/CalendarContentTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// 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
}
.dayRangeItemProvider(for: Set([Date.distantPast...Date.distantFuture])) { _ in
nil
}
.overlayItemProvider(for: Set([.day(containingDate: .distantPast)])) { _ 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))
}

}
Loading