Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions .github/workflows/test-all.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ @implementation RCTUITextField {
NSArray<UIBarButtonItemGroup *> *_initialValueLeadingBarButtonGroups;
NSArray<UIBarButtonItemGroup *> *_initialValueTrailingBarButtonGroups;
NSArray<NSString *> *_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.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -262,6 +284,7 @@ - (CGRect)editingRectForBounds:(CGRect)bounds
- (void)setSelectedTextRange:(UITextRange *)selectedTextRange
{
[super setSelectedTextRange:selectedTextRange];
[self _updateAppliedTintColor];
[_textInputDelegateAdapter selectedTextRangeWasSet];
}
#pragma clang diagnostic pop
Expand All @@ -275,6 +298,7 @@ - (void)setSelectedTextRange:(UITextRange *)selectedTextRange notifyDelegate:(BO
}

[super setSelectedTextRange:selectedTextRange];
[self _updateAppliedTintColor];
}

- (void)rct_invalidatePendingTextInputEdit
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
140 changes: 140 additions & 0 deletions packages/react-native/React/Tests/Text/RCTUITextFieldTests.mm
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,14 @@
* LICENSE file in the root directory of this source tree.
*/

#import <React/RCTConversions.h>
#import <React/RCTTextInputComponentView.h>
#import <React/RCTUITextField.h>
#import <React/RCTUITextView.h>
#import <XCTest/XCTest.h>
#import <react/renderer/components/iostextinput/TextInputProps.h>
#import <react/renderer/core/RawPropsParser.h>
#import <react/renderer/graphics/Color.h>

static NSParagraphStyle *RCTPlaceholderParagraphStyle(RCTUITextField *textField)
{
Expand Down Expand Up @@ -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<TextInputProps>();
rawProps.parse(parser);
auto singleline = std::make_shared<TextInputProps>(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<TextInputProps>(*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
8 changes: 8 additions & 0 deletions packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -163,6 +165,7 @@
F1A0B1C23D4E5F6071829301 /* RCTTurboModuleArrayBufferTests.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = RCTTurboModuleArrayBufferTests.mm; sourceTree = "<group>"; };
F1A0B1C23D4E5F6071829321 /* RCTTurboModuleStringConversionTests.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = RCTTurboModuleStringConversionTests.mm; sourceTree = "<group>"; };
D1A60AD11B9247A080734F01 /* RCTDevLoadingViewTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RCTDevLoadingViewTests.m; sourceTree = "<group>"; };
F58158000000000000000001 /* RCTUITextFieldTests.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; name = RCTUITextFieldTests.mm; path = "../../react-native/React/Tests/Text/RCTUITextFieldTests.mm"; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading