Skip to content

Commit df53f01

Browse files
Added support for tabbing through links in AttributedLabel (#558)
This change supports both VoiceOver and Full Keyboard Access through a new consolidated class that refactors the existing `UIAccessibilityElement` code with accommodations for the focus system. ### Demo (tested on an iPad with a hardware keyboard) Note that I activated the call link using the space bar. https://github.com/user-attachments/assets/7ebb3511-1e83-4663-976b-225c34007f80 Additionally, it updates how bounding rectangles are calculated for links so that a more relevant portion of the link is highlighted when it overflows into multiple lines. ### Demo for the bounding rectangles fix #### Before https://github.com/user-attachments/assets/ffdaee0a-d2a2-4e69-b80a-a8016f843653 #### After https://github.com/user-attachments/assets/b5b7ab41-56ff-4dd3-be47-6f3bf7ddb902
2 parents 6e4698b + 7f0464d commit df53f01

File tree

3 files changed

+165
-70
lines changed

3 files changed

+165
-70
lines changed

BlueprintUICommonControls/Sources/AttributedLabel.swift

Lines changed: 106 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ extension AttributedLabel {
201201
private var linkAttributes: [NSAttributedString.Key: Any] = [:]
202202
private var activeLinkAttributes: [NSAttributedString.Key: Any] = [:]
203203
private var links: [Link] = []
204+
private var linkElements: [LinkElement] = []
204205

205206
private var textRectOffset: UIOffset = .zero {
206207
didSet {
@@ -211,13 +212,14 @@ extension AttributedLabel {
211212
}
212213

213214
override var accessibilityCustomRotors: [UIAccessibilityCustomRotor]? {
214-
set { fatalError("accessibilityCustomRotors is not settable.") }
215-
get {
216-
guard let attributedText, !links.isEmpty else { return [] }
217-
return [accessibilityRotor(for: links, in: attributedText)]
218-
}
215+
set { assertionFailure("accessibilityCustomRotors is not settable.") }
216+
get { !linkElements.isEmpty ? [linkElements.accessibilityRotor(systemType: .link)] : [] }
219217
}
220218

219+
override var canBecomeFocused: Bool { false }
220+
221+
override func focusItems(in rect: CGRect) -> [any UIFocusItem] { linkElements }
222+
221223
var urlHandler: URLHandler?
222224

223225
func update(model: AttributedLabel, text: NSAttributedString, environment: Environment, isMeasuring: Bool) {
@@ -256,6 +258,9 @@ extension AttributedLabel {
256258
in: model.attributedText.string,
257259
linkAccessibilityLabel: environment.linkAccessibilityLabel
258260
)
261+
linkElements = links
262+
.sorted(by: { $0.range.location < $1.range.location })
263+
.compactMap { .init(sourceLabel: attributedText, link: $0) }
259264
}
260265

261266
if let shadow = model.shadow {
@@ -431,9 +436,9 @@ extension AttributedLabel {
431436
options: []
432437
) { link, range, _ in
433438
if let link = link as? URL {
434-
links.append(.init(url: link, range: range))
439+
links.append(.init(url: link, range: range, container: self))
435440
} else if let link = link as? String, let url = URL(string: link) {
436-
links.append(.init(url: url, range: range))
441+
links.append(.init(url: url, range: range, container: self))
437442
}
438443
}
439444

@@ -468,13 +473,13 @@ extension AttributedLabel {
468473
let charactersToRemove = CharacterSet.decimalDigits.inverted
469474
let trimmedPhoneNumber = phoneNumber.components(separatedBy: charactersToRemove).joined()
470475
if let url = URL(string: "tel:\(trimmedPhoneNumber)") {
471-
links.append(.init(url: url, range: result.range))
476+
links.append(.init(url: url, range: result.range, container: self))
472477
}
473478
}
474479

475480
case .link:
476481
if let url = result.url {
477-
links.append(.init(url: url, range: result.range))
482+
links.append(.init(url: url, range: result.range, container: self))
478483
}
479484

480485
case .address:
@@ -495,15 +500,15 @@ extension AttributedLabel {
495500
if let urlQuery = address.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
496501
let url = URL(string: "https://maps.apple.com/?address=\(urlQuery)")
497502
{
498-
links.append(.init(url: url, range: result.range))
503+
links.append(.init(url: url, range: result.range, container: self))
499504
}
500505
}
501506

502507
case .date:
503508
if let date = result.date,
504509
let url = URL(string: "calshow:\(date.timeIntervalSinceReferenceDate)")
505510
{
506-
links.append(.init(url: url, range: result.range))
511+
links.append(.init(url: url, range: result.range, container: self))
507512
}
508513

509514
default:
@@ -514,22 +519,6 @@ extension AttributedLabel {
514519
return links
515520
}
516521

517-
internal func accessibilityRotor(for links: [Link], in string: NSAttributedString) -> UIAccessibilityCustomRotor {
518-
let elements: [LinkAccessibilityElement] = links
519-
.sorted(by: { $0.range.location < $1.range.location })
520-
.compactMap { link in
521-
guard NSIntersectionRange(string.entireRange, link.range).length > 0 else {
522-
return nil
523-
}
524-
return LinkAccessibilityElement(
525-
container: self,
526-
label: string.attributedSubstring(from: link.range).string,
527-
link: link
528-
)
529-
}
530-
return elements.accessibilityRotor(systemType: .link)
531-
}
532-
533522
internal func accessibilityLabel(with links: [Link], in string: String, linkAccessibilityLabel: String?) -> String {
534523
// When reading an attributed string that contains the `.link` attribute VoiceOver will announce "link" when it encounters the applied range. This is important because it informs the user about the context and position of the linked text within the greater string. This can be particularly important when a string contains multiple links with the same linked text but different link destinations.
535524

@@ -663,41 +652,108 @@ extension AttributedLabel {
663652
struct Link: Equatable, Hashable {
664653
var url: URL
665654
var range: NSRange
655+
weak var container: LabelView?
656+
657+
var boundingRect: CGRect {
658+
guard let container = container,
659+
let textStorage = container.makeTextStorage(),
660+
let layoutManager = textStorage.layoutManagers.first,
661+
let textContainer = layoutManager.textContainers.first
662+
else {
663+
return .zero
664+
}
665+
666+
let glyphRange = layoutManager.glyphRange(forCharacterRange: range, actualCharacterRange: nil)
667+
668+
// In cases where a link overflows from one line to the next,
669+
// we will get back more than one rect. If we had used the boundingRect
670+
// function instead, it will only return us a bounding rect that can
671+
// fully encompass all the sub-rects - this is undesired behavior as
672+
// it will suppress focusable items within the label that have an
673+
// overlap with the larger bounding box.
674+
// Since we only select the first rectangle, if there is indeed an overflow,
675+
// only the portion of the link in the preceding line would be highlighted by
676+
// VoiceOver or Full Keyboard Access. While this is not ideal behavior, it is still
677+
// preferred over the former. The correct fix for this would be to return bezier paths
678+
// that encompass exactly the linked portion.
679+
let rects = { () -> [CGRect] in
680+
var glyphRects: [CGRect] = []
681+
layoutManager.enumerateEnclosingRects(
682+
forGlyphRange: glyphRange,
683+
withinSelectedGlyphRange: NSMakeRange(NSNotFound, 0),
684+
in: textContainer
685+
) { rect, _ in
686+
glyphRects.append(rect)
687+
}
688+
return glyphRects
689+
}()
690+
691+
return rects.first ?? .zero
692+
}
666693
}
667694

668-
private final class LinkAccessibilityElement: UIAccessibilityElement {
669-
private let link: AttributedLabel.Link
670-
private var container: LabelView? { accessibilityContainer as? LabelView }
695+
class LinkElement: UIAccessibilityElement, UIFocusItem {
696+
697+
var preferredFocusEnvironments: [any UIFocusEnvironment] = []
698+
699+
var focusItemContainer: (any UIFocusItemContainer)?
700+
701+
func setNeedsFocusUpdate() {}
702+
703+
func updateFocusIfNeeded() {}
671704

672-
init(
673-
container: LabelView,
674-
label: String,
705+
func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) {}
706+
707+
private var sourceLabel: NSAttributedString
708+
private var label: String {
709+
sourceLabel.attributedSubstring(from: link.range).string
710+
}
711+
712+
private var link: AttributedLabel.Link
713+
714+
init?(
715+
sourceLabel: NSAttributedString?,
675716
link: AttributedLabel.Link
676717
) {
718+
guard let sourceLabel, NSIntersectionRange(sourceLabel.entireRange, link.range).length > 0 else { return nil }
719+
720+
self.sourceLabel = sourceLabel
677721
self.link = link
678-
super.init(accessibilityContainer: container)
679-
accessibilityLabel = label
680-
accessibilityTraits = [.link]
722+
super.init(accessibilityContainer: link.container)
681723
}
682724

725+
var frame: CGRect {
726+
set { assertionFailure("cannot set frame") }
727+
get { link.boundingRect }
728+
}
729+
730+
weak var parentFocusEnvironment: (any UIFocusEnvironment)? {
731+
link.container
732+
}
733+
734+
func shouldUpdateFocus(in context: UIFocusUpdateContext) -> Bool {
735+
true
736+
}
737+
738+
var canBecomeFocused: Bool { true }
739+
683740
override var accessibilityFrameInContainerSpace: CGRect {
684-
set { fatalError("accessibilityFrameInContainerSpace") }
685-
get {
686-
guard let container = container,
687-
let textStorage = container.makeTextStorage(),
688-
let layoutManager = textStorage.layoutManagers.first,
689-
let textContainer = layoutManager.textContainers.first
690-
else {
691-
return .zero
692-
}
741+
set { assertionFailure("cannot set accessibilityFrameInContainerSpace") }
742+
get { link.boundingRect }
743+
}
693744

694-
let glyphRange = layoutManager.glyphRange(forCharacterRange: link.range, actualCharacterRange: nil)
695-
return layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
696-
}
745+
override var accessibilityLabel: String? {
746+
set { assertionFailure("cannot set accessibilityLabel") }
747+
get { label }
748+
}
749+
750+
override var accessibilityTraits: UIAccessibilityTraits {
751+
set { assertionFailure("cannot set accessibilityTraits") }
752+
get { [.link] }
697753
}
698754

699755
override func accessibilityActivate() -> Bool {
700-
container?.urlHandler?.onTap(url: link.url)
756+
link.container?.urlHandler?.onTap(url: link.url)
701757
return true
702758
}
703759
}
@@ -844,3 +900,4 @@ extension String {
844900
components(separatedBy: .newlines).filter { !$0.isEmpty }.joined(separator: " ")
845901
}
846902
}
903+

BlueprintUICommonControls/Tests/Sources/AttributedLabelTests.swift

Lines changed: 57 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -484,27 +484,6 @@ class AttributedLabelTests: XCTestCase {
484484
}
485485
}
486486

487-
488-
func test_linkAccessibility_Rotors() {
489-
let labelView = AttributedLabel.LabelView()
490-
let text: NSString = "The Fellowship of the ring was established at the Council of Elrond and consisted of Gandalf, Sam, Frodo, Aragorn, Gimli, Pippin, Boromir, Legolas, and Merry."
491-
492-
let url = URL(string: "https://one.ring")!
493-
494-
let links = ["Frodo", "Merry", "Sam", "Pippin"].map {
495-
AttributedLabel.Link(url: url, range: text.range(of: $0))
496-
}
497-
498-
let rotor = labelView.accessibilityRotor(for: links, in: NSAttributedString(string: text as String))
499-
XCTAssertNotNil(rotor)
500-
501-
// links should be sorted by their position in the main string.
502-
let sortedHobbits = rotor.dumpItems().map { $0.accessibilityLabel }
503-
XCTAssertEqual(sortedHobbits, ["Sam", "Frodo", "Pippin", "Merry"])
504-
}
505-
506-
507-
508487
func test_linkAccessibility_Rotors_update() {
509488
let string = "The Fellowship of the ring was established at the Council of Elrond and consisted of Gandalf, Sam, Frodo, Aragorn, Gimli, Pippin, Boromir, Legolas, and Merry."
510489
var attributedText = AttributedText(string)
@@ -712,6 +691,63 @@ class AttributedLabelTests: XCTestCase {
712691
}
713692
}
714693

694+
func test_focusItems() {
695+
let string = "The Fellowship of the ring was established at the Council of Elrond and consisted of Gandalf, Sam, Frodo, Aragorn, Gimli, Pippin, Boromir, Legolas, and Merry."
696+
var attributedText = AttributedText(string)
697+
698+
for hobbit in ["Frodo", "Merry", "Sam", "Pippin"] {
699+
let range = attributedText.range(of: hobbit)!
700+
attributedText[range].link = URL(string: "https://one.ring")!
701+
}
702+
703+
let label = AttributedLabel(attributedText: attributedText.attributedString)
704+
705+
let view = BlueprintView()
706+
view.element = label
707+
view.layoutIfNeeded()
708+
709+
guard let labelView = view.firstSubview(ofType: AttributedLabel.LabelView.self) else {
710+
XCTFail("Could not find AttributedLabel.LabelView")
711+
return
712+
}
713+
714+
XCTAssertFalse(labelView.canBecomeFocused, "Label view should not be focusable")
715+
716+
let focusItems = labelView.focusItems(in: labelView.bounds)
717+
XCTAssertEqual(focusItems.count, 4)
718+
719+
for item in focusItems {
720+
XCTAssertTrue(item.canBecomeFocused, "Focus item should be focusable")
721+
XCTAssertNotNil(item.parentFocusEnvironment, "Focus item should have a parent focus environment")
722+
}
723+
724+
let linkElements: [AttributedLabel.LinkElement] = focusItems.compactMap { $0 as? AttributedLabel.LinkElement }
725+
XCTAssertEqual(linkElements.count, focusItems.count, "All focus items should be LinkElement instances")
726+
727+
// Using accessibilityLabel here since label is private.
728+
XCTAssertEqual(
729+
linkElements.map { $0.accessibilityLabel! },
730+
["Sam", "Frodo", "Pippin", "Merry"],
731+
"Focus items ordering should match the ordering of the links in the attributed string"
732+
)
733+
}
734+
735+
func test_focusItems_emptyWhenNoLinks() {
736+
let string = NSAttributedString(string: "This is plain text with no links")
737+
let label = AttributedLabel(attributedText: string)
738+
739+
let view = BlueprintView()
740+
view.element = label
741+
view.layoutIfNeeded()
742+
743+
guard let labelView = view.firstSubview(ofType: AttributedLabel.LabelView.self) else {
744+
XCTFail("Could not find AttributedLabel.LabelView")
745+
return
746+
}
747+
748+
let focusItems = labelView.focusItems(in: labelView.bounds)
749+
XCTAssertTrue(focusItems.isEmpty, "Focus items should be empty when no links are present")
750+
}
715751
}
716752

717753
extension UIAccessibilityCustomRotor {

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
### Added
1313

14+
- Added support for tabbing through links in `AttributedLabel`
15+
1416
### Removed
1517

1618
### Changed

0 commit comments

Comments
 (0)