@@ -201,6 +201,7 @@ extension AttributedLabel {
201
201
private var linkAttributes : [ NSAttributedString . Key : Any ] = [ : ]
202
202
private var activeLinkAttributes : [ NSAttributedString . Key : Any ] = [ : ]
203
203
private var links : [ Link ] = [ ]
204
+ private var linkElements : [ LinkElement ] = [ ]
204
205
205
206
private var textRectOffset : UIOffset = . zero {
206
207
didSet {
@@ -211,13 +212,14 @@ extension AttributedLabel {
211
212
}
212
213
213
214
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) ] : [ ] }
219
217
}
220
218
219
+ override var canBecomeFocused : Bool { false }
220
+
221
+ override func focusItems( in rect: CGRect ) -> [ any UIFocusItem ] { linkElements }
222
+
221
223
var urlHandler : URLHandler ?
222
224
223
225
func update( model: AttributedLabel , text: NSAttributedString , environment: Environment , isMeasuring: Bool ) {
@@ -256,6 +258,9 @@ extension AttributedLabel {
256
258
in: model. attributedText. string,
257
259
linkAccessibilityLabel: environment. linkAccessibilityLabel
258
260
)
261
+ linkElements = links
262
+ . sorted ( by: { $0. range. location < $1. range. location } )
263
+ . compactMap { . init( sourceLabel: attributedText, link: $0) }
259
264
}
260
265
261
266
if let shadow = model. shadow {
@@ -431,9 +436,9 @@ extension AttributedLabel {
431
436
options: [ ]
432
437
) { link, range, _ in
433
438
if let link = link as? URL {
434
- links. append ( . init( url: link, range: range) )
439
+ links. append ( . init( url: link, range: range, container : self ) )
435
440
} 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 ) )
437
442
}
438
443
}
439
444
@@ -468,13 +473,13 @@ extension AttributedLabel {
468
473
let charactersToRemove = CharacterSet . decimalDigits. inverted
469
474
let trimmedPhoneNumber = phoneNumber. components ( separatedBy: charactersToRemove) . joined ( )
470
475
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 ) )
472
477
}
473
478
}
474
479
475
480
case . link:
476
481
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 ) )
478
483
}
479
484
480
485
case . address:
@@ -495,15 +500,15 @@ extension AttributedLabel {
495
500
if let urlQuery = address. addingPercentEncoding ( withAllowedCharacters: . urlQueryAllowed) ,
496
501
let url = URL ( string: " https://maps.apple.com/?address= \( urlQuery) " )
497
502
{
498
- links. append ( . init( url: url, range: result. range) )
503
+ links. append ( . init( url: url, range: result. range, container : self ) )
499
504
}
500
505
}
501
506
502
507
case . date:
503
508
if let date = result. date,
504
509
let url = URL ( string: " calshow: \( date. timeIntervalSinceReferenceDate) " )
505
510
{
506
- links. append ( . init( url: url, range: result. range) )
511
+ links. append ( . init( url: url, range: result. range, container : self ) )
507
512
}
508
513
509
514
default :
@@ -514,22 +519,6 @@ extension AttributedLabel {
514
519
return links
515
520
}
516
521
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
-
533
522
internal func accessibilityLabel( with links: [ Link ] , in string: String , linkAccessibilityLabel: String ? ) -> String {
534
523
// 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.
535
524
@@ -663,41 +652,108 @@ extension AttributedLabel {
663
652
struct Link : Equatable , Hashable {
664
653
var url : URL
665
654
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
+ }
666
693
}
667
694
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( ) { }
671
704
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 ? ,
675
716
link: AttributedLabel . Link
676
717
) {
718
+ guard let sourceLabel, NSIntersectionRange ( sourceLabel. entireRange, link. range) . length > 0 else { return nil }
719
+
720
+ self . sourceLabel = sourceLabel
677
721
self . link = link
678
- super. init ( accessibilityContainer: container)
679
- accessibilityLabel = label
680
- accessibilityTraits = [ . link]
722
+ super. init ( accessibilityContainer: link. container)
681
723
}
682
724
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
+
683
740
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
+ }
693
744
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] }
697
753
}
698
754
699
755
override func accessibilityActivate( ) -> Bool {
700
- container? . urlHandler? . onTap ( url: link. url)
756
+ link . container? . urlHandler? . onTap ( url: link. url)
701
757
return true
702
758
}
703
759
}
@@ -844,3 +900,4 @@ extension String {
844
900
components ( separatedBy: . newlines) . filter { !$0. isEmpty } . joined ( separator: " " )
845
901
}
846
902
}
903
+
0 commit comments