diff --git a/Example/HorizonCalendarExample/HorizonCalendarExample.xcodeproj/project.pbxproj b/Example/HorizonCalendarExample/HorizonCalendarExample.xcodeproj/project.pbxproj index 70664014..7a2faebf 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 00000000..3fcaef88 --- /dev/null +++ b/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/MonthOverlayDemoViewController.swift @@ -0,0 +1,129 @@ +// 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 { + + 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") + } + + deinit { + blurAnimator?.stopAnimation(true) + } + + // 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 06d99283..6a91d4a4 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), ] diff --git a/Sources/Internal/SubviewInsertionIndexTracker.swift b/Sources/Internal/SubviewInsertionIndexTracker.swift index 62e209a2..52f56a1e 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 c508ca2f..f39c2549 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 761c775a..24de7e36 100644 --- a/Sources/Internal/VisibleItemsProvider.swift +++ b/Sources/Internal/VisibleItemsProvider.swift @@ -295,8 +295,8 @@ final class VisibleItemsProvider { // Handle overlay items handleOverlayItemsIfNeeded(bounds: bounds, context: &context) - // Handle background items - handleMonthBackgroundItemsIfNeeded(context: &context) + // Handle month background and overlay items + handleMonthBackgroundAndOverlayItemsIfNeeded(context: &context) previousHeightsForVisibleMonthHeaders = context.heightsForVisibleMonthHeaders previousCalendarItemModelCache = context.calendarItemModelCache @@ -715,7 +715,8 @@ final class VisibleItemsProvider { ) let layoutNonVisibleItemsInPartiallyVisibleMonth = content.monthsLayout.isHorizontal || - content.monthBackgroundItemProvider != nil + content.monthBackgroundItemProvider != nil || + content.monthOverlayItemProvider != nil if layoutItem.frame.intersects(extendedBounds) || @@ -1056,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] @@ -1077,11 +1082,11 @@ final class VisibleItemsProvider { 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( @@ -1129,30 +1134,58 @@ 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 ) - let itemType = VisibleItem.ItemType.monthBackground(month) - let itemModel = context.calendarItemModelCache.optionalValue( - for: itemType, - missingValueProvider: { - previousCalendarItemModelCache?[itemType] ?? - monthBackgroundItemProvider(monthLayoutContext) - } + insertMonthItemIfNeeded( + itemType: .monthBackground(month), + provider: backgroundProvider, + monthLayoutContext: monthLayoutContext, + frame: expandedMonthFrame, + context: &context ) - if let itemModel { - let visibleItem = VisibleItem( - calendarItemModel: itemModel, - itemType: itemType, - frame: expandedMonthFrame - ) - context.visibleItems.insert(visibleItem) + + insertMonthItemIfNeeded( + itemType: .monthOverlay(month), + provider: overlayProvider, + monthLayoutContext: monthLayoutContext, + frame: expandedMonthFrame, + context: &context + ) + } + } + + 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) } + ) + if let itemModel { + context.visibleItems.insert( + VisibleItem(calendarItemModel: itemModel, itemType: itemType, frame: frame) + ) } } diff --git a/Sources/Public/CalendarViewContent.swift b/Sources/Public/CalendarViewContent.swift index f0ad25cf..2baac316 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/CalendarViewRepresentable.swift b/Sources/Public/CalendarViewRepresentable.swift index 6462b4af..d287bceb 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. diff --git a/Sources/Public/MonthLayoutContext.swift b/Sources/Public/MonthLayoutContext.swift index 37f2ea0b..3b82c777 100644 --- a/Sources/Public/MonthLayoutContext.swift +++ b/Sources/Public/MonthLayoutContext.swift @@ -42,6 +42,9 @@ public struct MonthLayoutContext: Hashable { /// 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 let monthDaysAreaBounds: CGRect? + public static func ==(lhs: MonthLayoutContext, rhs: MonthLayoutContext) -> Bool { lhs.month == rhs.month && lhs.monthHeaderFrame == rhs.monthHeaderFrame && @@ -51,9 +54,10 @@ 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 + lhs.bounds == rhs.bounds && + lhs.monthDaysAreaBounds == rhs.monthDaysAreaBounds } public func hash(into hasher: inout Hasher) { @@ -68,6 +72,7 @@ public struct MonthLayoutContext: Hashable { hasher.combine(frame) } hasher.combine(bounds) + hasher.combine(monthDaysAreaBounds) } } diff --git a/Tests/SubviewsManagerTests.swift b/Tests/SubviewsManagerTests.swift index 26b87798..30a57fe5 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 } } diff --git a/Tests/VisibleItemsProviderTests.swift b/Tests/VisibleItemsProviderTests.swift index a3c8ce39..cd964cdc 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() throws { + 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( + try XCTUnwrap(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 { @@ -1905,6 +2053,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