Skip to content

Commit

Permalink
Update CalendarViewContent closures to accept nil values
Browse files Browse the repository at this point in the history
  • Loading branch information
calda committed Sep 18, 2023
1 parent 7f237b5 commit 2b69eb8
Show file tree
Hide file tree
Showing 4 changed files with 197 additions and 64 deletions.
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) {
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)
}

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))
}

}

0 comments on commit 2b69eb8

Please sign in to comment.