diff --git a/packages/react-native/Libraries/Text/TextInput/RCTBackedTextInputViewProtocol.h b/packages/react-native/Libraries/Text/TextInput/RCTBackedTextInputViewProtocol.h index 7aa5bcd54e7b36..cc510133cc614f 100644 --- a/packages/react-native/Libraries/Text/TextInput/RCTBackedTextInputViewProtocol.h +++ b/packages/react-native/Libraries/Text/TextInput/RCTBackedTextInputViewProtocol.h @@ -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 *typingAttributes; // This protocol disallows direct access to `selectedTextRange` property because // unwise usage of it can break the `delegate` behavior. So, we always have to 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 8c532d85502bc1..cec786fa787824 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm @@ -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 *_originalTypingAttributes; } #pragma mark - UIView overrides @@ -79,11 +86,27 @@ - (instancetype)initWithFrame:(CGRect)frame [self addSubview:_backedTextInputView]; [self initializeReturnKeyType]; + + _originalTypingAttributes = [_backedTextInputView.typingAttributes copy]; } return self; } +- (void)updateEventEmitter:(const EventEmitter::Shared &)eventEmitter +{ + [super updateEventEmitter:eventEmitter]; + + NSMutableDictionary *defaultAttributes = + [_backedTextInputView.defaultTextAttributes mutableCopy]; + + RCTWeakEventEmitterWrapper *eventEmitterWrapper = [RCTWeakEventEmitterWrapper new]; + eventEmitterWrapper.eventEmitter = _eventEmitter; + defaultAttributes[RCTAttributedStringEventEmitterKey] = eventEmitterWrapper; + + _backedTextInputView.defaultTextAttributes = defaultAttributes; +} + - (void)didMoveToWindow { [super didMoveToWindow]; @@ -236,8 +259,11 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & } if (newTextInputProps.textAttributes != oldTextInputProps.textAttributes) { - _backedTextInputView.defaultTextAttributes = + NSMutableDictionary *defaultAttributes = RCTNSTextAttributesFromTextAttributes(newTextInputProps.getEffectiveTextAttributes(RCTFontSizeMultiplier())); + defaultAttributes[RCTAttributedStringEventEmitterKey] = + _backedTextInputView.defaultTextAttributes[RCTAttributedStringEventEmitterKey]; + _backedTextInputView.defaultTextAttributes = defaultAttributes; } if (newTextInputProps.selectionColor != oldTextInputProps.selectionColor) { @@ -732,9 +758,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(*_props).textAttributes); } } diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.h b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.h index c0158f3df61e7a..3427663d53d8f6 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.h +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.h @@ -22,7 +22,7 @@ NSString *const RCTTextAttributesAccessibilityRoleAttributeName = @"Accessibilit /* * Creates `NSTextAttributes` from given `facebook::react::TextAttributes` */ -NSDictionary *RCTNSTextAttributesFromTextAttributes( +NSMutableDictionary *RCTNSTextAttributesFromTextAttributes( const facebook::react::TextAttributes &textAttributes); /* @@ -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 *insensitiveAttributes, + const facebook::react::TextAttributes &baseTextAttributes); + @interface RCTWeakEventEmitterWrapper : NSObject @property (nonatomic, assign) facebook::react::SharedEventEmitter eventEmitter; @end diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm index 2b2cf02fa11604..36e379ce993801 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm @@ -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) @@ -178,7 +196,8 @@ inline static CGFloat RCTEffectiveFontSizeMultiplierFromTextAttributes(const Tex return effectiveBackgroundColor ?: [UIColor clearColor]; } -NSDictionary *RCTNSTextAttributesFromTextAttributes(const TextAttributes &textAttributes) +NSMutableDictionary *RCTNSTextAttributesFromTextAttributes( + const TextAttributes &textAttributes) { NSMutableDictionary *attributes = [NSMutableDictionary dictionaryWithCapacity:10]; @@ -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) @@ -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 *attributes1, + NSDictionary *attributes2, + NSDictionary *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 == NSParagraphStyleAttributeName) { + return RCTIsParagraphStyleEffectivelySame(attribute1, attribute2, baseTextAttributes); + } + + // Otherwise rely on built-in comparison + return [attribute1 isEqual:attribute2]; +} + +BOOL RCTIsAttributedStringEffectivelySame( + NSAttributedString *text1, + NSAttributedString *text2, + NSDictionary *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 *text1Attributes, + NSRange text1Range, + BOOL *text1Stop) { + [text2 enumerateAttributesInRange:text1Range + options:0 + usingBlock:^( + NSDictionary *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; +}