From 70155a10edb8e88775dc04298a6fcecea4f1153f Mon Sep 17 00:00:00 2001 From: Juan Rodriguez Date: Thu, 19 Mar 2026 11:32:30 +0100 Subject: [PATCH 01/13] [B2C-8878] feat(calendar): add month overlay item provider Add monthOverlayItemProvider API on CalendarViewContent that renders calendar item models on top of day content per month, complementing the existing monthBackgroundItemProvider which renders behind it. Introduce .monthOverlay item type through the visible-items pipeline, track its subview insertion index, and add a monthDaysAreaBounds computed property to MonthLayoutContext for convenient layout. Tests updated for new item type ordering. Made-with: Cursor --- .../SubviewInsertionIndexTracker.swift | 15 +++ Sources/Internal/VisibleItem.swift | 1 + Sources/Internal/VisibleItemsProvider.swift | 101 +++++++++++++++++- Sources/Public/CalendarViewContent.swift | 25 +++++ Sources/Public/MonthLayoutContext.swift | 7 ++ Tests/SubviewsManagerTests.swift | 11 +- 6 files changed, 155 insertions(+), 5 deletions(-) diff --git a/Sources/Internal/SubviewInsertionIndexTracker.swift b/Sources/Internal/SubviewInsertionIndexTracker.swift index 62e209a..52f56a1 100644 --- a/Sources/Internal/SubviewInsertionIndexTracker.swift +++ b/Sources/Internal/SubviewInsertionIndexTracker.swift @@ -34,6 +34,8 @@ final class SubviewInsertionIndexTracker { index = mainItemsEndIndex case .daysOfWeekRowSeparator: index = daysOfWeekRowSeparatorItemsEndIndex + case .monthOverlay: + index = monthOverlayItemsEndIndex case .overlayItem: index = overlayItemsEndIndex case .pinnedDaysOfWeekRowBackground: @@ -60,6 +62,7 @@ final class SubviewInsertionIndexTracker { private var dayRangeItemsEndIndex = 0 private var mainItemsEndIndex = 0 private var daysOfWeekRowSeparatorItemsEndIndex = 0 + private var monthOverlayItemsEndIndex = 0 private var overlayItemsEndIndex = 0 private var pinnedDaysOfWeekRowBackgroundEndIndex = 0 private var pinnedDayOfWeekItemsEndIndex = 0 @@ -72,6 +75,7 @@ final class SubviewInsertionIndexTracker { dayRangeItemsEndIndex += value mainItemsEndIndex += value daysOfWeekRowSeparatorItemsEndIndex += value + monthOverlayItemsEndIndex += value overlayItemsEndIndex += value pinnedDaysOfWeekRowBackgroundEndIndex += value pinnedDayOfWeekItemsEndIndex += value @@ -82,6 +86,7 @@ final class SubviewInsertionIndexTracker { dayRangeItemsEndIndex += value mainItemsEndIndex += value daysOfWeekRowSeparatorItemsEndIndex += value + monthOverlayItemsEndIndex += value overlayItemsEndIndex += value pinnedDaysOfWeekRowBackgroundEndIndex += value pinnedDayOfWeekItemsEndIndex += value @@ -91,6 +96,7 @@ final class SubviewInsertionIndexTracker { dayRangeItemsEndIndex += value mainItemsEndIndex += value daysOfWeekRowSeparatorItemsEndIndex += value + monthOverlayItemsEndIndex += value overlayItemsEndIndex += value pinnedDaysOfWeekRowBackgroundEndIndex += value pinnedDayOfWeekItemsEndIndex += value @@ -99,6 +105,7 @@ final class SubviewInsertionIndexTracker { case .layoutItemType: mainItemsEndIndex += value daysOfWeekRowSeparatorItemsEndIndex += value + monthOverlayItemsEndIndex += value overlayItemsEndIndex += value pinnedDaysOfWeekRowBackgroundEndIndex += value pinnedDayOfWeekItemsEndIndex += value @@ -106,6 +113,14 @@ final class SubviewInsertionIndexTracker { case .daysOfWeekRowSeparator: daysOfWeekRowSeparatorItemsEndIndex += value + monthOverlayItemsEndIndex += value + overlayItemsEndIndex += value + pinnedDaysOfWeekRowBackgroundEndIndex += value + pinnedDayOfWeekItemsEndIndex += value + pinnedDaysOfWeekRowSeparatorEndIndex += value + + case .monthOverlay: + monthOverlayItemsEndIndex += value overlayItemsEndIndex += value pinnedDaysOfWeekRowBackgroundEndIndex += value pinnedDayOfWeekItemsEndIndex += value diff --git a/Sources/Internal/VisibleItem.swift b/Sources/Internal/VisibleItem.swift index c508ca2..f39c254 100644 --- a/Sources/Internal/VisibleItem.swift +++ b/Sources/Internal/VisibleItem.swift @@ -87,6 +87,7 @@ extension VisibleItem { case pinnedDaysOfWeekRowSeparator case daysOfWeekRowSeparator(Month) case dayRange(DayRange) + case monthOverlay(Month) case overlayItem(OverlaidItemLocation) } diff --git a/Sources/Internal/VisibleItemsProvider.swift b/Sources/Internal/VisibleItemsProvider.swift index 761c775..de1c56e 100644 --- a/Sources/Internal/VisibleItemsProvider.swift +++ b/Sources/Internal/VisibleItemsProvider.swift @@ -298,6 +298,9 @@ final class VisibleItemsProvider { // Handle background items handleMonthBackgroundItemsIfNeeded(context: &context) + // Handle month overlay items + handleMonthOverlayItemsIfNeeded(context: &context) + previousHeightsForVisibleMonthHeaders = context.heightsForVisibleMonthHeaders previousCalendarItemModelCache = context.calendarItemModelCache @@ -715,7 +718,8 @@ final class VisibleItemsProvider { ) let layoutNonVisibleItemsInPartiallyVisibleMonth = content.monthsLayout.isHorizontal || - content.monthBackgroundItemProvider != nil + content.monthBackgroundItemProvider != nil || + content.monthOverlayItemProvider != nil if layoutItem.frame.intersects(extendedBounds) || @@ -1156,6 +1160,101 @@ final class VisibleItemsProvider { } } + private func handleMonthOverlayItemsIfNeeded(context: inout VisibleItemsContext) { + guard let monthOverlayItemProvider = content.monthOverlayItemProvider else { return } + + for (month, monthFrame) in context.framesForVisibleMonths { + let framesForDays: [Day: CGRect] + if let existingFrames = context.framesForDaysForVisibleMonths[month] { + framesForDays = existingFrames + } else if + let monthDayRange = content.monthDayRange(for: month), + !monthDayRange.hasVisibleDays(in: month, calendar: calendar) + { + framesForDays = [:] + } else { + continue + } + + let extraWidth: CGFloat + let extraHeight: CGFloat + if content.monthsLayout.isHorizontal { + extraWidth = content.interMonthSpacing + extraHeight = size.height - monthFrame.height + } else { + extraWidth = size.width - monthFrame.width + extraHeight = content.interMonthSpacing + } + + let expandedMonthFrame = CGRect( + x: monthFrame.minX - (extraWidth / 2), + y: monthFrame.minY - (extraHeight / 2), + width: monthFrame.width + extraWidth, + height: monthFrame.height + extraHeight + ) + let frameToBoundsTransform = CGAffineTransform( + translationX: -expandedMonthFrame.minX, + y: -expandedMonthFrame.minY + ) + + let monthHeaderHeight = monthHeaderHeight(for: month, context: &context) + + let monthHeaderFrame = frameProvider.frameOfMonthHeader( + inMonthWithOrigin: monthFrame.origin, + monthHeaderHeight: monthHeaderHeight + ) + let finalMonthHeaderFrame = monthHeaderFrame + .applying(frameToBoundsTransform) + .alignedToPixels(forScreenWithScale: scale) + + var dayOfWeekPositionsAndFrames = [(dayOfWeekPosition: DayOfWeekPosition, frame: CGRect)]() + for dayOfWeekPosition in DayOfWeekPosition.allCases { + let dayOfWeekFrame = frameProvider.frameOfDayOfWeek( + at: dayOfWeekPosition, + inMonthWithOrigin: monthFrame.origin, + monthHeaderHeight: monthHeaderHeight + ) + let finalDayOfWeekFrame = dayOfWeekFrame + .applying(frameToBoundsTransform) + .alignedToPixels(forScreenWithScale: scale) + dayOfWeekPositionsAndFrames.append((dayOfWeekPosition, finalDayOfWeekFrame)) + } + + var daysAndFrames = [(day: Day, frame: CGRect)]() + for (day, dayFrame) in framesForDays { + let finalDayFrame = dayFrame + .applying(frameToBoundsTransform) + .alignedToPixels(forScreenWithScale: scale) + daysAndFrames.append((day, finalDayFrame)) + } + + let monthLayoutContext = MonthLayoutContext( + month: month, + monthHeaderFrame: finalMonthHeaderFrame, + dayOfWeekPositionsAndFrames: dayOfWeekPositionsAndFrames, + daysAndFrames: daysAndFrames.sorted(by: { $0.day < $1.day }), + bounds: CGRect(origin: .zero, size: expandedMonthFrame.size) + ) + + let itemType = VisibleItem.ItemType.monthOverlay(month) + let itemModel = context.calendarItemModelCache.optionalValue( + for: itemType, + missingValueProvider: { + previousCalendarItemModelCache?[itemType] ?? + monthOverlayItemProvider(monthLayoutContext) + } + ) + if let itemModel { + let visibleItem = VisibleItem( + calendarItemModel: itemModel, + itemType: itemType, + frame: expandedMonthFrame + ) + context.visibleItems.insert(visibleItem) + } + } + } + /// This function takes a proposed frame for a target item toward which we're programmatically scrolling, and adjusts it so that it's a /// valid frame when the calendar is at rest / not being over-scrolled. /// diff --git a/Sources/Public/CalendarViewContent.swift b/Sources/Public/CalendarViewContent.swift index f0ad25c..2baac31 100644 --- a/Sources/Public/CalendarViewContent.swift +++ b/Sources/Public/CalendarViewContent.swift @@ -311,6 +311,30 @@ public final class CalendarViewContent { return self } + /// Configures the month overlay item provider. + /// + /// `CalendarView` invokes the provided `monthOverlayItemProvider` for each month being displayed. The + /// `CalendarItemModel` that you return for each month will be used to create a view that spans the entire frame of that month, + /// just like `monthBackgroundItemProvider`, but rendered **on top** of the day content instead of behind it. This makes + /// month overlays useful for things like semi-transparent tints or decorations that should appear above day views. + /// + /// If you don't configure your own month overlay item provider via this function, then months will not have overlay decoration. + /// + /// - Parameters: + /// - monthOverlayItemProvider: A closure (that is retained) that returns a `CalendarItemModel` representing the + /// overlay of a single month in the calendar. + /// - monthLayoutContext: The layout context for the month containing information about the frames of views in that month + /// and the bounds in which your month overlay will be displayed. + /// - Returns: A mutated `CalendarViewContent` instance with a new month overlay item provider. + public func monthOverlayItemProvider( + _ monthOverlayItemProvider: @escaping ( + _ monthLayoutContext: MonthLayoutContext + ) -> AnyCalendarItemModel? + ) -> CalendarViewContent { + self.monthOverlayItemProvider = monthOverlayItemProvider + return self + } + /// Configures the per-month day range provider. /// /// `CalendarView` invokes the provided `monthDayRangeProvider` for each month in the visible range. @@ -412,6 +436,7 @@ public final class CalendarViewContent { private(set) var dayItemProvider: (Day) -> AnyCalendarItemModel private(set) var dayBackgroundItemProvider: ((Day) -> AnyCalendarItemModel?)? private(set) var monthBackgroundItemProvider: ((MonthLayoutContext) -> AnyCalendarItemModel?)? + private(set) var monthOverlayItemProvider: ((MonthLayoutContext) -> AnyCalendarItemModel?)? private(set) var monthDayRangeProvider: ((Month) -> MonthDayRange?)? private(set) var dayRangesAndItemProvider: ( dayRanges: Set, diff --git a/Sources/Public/MonthLayoutContext.swift b/Sources/Public/MonthLayoutContext.swift index 37f2ea0..1375b6e 100644 --- a/Sources/Public/MonthLayoutContext.swift +++ b/Sources/Public/MonthLayoutContext.swift @@ -38,6 +38,13 @@ public struct MonthLayoutContext: Hashable { /// 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 bounding union rect of all day frames, or `nil` if there are no days in the month. + public var monthDaysAreaBounds: CGRect? { + daysAndFrames.dropFirst().reduce(daysAndFrames.first?.frame) { result, pair in + result?.union(pair.frame) + } + } + /// 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 diff --git a/Tests/SubviewsManagerTests.swift b/Tests/SubviewsManagerTests.swift index 26b8779..30a57fe 100644 --- a/Tests/SubviewsManagerTests.swift +++ b/Tests/SubviewsManagerTests.swift @@ -28,6 +28,7 @@ final class SubviewInsertionIndexTrackerTests: XCTestCase { .pinnedDaysOfWeekRowSeparator, .pinnedDaysOfWeekRowBackground, .overlayItem(.monthHeader(monthContainingDate: Date())), + .monthOverlay(Month(era: 1, year: 2022, month: 1, isInGregorianCalendar: true)), .daysOfWeekRowSeparator( Month(era: 1, year: 2022, month: 1, isInGregorianCalendar: true) ), @@ -78,6 +79,7 @@ final class SubviewInsertionIndexTrackerTests: XCTestCase { .monthHeader(Month(era: 1, year: 2022, month: 8, isInGregorianCalendar: true)) ), .overlayItem(.monthHeader(monthContainingDate: Date())), + .monthOverlay(Month(era: 1, year: 2022, month: 1, isInGregorianCalendar: true)), .pinnedDaysOfWeekRowBackground, .dayRange(.init(containing: Date()...Date(), in: .current)), .layoutItemType( @@ -163,10 +165,11 @@ extension VisibleItem.ItemType: Comparable { case .dayRange: return 2 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 .monthOverlay: return 5 + case .overlayItem: return 6 + case .pinnedDaysOfWeekRowBackground: return 7 + case .pinnedDayOfWeek: return 8 + case .pinnedDaysOfWeekRowSeparator: return 9 } } From 5f5ffd84974424000d4e327041ae371b73daee90 Mon Sep 17 00:00:00 2001 From: Juan Rodriguez Date: Thu, 19 Mar 2026 11:34:10 +0100 Subject: [PATCH 02/13] [B2C-8878] feat(example): add month overlay demo view controller Add MonthOverlayDemoViewController showcasing the new monthOverlayItemProvider API with a blur-tint overlay on February months, and register it in the demo picker. Made-with: Cursor --- .../project.pbxproj | 4 + .../MonthOverlayDemoViewController.swift | 125 ++++++++++++++++++ .../DemoPickerViewController.swift | 2 + 3 files changed, 131 insertions(+) create mode 100644 Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/MonthOverlayDemoViewController.swift diff --git a/Example/HorizonCalendarExample/HorizonCalendarExample.xcodeproj/project.pbxproj b/Example/HorizonCalendarExample/HorizonCalendarExample.xcodeproj/project.pbxproj index 7066401..7a2faeb 100644 --- a/Example/HorizonCalendarExample/HorizonCalendarExample.xcodeproj/project.pbxproj +++ b/Example/HorizonCalendarExample/HorizonCalendarExample.xcodeproj/project.pbxproj @@ -22,6 +22,7 @@ 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 */; }; + 9503CCD72F6BFAA400883FC3 /* MonthOverlayDemoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9503CCD62F6BFAA400883FC3 /* MonthOverlayDemoViewController.swift */; }; FD53899F299476AD007D56EB /* DayRangeSelectionTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD53899E299476AD007D56EB /* DayRangeSelectionTracker.swift */; }; FD55C5D7298B138A00A9B5D6 /* SwiftUIScreenDemoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD55C5D6298B138A00A9B5D6 /* SwiftUIScreenDemoViewController.swift */; }; FDA0FB3528F5EFD90066DEFA /* SwiftUIDayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA0FB3428F5EFD90066DEFA /* SwiftUIDayView.swift */; }; @@ -60,6 +61,7 @@ 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 = ""; }; + 9503CCD62F6BFAA400883FC3 /* MonthOverlayDemoViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MonthOverlayDemoViewController.swift; sourceTree = ""; }; FD53899E299476AD007D56EB /* DayRangeSelectionTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayRangeSelectionTracker.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 = ""; }; @@ -82,6 +84,7 @@ 9381769D249B79CB00E18FA3 /* Demo View Controllers */ = { isa = PBXGroup; children = ( + 9503CCD62F6BFAA400883FC3 /* MonthOverlayDemoViewController.swift */, 938176A6249B85CE00E18FA3 /* DemoViewController.swift */, 939E696E2484CD8E00A8BCC7 /* SingleDaySelectionDemoViewController.swift */, 938176A0249B7CE600E18FA3 /* DayRangeSelectionDemoViewController.swift */, @@ -213,6 +216,7 @@ FDA0FB3728F5EFF60066DEFA /* SwiftUIItemModelsDemoViewController.swift in Sources */, 939E696F2484CD8E00A8BCC7 /* SingleDaySelectionDemoViewController.swift in Sources */, 938176A3249B7F0800E18FA3 /* SelectedDayTooltipDemoViewController.swift in Sources */, + 9503CCD72F6BFAA400883FC3 /* MonthOverlayDemoViewController.swift in Sources */, FDA0FB3528F5EFD90066DEFA /* SwiftUIDayView.swift in Sources */, 938176A1249B7CE600E18FA3 /* DayRangeSelectionDemoViewController.swift in Sources */, FD53899F299476AD007D56EB /* DayRangeSelectionTracker.swift in Sources */, diff --git a/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/MonthOverlayDemoViewController.swift b/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/MonthOverlayDemoViewController.swift new file mode 100644 index 0000000..a2f1f0d --- /dev/null +++ b/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/MonthOverlayDemoViewController.swift @@ -0,0 +1,125 @@ +// Created by HorizonCalendar contributors. +// Copyright © 2024 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 + +// MARK: - MonthOverlayDemoViewController + +final class MonthOverlayDemoViewController: BaseDemoViewController { + + // MARK: Internal + + override func viewDidLoad() { + super.viewDidLoad() + title = "Month Days Overlay" + } + + override func makeContent() -> CalendarViewContent { + let startDate = calendar.date(from: DateComponents(year: 2020, month: 01, day: 10))! + let endDate = calendar.date(from: DateComponents(year: 2021, month: 12, day: 31))! + + return CalendarViewContent( + calendar: calendar, + visibleDateRange: startDate...endDate, + monthsLayout: monthsLayout + ) + .interMonthSpacing(24) + .verticalDayMargin(8) + .horizontalDayMargin(8) + .monthOverlayItemProvider { monthLayoutContext in + guard monthLayoutContext.month.month == 2 else { return nil } + return MonthDaysTintOverlayView.calendarItemModel( + invariantViewProperties: .init(), + content: .init(daysRect: monthLayoutContext.monthDaysAreaBounds) + ) + } + } + +} + +// MARK: - MonthDaysTintOverlayView + +final class MonthDaysTintOverlayView: UIView { + + // MARK: Lifecycle + + fileprivate init(invariantViewProperties: InvariantViewProperties) { + self.invariantViewProperties = invariantViewProperties + super.init(frame: .zero) + backgroundColor = .clear + isUserInteractionEnabled = false + + blurView.clipsToBounds = true + addSubview(blurView) + + blurAnimator = UIViewPropertyAnimator(duration: 1, curve: .linear) { [blurView] in + blurView.effect = UIBlurEffect(style: invariantViewProperties.blurStyle) + } + blurAnimator.fractionComplete = invariantViewProperties.blurIntensity + blurAnimator.pausesOnCompletion = true + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + // MARK: Internal + + override func layoutSubviews() { + super.layoutSubviews() + blurView.frame = daysRect ?? .zero + blurView.isHidden = daysRect == nil + } + + // MARK: Fileprivate + + fileprivate var daysRect: CGRect? { + didSet { + guard daysRect != oldValue else { return } + setNeedsLayout() + } + } + + // MARK: Private + + private let invariantViewProperties: InvariantViewProperties + private let blurView = UIVisualEffectView() + private var blurAnimator: UIViewPropertyAnimator! + +} + +// MARK: CalendarItemViewRepresentable + +extension MonthDaysTintOverlayView: CalendarItemViewRepresentable { + + struct InvariantViewProperties: Hashable { + var blurStyle: UIBlurEffect.Style = .extraLight + var blurIntensity: CGFloat = 0.1 + } + + struct Content: Equatable { + let daysRect: CGRect? + } + + static func makeView( + withInvariantViewProperties invariantViewProperties: InvariantViewProperties + ) -> MonthDaysTintOverlayView { + MonthDaysTintOverlayView(invariantViewProperties: invariantViewProperties) + } + + static func setContent(_ content: Content, on view: MonthDaysTintOverlayView) { + view.daysRect = content.daysRect + } + +} diff --git a/Example/HorizonCalendarExample/HorizonCalendarExample/DemoPickerViewController.swift b/Example/HorizonCalendarExample/HorizonCalendarExample/DemoPickerViewController.swift index 06d9928..6a91d4a 100644 --- a/Example/HorizonCalendarExample/HorizonCalendarExample/DemoPickerViewController.swift +++ b/Example/HorizonCalendarExample/HorizonCalendarExample/DemoPickerViewController.swift @@ -67,6 +67,7 @@ final class DemoPickerViewController: UIViewController { ("Scroll to Day With Animation", ScrollToDayWithAnimationDemoViewController.self), ("Partial Month Visibility", PartialMonthVisibilityDemoViewController.self), ("Month Grid Background", MonthBackgroundDemoViewController.self), + ("Month Days Overlay", MonthOverlayDemoViewController.self), ("SwiftUI Day and Month View", SwiftUIItemModelsDemoViewController.self), ("SwiftUI Screen", SwiftUIScreenDemoViewController.self), ] @@ -79,6 +80,7 @@ final class DemoPickerViewController: UIViewController { ("Large Day Range", LargeDayRangeDemoViewController.self), ("Scroll to Day With Animation", ScrollToDayWithAnimationDemoViewController.self), ("Month Grid Background", MonthBackgroundDemoViewController.self), + ("Month Days Overlay", MonthOverlayDemoViewController.self), ("SwiftUI Day and Month View", SwiftUIItemModelsDemoViewController.self), ("SwiftUI Screen", SwiftUIScreenDemoViewController.self), ] From 40e6aa5f7c15d97fecee70bc373791d8d4fafd97 Mon Sep 17 00:00:00 2001 From: Juan Rodriguez Date: Thu, 19 Mar 2026 12:12:38 +0100 Subject: [PATCH 03/13] [B2C-8878] feat(calendar): expose month overlay provider on CalendarViewRepresentable Add monthOverlayItemProvider property and wire it into content creation. Provide both a CalendarItemModel-based modifier and a SwiftUI ViewBuilder-based monthOverlays(_:) convenience modifier. Made-with: Cursor --- .../Public/CalendarViewRepresentable.swift | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/Sources/Public/CalendarViewRepresentable.swift b/Sources/Public/CalendarViewRepresentable.swift index 6462b4a..d287bce 100644 --- a/Sources/Public/CalendarViewRepresentable.swift +++ b/Sources/Public/CalendarViewRepresentable.swift @@ -108,6 +108,7 @@ public struct CalendarViewRepresentable: UIViewRepresentable { fileprivate var dayItemProvider: ((Day) -> AnyCalendarItemModel?)? fileprivate var dayBackgroundItemProvider: ((Day) -> AnyCalendarItemModel?)? fileprivate var monthBackgroundItemProvider: ((MonthLayoutContext) -> AnyCalendarItemModel?)? + fileprivate var monthOverlayItemProvider: ((MonthLayoutContext) -> AnyCalendarItemModel?)? fileprivate var monthDayRangeProvider: ((MonthComponents) -> CalendarViewContent.MonthDayRange?)? fileprivate var dateRangesAndItemProvider: ( dayRanges: Set>, @@ -185,6 +186,10 @@ public struct CalendarViewRepresentable: UIViewRepresentable { content = content.monthBackgroundItemProvider(monthBackgroundItemProvider) } + if let monthOverlayItemProvider { + content = content.monthOverlayItemProvider(monthOverlayItemProvider) + } + if let monthDayRangeProvider { content = content.monthDayRangeProvider(monthDayRangeProvider) } @@ -555,6 +560,64 @@ extension CalendarViewRepresentable { } } + /// Configures the month overlay item provider. Consider using the `monthOverlays(_:)` modifier instead if your + /// custom month overlay views are SwiftUI views. + /// + /// `CalendarView` invokes the provided `monthOverlayItemProvider` for each month being displayed. The + /// `CalendarItemModel` that you return for each month will be used to create a view that spans the entire frame of that month, + /// just like `monthBackgroundItemProvider`, but rendered **on top** of the day content instead of behind it. This makes + /// month overlays useful for things like semi-transparent tints or decorations that should appear above day views. + /// + /// If you don't configure your own month overlay item provider via this function, then months will not have any overlay decoration. + /// + /// - Note: Overlay views are rendered above day views and will intercept touches unless `isUserInteractionEnabled` is set to + /// `false` or `.allowsHitTesting(false)` is applied. + /// + /// - Parameters: + /// - monthOverlayItemProvider: A closure (that is retained) that returns a `CalendarItemModel` representing the + /// overlay of a single month in the calendar. + /// - monthLayoutContext: The layout context for the month containing information about the frames of views in that month + /// and the bounds in which your month overlay will be displayed. + /// - Returns: A new `CalendarViewRepresentable` with a new month overlay item provider. + public func monthOverlayItemProvider( + _ monthOverlayItemProvider: @escaping ( + _ monthLayoutContext: MonthLayoutContext + ) -> AnyCalendarItemModel? + ) -> Self { + var view = self + view.monthOverlayItemProvider = monthOverlayItemProvider + return view + } + + /// Configures month overlay views using a SwiftUI view builder. + /// + /// The `content` view builder closure is invoked for each month that's displayed. Each view will span the entire frame of that + /// month, rendered **on top** of the day content instead of behind it. This makes month overlays useful for things like + /// semi-transparent tints or decorations that should appear above day views. + /// + /// If you don't configure your own month overlay views via this modifier, then months will not have any overlay decoration. If + /// a particular month doesn't need an overlay view, return `EmptyView` for that month. + /// + /// - Note: Overlay views are rendered above day views and will intercept touches unless `.allowsHitTesting(false)` is applied. + /// + /// - Parameters: + /// - content: A view builder that creates a view for the overlay of a single month in the calendar. + /// - monthLayoutContext: The layout context for the month containing information about the frames of views in that month + /// and the bounds in which your month overlay will be displayed. + /// - Returns: A new `CalendarViewRepresentable` with month overlay views configured. + public func monthOverlays( + @ViewBuilder _ content: @escaping (_ monthLayoutContext: MonthLayoutContext) -> some View + ) -> Self { + monthOverlayItemProvider { monthLayoutContext in + let view = content(monthLayoutContext) + if view is EmptyView { + return nil + } else { + return view.calendarItemModel + } + } + } + /// Configures the per-month day range provider. /// /// `CalendarView` invokes the provided `monthDayRangeProvider` for each month in the visible range. From 2b87b27d4181d494a46e4f30dfae6ab112468f26 Mon Sep 17 00:00:00 2001 From: Juan Rodriguez Date: Thu, 19 Mar 2026 12:13:33 +0100 Subject: [PATCH 04/13] [B2C-8878] fix(calendar): correct frame equality check in MonthLayoutContext The daysAndFrames comparison was comparing $0.frame to itself instead of $1.frame, causing two instances with different frames to be considered equal. Made-with: Cursor --- Sources/Public/MonthLayoutContext.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Public/MonthLayoutContext.swift b/Sources/Public/MonthLayoutContext.swift index 1375b6e..95faec9 100644 --- a/Sources/Public/MonthLayoutContext.swift +++ b/Sources/Public/MonthLayoutContext.swift @@ -58,7 +58,7 @@ public struct MonthLayoutContext: Hashable { ) && lhs.daysAndFrames.elementsEqual( rhs.daysAndFrames, - by: { $0.day == $1.day && $0.frame == $0.frame } + by: { $0.day == $1.day && $0.frame == $1.frame } ) && lhs.bounds == rhs.bounds } From 8cb5cea838236772d78c95a838cb2ac59a6d1fa3 Mon Sep 17 00:00:00 2001 From: Juan Rodriguez Date: Thu, 19 Mar 2026 12:14:11 +0100 Subject: [PATCH 05/13] [B2C-8878] refactor(calendar): deduplicate month background and overlay item handling Merge handleMonthBackgroundItemsIfNeeded and handleMonthOverlayItemsIfNeeded into a single iteration that builds the MonthLayoutContext once per month. Extract insertMonthItemIfNeeded helper to share the caching and insertion logic between background and overlay providers. Made-with: Cursor --- Sources/Internal/VisibleItemsProvider.swift | 159 +++++--------------- Tests/VisibleItemsProviderTests.swift | 2 + 2 files changed, 42 insertions(+), 119 deletions(-) diff --git a/Sources/Internal/VisibleItemsProvider.swift b/Sources/Internal/VisibleItemsProvider.swift index de1c56e..a1e9368 100644 --- a/Sources/Internal/VisibleItemsProvider.swift +++ b/Sources/Internal/VisibleItemsProvider.swift @@ -295,11 +295,8 @@ final class VisibleItemsProvider { // Handle overlay items handleOverlayItemsIfNeeded(bounds: bounds, context: &context) - // Handle background items - handleMonthBackgroundItemsIfNeeded(context: &context) - - // Handle month overlay items - handleMonthOverlayItemsIfNeeded(context: &context) + // Handle month background and overlay items + handleMonthBackgroundAndOverlayItemsIfNeeded(context: &context) previousHeightsForVisibleMonthHeaders = context.heightsForVisibleMonthHeaders previousCalendarItemModelCache = context.calendarItemModelCache @@ -1060,8 +1057,12 @@ final class VisibleItemsProvider { } } - private func handleMonthBackgroundItemsIfNeeded(context: inout VisibleItemsContext) { - guard let monthBackgroundItemProvider = content.monthBackgroundItemProvider else { return } + private func handleMonthBackgroundAndOverlayItemsIfNeeded( + context: inout VisibleItemsContext + ) { + let backgroundProvider = content.monthBackgroundItemProvider + let overlayProvider = content.monthOverlayItemProvider + guard backgroundProvider != nil || overlayProvider != nil else { return } for (month, monthFrame) in context.framesForVisibleMonths { let framesForDays: [Day: CGRect] @@ -1076,16 +1077,14 @@ final class VisibleItemsProvider { continue } - // We need to expand the frame of the month so that we have enough room at the edges to draw - // the background without getting clipped. let extraWidth: CGFloat let extraHeight: CGFloat if content.monthsLayout.isHorizontal { - extraWidth = content.interMonthSpacing // half before leading edge, half after trailing edge + extraWidth = content.interMonthSpacing extraHeight = size.height - monthFrame.height } else { extraWidth = size.width - monthFrame.width - extraHeight = content.interMonthSpacing // half before top edge, half after bottom edge + extraHeight = content.interMonthSpacing } let expandedMonthFrame = CGRect( @@ -1101,7 +1100,6 @@ final class VisibleItemsProvider { let monthHeaderHeight = monthHeaderHeight(for: month, context: &context) - // Get the month header frame let monthHeaderFrame = frameProvider.frameOfMonthHeader( inMonthWithOrigin: monthFrame.origin, monthHeaderHeight: monthHeaderHeight @@ -1110,7 +1108,6 @@ final class VisibleItemsProvider { .applying(frameToBoundsTransform) .alignedToPixels(forScreenWithScale: scale) - // Get the days-of-the-week item frames var dayOfWeekPositionsAndFrames = [(dayOfWeekPosition: DayOfWeekPosition, frame: CGRect)]() for dayOfWeekPosition in DayOfWeekPosition.allCases { let dayOfWeekFrame = frameProvider.frameOfDayOfWeek( @@ -1124,7 +1121,6 @@ final class VisibleItemsProvider { dayOfWeekPositionsAndFrames.append((dayOfWeekPosition, finalDayOfWeekFrame)) } - // Get all frames for days in the month var daysAndFrames = [(day: Day, frame: CGRect)]() for (day, dayFrame) in framesForDays { let finalDayFrame = dayFrame @@ -1141,117 +1137,42 @@ final class VisibleItemsProvider { bounds: CGRect(origin: .zero, size: expandedMonthFrame.size) ) - let itemType = VisibleItem.ItemType.monthBackground(month) - let itemModel = context.calendarItemModelCache.optionalValue( - for: itemType, - missingValueProvider: { - previousCalendarItemModelCache?[itemType] ?? - monthBackgroundItemProvider(monthLayoutContext) - } - ) - if let itemModel { - let visibleItem = VisibleItem( - calendarItemModel: itemModel, - itemType: itemType, - frame: expandedMonthFrame - ) - context.visibleItems.insert(visibleItem) - } - } - } - - private func handleMonthOverlayItemsIfNeeded(context: inout VisibleItemsContext) { - guard let monthOverlayItemProvider = content.monthOverlayItemProvider else { return } - - for (month, monthFrame) in context.framesForVisibleMonths { - let framesForDays: [Day: CGRect] - if let existingFrames = context.framesForDaysForVisibleMonths[month] { - framesForDays = existingFrames - } else if - let monthDayRange = content.monthDayRange(for: month), - !monthDayRange.hasVisibleDays(in: month, calendar: calendar) - { - framesForDays = [:] - } else { - continue - } - - let extraWidth: CGFloat - let extraHeight: CGFloat - if content.monthsLayout.isHorizontal { - extraWidth = content.interMonthSpacing - extraHeight = size.height - monthFrame.height - } else { - extraWidth = size.width - monthFrame.width - extraHeight = content.interMonthSpacing - } - - let expandedMonthFrame = CGRect( - x: monthFrame.minX - (extraWidth / 2), - y: monthFrame.minY - (extraHeight / 2), - width: monthFrame.width + extraWidth, - height: monthFrame.height + extraHeight - ) - let frameToBoundsTransform = CGAffineTransform( - translationX: -expandedMonthFrame.minX, - y: -expandedMonthFrame.minY + insertMonthItemIfNeeded( + itemType: .monthBackground(month), + provider: backgroundProvider, + monthLayoutContext: monthLayoutContext, + frame: expandedMonthFrame, + context: &context ) - let monthHeaderHeight = monthHeaderHeight(for: month, context: &context) - - let monthHeaderFrame = frameProvider.frameOfMonthHeader( - inMonthWithOrigin: monthFrame.origin, - monthHeaderHeight: monthHeaderHeight + insertMonthItemIfNeeded( + itemType: .monthOverlay(month), + provider: overlayProvider, + monthLayoutContext: monthLayoutContext, + frame: expandedMonthFrame, + context: &context ) - let finalMonthHeaderFrame = monthHeaderFrame - .applying(frameToBoundsTransform) - .alignedToPixels(forScreenWithScale: scale) - - var dayOfWeekPositionsAndFrames = [(dayOfWeekPosition: DayOfWeekPosition, frame: CGRect)]() - for dayOfWeekPosition in DayOfWeekPosition.allCases { - let dayOfWeekFrame = frameProvider.frameOfDayOfWeek( - at: dayOfWeekPosition, - inMonthWithOrigin: monthFrame.origin, - monthHeaderHeight: monthHeaderHeight - ) - let finalDayOfWeekFrame = dayOfWeekFrame - .applying(frameToBoundsTransform) - .alignedToPixels(forScreenWithScale: scale) - dayOfWeekPositionsAndFrames.append((dayOfWeekPosition, finalDayOfWeekFrame)) - } + } + } - var daysAndFrames = [(day: Day, frame: CGRect)]() - for (day, dayFrame) in framesForDays { - let finalDayFrame = dayFrame - .applying(frameToBoundsTransform) - .alignedToPixels(forScreenWithScale: scale) - daysAndFrames.append((day, finalDayFrame)) + private func insertMonthItemIfNeeded( + itemType: VisibleItem.ItemType, + provider: ((MonthLayoutContext) -> AnyCalendarItemModel?)?, + monthLayoutContext: MonthLayoutContext, + frame: CGRect, + context: inout VisibleItemsContext + ) { + guard let provider else { return } + let itemModel = context.calendarItemModelCache.optionalValue( + for: itemType, + missingValueProvider: { + previousCalendarItemModelCache?[itemType] ?? provider(monthLayoutContext) } - - let monthLayoutContext = MonthLayoutContext( - month: month, - monthHeaderFrame: finalMonthHeaderFrame, - dayOfWeekPositionsAndFrames: dayOfWeekPositionsAndFrames, - daysAndFrames: daysAndFrames.sorted(by: { $0.day < $1.day }), - bounds: CGRect(origin: .zero, size: expandedMonthFrame.size) - ) - - let itemType = VisibleItem.ItemType.monthOverlay(month) - let itemModel = context.calendarItemModelCache.optionalValue( - for: itemType, - missingValueProvider: { - previousCalendarItemModelCache?[itemType] ?? - monthOverlayItemProvider(monthLayoutContext) - } + ) + if let itemModel { + context.visibleItems.insert( + VisibleItem(calendarItemModel: itemModel, itemType: itemType, frame: frame) ) - if let itemModel { - let visibleItem = VisibleItem( - calendarItemModel: itemModel, - itemType: itemType, - frame: expandedMonthFrame - ) - context.visibleItems.insert(visibleItem) - } } } diff --git a/Tests/VisibleItemsProviderTests.swift b/Tests/VisibleItemsProviderTests.swift index a3c8ce3..24ca7c5 100644 --- a/Tests/VisibleItemsProviderTests.swift +++ b/Tests/VisibleItemsProviderTests.swift @@ -1905,6 +1905,8 @@ extension VisibleItem: CustomStringConvertible { itemTypeText = ".dayBackground(\(day))" case .monthBackground(let month): itemTypeText = ".monthBackground(\(month.description))" + case .monthOverlay(let month): + itemTypeText = ".monthOverlay(\(month.description))" case .overlayItem(let overlaidItemLocation): let calendar = Calendar(identifier: .gregorian) let itemLocationText: String From 0ddfee2030c160ce3f99de0ced1f631bf15a41b9 Mon Sep 17 00:00:00 2001 From: Juan Rodriguez Date: Thu, 19 Mar 2026 12:14:40 +0100 Subject: [PATCH 06/13] [B2C-8878] fix(example): stop blur animator on deallocation in overlay demo Add deinit to MonthDaysTintOverlayView to explicitly stop the UIViewPropertyAnimator, avoiding a crash when the view is deallocated while the animator is still active. Made-with: Cursor --- .../MonthOverlayDemoViewController.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/MonthOverlayDemoViewController.swift b/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/MonthOverlayDemoViewController.swift index a2f1f0d..b8bf2de 100644 --- a/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/MonthOverlayDemoViewController.swift +++ b/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/MonthOverlayDemoViewController.swift @@ -81,6 +81,10 @@ final class MonthDaysTintOverlayView: UIView { blurView.frame = daysRect ?? .zero blurView.isHidden = daysRect == nil } + + deinit { + blurAnimator.stopAnimation(true) + } // MARK: Fileprivate From a234168b1e7e192abb2813db46e1a03b98372b7e Mon Sep 17 00:00:00 2001 From: Juan Rodriguez Date: Thu, 19 Mar 2026 12:22:22 +0100 Subject: [PATCH 07/13] [B2C-8878] style(calendar): clean up overlay demo formatting and property ordering Fix indentation in monthOverlayItemProvider closure and deinit block, reorder MonthLayoutContext to place stored properties before computed ones, and minor style tweaks from PR review. Made-with: Cursor --- .../MonthOverlayDemoViewController.swift | 26 +++++++++---------- Sources/Public/MonthLayoutContext.swift | 8 +++--- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/MonthOverlayDemoViewController.swift b/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/MonthOverlayDemoViewController.swift index b8bf2de..a450e7b 100644 --- a/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/MonthOverlayDemoViewController.swift +++ b/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/MonthOverlayDemoViewController.swift @@ -20,8 +20,6 @@ import UIKit final class MonthOverlayDemoViewController: BaseDemoViewController { - // MARK: Internal - override func viewDidLoad() { super.viewDidLoad() title = "Month Days Overlay" @@ -40,11 +38,11 @@ final class MonthOverlayDemoViewController: BaseDemoViewController { .verticalDayMargin(8) .horizontalDayMargin(8) .monthOverlayItemProvider { monthLayoutContext in - guard monthLayoutContext.month.month == 2 else { return nil } - return MonthDaysTintOverlayView.calendarItemModel( - invariantViewProperties: .init(), - content: .init(daysRect: monthLayoutContext.monthDaysAreaBounds) - ) + guard monthLayoutContext.month.month == 2 else { return nil } + return MonthDaysTintOverlayView.calendarItemModel( + invariantViewProperties: .init(), + content: .init(daysRect: monthLayoutContext.monthDaysAreaBounds) + ) } } @@ -72,7 +70,13 @@ final class MonthDaysTintOverlayView: UIView { blurAnimator.pausesOnCompletion = true } - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + blurAnimator.stopAnimation(true) + } // MARK: Internal @@ -81,10 +85,6 @@ final class MonthDaysTintOverlayView: UIView { blurView.frame = daysRect ?? .zero blurView.isHidden = daysRect == nil } - - deinit { - blurAnimator.stopAnimation(true) - } // MARK: Fileprivate @@ -108,7 +108,7 @@ final class MonthDaysTintOverlayView: UIView { extension MonthDaysTintOverlayView: CalendarItemViewRepresentable { struct InvariantViewProperties: Hashable { - var blurStyle: UIBlurEffect.Style = .extraLight + var blurStyle = UIBlurEffect.Style.extraLight var blurIntensity: CGFloat = 0.1 } diff --git a/Sources/Public/MonthLayoutContext.swift b/Sources/Public/MonthLayoutContext.swift index 95faec9..60e2550 100644 --- a/Sources/Public/MonthLayoutContext.swift +++ b/Sources/Public/MonthLayoutContext.swift @@ -38,6 +38,10 @@ public struct MonthLayoutContext: Hashable { /// 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 bounding union rect of all day frames, or `nil` if there are no days in the month. public var monthDaysAreaBounds: CGRect? { daysAndFrames.dropFirst().reduce(daysAndFrames.first?.frame) { result, pair in @@ -45,10 +49,6 @@ public struct MonthLayoutContext: Hashable { } } - /// 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 && From 038f38e5b678eb65f7068a164ee2cc15d78c4ccc Mon Sep 17 00:00:00 2001 From: Juan Rodriguez Date: Thu, 19 Mar 2026 12:32:09 +0100 Subject: [PATCH 08/13] [B2C-8878] perf(calendar): pre-compute monthDaysAreaBounds at construction time Convert monthDaysAreaBounds from a computed property to a stored let, avoiding repeated O(n) iteration when consumers access it multiple times per layout pass (e.g. in both background and overlay providers). Made-with: Cursor --- Sources/Internal/VisibleItemsProvider.swift | 12 ++++++++++-- Sources/Public/MonthLayoutContext.swift | 6 +----- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/Sources/Internal/VisibleItemsProvider.swift b/Sources/Internal/VisibleItemsProvider.swift index a1e9368..2be9e38 100644 --- a/Sources/Internal/VisibleItemsProvider.swift +++ b/Sources/Internal/VisibleItemsProvider.swift @@ -1129,12 +1129,20 @@ final class VisibleItemsProvider { daysAndFrames.append((day, finalDayFrame)) } + let sortedDaysAndFrames = daysAndFrames.sorted(by: { $0.day < $1.day }) + let monthDaysAreaBounds = sortedDaysAndFrames.dropFirst().reduce( + sortedDaysAndFrames.first?.frame + ) { result, pair in + result?.union(pair.frame) + } + let monthLayoutContext = MonthLayoutContext( month: month, monthHeaderFrame: finalMonthHeaderFrame, dayOfWeekPositionsAndFrames: dayOfWeekPositionsAndFrames, - daysAndFrames: daysAndFrames.sorted(by: { $0.day < $1.day }), - bounds: CGRect(origin: .zero, size: expandedMonthFrame.size) + daysAndFrames: sortedDaysAndFrames, + bounds: CGRect(origin: .zero, size: expandedMonthFrame.size), + monthDaysAreaBounds: monthDaysAreaBounds ) insertMonthItemIfNeeded( diff --git a/Sources/Public/MonthLayoutContext.swift b/Sources/Public/MonthLayoutContext.swift index 60e2550..818e507 100644 --- a/Sources/Public/MonthLayoutContext.swift +++ b/Sources/Public/MonthLayoutContext.swift @@ -43,11 +43,7 @@ public struct MonthLayoutContext: Hashable { public let bounds: CGRect /// The bounding union rect of all day frames, or `nil` if there are no days in the month. - public var monthDaysAreaBounds: CGRect? { - daysAndFrames.dropFirst().reduce(daysAndFrames.first?.frame) { result, pair in - result?.union(pair.frame) - } - } + public let monthDaysAreaBounds: CGRect? public static func ==(lhs: MonthLayoutContext, rhs: MonthLayoutContext) -> Bool { lhs.month == rhs.month && From fe2b56694c8ca804d2735a07d7702a547446bafc Mon Sep 17 00:00:00 2001 From: Juan Rodriguez Date: Thu, 19 Mar 2026 12:48:39 +0100 Subject: [PATCH 09/13] [B2C-8878] fix(example): replace force-unwrapped blur animator with safe optional Made-with: Cursor --- .../MonthOverlayDemoViewController.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/MonthOverlayDemoViewController.swift b/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/MonthOverlayDemoViewController.swift index a450e7b..3fcaef8 100644 --- a/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/MonthOverlayDemoViewController.swift +++ b/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/MonthOverlayDemoViewController.swift @@ -66,8 +66,8 @@ final class MonthDaysTintOverlayView: UIView { blurAnimator = UIViewPropertyAnimator(duration: 1, curve: .linear) { [blurView] in blurView.effect = UIBlurEffect(style: invariantViewProperties.blurStyle) } - blurAnimator.fractionComplete = invariantViewProperties.blurIntensity - blurAnimator.pausesOnCompletion = true + blurAnimator?.fractionComplete = invariantViewProperties.blurIntensity + blurAnimator?.pausesOnCompletion = true } required init?(coder _: NSCoder) { @@ -75,7 +75,7 @@ final class MonthDaysTintOverlayView: UIView { } deinit { - blurAnimator.stopAnimation(true) + blurAnimator?.stopAnimation(true) } // MARK: Internal @@ -99,7 +99,7 @@ final class MonthDaysTintOverlayView: UIView { private let invariantViewProperties: InvariantViewProperties private let blurView = UIVisualEffectView() - private var blurAnimator: UIViewPropertyAnimator! + private var blurAnimator: UIViewPropertyAnimator? } From 6007b14be98cb3354139e4d7632bc78763eda22d Mon Sep 17 00:00:00 2001 From: Juan Rodriguez Date: Fri, 20 Mar 2026 08:16:53 +0100 Subject: [PATCH 10/13] [B2C-8878] fix(calendar): include monthDaysAreaBounds in Equatable and Hashable conformance The custom `==` and `hash(into:)` implementations on `MonthLayoutContext` were missing the `monthDaysAreaBounds` property, which could cause two contexts with different day-area bounds to be treated as equal. Made-with: Cursor --- Sources/Public/MonthLayoutContext.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/Public/MonthLayoutContext.swift b/Sources/Public/MonthLayoutContext.swift index 818e507..3b82c77 100644 --- a/Sources/Public/MonthLayoutContext.swift +++ b/Sources/Public/MonthLayoutContext.swift @@ -56,7 +56,8 @@ public struct MonthLayoutContext: Hashable { rhs.daysAndFrames, by: { $0.day == $1.day && $0.frame == $1.frame } ) && - lhs.bounds == rhs.bounds + lhs.bounds == rhs.bounds && + lhs.monthDaysAreaBounds == rhs.monthDaysAreaBounds } public func hash(into hasher: inout Hasher) { @@ -71,6 +72,7 @@ public struct MonthLayoutContext: Hashable { hasher.combine(frame) } hasher.combine(bounds) + hasher.combine(monthDaysAreaBounds) } } From 412dd2780b27c3f4d965f626cdd853e0c23fbb05 Mon Sep 17 00:00:00 2001 From: Juan Rodriguez Date: Fri, 20 Mar 2026 08:21:32 +0100 Subject: [PATCH 11/13] [B2C-8878] fix(calendar): restore comments removed during refactor in VisibleItemsProvider Made-with: Cursor --- Sources/Internal/VisibleItemsProvider.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Sources/Internal/VisibleItemsProvider.swift b/Sources/Internal/VisibleItemsProvider.swift index 2be9e38..24de7e3 100644 --- a/Sources/Internal/VisibleItemsProvider.swift +++ b/Sources/Internal/VisibleItemsProvider.swift @@ -1077,6 +1077,8 @@ final class VisibleItemsProvider { continue } + // We need to expand the frame of the month so that we have enough room at the edges to draw + // the background without getting clipped. let extraWidth: CGFloat let extraHeight: CGFloat if content.monthsLayout.isHorizontal { @@ -1100,6 +1102,7 @@ final class VisibleItemsProvider { let monthHeaderHeight = monthHeaderHeight(for: month, context: &context) + // Get the month header frame let monthHeaderFrame = frameProvider.frameOfMonthHeader( inMonthWithOrigin: monthFrame.origin, monthHeaderHeight: monthHeaderHeight @@ -1108,6 +1111,7 @@ final class VisibleItemsProvider { .applying(frameToBoundsTransform) .alignedToPixels(forScreenWithScale: scale) + // Get the days-of-the-week item frames var dayOfWeekPositionsAndFrames = [(dayOfWeekPosition: DayOfWeekPosition, frame: CGRect)]() for dayOfWeekPosition in DayOfWeekPosition.allCases { let dayOfWeekFrame = frameProvider.frameOfDayOfWeek( @@ -1121,6 +1125,7 @@ final class VisibleItemsProvider { dayOfWeekPositionsAndFrames.append((dayOfWeekPosition, finalDayOfWeekFrame)) } + // Get all frames for days in the month var daysAndFrames = [(day: Day, frame: CGRect)]() for (day, dayFrame) in framesForDays { let finalDayFrame = dayFrame From b739798ecbecffa2e7c59a5ab56ba134c0519bc8 Mon Sep 17 00:00:00 2001 From: Juan Rodriguez Date: Fri, 20 Mar 2026 08:37:48 +0100 Subject: [PATCH 12/13] [B2C-8878] test(calendar): add month overlay test coverage for VisibleItemsProvider Cover three behaviors requested in PR review: - overlay items are produced when monthOverlayItemProvider returns a model - overlay items are excluded when monthOverlayItemProvider returns nil - monthDaysAreaBounds equals the union of all day frames for normal months and is nil for .noDays months Made-with: Cursor --- Tests/VisibleItemsProviderTests.swift | 148 ++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) diff --git a/Tests/VisibleItemsProviderTests.swift b/Tests/VisibleItemsProviderTests.swift index 24ca7c5..ad7b4b8 100644 --- a/Tests/VisibleItemsProviderTests.swift +++ b/Tests/VisibleItemsProviderTests.swift @@ -1675,6 +1675,122 @@ final class VisibleItemsProviderTests: XCTestCase { XCTAssert(hasJulyDayOfWeek, "July dayOfWeek items should be visible for .partialRange month") } + func testVerticalVisibleItemsContainMonthOverlay() { + let details = verticalOverlayVisibleItemsProvider.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 visibleDescriptions = Set(details.visibleItems.map { $0.description }) + + let overlayItems = visibleDescriptions.filter { $0.contains(".monthOverlay(") } + XCTAssertFalse(overlayItems.isEmpty, "Month overlay items should be present when monthOverlayItemProvider returns a model") + + let hasBackgrounds = visibleDescriptions.contains { $0.contains(".monthBackground(") } + XCTAssert(hasBackgrounds, "Month background items should also still be present") + } + + func testVerticalVisibleItemsExcludeMonthOverlayWhenProviderReturnsNil() { + let details = verticalNilOverlayVisibleItemsProvider.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 visibleDescriptions = Set(details.visibleItems.map { $0.description }) + + let overlayItems = visibleDescriptions.filter { $0.contains(".monthOverlay(") } + XCTAssert(overlayItems.isEmpty, "No month overlay items should be present when monthOverlayItemProvider returns nil") + + let hasBackgrounds = visibleDescriptions.contains { $0.contains(".monthBackground(") } + XCTAssert(hasBackgrounds, "Month background items should still be present even though overlay returns nil") + } + + func testMonthDaysAreaBoundsComputation() { + var capturedContexts = [Month: MonthLayoutContext]() + + let june2020 = Month(era: 1, year: 2020, month: 06, isInGregorianCalendar: true) + + let provider = VisibleItemsProvider( + calendar: Self.calendar, + content: Self.makeContent( + fromBaseContent: CalendarViewContent( + calendar: Self.calendar, + visibleDateRange: Self.dateRange, + monthsLayout: .vertical(options: VerticalMonthsLayoutOptions()) + ) + ) + .monthDayRangeProvider { month in + if month == june2020 { + return .noDays + } + return nil + } + .monthOverlayItemProvider { context in + capturedContexts[context.month] = context + return Self.mockCalendarItemModel() + }, + size: Self.size, + layoutMargins: .zero, + scale: 2, + backgroundColor: nil + ) + + // Scroll to June to capture a .noDays month alongside normal months + _ = provider.detailsForVisibleItems( + surroundingPreviouslyVisibleLayoutItem: LayoutItem( + itemType: .monthHeader(june2020), + frame: CGRect(x: 0, y: 200, width: 320, height: 50) + ), + offset: CGPoint(x: 0, y: 150), + extendLayoutRegion: false + ) + + // Verify .noDays month has nil monthDaysAreaBounds + if let juneContext = capturedContexts[june2020] { + XCTAssertNil( + juneContext.monthDaysAreaBounds, + "monthDaysAreaBounds should be nil for a .noDays month" + ) + XCTAssert( + juneContext.daysAndFrames.isEmpty, + "daysAndFrames should be empty for a .noDays month" + ) + } else { + XCTFail("June 2020 overlay context was not captured") + } + + // Verify a normal month has non-nil monthDaysAreaBounds equal to the union of all day frames + let normalMonthContexts = capturedContexts.filter { $0.key != june2020 } + XCTAssertFalse(normalMonthContexts.isEmpty, "At least one normal month context should be captured") + + for (month, context) in normalMonthContexts { + XCTAssertFalse( + context.daysAndFrames.isEmpty, + "Normal month \(month) should have day frames" + ) + + let expectedBounds = context.daysAndFrames.dropFirst().reduce( + context.daysAndFrames.first!.frame + ) { result, pair in + result.union(pair.frame) + } + + XCTAssertEqual( + context.monthDaysAreaBounds, + expectedBounds, + "monthDaysAreaBounds for \(month) should equal the union of all day frames" + ) + } + } + // MARK: Private private static let calendar = Calendar(identifier: .gregorian) @@ -1810,6 +1926,38 @@ final class VisibleItemsProviderTests: XCTestCase { ) }() + private var verticalOverlayVisibleItemsProvider = VisibleItemsProvider( + calendar: calendar, + content: makeContent( + fromBaseContent: CalendarViewContent( + calendar: calendar, + visibleDateRange: dateRange, + monthsLayout: .vertical(options: VerticalMonthsLayoutOptions()) + ) + ) + .monthOverlayItemProvider { _ in mockCalendarItemModel() }, + size: size, + layoutMargins: .zero, + scale: 2, + backgroundColor: nil + ) + + private var verticalNilOverlayVisibleItemsProvider = VisibleItemsProvider( + calendar: calendar, + content: makeContent( + fromBaseContent: CalendarViewContent( + calendar: calendar, + visibleDateRange: dateRange, + monthsLayout: .vertical(options: VerticalMonthsLayoutOptions()) + ) + ) + .monthOverlayItemProvider { _ in nil }, + size: size, + layoutMargins: .zero, + scale: 2, + backgroundColor: nil + ) + private static func mockCalendarItemModel(height: CGFloat = 50) -> AnyCalendarItemModel { final class MockView: UIView, CalendarItemViewRepresentable { From 724189aaf5a44ae9a9f262d5026edbf3410c75b3 Mon Sep 17 00:00:00 2001 From: Juan Rodriguez Date: Fri, 20 Mar 2026 08:55:20 +0100 Subject: [PATCH 13/13] [B2C-8878] fix(calendar): replace force-unwrap with XCTUnwrap in monthDaysAreaBounds test Made-with: Cursor --- Tests/VisibleItemsProviderTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/VisibleItemsProviderTests.swift b/Tests/VisibleItemsProviderTests.swift index ad7b4b8..cd964cd 100644 --- a/Tests/VisibleItemsProviderTests.swift +++ b/Tests/VisibleItemsProviderTests.swift @@ -1713,7 +1713,7 @@ final class VisibleItemsProviderTests: XCTestCase { XCTAssert(hasBackgrounds, "Month background items should still be present even though overlay returns nil") } - func testMonthDaysAreaBoundsComputation() { + func testMonthDaysAreaBoundsComputation() throws { var capturedContexts = [Month: MonthLayoutContext]() let june2020 = Month(era: 1, year: 2020, month: 06, isInGregorianCalendar: true) @@ -1778,7 +1778,7 @@ final class VisibleItemsProviderTests: XCTestCase { ) let expectedBounds = context.daysAndFrames.dropFirst().reduce( - context.daysAndFrames.first!.frame + try XCTUnwrap(context.daysAndFrames.first?.frame) ) { result, pair in result.union(pair.frame) }