Skip to content

Commit

Permalink
Optimize SwiftUIWrapperView (#320)
Browse files Browse the repository at this point in the history
  • Loading branch information
bryankeller authored Nov 10, 2024
1 parent 7ad26c0 commit b7ebeee
Show file tree
Hide file tree
Showing 7 changed files with 41 additions and 141 deletions.
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())
}
}
}

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

}

0 comments on commit b7ebeee

Please sign in to comment.