diff --git a/CHANGELOG.md b/CHANGELOG.md index 73457245..4a40852b 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 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/SwiftUIScreenDemoViewController.swift b/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/SwiftUIScreenDemoViewController.swift index 28229228..1b9def05 100644 --- a/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/SwiftUIScreenDemoViewController.swift +++ b/Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/SwiftUIScreenDemoViewController.swift @@ -121,8 +121,7 @@ struct SwiftUIScreenDemo: View { } else { isSelected = false } - return SwiftUIDayView(dayNumber: day.day, isSelected: isSelected) - .calendarItemModel + return SwiftUIDayView(dayNumber: day.day, isSelected: isSelected).calendarItemModel } .dayRangeItemProvider(for: selectedDateRanges) { dayRangeLayoutContext in 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 49899c11..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? } @@ -169,9 +182,20 @@ 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)) + 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 00b23b07..d079dcf0 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,24 +144,29 @@ extension SwiftUIWrapperView: CalendarItemViewRepresentable { // MARK: Fileprivate - fileprivate let initialContent: Content + fileprivate let initialContentAndID: ContentAndID } - public struct ContentWrapper: Equatable { + public struct ContentAndID: Equatable, SwiftUIWrapperViewContentIDUpdatable { // 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: Internal + + var id: AnyHashable + // MARK: Fileprivate fileprivate let content: Content @@ -168,17 +175,26 @@ extension SwiftUIWrapperView: CalendarItemViewRepresentable { 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 } } +// MARK: - SwiftUIWrapperViewContentIDUpdatable + +protocol SwiftUIWrapperViewContentIDUpdatable { + var id: AnyHashable { get set } +} + // MARK: UIResponder Next View Controller Helper extension UIResponder { @@ -188,35 +204,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 } } 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) { } + }