From 1a52e56e52b70bd454140ba5a32abde388e04e28 Mon Sep 17 00:00:00 2001 From: Bryan Keller Date: Fri, 8 Sep 2023 11:12:18 -0700 Subject: [PATCH 1/3] Fix SwiftUI item view onAppear not getting called --- CHANGELOG.md | 1 + .../SwiftUIItemModelsDemoViewController.swift | 4 +- .../SwiftUIScreenDemoViewController.swift | 6 +- README.md | 2 +- Sources/Public/CalendarItemModel.swift | 13 ++- .../Public/ItemViews/SwiftUIWrapperView.swift | 88 ++++++++++++------- 6 files changed, 70 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73457245..aad23d62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed Storyboard support by removing the `fatalError` in `init?(coder: NSCoder)` - Fixed an issue that could cause the calendar to layout unnecessarily due to a trait collection change notification - Fixed an issue that could cause off-screen items to appear or disappear instantly, rather than animating in or out during animated content changes +- Fixed an issue (via a breaking API change) that caused a SwiftUI view being used as a calendar item to not receive calls to `onAppear`. Use `calendarItemModel(id:)` to create calendar item models from SwiftUI views. The previous `calendarItemModel` get-only property has been removed. ### Changed - Removed all deprecated code, simplifying the public API in preparation for a 2.0 release diff --git a/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/SwiftUIItemModelsDemoViewController.swift b/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/SwiftUIItemModelsDemoViewController.swift index 2d561077..97456217 100644 --- a/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/SwiftUIItemModelsDemoViewController.swift +++ b/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/SwiftUIItemModelsDemoViewController.swift @@ -59,13 +59,13 @@ final class SwiftUIItemModelsDemoViewController: BaseDemoViewController { Spacer() } .padding(.vertical) - .calendarItemModel + .calendarItemModel(id: month) } .dayItemProvider { [calendar, selectedDate] day in let date = calendar.date(from: day.components) let isSelected = date == selectedDate - return SwiftUIDayView(dayNumber: day.day, isSelected: isSelected).calendarItemModel + return SwiftUIDayView(dayNumber: day.day, isSelected: isSelected).calendarItemModel(id: day) } } diff --git a/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/SwiftUIScreenDemoViewController.swift b/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/SwiftUIScreenDemoViewController.swift index 28229228..31a5fbdf 100644 --- a/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/SwiftUIScreenDemoViewController.swift +++ b/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/SwiftUIScreenDemoViewController.swift @@ -105,12 +105,12 @@ struct SwiftUIScreenDemo: View { Spacer() } .padding() - .calendarItemModel + .calendarItemModel(id: month) } else { return Text(monthHeaderText) .font(.title2) .padding() - .calendarItemModel + .calendarItemModel(id: month) } } @@ -122,7 +122,7 @@ struct SwiftUIScreenDemo: View { isSelected = false } return SwiftUIDayView(dayNumber: day.day, isSelected: isSelected) - .calendarItemModel + .calendarItemModel(id: day) } .dayRangeItemProvider(for: selectedDateRanges) { dayRangeLayoutContext in diff --git a/README.md b/README.md index fe63e622..9af3ef5b 100644 --- a/README.md +++ b/README.md @@ -267,7 +267,7 @@ Using a SwiftUI view is even easier - simply initialize your SwiftUI view and ca Text("\(day.day)") .font(.system(size: 18)) .foregroundColor(Color(UIColor.darkGray)) - .calendarItemModel + .calendarItemModel(id: day) // Use this to ensure stable and unique view identity } ``` diff --git a/Sources/Public/CalendarItemModel.swift b/Sources/Public/CalendarItemModel.swift index 49899c11..7ae1be6c 100644 --- a/Sources/Public/CalendarItemModel.swift +++ b/Sources/Public/CalendarItemModel.swift @@ -168,10 +168,15 @@ extension View { /// /// - Warning: Using a SwiftUI view with the calendar will cause `SwiftUIView.HostingController`(s) to be added to the /// closest view controller in the responder chain in relation to the `CalendarView`. - public var calendarItemModel: CalendarItemModel> { - CalendarItemModel>( - invariantViewProperties: .init(initialContent: self), - content: .init(content: self)) + /// + /// - Parameters: + /// - id: An ID that uniquely identifies this item relative to other items in the calendar. For simple cases, this can be a `Day` for + /// day items or a `Month` for month header items, for example. + public func calendarItemModel(id: AnyHashable) -> CalendarItemModel> { + let contentAndID = SwiftUIWrapperView.ContentAndID(content: self, id: id) + return CalendarItemModel>( + invariantViewProperties: .init(initialContentAndID: contentAndID), + content: contentAndID) } } diff --git a/Sources/Public/ItemViews/SwiftUIWrapperView.swift b/Sources/Public/ItemViews/SwiftUIWrapperView.swift index 00b23b07..a246b9cc 100644 --- a/Sources/Public/ItemViews/SwiftUIWrapperView.swift +++ b/Sources/Public/ItemViews/SwiftUIWrapperView.swift @@ -30,8 +30,10 @@ public final class SwiftUIWrapperView: UIView { // MARK: Lifecycle - public init(content: Content) { - self.content = content + public init(contentAndID: ContentAndID) { + self.contentAndID = contentAndID + hostingController = HostingController( + rootView: .init(content: contentAndID.content, id: contentAndID.id)) super.init(frame: .zero) @@ -69,16 +71,16 @@ public final class SwiftUIWrapperView: UIView { // MARK: Fileprivate - fileprivate var content: Content { + fileprivate var contentAndID: ContentAndID { didSet { - hostingController.rootView = content + hostingController.rootView = .init(content: contentAndID.content, id: contentAndID.id) configureGestureRecognizers() } } // MARK: Private - private lazy var hostingController = HostingController(rootView: content) + private let hostingController: HostingController> private weak var hostingControllerView: UIView? @@ -126,8 +128,8 @@ extension SwiftUIWrapperView: CalendarItemViewRepresentable { // MARK: Lifecycle - init(initialContent: Content) { - self.initialContent = initialContent + init(initialContentAndID: ContentAndID) { + self.initialContentAndID = initialContentAndID } // MARK: Public @@ -142,39 +144,44 @@ extension SwiftUIWrapperView: CalendarItemViewRepresentable { // MARK: Fileprivate - fileprivate let initialContent: Content + fileprivate let initialContentAndID: ContentAndID } - public struct ContentWrapper: Equatable { + public struct ContentAndID: Equatable { // MARK: Lifecycle - public init(content: Content) { + public init(content: Content, id: AnyHashable) { self.content = content + self.id = id } // MARK: Public - public static func == (_: ContentWrapper, _: ContentWrapper) -> Bool { + public static func == (_: ContentAndID, _: ContentAndID) -> Bool { false } // MARK: Fileprivate fileprivate let content: Content + fileprivate let id: AnyHashable } public static func makeView( withInvariantViewProperties invariantViewProperties: InvariantViewProperties) - -> SwiftUIWrapperView + -> SwiftUIWrapperView { - SwiftUIWrapperView(content: invariantViewProperties.initialContent) + SwiftUIWrapperView(contentAndID: invariantViewProperties.initialContentAndID) } - public static func setContent(_ content: ContentWrapper, on view: SwiftUIWrapperView) { - view.content = content.content + public static func setContent( + _ contentAndID: ContentAndID, + on view: SwiftUIWrapperView) + { + view.contentAndID = contentAndID } } @@ -188,35 +195,48 @@ extension UIResponder { } } -// MARK: - SwiftUIWrapperView.HostingController +// MARK: - IDWrapperView +/// A wrapper view that uses the `id(_:)` modifier on the wrapped view so that each one has its own identity, even if it was reused. @available(iOS 13.0, *) -extension SwiftUIWrapperView { +private struct IDWrapperView: View { - /// The `UIHostingController` type used by `SwiftUIWrapperView` to embed SwiftUI views in a UIKit view hierarchy. - public final class HostingController: UIHostingController { + let content: Content + let id: AnyHashable - // MARK: Lifecycle + var body: some View { + content + .id(id) + } - fileprivate override init(rootView: Content) { - super.init(rootView: rootView) +} - // This prevents the safe area from affecting layout. - _disableSafeArea = true - } +// MARK: - HostingController - @MainActor required dynamic init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } +/// The `UIHostingController` type used by `SwiftUIWrapperView` to embed SwiftUI views in a UIKit view hierarchy. This +/// exists to disable safe area insets and set the background color to clear. +@available(iOS 13.0, *) +private final class HostingController: UIHostingController { - public override func viewDidLoad() { - super.viewDidLoad() + // MARK: Lifecycle - // Override the default `.systemBackground` color since `CalendarView` subviews should be - // clear. - view.backgroundColor = .clear - } + override init(rootView: Content) { + super.init(rootView: rootView) + + // This prevents the safe area from affecting layout. + _disableSafeArea = true + } + + @MainActor required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + // Override the default `.systemBackground` color since `CalendarView` subviews should be + // clear. + view.backgroundColor = .clear } } From b50b03afa5918272eb062d709130bbdf9b355297 Mon Sep 17 00:00:00 2001 From: Bryan Keller Date: Sat, 9 Sep 2023 13:33:31 -0700 Subject: [PATCH 2/3] Set IDs internally and make this a non-breaking change --- CHANGELOG.md | 2 +- .../SwiftUIItemModelsDemoViewController.swift | 4 +-- .../SwiftUIScreenDemoViewController.swift | 7 ++-- README.md | 2 +- Sources/Public/AnyCalendarItemModel.swift | 2 ++ Sources/Public/CalendarItemModel.swift | 33 +++++++++++++++---- Sources/Public/CalendarView.swift | 4 ++- .../Public/ItemViews/SwiftUIWrapperView.swift | 13 ++++++-- 8 files changed, 49 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aad23d62..4a40852b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed Storyboard support by removing the `fatalError` in `init?(coder: NSCoder)` - Fixed an issue that could cause the calendar to layout unnecessarily due to a trait collection change notification - Fixed an issue that could cause off-screen items to appear or disappear instantly, rather than animating in or out during animated content changes -- Fixed an issue (via a breaking API change) that caused a SwiftUI view being used as a calendar item to not receive calls to `onAppear`. Use `calendarItemModel(id:)` to create calendar item models from SwiftUI views. The previous `calendarItemModel` get-only property has been removed. +- Fixed an issue that caused a SwiftUI view being used as a calendar item to not receive calls to `onAppear` ### Changed - Removed all deprecated code, simplifying the public API in preparation for a 2.0 release diff --git a/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/SwiftUIItemModelsDemoViewController.swift b/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/SwiftUIItemModelsDemoViewController.swift index 97456217..2d561077 100644 --- a/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/SwiftUIItemModelsDemoViewController.swift +++ b/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/SwiftUIItemModelsDemoViewController.swift @@ -59,13 +59,13 @@ final class SwiftUIItemModelsDemoViewController: BaseDemoViewController { Spacer() } .padding(.vertical) - .calendarItemModel(id: month) + .calendarItemModel } .dayItemProvider { [calendar, selectedDate] day in let date = calendar.date(from: day.components) let isSelected = date == selectedDate - return SwiftUIDayView(dayNumber: day.day, isSelected: isSelected).calendarItemModel(id: day) + return SwiftUIDayView(dayNumber: day.day, isSelected: isSelected).calendarItemModel } } diff --git a/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/SwiftUIScreenDemoViewController.swift b/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/SwiftUIScreenDemoViewController.swift index 31a5fbdf..1b9def05 100644 --- a/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/SwiftUIScreenDemoViewController.swift +++ b/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/SwiftUIScreenDemoViewController.swift @@ -105,12 +105,12 @@ struct SwiftUIScreenDemo: View { Spacer() } .padding() - .calendarItemModel(id: month) + .calendarItemModel } else { return Text(monthHeaderText) .font(.title2) .padding() - .calendarItemModel(id: month) + .calendarItemModel } } @@ -121,8 +121,7 @@ struct SwiftUIScreenDemo: View { } else { isSelected = false } - return SwiftUIDayView(dayNumber: day.day, isSelected: isSelected) - .calendarItemModel(id: day) + return SwiftUIDayView(dayNumber: day.day, isSelected: isSelected).calendarItemModel } .dayRangeItemProvider(for: selectedDateRanges) { dayRangeLayoutContext in diff --git a/README.md b/README.md index 9af3ef5b..fe63e622 100644 --- a/README.md +++ b/README.md @@ -267,7 +267,7 @@ Using a SwiftUI view is even easier - simply initialize your SwiftUI view and ca Text("\(day.day)") .font(.system(size: 18)) .foregroundColor(Color(UIColor.darkGray)) - .calendarItemModel(id: day) // Use this to ensure stable and unique view identity + .calendarItemModel } ``` diff --git a/Sources/Public/AnyCalendarItemModel.swift b/Sources/Public/AnyCalendarItemModel.swift index df31c1ca..75833188 100644 --- a/Sources/Public/AnyCalendarItemModel.swift +++ b/Sources/Public/AnyCalendarItemModel.swift @@ -43,6 +43,8 @@ public protocol AnyCalendarItemModel { /// - Note: There is no reason to invoke this function from your feature code; it should only be invoked internally. func _isContentEqual(toContentOf other: AnyCalendarItemModel) -> Bool + mutating func _setSwiftUIWrapperViewContentIDIfNeeded(_ id: AnyHashable) + } // MARK: - _CalendarItemViewDifferentiator diff --git a/Sources/Public/CalendarItemModel.swift b/Sources/Public/CalendarItemModel.swift index 7ae1be6c..b95875d1 100644 --- a/Sources/Public/CalendarItemModel.swift +++ b/Sources/Public/CalendarItemModel.swift @@ -82,10 +82,23 @@ public struct CalendarItemModel: AnyCalendarItemModel where return content == other.content } + public mutating func _setSwiftUIWrapperViewContentIDIfNeeded(_ id: AnyHashable) { + guard + var content = content as? SwiftUIWrapperViewContentIDUpdatable, + content.id == AnyHashable(PlaceholderID.placeholderID) + else { + return + } + content.id = id + self.content = content as? ViewRepresentable.Content + } + // MARK: Private private let invariantViewProperties: ViewRepresentable.InvariantViewProperties - private let content: ViewRepresentable.Content? + + // This is only mutable because we need to update the ID for `SwiftUIWrapperView`'s content. + private var content: ViewRepresentable.Content? } @@ -168,15 +181,21 @@ extension View { /// /// - Warning: Using a SwiftUI view with the calendar will cause `SwiftUIView.HostingController`(s) to be added to the /// closest view controller in the responder chain in relation to the `CalendarView`. - /// - /// - Parameters: - /// - id: An ID that uniquely identifies this item relative to other items in the calendar. For simple cases, this can be a `Day` for - /// day items or a `Month` for month header items, for example. - public func calendarItemModel(id: AnyHashable) -> CalendarItemModel> { - let contentAndID = SwiftUIWrapperView.ContentAndID(content: self, id: id) + public var calendarItemModel: CalendarItemModel> { + let contentAndID = SwiftUIWrapperView.ContentAndID( + content: self, + id: PlaceholderID.placeholderIDAnyHashable) return CalendarItemModel>( invariantViewProperties: .init(initialContentAndID: contentAndID), content: contentAndID) } } + +// MARK: - PlaceholderID + +/// This exists only to facilitate internally updating the ID of a `SwiftUIWrapperView`'s content. +private enum PlaceholderID: Hashable { + case placeholderID + static let placeholderIDAnyHashable = AnyHashable(PlaceholderID.placeholderID) +} diff --git a/Sources/Public/CalendarView.swift b/Sources/Public/CalendarView.swift index 740dad63..6cc34d01 100644 --- a/Sources/Public/CalendarView.swift +++ b/Sources/Public/CalendarView.swift @@ -806,7 +806,9 @@ public final class CalendarView: UIView { } private func configureView(_ view: ItemView, with visibleItem: VisibleItem) { - view.calendarItemModel = visibleItem.calendarItemModel + var calendarItemModel = visibleItem.calendarItemModel + calendarItemModel._setSwiftUIWrapperViewContentIDIfNeeded(visibleItem.itemType) + view.calendarItemModel = calendarItemModel view.itemType = visibleItem.itemType view.frame = visibleItem.frame.alignedToPixels(forScreenWithScale: scale) diff --git a/Sources/Public/ItemViews/SwiftUIWrapperView.swift b/Sources/Public/ItemViews/SwiftUIWrapperView.swift index a246b9cc..d079dcf0 100644 --- a/Sources/Public/ItemViews/SwiftUIWrapperView.swift +++ b/Sources/Public/ItemViews/SwiftUIWrapperView.swift @@ -148,7 +148,7 @@ extension SwiftUIWrapperView: CalendarItemViewRepresentable { } - public struct ContentAndID: Equatable { + public struct ContentAndID: Equatable, SwiftUIWrapperViewContentIDUpdatable { // MARK: Lifecycle @@ -163,10 +163,13 @@ extension SwiftUIWrapperView: CalendarItemViewRepresentable { false } + // MARK: Internal + + var id: AnyHashable + // MARK: Fileprivate fileprivate let content: Content - fileprivate let id: AnyHashable } @@ -186,6 +189,12 @@ extension SwiftUIWrapperView: CalendarItemViewRepresentable { } +// MARK: - SwiftUIWrapperViewContentIDUpdatable + +protocol SwiftUIWrapperViewContentIDUpdatable { + var id: AnyHashable { get set } +} + // MARK: UIResponder Next View Controller Helper extension UIResponder { From 0d050141660399019190c39ea71daea75716a5da Mon Sep 17 00:00:00 2001 From: Bryan Keller Date: Wed, 13 Sep 2023 14:55:09 -0700 Subject: [PATCH 3/3] Fix tests --- Tests/ItemViewReuseManagerTests.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Tests/ItemViewReuseManagerTests.swift b/Tests/ItemViewReuseManagerTests.swift index 52d425f9..c051feb3 100644 --- a/Tests/ItemViewReuseManagerTests.swift +++ b/Tests/ItemViewReuseManagerTests.swift @@ -521,4 +521,6 @@ private struct MockCalendarItemModel: AnyCalendarItemModel, Equatable { false } + mutating func _setSwiftUIWrapperViewContentIDIfNeeded(_ id: AnyHashable) { } + }