Repository navigation
feat: support partial text selection on iOS <Text selectable> with feature flag #58363
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
face14d
bfb22e7
d185a90
2dbe5b7
ef3e8d7
a401293
061745c
1c80a02
06858b5
cfff5db
804fc55
ec3503a
d974ca1
44645fc
8c28b3f
089b0b1
fdb5bc7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,7 @@ | |
|
|
||
| #import "RCTParagraphComponentView.h" | ||
| #import "RCTParagraphComponentAccessibilityProvider.h" | ||
| #import "RCTSelectableTextView+Internal.h" | ||
|
|
||
| #import <MobileCoreServices/UTCoreTypes.h> | ||
| #import <react/featureflags/ReactNativeFeatureFlags.h> | ||
|
|
@@ -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) | ||
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
|
@@ -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 ¶graphProps = 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 | ||
|
|
@@ -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; | ||
| } | ||
| #endif | ||
|
|
||
| const auto &state = _textView.state; | ||
| if (!state) { | ||
| return _eventEmitter; | ||
|
|
@@ -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:)]; | ||
|
|
||
|
|
@@ -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]; | ||
|
|
@@ -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 ¶graphProps = 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 ¶graphProps = static_cast<const ParagraphProps &>(*_props); | ||
|
|
||
| if (paragraphProps.isSelectable && action == @selector(copy:)) { | ||
|
|
@@ -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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This creates a 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Inserting the text view right below
Could we keep inline children ahead of the selection view in hit testing (also after a rebuild), preserve the |
||
|
|
||
| _selectionRenderedText = [layoutText copy]; | ||
| _selectionRenderedSize = drawingFrame.size; | ||
| } | ||
|
|
||
| _selectableTextView.frame = drawingFrame; | ||
| _selectableTextView.sourceAttributedText = attributedText; | ||
| } | ||
| #else | ||
| - (void)enableContextMenu | ||
| { | ||
|
|
||
| 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 |
There was a problem hiding this comment.
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
pointerUpandpointerCancel, but the selection only resets the touch handler. If the selection becomes non-empty afterpointerDown, isn't the final pointer event dropped? Can you verify the callback ordering and the pointer-capture cleanup on a device?