Skip to content

Commit d185a90

Browse files
committed
fix: enhance RCTSelectableTextView for improved text selection handling
1 parent bfb22e7 commit d185a90

1 file changed

Lines changed: 142 additions & 21 deletions

File tree

packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm

Lines changed: 142 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -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 &paragraphProps = 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

Comments
 (0)