From c745fcfbeeb98698cccabaff66801697164464e5 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 20 Mar 2026 23:11:59 -0500 Subject: [PATCH] feat: support native text selection for selectable Text on iOS (#XXXXX) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: The `selectable` prop on `` currently only enables a context menu (long press → copy) but does not provide actual native text selection. This adds real text selection by swapping the content view to a UITextView when `selectable={true}`. When `selectable={false}` (the default, ~99% of text), behavior is completely unchanged — no UITextView is created. When enabled: - A UITextView (RCTParagraphSelectableTextView) is created and swapped in as the content view, replacing the lightweight RCTParagraphTextView - Text storage is synced from RCTTextLayoutManager so rendering matches - The UITextView handles selection natively (long press to select, drag handles, double-tap for word selection) - The context menu (copy) continues to work alongside selection - The UITextView is torn down on prepareForRecycle and when the prop toggles back to false Also exposes `getTextStorageForAttributedString:paragraphAttributes:size:` on RCTTextLayoutManager as a public API, which simply wraps the existing private `_textStorageAndLayoutManagerWithAttributesString:` method. Changelog: [iOS][Added] - Native text selection support for `` Co-Authored-By: Claude Opus 4.6 --- .../Text/RCTParagraphComponentView.mm | 94 ++++++++++++++++++- .../textlayoutmanager/RCTTextLayoutManager.h | 4 + .../textlayoutmanager/RCTTextLayoutManager.mm | 12 +++ 3 files changed, 108 insertions(+), 2 deletions(-) diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm index 07fb9c4e7b67..1c356b0517e4 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm @@ -34,6 +34,9 @@ @interface RCTParagraphTextView : UIView @end +@interface RCTParagraphSelectableTextView : UITextView +@end + #if !TARGET_OS_TV @interface RCTParagraphComponentView () @@ -50,6 +53,7 @@ @implementation RCTParagraphComponentView { RCTParagraphComponentAccessibilityProvider *_accessibilityProvider; UILongPressGestureRecognizer *_longPressGestureRecognizer; RCTParagraphTextView *_textView; + RCTParagraphSelectableTextView *_selectableTextView; } - (instancetype)initWithFrame:(CGRect)frame @@ -111,9 +115,9 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & if (newParagraphProps.isSelectable != oldParagraphProps.isSelectable) { if (newParagraphProps.isSelectable) { - [self enableContextMenu]; + [self _enableSelection]; } else { - [self disableContextMenu]; + [self _disableSelection]; } } @@ -125,6 +129,10 @@ - (void)updateState:(const State::Shared &)state oldState:(const State::Shared & _textView.state = std::static_pointer_cast(state); [_textView setNeedsDisplay]; [self setNeedsLayout]; + + if (_selectableTextView) { + [self updateSelectableTextStorage]; + } } - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics @@ -136,11 +144,18 @@ - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics _textView.layoutMetrics = _layoutMetrics; [_textView setNeedsDisplay]; [self setNeedsLayout]; + + if (_selectableTextView) { + [self updateSelectableTextStorage]; + } } - (void)prepareForRecycle { [super prepareForRecycle]; + if (_selectableTextView) { + [self _disableSelection]; + } _textView.state = nullptr; _accessibilityProvider = nil; } @@ -149,7 +164,79 @@ - (void)layoutSubviews { [super layoutSubviews]; + if (_selectableTextView) { + _selectableTextView.frame = self.bounds; + } else { + _textView.frame = self.bounds; + } +} + +#pragma mark - Selection Management + +- (void)_enableSelection +{ + if (_selectableTextView) { + return; + } + + _selectableTextView = [[RCTParagraphSelectableTextView alloc] initWithFrame:self.bounds]; + _selectableTextView.editable = NO; + _selectableTextView.selectable = YES; + _selectableTextView.scrollEnabled = NO; + _selectableTextView.textContainerInset = UIEdgeInsetsZero; + _selectableTextView.textContainer.lineFragmentPadding = 0; + _selectableTextView.backgroundColor = [UIColor clearColor]; + + // Sync text content into the UITextView. + [self updateSelectableTextStorage]; + + // Swap: remove the default text view, install the selectable one. + [_textView removeFromSuperview]; + self.contentView = _selectableTextView; + + // Also enable the context menu (long press to copy). + [self enableContextMenu]; +} + +- (void)_disableSelection +{ + if (!_selectableTextView) { + return; + } + + [self disableContextMenu]; + + // Swap back: remove the selectable text view, restore the default one. + [_selectableTextView removeFromSuperview]; + _selectableTextView = nil; + + self.contentView = _textView; _textView.frame = self.bounds; + [_textView setNeedsDisplay]; +} + +- (void)updateSelectableTextStorage +{ + if (!_selectableTextView || !_textView.state) { + return; + } + + const auto &stateData = _textView.state->getData(); + auto textLayoutManager = stateData.layoutManager.lock(); + if (!textLayoutManager) { + return; + } + + RCTTextLayoutManager *nativeTextLayoutManager = + (RCTTextLayoutManager *)unwrapManagedObject(textLayoutManager->getNativeTextLayoutManager()); + CGRect frame = RCTCGRectFromRect(_layoutMetrics.getContentFrame()); + + NSTextStorage *textStorage = [nativeTextLayoutManager getTextStorageForAttributedString:stateData.attributedString + paragraphAttributes:_paragraphAttributes + size:frame.size]; + + _selectableTextView.attributedText = textStorage; + _selectableTextView.frame = frame; } #pragma mark - Accessibility @@ -426,3 +513,6 @@ - (void)drawRect:(CGRect)rect } @end + +@implementation RCTParagraphSelectableTextView +@end diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.h b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.h index a4b9be72c453..857654ce8ae5 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.h +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.h @@ -59,6 +59,10 @@ using RCTTextLayoutFragmentEnumerationBlock = frame:(CGRect)frame usingBlock:(RCTTextLayoutFragmentEnumerationBlock)block; +- (NSTextStorage *)getTextStorageForAttributedString:(facebook::react::AttributedString)attributedString + paragraphAttributes:(facebook::react::ParagraphAttributes)paragraphAttributes + size:(CGSize)size; + @end NS_ASSUME_NONNULL_END diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm index 5fed44e18a45..fe368236d00a 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm @@ -443,4 +443,16 @@ - (TextMeasurement)_measureTextStorage:(NSTextStorage *)textStorage return TextMeasurement{.size = {.width = size.width, .height = size.height}, .attachments = attachments}; } +- (NSTextStorage *)getTextStorageForAttributedString:(AttributedString)attributedString + paragraphAttributes:(ParagraphAttributes)paragraphAttributes + size:(CGSize)size +{ + NSAttributedString *nsAttributedString = [self _nsAttributedStringFromAttributedString:attributedString]; + NSTextStorage *textStorage = [self _textStorageAndLayoutManagerWithAttributesString:nsAttributedString + paragraphAttributes:paragraphAttributes + size:size]; + + return textStorage; +} + @end