diff --git a/CHANGELOG.md b/CHANGELOG.md index 41d4eaf..f90d3ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Rewrote accessibility code to avoid posting notifications, which causes poor Voice Over performance and odd focus bugs - Rewrote `ItemViewReuseManager` to perform fewer set operations, improving CPU usage by ~15% when scrolling quickly on an iPhone XR +- Updated how we embed SwiftUI views to improve scroll performance by ~35% when scrolling quickly ## [v2.0.0](https://github.com/airbnb/HorizonCalendar/compare/v1.16.0...v2.0.0) - 2023-12-19 diff --git a/Sources/Internal/ItemView.swift b/Sources/Internal/ItemView.swift index fa8bdf4..373855f 100644 --- a/Sources/Internal/ItemView.swift +++ b/Sources/Internal/ItemView.swift @@ -55,6 +55,11 @@ final class ItemView: UIView { set { } } + override var isHidden: Bool { + get { contentView.isHidden } + set { contentView.isHidden = newValue } + } + var calendarItemModel: AnyCalendarItemModel { didSet { guard calendarItemModel._itemViewDifferentiator == oldValue._itemViewDifferentiator else { diff --git a/Sources/Public/AnyCalendarItemModel.swift b/Sources/Public/AnyCalendarItemModel.swift index 0249d57..2387194 100644 --- a/Sources/Public/AnyCalendarItemModel.swift +++ b/Sources/Public/AnyCalendarItemModel.swift @@ -43,6 +43,7 @@ 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 + // TODO: Remove this in the next major release. mutating func _setSwiftUIWrapperViewContentIDIfNeeded(_ id: AnyHashable) } diff --git a/Sources/Public/CalendarItemModel.swift b/Sources/Public/CalendarItemModel.swift index 3ab6cba..4e3df72 100644 --- a/Sources/Public/CalendarItemModel.swift +++ b/Sources/Public/CalendarItemModel.swift @@ -81,16 +81,7 @@ 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 - } + public mutating func _setSwiftUIWrapperViewContentIDIfNeeded(_ id: AnyHashable) { } // MARK: Private @@ -176,24 +167,11 @@ extension View { /// /// This is equivalent to manually creating a /// `CalendarItemModel>`, where `YourView` is some SwiftUI `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> { - let contentAndID = SwiftUIWrapperView.ContentAndID( - content: self, - id: PlaceholderID.placeholderIDAnyHashable) + let contentAndID = SwiftUIWrapperView.ContentAndID(content: self, id: 0) 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 a94eb26..487a05c 100644 --- a/Sources/Public/CalendarView.swift +++ b/Sources/Public/CalendarView.swift @@ -811,8 +811,7 @@ public final class CalendarView: UIView { } private func configureView(_ view: ItemView, with visibleItem: VisibleItem) { - var calendarItemModel = visibleItem.calendarItemModel - calendarItemModel._setSwiftUIWrapperViewContentIDIfNeeded(visibleItem.itemType) + let calendarItemModel = visibleItem.calendarItemModel view.calendarItemModel = calendarItemModel view.itemType = visibleItem.itemType view.frame = visibleItem.frame.alignedToPixels(forScreenWithScale: scale) diff --git a/Sources/Public/CalendarViewRepresentable.swift b/Sources/Public/CalendarViewRepresentable.swift index 186aa46..c989bcd 100644 --- a/Sources/Public/CalendarViewRepresentable.swift +++ b/Sources/Public/CalendarViewRepresentable.swift @@ -480,7 +480,7 @@ extension CalendarViewRepresentable { /// /// The `content` view builder closure is invoked for each day that's displayed. /// - /// If you don't configure your own day background views via this modifier, then months will not have any background decoration. If + /// If you don't configure your own day background views via this modifier, then days will not have any background decoration. If /// a particular day doesn't need a background view, return `EmptyView` for that day. /// /// - Parameters: diff --git a/Sources/Public/ItemViews/SwiftUIWrapperView.swift b/Sources/Public/ItemViews/SwiftUIWrapperView.swift index a47e89a..463779a 100644 --- a/Sources/Public/ItemViews/SwiftUIWrapperView.swift +++ b/Sources/Public/ItemViews/SwiftUIWrapperView.swift @@ -22,9 +22,6 @@ import SwiftUI /// Consider using the `calendarItemModel` property, defined as an extension on SwiftUI's`View`, to avoid needing to work with /// this wrapper view directly. /// e.g. `Text("\(dayNumber)").calendarItemModel` -/// -/// - 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`. @available(iOS 13.0, *) public final class SwiftUIWrapperView: UIView { @@ -32,13 +29,16 @@ public final class SwiftUIWrapperView: UIView { public init(contentAndID: ContentAndID) { self.contentAndID = contentAndID - hostingController = HostingController( - rootView: .init(content: contentAndID.content, id: contentAndID.id)) + hostingController = UIHostingController(rootView: AnyView(contentAndID.content)) + hostingController._disableSafeArea = true super.init(frame: .zero) insetsLayoutMarginsFromSafeArea = false layoutMargins = .zero + + hostingControllerView.backgroundColor = .clear + addSubview(hostingControllerView) } required init?(coder _: NSCoder) { @@ -47,16 +47,20 @@ public final class SwiftUIWrapperView: UIView { // MARK: Public + public override class var layerClass: AnyClass { + CATransformLayer.self + } + public override var isAccessibilityElement: Bool { get { false } set { } } - public override func didMoveToWindow() { - super.didMoveToWindow() - - if window != nil { - setUpHostingControllerIfNeeded() + public override var isHidden: Bool { + didSet { + if isHidden { + hostingController.rootView = AnyView(EmptyView()) + } } } @@ -65,7 +69,7 @@ public final class SwiftUIWrapperView: UIView { // modifier. Its first subview's `isUserInteractionEnabled` _does_ appear to be affected by the // `allowsHitTesting` modifier, enabling us to properly ignore touch handling. if - let firstSubview = hostingController.view.subviews.first, + let firstSubview = hostingControllerView.subviews.first, !firstSubview.isUserInteractionEnabled { return false @@ -76,62 +80,42 @@ public final class SwiftUIWrapperView: UIView { public override func layoutSubviews() { super.layoutSubviews() - hostingControllerView?.frame = bounds + hostingControllerView.frame = bounds } public override func systemLayoutSizeFitting( _ targetSize: CGSize, - withHorizontalFittingPriority _: UILayoutPriority, - verticalFittingPriority _: UILayoutPriority) + withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, + verticalFittingPriority: UILayoutPriority) -> CGSize { - hostingController.sizeThatFits(in: targetSize) + hostingControllerView.systemLayoutSizeFitting( + targetSize, + withHorizontalFittingPriority: horizontalFittingPriority, + verticalFittingPriority: verticalFittingPriority) } // MARK: Fileprivate fileprivate var contentAndID: ContentAndID { didSet { - hostingController.rootView = .init(content: contentAndID.content, id: contentAndID.id) + hostingController.rootView = AnyView(contentAndID.content) configureGestureRecognizers() } } // MARK: Private - private let hostingController: HostingController> - - private weak var hostingControllerView: UIView? - - private func setUpHostingControllerIfNeeded() { - guard let closestViewController = closestViewController() else { - assertionFailure( - "Could not find a view controller to which the `UIHostingController` could be added.") - return - } - - guard hostingController.parent !== closestViewController else { return } - - if hostingController.parent != nil { - hostingController.willMove(toParent: nil) - hostingController.view.removeFromSuperview() - hostingController.removeFromParent() - hostingController.didMove(toParent: nil) - } - - hostingController.willMove(toParent: closestViewController) - closestViewController.addChild(hostingController) - hostingControllerView = hostingController.view - addSubview(hostingController.view) - hostingController.didMove(toParent: closestViewController) + private let hostingController: UIHostingController - setNeedsLayout() + private var hostingControllerView: UIView { + hostingController.view } // This allows touches to be passed to `ItemView` even if the SwiftUI `View` has a gesture // recognizer. private func configureGestureRecognizers() { - for gestureRecognizer in hostingControllerView?.gestureRecognizers ?? [] { + for gestureRecognizer in hostingControllerView.gestureRecognizers ?? [] { gestureRecognizer.cancelsTouchesInView = false } } @@ -167,13 +151,13 @@ extension SwiftUIWrapperView: CalendarItemViewRepresentable { } - public struct ContentAndID: Equatable, SwiftUIWrapperViewContentIDUpdatable { + public struct ContentAndID: Equatable { // MARK: Lifecycle + // TODO: Remove `id` and rename this type in the next major release. public init(content: Content, id: AnyHashable) { self.content = content - self.id = id } // MARK: Public @@ -182,10 +166,6 @@ extension SwiftUIWrapperView: CalendarItemViewRepresentable { false } - // MARK: Internal - - var id: AnyHashable - // MARK: Fileprivate fileprivate let content: Content @@ -207,67 +187,3 @@ extension SwiftUIWrapperView: CalendarItemViewRepresentable { } } - -// MARK: - SwiftUIWrapperViewContentIDUpdatable - -protocol SwiftUIWrapperViewContentIDUpdatable { - var id: AnyHashable { get set } -} - -// MARK: UIResponder Next View Controller Helper - -extension UIResponder { - /// Recursively traverses up the responder chain to find the closest view controller. - fileprivate func closestViewController() -> UIViewController? { - self as? UIViewController ?? next?.closestViewController() - } -} - -// 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, *) -private struct IDWrapperView: View { - - let content: Content - let id: AnyHashable - - var body: some View { - content - .id(id) - } - -} - -// MARK: - HostingController - -/// 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 { - - // MARK: Lifecycle - - override init(rootView: Content) { - super.init(rootView: rootView) - - // This prevents the safe area from affecting layout. - _disableSafeArea = true - } - - @MainActor - required dynamic init?(coder _: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: Internal - - override func viewDidLoad() { - super.viewDidLoad() - - // Override the default `.systemBackground` color since `CalendarView` subviews should be - // clear. - view.backgroundColor = .clear - } - -}