Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
face14d
feat: implement selectable text view and update text layout manager f…
raajnadar Sep 6, 2026
bfb22e7
fix: add textStorageForNSAttributedString method to RCTTextLayoutManager
raajnadar Sep 7, 2026
d185a90
fix: enhance RCTSelectableTextView for improved text selection handling
raajnadar Sep 12, 2026
2dbe5b7
fix: add sourceAttributedText property and update copy method in RCTS…
raajnadar Sep 12, 2026
ef3e8d7
Merge branch 'main' into fix/ios-partial-text-selection
raajnadar Sep 18, 2026
a401293
Merge remote-tracking branch 'upstream/main' into fix/ios-partial-tex…
raajnadar Sep 18, 2026
061745c
remove redundant comments in RCTParagraphComponentView.mm for clarity
raajnadar Sep 19, 2026
1c80a02
fix: text selection handling in RCTParagraphComponentView and add tes…
raajnadar Sep 19, 2026
06858b5
fix: improve selection text view handling in recycled paragraphs and …
raajnadar Sep 25, 2026
cfff5db
feat: split RCTSelectableTextView to add it behind feature flag
raajnadar Oct 10, 2026
804fc55
Merge remote-tracking branch 'upstream/main' into fix/ios-partial-tex…
raajnadar Oct 10, 2026
ec3503a
fix: build the selectable text view in finalizeUpdates after layoutSu…
raajnadar Oct 10, 2026
d974ca1
feat: put partial text selection behind the enableIOSPartialTextSelec…
raajnadar Oct 10, 2026
44645fc
Merge remote-tracking branch 'upstream/main' into fix/ios-partial-tex…
raajnadar Oct 14, 2026
8c28b3f
Merge remote-tracking branch 'upstream/main' into fix/ios-partial-tex…
raajnadar Oct 16, 2026
089b0b1
Merge remote-tracking branch 'upstream/main' into fix/ios-partial-tex…
raajnadar Oct 23, 2026
fdb5bc7
Merge branch 'main' of https://github.com/react/react-native into fix…
raajnadar Oct 31, 2026
354dbf4
test(iOS): prove the selection text view strips native painting attri…
raajnadar Nov 1, 2026
cf789ab
Merge remote-tracking branch 'upstream/main' into fix/ios-partial-tex…
raajnadar Nov 4, 2026
8aff841
fix: build the selection text view on the first touch
raajnadar Nov 4, 2026
bff1c36
Merge remote-tracking branch 'upstream/main' into fix/ios-partial-tex…
raajnadar Nov 7, 2026
1355738
fix: lay out the selection text in the same container size as the dra…
raajnadar Nov 7, 2026
4e07d1e
fix: cancel only the selection touch and scope the emitter guard to t…
raajnadar Nov 7, 2026
5913659
fix: enhance paragraph selection tests for layout consistency and fon…
raajnadar Nov 7, 2026
422d8c0
fix: keep a touch on the selection handles out of React Native from s…
raajnadar Nov 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

#import "RCTParagraphComponentView.h"
#import "RCTParagraphComponentAccessibilityProvider.h"
#import "RCTSelectableTextView+Internal.h"

#import <MobileCoreServices/UTCoreTypes.h>
#import <react/featureflags/ReactNativeFeatureFlags.h>
Expand All @@ -32,6 +33,10 @@ - (CGRect)drawingFrameForAttributedString:(const facebook::react::AttributedStri
frame:(CGRect)frame
containerFrame:(CGRect *)containerFrame;

- (NSTextStorage *)textStorageForNSAttributedString:(NSAttributedString *)attributedString
paragraphAttributes:(facebook::react::ParagraphAttributes)paragraphAttributes
size:(CGSize)size;

@end

static BOOL RCTAttributedStringHasAttachments(const AttributedString &attributedString)
Expand Down Expand Up @@ -72,6 +77,15 @@ @implementation RCTParagraphComponentView {
UILongPressGestureRecognizer *_longPressGestureRecognizer;
RCTParagraphTextView *_textView;
CGRect _textLayoutFrame;
#if !TARGET_OS_TV
RCTSelectableTextView *_selectableTextView;
RCTTextLayoutManager *_selectionLayoutManager;
ParagraphShadowNode::ConcreteState::Shared _selectionRenderedState;
NSAttributedString *_selectionRenderedText;
CGSize _selectionRenderedSize;
ParagraphAttributes _selectionRenderedParagraphAttributes;
BOOL _selectableTextViewNeedsUpdate;
#endif
}

- (instancetype)initWithFrame:(CGRect)frame
Expand Down Expand Up @@ -140,6 +154,16 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
}
}

#if !TARGET_OS_TV
// The selection text view lays the text out with these attributes. A change
// of them alone computes no new frame, so nothing else marks the view dirty.
if (newParagraphProps.isSelectable &&
newParagraphProps.paragraphAttributes != oldParagraphProps.paragraphAttributes &&
ReactNativeFeatureFlags::enableIOSPartialTextSelection()) {
_selectableTextViewNeedsUpdate = YES;
}
#endif

[super updateProps:props oldProps:oldProps];
}

Expand Down Expand Up @@ -206,13 +230,30 @@ - (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask
if ((updateMask & (RNComponentViewUpdateMaskState | RNComponentViewUpdateMaskLayoutMetrics)) != 0) {
[self _updateTextViewFrame];
}
#if !TARGET_OS_TV
// `updateProps:` runs before the state and the layout of the same mutation,
// and a change of `selectable` alone computes no frames. So the text view is
// updated here, once, after all of the mutation. A text view that does not
// exist yet waits for the first touch.
if (_selectableTextView != nil) {
[self updateSelectableTextViewIfNeeded];
}
#endif
}

- (void)prepareForRecycle
{
[super prepareForRecycle];
_textView.state = nullptr;
_accessibilityProvider = nil;
#if !TARGET_OS_TV
// Recycling removes only the selection text view. The long press stays,
// because the view keeps its `_props`: when the next paragraph is selectable
// too, `updateProps:` sees no change and does not add the long press again.
if (ReactNativeFeatureFlags::enableIOSPartialTextSelection()) {
[self disableTextSelection];
}
#endif
}

- (void)_updateTextViewFrame
Expand Down Expand Up @@ -246,6 +287,12 @@ - (void)_updateTextViewFrame
_textLayoutFrame = drawingFrame;
_textView.frame = textViewFrame;
_textView.drawingFrame = CGRectOffset(drawingFrame, -textViewFrame.origin.x, -textViewFrame.origin.y);

#if !TARGET_OS_TV
if ([self isPartialTextSelectionEnabled]) {
_selectableTextViewNeedsUpdate = YES;
}
#endif
}

#pragma mark - Accessibility
Expand Down Expand Up @@ -350,6 +397,17 @@ - (UIAccessibilityTraits)accessibilityTraits

- (SharedTouchEventEmitter)touchEventEmitterAtPoint:(CGPoint)point
{
#if !TARGET_OS_TV
// A drag of a selection handle starts on the glyphs the handle sits on, and
// that is often a pressable <Text>. Adjusting a selection must not press it.
// Only a touch on the selected text or on its handles is held back: a tap in
// the padding still presses the paragraph, or the <Pressable> around it.
if (_selectableTextView != nil &&
[_selectableTextView selectionOwnsTouchAtPoint:[self convertPoint:point toView:_selectableTextView]]) {
return nullptr;
}
#endif

const auto &state = _textView.state;
if (!state) {
return _eventEmitter;
Expand Down Expand Up @@ -384,6 +442,11 @@ - (SharedTouchEventEmitter)touchEventEmitterAtPoint:(CGPoint)point
#if !TARGET_OS_TV
- (void)enableContextMenu
{
if (ReactNativeFeatureFlags::enableIOSPartialTextSelection()) {
[self enableTextSelection];
return;
}

_longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self
action:@selector(handleLongPress:)];

Expand All @@ -396,6 +459,11 @@ - (void)enableContextMenu

- (void)disableContextMenu
{
if (ReactNativeFeatureFlags::enableIOSPartialTextSelection()) {
[self disableTextSelection];
return;
}

[self removeGestureRecognizer:_longPressGestureRecognizer];
if (@available(iOS 16.0, *)) {
[self removeInteraction:_editMenuInteraction];
Expand Down Expand Up @@ -425,12 +493,24 @@ - (void)handleLongPress:(UILongPressGestureRecognizer *)gesture

- (BOOL)canBecomeFirstResponder
{
// With partial selection, `_selectableTextView` is the responder that owns the selection.
if (ReactNativeFeatureFlags::enableIOSPartialTextSelection()) {
return NO;
}

const auto &paragraphProps = static_cast<const ParagraphProps &>(*_props);
return paragraphProps.isSelectable;
}

- (BOOL)canPerformAction:(SEL)action withSender:(id)sender
{
// With partial selection, `_selectableTextView` copies the selected range.
// This class implements `copy:` for the long-press menu, so the inherited
// answer for `copy:` is YES and would make the paragraph copy all its text.
if (ReactNativeFeatureFlags::enableIOSPartialTextSelection()) {
return action != @selector(copy:) && [super canPerformAction:action withSender:sender];
}

const auto &paragraphProps = static_cast<const ParagraphProps &>(*_props);

if (paragraphProps.isSelectable && action == @selector(copy:)) {
Expand Down Expand Up @@ -459,6 +539,184 @@ - (void)copy:(id)sender
UIPasteboard *pasteboard = [UIPasteboard generalPasteboard];
pasteboard.items = @[ item ];
}

#pragma mark - Text Selection

/*
* Selection is provided by a `UITextView` laid out with the paragraph's own
* TextKit stack, which gives the platform behaviour users expect: long press to
* select a word, drag handles to extend the range and an edit menu that copies
* only what is selected.
*/
- (void)enableTextSelection
{
_selectableTextViewNeedsUpdate = YES;
}

- (void)disableTextSelection
{
_selectableTextViewNeedsUpdate = NO;
[self removeSelectableTextView];
_selectionLayoutManager = nil;
}

- (BOOL)isPartialTextSelectionEnabled
{
const auto &paragraphProps = static_cast<const ParagraphProps &>(*_props);
return paragraphProps.isSelectable && ReactNativeFeatureFlags::enableIOSPartialTextSelection();
}

/*
* The selection text view is built on the first touch that lands on the
* paragraph, and not on mount. Most selectable paragraphs are never selected,
* and each text view holds a TextKit stack of its own.
*
* The text view sits right below the glyphs, and so above the inline views of
* the paragraph. The inline views must still get their touches first, as they
* do without the text view. The text view then takes only the touches that
* the paragraph itself takes: none with `pointerEvents="box-none"`, and all on
* the text with `pointerEvents="box-only"`.
*/
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
UIView *hitView = [super hitTest:point withEvent:event];
if (![self isPartialTextSelectionEnabled]) {
return hitView;
}

if (hitView == self) {
[self updateSelectableTextViewIfNeeded];
return [self hitTestSelectableTextView:point withEvent:event] ?: self;
}

if (_selectableTextView != nil && [hitView isDescendantOfView:_selectableTextView]) {
UIView *inlineHitView = [self hitTestInlineViews:point withEvent:event];
if (inlineHitView != nil) {
return inlineHitView;
}
return _props->pointerEvents == PointerEventsMode::BoxNone ? nil : hitView;
}

return hitView;
}

- (UIView *)hitTestSelectableTextView:(CGPoint)point withEvent:(UIEvent *)event
{
if (_selectableTextView == nil) {
return nil;
}
return [_selectableTextView hitTest:[_selectableTextView convertPoint:point fromView:self] withEvent:event];
}

- (UIView *)hitTestInlineViews:(CGPoint)point withEvent:(UIEvent *)event
{
for (UIView *subview in [_selectableTextView.superview.subviews reverseObjectEnumerator]) {
if (subview == _selectableTextView || subview == _textView) {
continue;
}
UIView *hitView = [subview hitTest:[subview convertPoint:point fromView:self] withEvent:event];
if (hitView != nil) {
return hitView;
}
}
return nil;
}

- (void)removeSelectableTextView
{
[_selectableTextView removeFromSuperview];
_selectableTextView = nil;
_selectionRenderedState = nullptr;
_selectionRenderedText = nil;
_selectionRenderedSize = CGSizeZero;
_selectionRenderedParagraphAttributes = ParagraphAttributes{};
}

- (void)updateSelectableTextViewIfNeeded
{
if (!_selectableTextViewNeedsUpdate) {
return;
}
_selectableTextViewNeedsUpdate = NO;
// `_textLayoutFrame` is the frame `RCTParagraphTextView` draws the glyphs
// into, compression adjustment included. The selection must use the same
// frame, or the selection rects sit away from the glyphs they select.
[self updateSelectableTextViewWithDrawingFrame:_textLayoutFrame];
}

/*
* A `UITextView` binds its text container at initialisation, so the selectable
* text view is rebuilt only when the text, the available size or the paragraph
* attributes that configure the container change.
*/
- (void)updateSelectableTextViewWithDrawingFrame:(CGRect)drawingFrame

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This creates a UITextView and a separate TextKit stack for every selectable paragraph, before the user interacts with it. It also doesn't reuse the stack we already built for measurement. Even when nothing needs rebuilding, we convert the whole attributed string, strip it and compare it. When the flag is first enabled, both _updateTextViewFrame and the pending-update branch in finalizeUpdates call this method, so the work happens twice.

Could we do this once per mutation? And can you attach flag-off/flag-on measurements for main-thread mounting, scroll frame times and retained memory? The interesting cases are long paragraphs with rich attributes, text updates and recycled list cells.

{
const auto &state = _textView.state;
if (!state || CGRectIsEmpty(drawingFrame)) {
[self removeSelectableTextView];
return;
}

// A relayout of unchanged text is the most frequent update, and it needs no
// conversion of the text. The content comparison does not look at the layout
// metrics that size the attachments, so a paragraph with attachments always
// takes the full comparison.
const auto &attributedString = state->getData().attributedString;
if (_selectableTextView != nil && _selectionRenderedState &&
CGSizeEqualToSize(drawingFrame.size, _selectionRenderedSize) &&
_paragraphAttributes == _selectionRenderedParagraphAttributes &&
!RCTAttributedStringHasAttachments(attributedString) &&
attributedString.isContentEqual(_selectionRenderedState->getData().attributedString)) {
_selectableTextView.frame = drawingFrame;
return;
}

NSAttributedString *attributedText = RCTNSAttributedStringFromAttributedString(attributedString);
if (attributedText.length == 0) {
[self removeSelectableTextView];
return;
}

// The layout string decides the rebuild, and the painted string does not. A
// press on a nested pressable <Text> paints a highlight, which changes the
// painted string. A rebuild in the middle of that touch destroys the text
// view before its long press starts, so the paragraph never selects.
NSAttributedString *layoutText = RCTUnpaintedAttributedString(attributedText);

BOOL needsRebuild = _selectableTextView == nil || ![layoutText isEqualToAttributedString:_selectionRenderedText] ||
!CGSizeEqualToSize(drawingFrame.size, _selectionRenderedSize) ||
_paragraphAttributes != _selectionRenderedParagraphAttributes;

if (needsRebuild) {
if (_selectionLayoutManager == nil) {
_selectionLayoutManager = [RCTTextLayoutManager new];
}
NSTextStorage *textStorage = [_selectionLayoutManager textStorageForNSAttributedString:layoutText
paragraphAttributes:_paragraphAttributes
size:drawingFrame.size];
NSTextContainer *textContainer = textStorage.layoutManagers.firstObject.textContainers.firstObject;

[_selectableTextView removeFromSuperview];
_selectableTextView = [[RCTSelectableTextView alloc] initWithFrame:drawingFrame textContainer:textContainer];
// Under the drawn paragraph, which is how a native text view stacks the two:
// UIKit paints the selection, and the glyphs go on top of it. The drawn
// paragraph passes touches through, so the text view still gets them.
UIView *container = _textView.superview;
if (container != nil) {
[container insertSubview:_selectableTextView belowSubview:_textView];
} else {
[self addSubview:_selectableTextView];
}
Comment on lines +701 to +709

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inserting the text view right below _textView raises a few questions:

  • Inline children: they're mounted starting at index 0, so this view ends up above them. Hit testing goes in reverse order and the drawing view returns nil. So which view gets the touch when the user taps an inline Pressable, TextInput or native control? I think it's the selection UITextView, even when nothing is selected. The test plan checks that inline views are positioned correctly, but not that they're still interactive.
  • pointerEvents="box-none": before, a plain paragraph let touches through because the drawing view returned no hit. Now the UITextView is a child that returns a hit. Wouldn't it steal touches from the controls underneath?
  • pointerEvents="box-only": hit testing stops at the paragraph, so the UITextView recognizers never see the touch. Now that the paragraph-level long-press recognizer is gone, can the user still select text in this mode?
  • Backgrounds: the selection view sits below the paragraph's background pass, not just below the glyphs. If a nested <Text> has an opaque backgroundColor, drawBackgroundForGlyphRange: still paints it in the drawing view above. Wouldn't that cover the selection highlight? Stripping the background attributes from the selection storage doesn't change what the upper view paints.

Could we keep inline children ahead of the selection view in hit testing (also after a rebuild), preserve the pointerEvents behaviors without the auxiliary view becoming a touch target, and keep the order background → selection → glyphs? A render test that selects a range inside a nested text with an opaque background would catch the last one.


_selectionRenderedText = [layoutText copy];
_selectionRenderedSize = drawingFrame.size;
_selectionRenderedParagraphAttributes = _paragraphAttributes;
}

_selectionRenderedState = state;
_selectableTextView.frame = drawingFrame;
_selectableTextView.sourceAttributedText = attributedText;
}
#else
- (void)enableContextMenu
{
Expand Down
Loading
Loading