@@ -51,17 +51,46 @@ @interface RCTParagraphTextView : UIView
5151
5252#if !TARGET_OS_TV
5353/*
54- * A non-editable `UITextView` used to render a selectable paragraph.
54+ * Strips every attribute that paints, and keeps every attribute that lays out.
55+ *
56+ * `RCTTextLayoutManager` draws the paragraph itself, and it draws effects UIKit
57+ * knows nothing about: wavy, dotted and dashed decorations, and the pressed
58+ * highlight of a nested pressable <Text>. The selection text view must lay the
59+ * same glyphs out, because that is what places the selection rects, but it must
60+ * not paint them. So the font, the kerning, the paragraph style and the
61+ * attachments stay, and the colors, the decorations and the shadow go.
62+ */
63+ static NSAttributedString *RCTUnpaintedAttributedString (NSAttributedString *attributedString)
64+ {
65+ NSMutableAttributedString *unpainted = [attributedString mutableCopy ];
66+ NSRange range = NSMakeRange (0 , unpainted.length );
67+
68+ [unpainted beginEditing ];
69+ [unpainted addAttribute: NSForegroundColorAttributeName value: UIColor.clearColor range: range];
70+ [unpainted addAttribute: NSBackgroundColorAttributeName value: UIColor.clearColor range: range];
71+ [unpainted removeAttribute: NSUnderlineStyleAttributeName range: range];
72+ [unpainted removeAttribute: NSStrikethroughStyleAttributeName range: range];
73+ [unpainted removeAttribute: NSShadowAttributeName range: range];
74+ [unpainted endEditing ];
75+
76+ return unpainted;
77+ }
78+
79+ /*
80+ * A non-editable `UITextView` that provides selection for a paragraph, and
81+ * nothing else.
5582 *
5683 * It is created with the very `NSTextContainer` that `RCTTextLayoutManager`
5784 * measured the paragraph with, so its layout matches the measurement by
5885 * construction rather than by coincidence. UIKit performs the selection; it
59- * never performs the layout.
86+ * never performs the layout, and it never paints the text .
6087 */
6188@interface RCTSelectableTextView : UITextView
6289@end
6390
64- @implementation RCTSelectableTextView
91+ @implementation RCTSelectableTextView {
92+ UITapGestureRecognizer *_dismissSelectionRecognizer;
93+ }
6594
6695- (instancetype )initWithFrame : (CGRect)frame textContainer : (NSTextContainer *)textContainer
6796{
@@ -86,6 +115,87 @@ - (instancetype)initWithFrame:(CGRect)frame textContainer:(NSTextContainer *)tex
86115 return self;
87116}
88117
118+ #pragma mark - Dismissing the selection
119+
120+ /*
121+ * A tap outside the text clears the selection, which is what Android does and
122+ * what a user expects. Nothing else in React Native takes first responder on a
123+ * tap, so without this the selection stays on screen forever.
124+ */
125+ - (BOOL )becomeFirstResponder
126+ {
127+ BOOL didBecomeFirstResponder = [super becomeFirstResponder ];
128+ if (didBecomeFirstResponder) {
129+ [self _addDismissSelectionRecognizer ];
130+ }
131+ return didBecomeFirstResponder;
132+ }
133+
134+ - (BOOL )resignFirstResponder
135+ {
136+ BOOL didResignFirstResponder = [super resignFirstResponder ];
137+ if (didResignFirstResponder) {
138+ [self _removeDismissSelectionRecognizer ];
139+ self.selectedRange = NSMakeRange (0 , 0 );
140+ }
141+ return didResignFirstResponder;
142+ }
143+
144+ - (void )willMoveToWindow : (UIWindow *)newWindow
145+ {
146+ [super willMoveToWindow: newWindow];
147+ if (newWindow == nil ) {
148+ // The recognizer holds this view, so it has to go when the view does.
149+ [self _removeDismissSelectionRecognizer ];
150+ }
151+ }
152+
153+ - (void )_addDismissSelectionRecognizer
154+ {
155+ if (_dismissSelectionRecognizer != nil ) {
156+ return ;
157+ }
158+
159+ // The recognizer belongs on the topmost React Native view, and not on the
160+ // window: `RCTSurfaceTouchHandler` gives way to a recognizer that sits
161+ // outside the surface, so a recognizer on the window would make every touch
162+ // in the application wait for this one.
163+ UIView *rootView = nil ;
164+ for (UIView *ancestor = self.superview ; ancestor != nil ; ancestor = ancestor.superview ) {
165+ if ([ancestor isKindOfClass: [RCTViewComponentView class ]]) {
166+ rootView = ancestor;
167+ }
168+ }
169+ if (rootView == nil ) {
170+ return ;
171+ }
172+
173+ _dismissSelectionRecognizer =
174+ [[UITapGestureRecognizer alloc ] initWithTarget: self action: @selector (_handleTapToDismissSelection: )];
175+ // The tap still reaches the component the user tapped.
176+ _dismissSelectionRecognizer.cancelsTouchesInView = NO ;
177+ _dismissSelectionRecognizer.delaysTouchesBegan = NO ;
178+ _dismissSelectionRecognizer.delaysTouchesEnded = NO ;
179+ [rootView addGestureRecognizer: _dismissSelectionRecognizer];
180+ }
181+
182+ - (void )_removeDismissSelectionRecognizer
183+ {
184+ [_dismissSelectionRecognizer.view removeGestureRecognizer: _dismissSelectionRecognizer];
185+ _dismissSelectionRecognizer = nil ;
186+ }
187+
188+ - (void )_handleTapToDismissSelection : (UITapGestureRecognizer *)recognizer
189+ {
190+ // A tap on the text itself belongs to the text view, which moves or clears
191+ // the selection on its own.
192+ if ([self pointInside: [recognizer locationInView: self ] withEvent: nil ]) {
193+ return ;
194+ }
195+
196+ [self resignFirstResponder ];
197+ }
198+
89199@end
90200#endif // !TARGET_OS_TV
91201
@@ -248,7 +358,10 @@ - (void)layoutSubviews
248358#if !TARGET_OS_TV
249359 const auto ¶graphProps = static_cast <const ParagraphProps &>(*_props);
250360 if (paragraphProps.isSelectable ) {
251- [self updateSelectableTextViewWithFrame: RCTCGRectFromRect (_layoutMetrics.getContentFrame ())];
361+ // `drawingFrame` is the frame `RCTParagraphTextView` draws the glyphs into,
362+ // compression adjustment included. The selection must use the same frame,
363+ // or the selection rects sit away from the glyphs they select.
364+ [self updateSelectableTextViewWithDrawingFrame: drawingFrame];
252365 }
253366#endif
254367}
@@ -403,51 +516,59 @@ - (void)enableContextMenu
403516}
404517
405518- (void )disableContextMenu
519+ {
520+ [self removeSelectableTextView ];
521+ }
522+
523+ - (void )removeSelectableTextView
406524{
407525 [_selectableTextView removeFromSuperview ];
408526 _selectableTextView = nil ;
409527 _selectionRenderedText = nil ;
410528 _selectionRenderedSize = CGSizeZero;
411- _textView.hidden = NO ;
412529}
413530
414531/*
415532 * Builds or repositions the selectable text view. A `UITextView` binds its text
416533 * container at initialisation, so it is rebuilt only when the text or the
417534 * available size actually changes.
418535 */
419- - (void )updateSelectableTextViewWithFrame : (CGRect)contentFrame
536+ - (void )updateSelectableTextViewWithDrawingFrame : (CGRect)drawingFrame
420537{
421538 NSAttributedString *attributedText = self.attributedText ;
422- if (attributedText == nil || CGRectIsEmpty (contentFrame)) {
423- [_selectableTextView removeFromSuperview ];
424- _selectableTextView = nil ;
425- _selectionRenderedText = nil ;
426- _textView.hidden = NO ;
539+ if (attributedText.length == 0 || CGRectIsEmpty (drawingFrame)) {
540+ [self removeSelectableTextView ];
427541 return ;
428542 }
429543
430544 BOOL needsRebuild = _selectableTextView == nil ||
431545 ![attributedText isEqualToAttributedString: _selectionRenderedText] ||
432- !CGSizeEqualToSize (contentFrame .size , _selectionRenderedSize);
546+ !CGSizeEqualToSize (drawingFrame .size , _selectionRenderedSize);
433547
434548 if (needsRebuild) {
435- NSTextStorage *textStorage = [_selectionLayoutManager textStorageForNSAttributedString: attributedText
436- paragraphAttributes: _paragraphAttributes
437- size: contentFrame.size];
549+ NSTextStorage *textStorage =
550+ [_selectionLayoutManager textStorageForNSAttributedString: RCTUnpaintedAttributedString (attributedText)
551+ paragraphAttributes: _paragraphAttributes
552+ size: drawingFrame.size];
438553 NSTextContainer *textContainer = textStorage.layoutManagers .firstObject .textContainers .firstObject ;
439554
440555 [_selectableTextView removeFromSuperview ];
441- _selectableTextView = [[RCTSelectableTextView alloc ] initWithFrame: contentFrame textContainer: textContainer];
442- [self addSubview: _selectableTextView];
556+ _selectableTextView = [[RCTSelectableTextView alloc ] initWithFrame: drawingFrame textContainer: textContainer];
557+ // Under the drawn paragraph, which is how a native text view stacks the two:
558+ // UIKit paints the selection, and the glyphs go on top of it. The drawn
559+ // paragraph passes touches through, so the text view still gets them.
560+ UIView *container = _textView.superview ;
561+ if (container != nil ) {
562+ [container insertSubview: _selectableTextView belowSubview: _textView];
563+ } else {
564+ [self addSubview: _selectableTextView];
565+ }
443566
444567 _selectionRenderedText = [attributedText copy ];
445- _selectionRenderedSize = contentFrame .size ;
568+ _selectionRenderedSize = drawingFrame .size ;
446569 }
447570
448- _selectableTextView.frame = contentFrame;
449- // The text view renders the paragraph, so the drawn copy must stay hidden.
450- _textView.hidden = YES ;
571+ _selectableTextView.frame = drawingFrame;
451572}
452573
453574- (BOOL )canBecomeFirstResponder
0 commit comments