Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Optimize SwiftUIWrapperView #320

Merged
merged 1 commit into from
Nov 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions Sources/Internal/ItemView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions Sources/Public/AnyCalendarItemModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

}
Expand Down
26 changes: 2 additions & 24 deletions Sources/Public/CalendarItemModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,16 +81,7 @@ public struct CalendarItemModel<ViewRepresentable>: 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(_: AnyHashable) { }

// MARK: Private

Expand Down Expand Up @@ -176,24 +167,11 @@ extension View {
///
/// This is equivalent to manually creating a
/// `CalendarItemModel<SwiftUIWrapperView<YourView>>`, 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<SwiftUIWrapperView<Self>> {
let contentAndID = SwiftUIWrapperView.ContentAndID(
content: self,
id: PlaceholderID.placeholderIDAnyHashable)
let contentAndID = SwiftUIWrapperView.ContentAndID(content: self, id: 0)
return CalendarItemModel<SwiftUIWrapperView<Self>>(
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)
}
3 changes: 1 addition & 2 deletions Sources/Public/CalendarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion Sources/Public/CalendarViewRepresentable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
144 changes: 30 additions & 114 deletions Sources/Public/ItemViews/SwiftUIWrapperView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,23 +22,23 @@ 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<Content: View>: UIView {

// MARK: Lifecycle

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) {
Expand All @@ -47,16 +47,20 @@ public final class SwiftUIWrapperView<Content: View>: 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())
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not ideal, but way faster than the previous strategy of giving every view an ID

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could also add an API in the future to opt-out of this behavior if someone doesn't need onAppear or onDisappear callbacks in their views, which would get them back ~20% CPU usage when scrolling fast 🤯

}
}
}

Expand All @@ -65,7 +69,7 @@ public final class SwiftUIWrapperView<Content: View>: 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
Expand All @@ -76,62 +80,42 @@ public final class SwiftUIWrapperView<Content: View>: 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)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I profiled and there was no perf hit from switching to AnyView

configureGestureRecognizers()
}
}

// MARK: Private

private let hostingController: HostingController<IDWrapperView<Content>>

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<AnyView>

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
}
}
Expand Down Expand Up @@ -167,13 +151,13 @@ extension SwiftUIWrapperView: CalendarItemViewRepresentable {

}

public struct ContentAndID: Equatable, SwiftUIWrapperViewContentIDUpdatable {
public struct ContentAndID: Equatable {

// MARK: Lifecycle

public init(content: Content, id: AnyHashable) {
// 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
Expand All @@ -182,10 +166,6 @@ extension SwiftUIWrapperView: CalendarItemViewRepresentable {
false
}

// MARK: Internal

var id: AnyHashable

// MARK: Fileprivate

fileprivate let content: Content
Expand All @@ -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<Content: View>: 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<Content: View>: UIHostingController<Content> {

// 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
}

}