Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 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
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,13 @@ @implementation RCTParagraphComponentView {
UILongPressGestureRecognizer *_longPressGestureRecognizer;
RCTParagraphTextView *_textView;
CGRect _textLayoutFrame;
#if !TARGET_OS_TV
RCTSelectableTextView *_selectableTextView;
RCTTextLayoutManager *_selectionLayoutManager;
NSAttributedString *_selectionRenderedText;
CGSize _selectionRenderedSize;
BOOL _selectableTextViewNeedsUpdate;
#endif
}

- (instancetype)initWithFrame:(CGRect)frame
Expand Down Expand Up @@ -206,13 +218,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
// built here, after all of the mutation, from the drawing frame computed last.
if (_selectableTextViewNeedsUpdate) {
_selectableTextViewNeedsUpdate = NO;
[self updateSelectableTextViewWithDrawingFrame:_textLayoutFrame];
}
#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 +275,16 @@ - (void)_updateTextViewFrame
_textLayoutFrame = drawingFrame;
_textView.frame = textViewFrame;
_textView.drawingFrame = CGRectOffset(drawingFrame, -textViewFrame.origin.x, -textViewFrame.origin.y);

#if !TARGET_OS_TV
const auto &paragraphProps = static_cast<const ParagraphProps &>(*_props);
if (paragraphProps.isSelectable && ReactNativeFeatureFlags::enableIOSPartialTextSelection()) {
// `drawingFrame` 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:drawingFrame];
}
#endif
}

#pragma mark - Accessibility
Expand Down Expand Up @@ -350,6 +389,16 @@ - (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.
// `resignFirstResponder` empties the range, so an empty range means no
// selection is on screen and an ordinary press goes through.
if (_selectableTextView.selectedRange.length > 0) {
return nullptr;
}
Comment on lines +397 to +399

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.

While there's a selection, this returns a null emitter for any touch on the paragraph, wherever it lands. Is that intended? For example, take a padded paragraph inside a Pressable. A tap in the padding, outside the selection view, doesn't fall back to the parent. Dismissing the selection on release can't restore the emitter captured on touch down either.

What about W3C pointer events? Pointer dispatch calls this getter again for pointerUp and pointerCancel, but the selection only resets the touch handler. If the selection becomes non-empty after pointerDown, isn't the final pointer event dropped? Can you verify the callback ordering and the pointer-capture cleanup on a device?

#endif

const auto &state = _textView.state;
if (!state) {
return _eventEmitter;
Expand Down Expand Up @@ -384,6 +433,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 +450,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 +484,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 +530,84 @@ - (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;
}

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

/*
* A `UITextView` binds its text container at initialisation, so the selectable
* text view is rebuilt only when the text or the available size changes.
*/
- (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.

{
NSAttributedString *attributedText = self.attributedText;
if (attributedText.length == 0 || CGRectIsEmpty(drawingFrame)) {
[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);
Comment on lines +580 to +581

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.

The rebuild check only compares the attributed string and the frame size, but the builder also reads maximumNumberOfLines, ellipsizeMode, adjustsFontSizeToFit and the minimum font scale. What happens if a fixed-size paragraph switches from ellipsizeMode="head" to "tail"? As far as I can tell, the selection container keeps the old configuration, and a props-only update never marks the selection as dirty.

Could we include the paragraph attributes in the invalidation check and schedule an update when they change? A test that changes these props on a fixed-frame paragraph would help. The current tests only cover the initial render.


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 +594 to +602

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;
}

_selectableTextView.frame = drawingFrame;
_selectableTextView.sourceAttributedText = attributedText;
}
#else
- (void)enableContextMenu
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

#import <UIKit/UIKit.h>

#import <React/RCTDefines.h>

#if !TARGET_OS_TV

NS_ASSUME_NONNULL_BEGIN

/*
* Strips every attribute that paints, and keeps every attribute that lays out.
*
* `RCTTextLayoutManager` draws the paragraph itself, and it draws effects UIKit
* knows nothing about: wavy, dotted and dashed decorations, and the pressed
* highlight of a nested pressable <Text>. The selection text view must lay the
* same glyphs out, because that is what places the selection rects, but it must
* not paint them. So the font, the kerning, the paragraph style and the
* attachments stay, and the colors, the decorations and the shadow go.
*/
RCT_EXTERN NSAttributedString *RCTUnpaintedAttributedString(NSAttributedString *attributedString);

/*
* A non-editable `UITextView` that provides selection for a paragraph, and
* nothing else.
*
* It is created with the very `NSTextContainer` that `RCTTextLayoutManager`
* measured the paragraph with, so its layout matches the measurement by
* construction rather than by coincidence. UIKit performs the selection; it
* never performs the layout, and it never paints the text.
*/
@interface RCTSelectableTextView : UITextView <UITextViewDelegate>

/*
* The paragraph as it is painted, before `RCTUnpaintedAttributedString` strips
* it. The text view lays the stripped copy out, so `copy:` must read the range
* from this string instead, or the pasteboard receives clear text with no
* decorations. Both strings hold the same characters, so the range maps
* directly from one to the other.
*/
@property (nonatomic, copy, nullable) NSAttributedString *sourceAttributedText;

@end

NS_ASSUME_NONNULL_END

#endif // !TARGET_OS_TV
Loading
Loading