Skip to content

Commit 1c80a02

Browse files
committed
fix: text selection handling in RCTParagraphComponentView and add tests for selection behavior
1 parent 061745c commit 1c80a02

2 files changed

Lines changed: 203 additions & 11 deletions

File tree

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

Lines changed: 63 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
#import <react/renderer/textlayoutmanager/TextLayoutManager.h>
2121
#import <react/utils/ManagedObjectWrapper.h>
2222

23+
#import <React/RCTSurfaceTouchHandler.h>
24+
2325
#import "RCTConversions.h"
2426
#import "RCTFabricComponentsPlugins.h"
2527

@@ -71,6 +73,7 @@ @interface RCTParagraphTextView : UIView
7173
[unpainted removeAttribute:NSUnderlineStyleAttributeName range:range];
7274
[unpainted removeAttribute:NSStrikethroughStyleAttributeName range:range];
7375
[unpainted removeAttribute:NSShadowAttributeName range:range];
76+
[unpainted removeAttribute:RCTAttributedStringIsHighlightedAttributeName range:range];
7477
[unpainted endEditing];
7578

7679
return unpainted;
@@ -85,7 +88,7 @@ @interface RCTParagraphTextView : UIView
8588
* construction rather than by coincidence. UIKit performs the selection; it
8689
* never performs the layout, and it never paints the text.
8790
*/
88-
@interface RCTSelectableTextView : UITextView
91+
@interface RCTSelectableTextView : UITextView <UITextViewDelegate>
8992

9093
/*
9194
* The paragraph as it is painted, before `RCTUnpaintedAttributedString` strips
@@ -100,6 +103,7 @@ @interface RCTSelectableTextView : UITextView
100103

101104
@implementation RCTSelectableTextView {
102105
UITapGestureRecognizer *_dismissSelectionRecognizer;
106+
BOOL _didCancelTouchesForSelection;
103107
}
104108

105109
- (instancetype)initWithFrame:(CGRect)frame textContainer:(NSTextContainer *)textContainer
@@ -121,10 +125,47 @@ - (instancetype)initWithFrame:(CGRect)frame textContainer:(NSTextContainer *)tex
121125
// `RCTParagraphComponentAccessibilityProvider`. Keeping the text view out of
122126
// the accessibility tree leaves that contract exactly as it was.
123127
self.accessibilityElementsHidden = YES;
128+
self.delegate = self;
124129
}
125130
return self;
126131
}
127132

133+
#pragma mark - Giving the touch to the selection
134+
135+
/*
136+
* The selection gesture and `RCTSurfaceTouchHandler` track the same touch, and
137+
* neither cancels the other. `RCTSurfaceTouchHandler` resolves its event
138+
* emitter when the finger lands, before any selection exists, so lifting the
139+
* finger after a long press presses the <Text> under it. Selecting a link
140+
* follows the link.
141+
*
142+
* Once a selection exists the touch belongs to the selection, so the other
143+
* recognizer has to let go. Disabling a recognizer cancels what it tracks,
144+
* which reaches React Native as a cancelled touch and stops the press.
145+
*/
146+
- (void)textViewDidChangeSelection:(UITextView *)textView
147+
{
148+
BOOL hasSelection = textView.selectedRange.length > 0;
149+
if (hasSelection && !_didCancelTouchesForSelection) {
150+
_didCancelTouchesForSelection = YES;
151+
[self _cancelSurfaceTouches];
152+
} else if (!hasSelection) {
153+
_didCancelTouchesForSelection = NO;
154+
}
155+
}
156+
157+
- (void)_cancelSurfaceTouches
158+
{
159+
for (UIView *ancestor = self.superview; ancestor != nil; ancestor = ancestor.superview) {
160+
for (UIGestureRecognizer *recognizer in ancestor.gestureRecognizers) {
161+
if ([recognizer isKindOfClass:[RCTSurfaceTouchHandler class]] && recognizer.isEnabled) {
162+
recognizer.enabled = NO;
163+
recognizer.enabled = YES;
164+
}
165+
}
166+
}
167+
}
168+
128169
#pragma mark - Dismissing the selection
129170

130171
/*
@@ -332,9 +373,6 @@ - (void)updateState:(const State::Shared &)state oldState:(const State::Shared &
332373
{
333374
_textView.state = std::static_pointer_cast<const ParagraphShadowNode::ConcreteState>(state);
334375
[_textView setNeedsDisplay];
335-
#if !TARGET_OS_TV
336-
_selectionRenderedText = nil;
337-
#endif
338376
[self setNeedsLayout];
339377

340378
// If the attributed string has changed, we need to notify the accessibility system that something changed,
@@ -511,6 +549,16 @@ - (UIAccessibilityTraits)accessibilityTraits
511549

512550
- (SharedTouchEventEmitter)touchEventEmitterAtPoint:(CGPoint)point
513551
{
552+
#if !TARGET_OS_TV
553+
// A drag of a selection handle starts on the glyphs the handle sits on, and
554+
// that is often a pressable <Text>. Adjusting a selection must not press it.
555+
// `resignFirstResponder` empties the range, so an empty range means no
556+
// selection is on screen and an ordinary press goes through.
557+
if (_selectableTextView.selectedRange.length > 0) {
558+
return nullptr;
559+
}
560+
#endif
561+
514562
const auto &state = _textView.state;
515563
if (!state) {
516564
return _eventEmitter;
@@ -584,15 +632,19 @@ - (void)updateSelectableTextViewWithDrawingFrame:(CGRect)drawingFrame
584632
return;
585633
}
586634

587-
BOOL needsRebuild = _selectableTextView == nil ||
588-
![attributedText isEqualToAttributedString:_selectionRenderedText] ||
635+
// The layout string decides the rebuild, and the painted string does not. A
636+
// press on a nested pressable <Text> paints a highlight, which changes the
637+
// painted string. A rebuild in the middle of that touch destroys the text
638+
// view before its long press starts, so the paragraph never selects.
639+
NSAttributedString *layoutText = RCTUnpaintedAttributedString(attributedText);
640+
641+
BOOL needsRebuild = _selectableTextView == nil || ![layoutText isEqualToAttributedString:_selectionRenderedText] ||
589642
!CGSizeEqualToSize(drawingFrame.size, _selectionRenderedSize);
590643

591644
if (needsRebuild) {
592-
NSTextStorage *textStorage =
593-
[_selectionLayoutManager textStorageForNSAttributedString:RCTUnpaintedAttributedString(attributedText)
594-
paragraphAttributes:_paragraphAttributes
595-
size:drawingFrame.size];
645+
NSTextStorage *textStorage = [_selectionLayoutManager textStorageForNSAttributedString:layoutText
646+
paragraphAttributes:_paragraphAttributes
647+
size:drawingFrame.size];
596648
NSTextContainer *textContainer = textStorage.layoutManagers.firstObject.textContainers.firstObject;
597649

598650
[_selectableTextView removeFromSuperview];
@@ -607,7 +659,7 @@ - (void)updateSelectableTextViewWithDrawingFrame:(CGRect)drawingFrame
607659
[self addSubview:_selectableTextView];
608660
}
609661

610-
_selectionRenderedText = [attributedText copy];
662+
_selectionRenderedText = [layoutText copy];
611663
_selectionRenderedSize = drawingFrame.size;
612664
}
613665

‎packages/react-native/React/Tests/Text/RCTParagraphSelectionTests.mm‎

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,14 @@
1010

1111
#import <React/RCTParagraphComponentView.h>
1212

13+
#import <React/RCTTouchableComponentViewProtocol.h>
1314
#import <react/renderer/components/text/ParagraphComponentDescriptor.h>
1415
#import <react/renderer/components/text/ParagraphProps.h>
1516
#import <react/renderer/components/text/ParagraphShadowNode.h>
1617
#import <react/renderer/components/text/ParagraphState.h>
18+
#import <react/renderer/components/text/TextShadowNode.h>
19+
#import <react/renderer/components/view/TouchEventEmitter.h>
20+
#import <react/renderer/core/EventDispatcher.h>
1721
#import <react/renderer/graphics/Color.h>
1822
#import <react/renderer/textlayoutmanager/TextLayoutManager.h>
1923
#import <react/utils/ContextContainer.h>
@@ -46,16 +50,73 @@ - (void)setUp
4650
* a strikethrough and a shadow.
4751
*/
4852
- (AttributedString)decoratedAttributedString
53+
{
54+
return [self decoratedAttributedStringHighlighted:NO];
55+
}
56+
57+
- (AttributedString)decoratedAttributedStringHighlighted:(BOOL)highlighted
4958
{
5059
auto textAttributes = TextAttributes{};
5160
textAttributes.foregroundColor = colorFromRGBA(255, 0, 0, 255);
5261
textAttributes.fontSize = 20;
5362
textAttributes.textDecorationLineType = TextDecorationLineType::UnderlineStrikethrough;
5463
textAttributes.textDecorationStyle = TextDecorationStyle::Wavy;
64+
if (highlighted) {
65+
textAttributes.isHighlighted = true;
66+
}
67+
68+
auto fragment = AttributedString::Fragment{};
69+
fragment.string = "Selectable wavy decoration";
70+
fragment.textAttributes = textAttributes;
71+
72+
auto attributedString = AttributedString{};
73+
attributedString.appendFragment(std::move(fragment));
74+
return attributedString;
75+
}
76+
77+
/*
78+
* A paragraph whose single fragment carries a touch emitter, which is how a
79+
* nested pressable <Text> reaches `touchEventEmitterAtPoint:`.
80+
*/
81+
- (AttributedString)attributedStringWithEventEmitter:(std::shared_ptr<TouchEventEmitter>)eventEmitter
82+
{
83+
auto textAttributes = TextAttributes{};
84+
textAttributes.fontSize = 20;
5585

5686
auto fragment = AttributedString::Fragment{};
5787
fragment.string = "Selectable wavy decoration";
5888
fragment.textAttributes = textAttributes;
89+
fragment.parentShadowView.eventEmitter = eventEmitter;
90+
// The emitter only reaches the attributed string when the fragment names a
91+
// component, which is what marks it as a nested <Text> rather than raw text.
92+
fragment.parentShadowView.componentHandle = TextShadowNode::Handle();
93+
94+
auto attributedString = AttributedString{};
95+
attributedString.appendFragment(std::move(fragment));
96+
return attributedString;
97+
}
98+
99+
- (RCTParagraphComponentView *)paragraphViewWithEventEmitter:(std::shared_ptr<TouchEventEmitter>)eventEmitter
100+
{
101+
RCTParagraphComponentView *view = [RCTParagraphComponentView new];
102+
view.frame = CGRectMake(0, 0, 320, 100);
103+
104+
[view updateProps:[self propsWithSelectable:YES] oldProps:nullptr];
105+
[view updateState:[self stateWithAttributedString:[self attributedStringWithEventEmitter:eventEmitter]] oldState:nil];
106+
107+
auto layoutMetrics = LayoutMetrics{};
108+
layoutMetrics.frame = facebook::react::Rect{facebook::react::Point{0, 0}, facebook::react::Size{320, 100}};
109+
[view updateLayoutMetrics:layoutMetrics oldLayoutMetrics:{}];
110+
111+
[view layoutIfNeeded];
112+
return view;
113+
}
114+
115+
- (AttributedString)attributedStringWithText:(std::string)text
116+
{
117+
auto fragment = AttributedString::Fragment{};
118+
fragment.string = std::move(text);
119+
fragment.textAttributes = TextAttributes{};
59120

60121
auto attributedString = AttributedString{};
61122
attributedString.appendFragment(std::move(fragment));
@@ -291,6 +352,85 @@ - (void)testRecyclingRemovesTheSelectionTextView
291352
XCTAssertNil([self selectionTextViewIn:view], @"A recycled paragraph must not keep a selection text view.");
292353
}
293354

355+
#pragma mark - A press must not rebuild the selection
356+
357+
/*
358+
* Pressing a nested pressable <Text> paints a highlight, which changes the
359+
* painted string but not the layout. Rebuilding the text view in the middle of
360+
* that touch destroys the long press that UIKit has already started, and the
361+
* paragraph never selects. Only a layout change may rebuild it.
362+
*/
363+
- (void)testHighlightOnlyUpdateKeepsTheSameSelectionTextView
364+
{
365+
RCTParagraphComponentView *view = [self paragraphViewSelectable:YES];
366+
UITextView *before = [self selectionTextViewIn:view];
367+
XCTAssertNotNil(before);
368+
369+
[view updateState:[self stateWithAttributedString:[self decoratedAttributedStringHighlighted:YES]] oldState:nil];
370+
[view layoutIfNeeded];
371+
372+
XCTAssertEqual(
373+
[self selectionTextViewIn:view], before, @"A pressed highlight must not rebuild the selection text view.");
374+
}
375+
376+
/*
377+
* The cache must not go too far the other way: different text lays out
378+
* differently, so it has to produce a new text view.
379+
*/
380+
- (void)testTextChangeRebuildsTheSelectionTextView
381+
{
382+
RCTParagraphComponentView *view = [self paragraphViewSelectable:YES];
383+
UITextView *before = [self selectionTextViewIn:view];
384+
XCTAssertNotNil(before);
385+
386+
[view updateState:[self stateWithAttributedString:[self attributedStringWithText:"A different paragraph"]]
387+
oldState:nil];
388+
[view layoutIfNeeded];
389+
390+
XCTAssertNotEqual([self selectionTextViewIn:view], before, @"New text must rebuild the selection text view.");
391+
}
392+
393+
#pragma mark - A showing selection owns the touch
394+
395+
/*
396+
* A drag of a selection handle starts on the glyphs the handle sits on, which
397+
* is often a pressable <Text>. Delivering that touch presses the link, so
398+
* adjusting a selection follows it. While a selection shows, the paragraph
399+
* must hand out no event emitter at all.
400+
*/
401+
- (void)testShowingSelectionGivesNoEventEmitter
402+
{
403+
auto eventEmitter = std::make_shared<TouchEventEmitter>(nullptr, EventDispatcher::Weak{});
404+
RCTParagraphComponentView *view = [self paragraphViewWithEventEmitter:eventEmitter];
405+
UITextView *selectionTextView = [self selectionTextViewIn:view];
406+
XCTAssertNotNil(selectionTextView);
407+
408+
selectionTextView.selectedRange = NSMakeRange(0, 5);
409+
410+
XCTAssertTrue(
411+
[(id<RCTTouchableComponentViewProtocol>)view touchEventEmitterAtPoint:CGPointMake(100, 10)] == nullptr,
412+
@"A touch that lands while a selection shows must not press the text under it.");
413+
}
414+
415+
/*
416+
* The guard must not reach an ordinary tap. With nothing selected the pressable
417+
* fragment still gets its press.
418+
*/
419+
- (void)testWithoutASelectionThePressStillGoesThrough
420+
{
421+
auto eventEmitter = std::make_shared<TouchEventEmitter>(nullptr, EventDispatcher::Weak{});
422+
RCTParagraphComponentView *view = [self paragraphViewWithEventEmitter:eventEmitter];
423+
UITextView *selectionTextView = [self selectionTextViewIn:view];
424+
XCTAssertNotNil(selectionTextView);
425+
426+
selectionTextView.selectedRange = NSMakeRange(0, 0);
427+
428+
XCTAssertEqual(
429+
(const void *)[(id<RCTTouchableComponentViewProtocol>)view touchEventEmitterAtPoint:CGPointMake(100, 10)].get(),
430+
(const void *)eventEmitter.get(),
431+
@"With nothing selected, a press must still reach the pressable text.");
432+
}
433+
294434
#pragma mark - Accessibility
295435

296436
/*

0 commit comments

Comments
 (0)