Skip to content

Commit c2b85d0

Browse files
Improve perf of AttributedLabel link focus changes and suppress console messages (#565)
This PR mainly fixes two separate things: 1. Performance issues with the focus changes in `AttributedLabel` - Due to the `frame` property being requested each time the focus is changed, the code was doing an expensive recalculation of the bounding shape. This change introduces caching for the bounding shape and only ensures that the cache is invalidated when the label is updated or when `layoutSubviews` is called on the label view. [This](7876c9e) commit (not being pushed to main) contains the test code that I used for measuring the performance of this code. In the "before" version, the frame rate had dropped to ~25 FPS. In the "after" version, the frame rate is ~110 FPS. 2. Despite using the [API](https://developer.apple.com/documentation/uikit/uifocusitemcontainer/focusitems(in:)) in the prescribed manner, there would be repeated console output of the form `_TtCV25BlueprintUICommonControls15AttributedLabel9LabelView implements focusItemsInRect: - caching for linear focus movement is limited as long as this view is on screen.` I am able to replicate this on both iPadOS and tvOS (the platform that Apple had [originally](https://devstreaming-cdn.apple.com/videos/wwdc/2018/208piymryv9im6/208/208_whats_new_in_tvos_12.pdf) demonstrated this API on). This change implements the `_supportsInvalidatingFocusCache()` function similar to how Apple does it in a number of their system classes such as `UITableView` and `UICollectionView`, amongst others. ![image](https://github.com/user-attachments/assets/5d226b9f-694b-48e6-924f-19045023d379)
2 parents 2a641fb + 6f6cea7 commit c2b85d0

File tree

2 files changed

+81
-1
lines changed

2 files changed

+81
-1
lines changed

BlueprintUICommonControls/Sources/AttributedLabel.swift

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,9 @@ extension AttributedLabel {
211211
}
212212
}
213213

214+
// Store bounding shapes in this cache to avoid costly recalculations
215+
private var boundingShapeCache: [Link: Link.BoundingShape] = [:]
216+
214217
override var accessibilityCustomRotors: [UIAccessibilityCustomRotor]? {
215218
set { assertionFailure("accessibilityCustomRotors is not settable.") }
216219
get { !linkElements.isEmpty ? [linkElements.accessibilityRotor(systemType: .link)] : [] }
@@ -222,6 +225,46 @@ extension AttributedLabel {
222225

223226
var urlHandler: URLHandler?
224227

228+
override init(frame: CGRect) {
229+
super.init(frame: frame)
230+
231+
if #available(iOS 17.0, *) {
232+
registerForTraitChanges([UITraitPreferredContentSizeCategory.self]) { (
233+
view: LabelView,
234+
previousTraitCollection: UITraitCollection
235+
) in
236+
view.invalidateLinkBoundingShapeCaches()
237+
}
238+
} else {
239+
NotificationCenter
240+
.default
241+
.addObserver(
242+
self,
243+
selector: #selector(sizeCategoryChanged(notification:)),
244+
name: UIContentSizeCategory.didChangeNotification,
245+
object: nil
246+
)
247+
}
248+
}
249+
250+
deinit {
251+
if #available(iOS 17.0, *) {
252+
// Do nothing
253+
} else {
254+
NotificationCenter
255+
.default
256+
.removeObserver(self)
257+
}
258+
}
259+
260+
@objc private func sizeCategoryChanged(notification: Notification) {
261+
invalidateLinkBoundingShapeCaches()
262+
}
263+
264+
required init?(coder: NSCoder) {
265+
fatalError("init(coder:) has not been implemented")
266+
}
267+
225268
func update(model: AttributedLabel, text: NSAttributedString, environment: Environment, isMeasuring: Bool) {
226269
let previousAttributedText = isMeasuring ? nil : attributedText
227270

@@ -251,6 +294,8 @@ extension AttributedLabel {
251294
layoutDirection = environment.layoutDirection
252295

253296
if !isMeasuring {
297+
invalidateLinkBoundingShapeCaches()
298+
254299
if previousAttributedText != attributedText {
255300
links = attributedLinks(in: model.attributedText) + detectedDataLinks(in: model.attributedText)
256301
accessibilityLabel = accessibilityLabel(
@@ -644,8 +689,38 @@ extension AttributedLabel {
644689
trackingLinks = nil
645690
applyLinkColors()
646691
}
692+
693+
override func layoutSubviews() {
694+
super.layoutSubviews()
695+
696+
invalidateLinkBoundingShapeCaches()
697+
}
698+
699+
func boundingShape(for link: Link) -> Link.BoundingShape {
700+
if let cachedShape = boundingShapeCache[link] {
701+
return cachedShape
702+
}
703+
704+
let calculatedShape = link.calculateBoundingShape()
705+
boundingShapeCache[link] = calculatedShape
706+
return calculatedShape
707+
}
708+
709+
private func invalidateLinkBoundingShapeCaches() {
710+
boundingShapeCache.removeAll()
711+
}
647712
}
713+
}
648714

715+
extension AttributedLabel.LabelView {
716+
// Without this, we were seeing console messages like the following:
717+
// "LabelView implements focusItemsInRect: - caching for linear focus movement is limited as long as this view is on screen."
718+
// It's unclear as to why they are appearing despite using the API in the intended manner.
719+
// To suppress the messages, we implemented this function much like Apple did with `UITableView`,
720+
// `UICollectionView`, etc.
721+
@objc private class func _supportsInvalidatingFocusCache() -> Bool {
722+
true
723+
}
649724
}
650725

651726
extension AttributedLabel {
@@ -674,6 +749,10 @@ extension AttributedLabel {
674749
}
675750

676751
var boundingShape: BoundingShape {
752+
container?.boundingShape(for: self) ?? calculateBoundingShape()
753+
}
754+
755+
fileprivate func calculateBoundingShape() -> BoundingShape {
677756
guard let container = container,
678757
let textStorage = container.makeTextStorage(),
679758
let layoutManager = textStorage.layoutManagers.first,
@@ -776,7 +855,7 @@ extension AttributedLabel {
776855
override var accessibilityPath: UIBezierPath? {
777856
set { assertionFailure("cannot set accessibilityPath") }
778857
get {
779-
if let path = link.boundingShape.path, let container = link.container {
858+
if let path = link.boundingShape.path?.copy() as? UIBezierPath, let container = link.container {
780859
return UIAccessibility.convertToScreenCoordinates(path, in: container)
781860
}
782861

SampleApp/Sources/XcodePreviewDemo.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import BlueprintUI
22
import BlueprintUICommonControls
3+
import Foundation
34

45

56
struct TestElement: ProxyElement {

0 commit comments

Comments
 (0)