diff --git a/.github/workflows/test-all.yml b/.github/workflows/test-all.yml index f724039a046f..7e5cb93429b7 100644 --- a/.github/workflows/test-all.yml +++ b/.github/workflows/test-all.yml @@ -162,6 +162,17 @@ jobs: # test_ios_rntester_dynamic_frameworks). use-prebuilds: true flavor: ${{ matrix.flavor }} + - name: Test TextInput caret and selection behavior + if: ${{ matrix.flavor == 'Debug' && matrix.frameworks == false }} + run: | + xcodebuild test \ + -workspace packages/rn-tester/RNTesterPods.xcworkspace \ + -scheme RNTesterUnitTests \ + -configuration Debug \ + -sdk iphonesimulator \ + -destination 'platform=iOS Simulator,name=iPhone 16' \ + -derivedDataPath /tmp/RNTesterBuild \ + -only-testing:RNTesterUnitTests/RCTUITextFieldTests test_maestro_cloud_ios: needs: test_ios_rntester diff --git a/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm b/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm index 8f07036e875c..6d022f98a19b 100644 --- a/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm +++ b/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm @@ -22,6 +22,8 @@ @implementation RCTUITextField { NSArray *_initialValueLeadingBarButtonGroups; NSArray *_initialValueTrailingBarButtonGroups; NSArray *_acceptDragAndDropTypes; + // Keep the requested tint while a transparent tint hides the caret. + UIColor *_requestedTintColor; } // This should not be needed but internal build were failing without it. @@ -48,6 +50,8 @@ - (instancetype)initWithFrame:(CGRect)frame - (void)_textDidChange { _textWasPasted = NO; + // Typing can collapse a selection without calling setSelectedTextRange:. + [self _updateAppliedTintColor]; } #pragma mark - Accessibility @@ -233,13 +237,31 @@ - (void)removeDictationResultPlaceholder:(id)placeholder willInsertResult:(BOOL) #pragma mark - Caret Manipulation -- (CGRect)caretRectForPosition:(UITextPosition *)position +// Returning CGRectZero for a hidden caret causes oversized Metal surface errors on iOS +// 17. A transparent tint hides the caret without changing its bounds. Restore the tint +// for non-empty selections to preserve the selection highlight and handles. +- (void)setCaretHidden:(BOOL)caretHidden { - if (_caretHidden) { - return CGRectZero; + if (_caretHidden == caretHidden) { + return; } - return [super caretRectForPosition:position]; + _caretHidden = caretHidden; + [self _updateAppliedTintColor]; +} + +// Do not override the getter. UIKit reads the applied caret color through `-tintColor`. +- (void)setTintColor:(UIColor *)tintColor +{ + _requestedTintColor = tintColor; + [self _updateAppliedTintColor]; +} + +- (void)_updateAppliedTintColor +{ + UITextRange *selectedTextRange = self.selectedTextRange; + BOOL shouldHideCaret = _caretHidden && (selectedTextRange == nil || selectedTextRange.isEmpty); + [super setTintColor:shouldHideCaret ? [UIColor clearColor] : _requestedTintColor]; } #pragma mark - Positioning Overrides @@ -262,6 +284,7 @@ - (CGRect)editingRectForBounds:(CGRect)bounds - (void)setSelectedTextRange:(UITextRange *)selectedTextRange { [super setSelectedTextRange:selectedTextRange]; + [self _updateAppliedTintColor]; [_textInputDelegateAdapter selectedTextRangeWasSet]; } #pragma clang diagnostic pop @@ -275,6 +298,7 @@ - (void)setSelectedTextRange:(UITextRange *)selectedTextRange notifyDelegate:(BO } [super setSelectedTextRange:selectedTextRange]; + [self _updateAppliedTintColor]; } - (void)rct_invalidatePendingTextInputEdit diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm index e042d4868a0f..a39cde25ae6c 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm @@ -323,7 +323,9 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & _backedTextInputView.defaultTextAttributes = defaultAttributes; } - if (newTextInputProps.selectionColor != oldTextInputProps.selectionColor) { + // A new backing view may have copied a transparent tint from a hidden caret. + if (newTextInputProps.selectionColor != oldTextInputProps.selectionColor || + newTextInputProps.multiline != oldTextInputProps.multiline) { _backedTextInputView.tintColor = RCTUIColorFromSharedColor(newTextInputProps.selectionColor); } diff --git a/packages/react-native/React/Tests/Text/RCTUITextFieldTests.mm b/packages/react-native/React/Tests/Text/RCTUITextFieldTests.mm index 7baf64a6eedd..f6d774152853 100644 --- a/packages/react-native/React/Tests/Text/RCTUITextFieldTests.mm +++ b/packages/react-native/React/Tests/Text/RCTUITextFieldTests.mm @@ -5,8 +5,14 @@ * LICENSE file in the root directory of this source tree. */ +#import +#import #import +#import #import +#import +#import +#import static NSParagraphStyle *RCTPlaceholderParagraphStyle(RCTUITextField *textField) { @@ -54,4 +60,138 @@ - (void)testPlaceholderKeepsParagraphStyleFromTextAttributes XCTAssertEqual(RCTPlaceholderParagraphStyle(textField).alignment, NSTextAlignmentCenter); } +- (void)testCaretHiddenMakesTheCaretTransparent +{ + RCTUITextField *textField = [RCTUITextField new]; + textField.tintColor = UIColor.redColor; + + textField.caretHidden = YES; + + XCTAssertEqualObjects(textField.tintColor, UIColor.clearColor); +} + +- (void)testClearingCaretHiddenRestoresTheSelectionColor +{ + RCTUITextField *textField = [RCTUITextField new]; + textField.tintColor = UIColor.redColor; + textField.caretHidden = YES; + + textField.caretHidden = NO; + + XCTAssertEqualObjects(textField.tintColor, UIColor.redColor); +} + +- (void)testSelectionColorSetWhileCaretHiddenIsAppliedOnceTheCaretIsShown +{ + RCTUITextField *textField = [RCTUITextField new]; + textField.caretHidden = YES; + + textField.tintColor = UIColor.redColor; + + XCTAssertEqualObjects(textField.tintColor, UIColor.clearColor); + + textField.caretHidden = NO; + + XCTAssertEqualObjects(textField.tintColor, UIColor.redColor); +} + +- (void)testSelectionColorIsRestoredForSelectionsAndHiddenAgainWhenCollapsed +{ + RCTUITextField *textField = [RCTUITextField new]; + textField.attributedText = [[NSAttributedString alloc] initWithString:@"Hello"]; + textField.tintColor = UIColor.redColor; + textField.caretHidden = YES; + + UITextPosition *start = textField.beginningOfDocument; + UITextPosition *end = [textField positionFromPosition:start offset:textField.attributedText.length]; + for (NSNumber *notifyDelegate in @[ @YES, @NO ]) { + [textField setSelectedTextRange:[textField textRangeFromPosition:start toPosition:end] + notifyDelegate:notifyDelegate.boolValue]; + XCTAssertEqualObjects(textField.tintColor, UIColor.redColor); + + [textField setSelectedTextRange:[textField textRangeFromPosition:end toPosition:end] + notifyDelegate:notifyDelegate.boolValue]; + XCTAssertEqualObjects(textField.tintColor, UIColor.clearColor); + } +} + +- (void)testReplacingASelectionHidesTheCaretAgain +{ + RCTUITextField *textField = [RCTUITextField new]; + textField.delegate = nil; + textField.attributedText = [[NSAttributedString alloc] initWithString:@"Hello"]; + textField.tintColor = UIColor.redColor; + textField.caretHidden = YES; + [textField setSelectedTextRange:[textField textRangeFromPosition:textField.beginningOfDocument + toPosition:textField.endOfDocument] + notifyDelegate:NO]; + XCTAssertEqualObjects(textField.tintColor, UIColor.redColor); + + [textField insertText:@"X"]; + + XCTAssertEqualObjects(textField.attributedText.string, @"X"); + XCTAssertTrue(textField.selectedTextRange.isEmpty); + XCTAssertEqualObjects(textField.tintColor, UIColor.clearColor); +} + +- (void)testHidingCaretPreservesItsGeometry +{ + RCTUITextField *textField = [[RCTUITextField alloc] initWithFrame:CGRectMake(0, 0, 200, 40)]; + textField.attributedText = [[NSAttributedString alloc] initWithString:@"Hello"]; + [textField layoutIfNeeded]; + CGRect visibleCaret = [textField caretRectForPosition:textField.endOfDocument]; + XCTAssertGreaterThan(visibleCaret.size.width, 0); + XCTAssertGreaterThan(visibleCaret.size.height, 0); + + textField.caretHidden = YES; + + XCTAssertTrue(CGRectEqualToRect([textField caretRectForPosition:textField.endOfDocument], visibleCaret)); +} + +- (void)testClearingSelectionColorRestoresInheritedTint +{ + UIView *parent = [UIView new]; + parent.tintColor = UIColor.blueColor; + RCTUITextField *textField = [RCTUITextField new]; + [parent addSubview:textField]; + textField.tintColor = UIColor.redColor; + textField.caretHidden = YES; + + textField.tintColor = nil; + XCTAssertEqualObjects(textField.tintColor, UIColor.clearColor); + textField.caretHidden = NO; + XCTAssertEqualObjects(textField.tintColor, UIColor.blueColor); +} + +- (void)testSwitchingBackingViewsPreservesSelectionColor +{ + using namespace facebook::react; + + RCTTextInputComponentView *componentView = [RCTTextInputComponentView new]; + ContextContainer contextContainer{}; + PropsParserContext context{-1, contextContainer}; + RawProps rawProps(folly::dynamic::object("caretHidden", true)); + RawPropsParser parser; + parser.prepare(); + rawProps.parse(parser); + auto singleline = std::make_shared(context, TextInputProps{}, rawProps); + singleline->selectionColor = colorFromRGBA(255, 0, 0, 255); + UIColor *selectionColor = RCTUIColorFromSharedColor(singleline->selectionColor); + [componentView updateProps:singleline oldProps:nullptr]; + XCTAssertEqualObjects(componentView.subviews.firstObject.tintColor, UIColor.clearColor); + + auto multiline = std::make_shared(*singleline); + multiline->multiline = true; + [componentView updateProps:multiline oldProps:singleline]; + XCTAssertTrue([componentView.subviews.firstObject isKindOfClass:RCTUITextView.class]); + XCTAssertEqualObjects(componentView.subviews.firstObject.tintColor, selectionColor); + + [componentView updateProps:singleline oldProps:multiline]; + RCTUITextField *textField = (RCTUITextField *)componentView.subviews.firstObject; + XCTAssertTrue([textField isKindOfClass:RCTUITextField.class]); + XCTAssertEqualObjects(textField.tintColor, UIColor.clearColor); + textField.caretHidden = NO; + XCTAssertEqualObjects(textField.tintColor, selectionColor); +} + @end diff --git a/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj b/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj index 1d336b424136..df85c61c420f 100644 --- a/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj +++ b/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj @@ -56,6 +56,8 @@ F1A0B1C23D4E5F6071829302 /* RCTTurboModuleArrayBufferTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = F1A0B1C23D4E5F6071829301 /* RCTTurboModuleArrayBufferTests.mm */; }; F1A0B1C23D4E5F6071829322 /* RCTTurboModuleStringConversionTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = F1A0B1C23D4E5F6071829321 /* RCTTurboModuleStringConversionTests.mm */; }; D1A60AD11B9247A080734F02 /* RCTDevLoadingViewTests.m in Sources */ = {isa = PBXBuildFile; fileRef = D1A60AD11B9247A080734F01 /* RCTDevLoadingViewTests.m */; }; + F58158000000000000000002 /* RCTUITextFieldTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = F58158000000000000000001 /* RCTUITextFieldTests.mm */; }; + F58158000000000000000003 /* SwiftTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 832F45BA2A8A6E1F0097B4E6 /* SwiftTest.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -163,6 +165,7 @@ F1A0B1C23D4E5F6071829301 /* RCTTurboModuleArrayBufferTests.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = RCTTurboModuleArrayBufferTests.mm; sourceTree = ""; }; F1A0B1C23D4E5F6071829321 /* RCTTurboModuleStringConversionTests.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = RCTTurboModuleStringConversionTests.mm; sourceTree = ""; }; D1A60AD11B9247A080734F01 /* RCTDevLoadingViewTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RCTDevLoadingViewTests.m; sourceTree = ""; }; + F58158000000000000000001 /* RCTUITextFieldTests.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; name = RCTUITextFieldTests.mm; path = "../../react-native/React/Tests/Text/RCTUITextFieldTests.mm"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -346,6 +349,7 @@ A975CA6B2C05EADE0043F72A /* RCTNetworkTaskTests.m */, E7DB20BE22B2BAA4005AC45F /* RCTNativeAnimatedNodesManagerTests.m */, E7DB20AD22B2BAA3005AC45F /* RCTPerformanceLoggerTests.m */, + F58158000000000000000001 /* RCTUITextFieldTests.mm */, E7DB20C122B2BAA4005AC45F /* RCTUnicodeDecodeTests.m */, E7DB20D022B2BAA5005AC45F /* RCTURLUtilsTests.m */, D5D13FB1445C5C19639921CC /* RCTUtilsTests.m */, @@ -749,6 +753,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + F58158000000000000000002 /* RCTUITextFieldTests.mm in Sources */, + F58158000000000000000003 /* SwiftTest.swift in Sources */, A975CA6C2C05EADF0043F72A /* RCTNetworkTaskTests.m in Sources */, E7DB20DF22B2BAA6005AC45F /* RCTImageLoaderTests.m in Sources */, E7DB20D522B2BAA6005AC45F /* RCTPerformanceLoggerTests.m in Sources */, @@ -1109,6 +1115,7 @@ OTHER_SWIFT_FLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = com.meta.RNTesterUnitTests; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -1147,6 +1154,7 @@ OTHER_SWIFT_FLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = com.meta.RNTesterUnitTests; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release;