Skip to content
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

Fix cursor moving while typing quickly and autocorrection triggered in controlled single line TextInput on iOS (New Arch) #46970

Closed
wants to merge 1 commit into from
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ NS_ASSUME_NONNULL_BEGIN
@property (nonatomic, assign, readonly) CGFloat zoomScale;
@property (nonatomic, assign, readonly) CGPoint contentOffset;
@property (nonatomic, assign, readonly) UIEdgeInsets contentInset;
@property (nullable, nonatomic, copy) NSDictionary<NSAttributedStringKey, id> *typingAttributes;

// This protocol disallows direct access to `selectedTextRange` property because
// unwise usage of it can break the `delegate` behavior. So, we always have to
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ @implementation RCTTextInputComponentView {
*/
BOOL _comingFromJS;
BOOL _didMoveToWindow;

/*
* Newly initialized default typing attributes contain a no-op NSParagraphStyle and NSShadow. These cause inequality
* between the AttributedString backing the input and those generated from state. We store these attributes to make
* later comparison insensitive to them.
*/
NSDictionary<NSAttributedStringKey, id> *_originalTypingAttributes;
}

#pragma mark - UIView overrides
Expand All @@ -76,6 +83,7 @@ - (instancetype)initWithFrame:(CGRect)frame
_ignoreNextTextInputCall = NO;
_comingFromJS = NO;
_didMoveToWindow = NO;
_originalTypingAttributes = [_backedTextInputView.typingAttributes copy];

[self addSubview:_backedTextInputView];
[self initializeReturnKeyType];
Expand All @@ -84,6 +92,20 @@ - (instancetype)initWithFrame:(CGRect)frame
return self;
}

- (void)updateEventEmitter:(const EventEmitter::Shared &)eventEmitter
{
[super updateEventEmitter:eventEmitter];

NSMutableDictionary<NSAttributedStringKey, id> *defaultAttributes =
[_backedTextInputView.defaultTextAttributes mutableCopy];

RCTWeakEventEmitterWrapper *eventEmitterWrapper = [RCTWeakEventEmitterWrapper new];
eventEmitterWrapper.eventEmitter = _eventEmitter;
defaultAttributes[RCTAttributedStringEventEmitterKey] = eventEmitterWrapper;

_backedTextInputView.defaultTextAttributes = defaultAttributes;
}

- (void)didMoveToWindow
{
[super didMoveToWindow];
Expand Down Expand Up @@ -236,8 +258,11 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
}

if (newTextInputProps.textAttributes != oldTextInputProps.textAttributes) {
_backedTextInputView.defaultTextAttributes =
NSMutableDictionary<NSAttributedStringKey, id> *defaultAttributes =
RCTNSTextAttributesFromTextAttributes(newTextInputProps.getEffectiveTextAttributes(RCTFontSizeMultiplier()));
defaultAttributes[RCTAttributedStringEventEmitterKey] =
_backedTextInputView.defaultTextAttributes[RCTAttributedStringEventEmitterKey];
_backedTextInputView.defaultTextAttributes = defaultAttributes;
}

if (newTextInputProps.selectionColor != oldTextInputProps.selectionColor) {
Expand Down Expand Up @@ -732,9 +757,10 @@ - (BOOL)_textOf:(NSAttributedString *)newText equals:(NSAttributedString *)oldTe
_backedTextInputView.markedTextRange || _backedTextInputView.isSecureTextEntry || fontHasBeenUpdatedBySystem;

if (shouldFallbackToBareTextComparison) {
return ([newText.string isEqualToString:oldText.string]);
return [newText.string isEqualToString:oldText.string];
} else {
return ([newText isEqualToAttributedString:oldText]);
return RCTIsAttributedStringEffectivelySame(
newText, oldText, _originalTypingAttributes, static_cast<const TextInputProps &>(*_props).textAttributes);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ NSString *const RCTTextAttributesAccessibilityRoleAttributeName = @"Accessibilit
/*
* Creates `NSTextAttributes` from given `facebook::react::TextAttributes`
*/
NSDictionary<NSAttributedStringKey, id> *RCTNSTextAttributesFromTextAttributes(
NSMutableDictionary<NSAttributedStringKey, id> *RCTNSTextAttributesFromTextAttributes(
const facebook::react::TextAttributes &textAttributes);

/*
Expand All @@ -41,6 +41,17 @@ NSString *RCTNSStringFromStringApplyingTextTransform(NSString *string, facebook:

void RCTApplyBaselineOffset(NSMutableAttributedString *attributedText);

/*
* Whether two `NSAttributedString` lead to the same underlying displayed text, even if they are not strictly equal.
* I.e. is one string substitutable for the other when backing a control (which may have some ignorable attributes
* provided).
*/
BOOL RCTIsAttributedStringEffectivelySame(
NSAttributedString *text1,
NSAttributedString *text2,
NSDictionary<NSAttributedStringKey, id> *insensitiveAttributes,
const facebook::react::TextAttributes &baseTextAttributes);

@interface RCTWeakEventEmitterWrapper : NSObject
@property (nonatomic, assign) facebook::react::SharedEventEmitter eventEmitter;
@end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,24 @@ - (void)dealloc
_weakEventEmitter.reset();
}

- (BOOL)isEqual:(id)object
{
// We consider the underlying EventEmitter as the identity
if (![object isKindOfClass:[self class]]) {
return NO;
}

auto thisEventEmitter = [self eventEmitter];
auto otherEventEmitter = [((RCTWeakEventEmitterWrapper *)object) eventEmitter];
return thisEventEmitter == otherEventEmitter;
}

- (NSUInteger)hash
{
// We consider the underlying EventEmitter as the identity
return (NSUInteger)_weakEventEmitter.lock().get();
}

@end

inline static UIFontWeight RCTUIFontWeightFromInteger(NSInteger fontWeight)
Expand Down Expand Up @@ -178,7 +196,8 @@ inline static CGFloat RCTEffectiveFontSizeMultiplierFromTextAttributes(const Tex
return effectiveBackgroundColor ?: [UIColor clearColor];
}

NSDictionary<NSAttributedStringKey, id> *RCTNSTextAttributesFromTextAttributes(const TextAttributes &textAttributes)
NSMutableDictionary<NSAttributedStringKey, id> *RCTNSTextAttributesFromTextAttributes(
const TextAttributes &textAttributes)
{
NSMutableDictionary<NSAttributedStringKey, id> *attributes = [NSMutableDictionary dictionaryWithCapacity:10];

Expand Down Expand Up @@ -302,7 +321,7 @@ inline static CGFloat RCTEffectiveFontSizeMultiplierFromTextAttributes(const Tex
attributes[RCTTextAttributesAccessibilityRoleAttributeName] = [NSString stringWithUTF8String:roleStr.c_str()];
}

return [attributes copy];
return attributes;
}

void RCTApplyBaselineOffset(NSMutableAttributedString *attributedText)
Expand Down Expand Up @@ -466,3 +485,147 @@ AttributedStringBox RCTAttributedStringBoxFromNSAttributedString(NSAttributedStr
return string;
}
}

static BOOL RCTIsParagraphStyleEffectivelySame(
NSParagraphStyle *style1,
NSParagraphStyle *style2,
const TextAttributes &baseTextAttributes)
{
if (style1 == nil || style2 == nil) {
return style1 == nil && style2 == nil;
}

// The NSParagraphStyle included as part of typingAttributes may eventually resolve "natural" directions to
// physical direction, so we should compare resolved directions
auto naturalAlignment =
baseTextAttributes.layoutDirection.value_or(LayoutDirection::LeftToRight) == LayoutDirection::LeftToRight
? NSTextAlignmentLeft
: NSTextAlignmentRight;

NSWritingDirection naturalBaseWritingDirection = baseTextAttributes.baseWritingDirection.has_value()
? RCTNSWritingDirectionFromWritingDirection(baseTextAttributes.baseWritingDirection.value())
: [NSParagraphStyle defaultWritingDirectionForLanguage:nil];

if (style1.alignment == NSTextAlignmentNatural || style1.baseWritingDirection == NSWritingDirectionNatural) {
NSMutableParagraphStyle *mutableStyle1 = [style1 mutableCopy];
style1 = mutableStyle1;

if (mutableStyle1.alignment == NSTextAlignmentNatural) {
mutableStyle1.alignment = naturalAlignment;
}

if (mutableStyle1.baseWritingDirection == NSWritingDirectionNatural) {
mutableStyle1.baseWritingDirection = naturalBaseWritingDirection;
}
}

if (style2.alignment == NSTextAlignmentNatural || style2.baseWritingDirection == NSWritingDirectionNatural) {
NSMutableParagraphStyle *mutableStyle2 = [style2 mutableCopy];
style2 = mutableStyle2;

if (mutableStyle2.alignment == NSTextAlignmentNatural) {
mutableStyle2.alignment = naturalAlignment;
}

if (mutableStyle2.baseWritingDirection == NSWritingDirectionNatural) {
mutableStyle2.baseWritingDirection = naturalBaseWritingDirection;
}
}

return [style1 isEqual:style2];
}

static BOOL RCTIsAttributeEffectivelySame(
NSAttributedStringKey attributeKey,
NSDictionary<NSAttributedStringKey, id> *attributes1,
NSDictionary<NSAttributedStringKey, id> *attributes2,
NSDictionary<NSAttributedStringKey, id> *insensitiveAttributes,
const TextAttributes &baseTextAttributes)
{
id attribute1 = attributes1[attributeKey] ?: insensitiveAttributes[attributeKey];
id attribute2 = attributes2[attributeKey] ?: insensitiveAttributes[attributeKey];

// Normalize attributes which can inexact but still effectively the same
if ([attributeKey isEqualToString:NSParagraphStyleAttributeName]) {
return RCTIsParagraphStyleEffectivelySame(attribute1, attribute2, baseTextAttributes);
}

// Otherwise rely on built-in comparison
return [attribute1 isEqual:attribute2];
}

BOOL RCTIsAttributedStringEffectivelySame(
NSAttributedString *text1,
NSAttributedString *text2,
NSDictionary<NSAttributedStringKey, id> *insensitiveAttributes,
const TextAttributes &baseTextAttributes)
{
if (![text1.string isEqualToString:text2.string]) {
return NO;
}

// We check that for every fragment in the old string
// 1. The new string's fragment overlapping the first spans the same characters
// 2. The attributes of each matching fragment are the same, ignoring those which match insensitive attibutes
__block BOOL areAttributesSame = YES;
[text1 enumerateAttributesInRange:NSMakeRange(0, text1.length)
options:0
usingBlock:^(
NSDictionary<NSAttributedStringKey, id> *text1Attributes,
NSRange text1Range,
BOOL *text1Stop) {
[text2 enumerateAttributesInRange:text1Range
options:0
usingBlock:^(
NSDictionary<NSAttributedStringKey, id> *text2Attributes,
NSRange text2Range,
BOOL *text2Stop) {
if (!NSEqualRanges(text1Range, text2Range)) {
areAttributesSame = NO;
*text1Stop = YES;
*text2Stop = YES;
return;
}

// Compare every attribute in text1 to the corresponding attribute
// in text2, or the set of insensitive attributes if not present
for (NSAttributedStringKey key in text1Attributes) {
if (!RCTIsAttributeEffectivelySame(
key,
text1Attributes,
text2Attributes,
insensitiveAttributes,
baseTextAttributes)) {
areAttributesSame = NO;
*text1Stop = YES;
*text2Stop = YES;
return;
}
}

for (NSAttributedStringKey key in text2Attributes) {
// We have already compared this attribute if it is present in
// both
if (text1Attributes[key] != nil) {
continue;
}

// But we still need to compare attributes if it is only present
// in text 2, to compare against insensitive attributes
if (!RCTIsAttributeEffectivelySame(
key,
text1Attributes,
text2Attributes,
insensitiveAttributes,
baseTextAttributes)) {
areAttributesSame = NO;
*text1Stop = YES;
*text2Stop = YES;
return;
}
}
}];
}];

return areAttributesSame;
}
Loading