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..e34a5e3a4e828d 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm @@ -9,6 +9,7 @@ #import #import +#import #import #import @@ -61,6 +62,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 +87,26 @@ - (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..441a100167c795 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 &textAttributes); + @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..a870a67bd14a0a 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) @@ -156,7 +174,8 @@ inline static CGFloat RCTEffectiveFontSizeMultiplierFromTextAttributes(const Tex inline static UIColor *RCTEffectiveForegroundColorFromTextAttributes(const TextAttributes &textAttributes) { - UIColor *effectiveForegroundColor = RCTUIColorFromSharedColor(textAttributes.foregroundColor) ?: [UIColor blackColor]; + UIColor *effectiveForegroundColor = + RCTPlatformColorFromColor(*textAttributes.foregroundColor) ?: [UIColor blackColor]; if (!isnan(textAttributes.opacity)) { effectiveForegroundColor = [effectiveForegroundColor @@ -168,7 +187,7 @@ inline static CGFloat RCTEffectiveFontSizeMultiplierFromTextAttributes(const Tex inline static UIColor *RCTEffectiveBackgroundColorFromTextAttributes(const TextAttributes &textAttributes) { - UIColor *effectiveBackgroundColor = RCTUIColorFromSharedColor(textAttributes.backgroundColor); + UIColor *effectiveBackgroundColor = RCTPlatformColorFromColor(*textAttributes.backgroundColor); if (effectiveBackgroundColor && !isnan(textAttributes.opacity)) { effectiveBackgroundColor = [effectiveBackgroundColor @@ -178,7 +197,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]; @@ -256,7 +276,7 @@ inline static CGFloat RCTEffectiveFontSizeMultiplierFromTextAttributes(const Tex NSUnderlineStyle style = RCTNSUnderlineStyleFromTextDecorationStyle( textAttributes.textDecorationStyle.value_or(TextDecorationStyle::Solid)); - UIColor *textDecorationColor = RCTUIColorFromSharedColor(textAttributes.textDecorationColor); + UIColor *textDecorationColor = RCTPlatformColorFromColor(*textAttributes.textDecorationColor); // Underline if (textDecorationLineType == TextDecorationLineType::Underline || @@ -285,7 +305,7 @@ inline static CGFloat RCTEffectiveFontSizeMultiplierFromTextAttributes(const Tex NSShadow *shadow = [NSShadow new]; shadow.shadowOffset = CGSize{textShadowOffset.width, textShadowOffset.height}; shadow.shadowBlurRadius = textAttributes.textShadowRadius; - shadow.shadowColor = RCTUIColorFromSharedColor(textAttributes.textShadowColor); + shadow.shadowColor = RCTPlatformColorFromColor(*textAttributes.textShadowColor); attributes[NSShadowAttributeName] = shadow; } @@ -302,7 +322,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 +486,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; +} diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextPrimitivesConversions.h b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextPrimitivesConversions.h index 6a9624bff9f9de..707fd0072f64f8 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextPrimitivesConversions.h +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextPrimitivesConversions.h @@ -7,9 +7,9 @@ #import -#include -#include -#include +#import +#import +#import inline static NSTextAlignment RCTNSTextAlignmentFromTextAlignment(facebook::react::TextAlignment textAlignment) { @@ -112,9 +112,3 @@ inline static NSUnderlineStyle RCTNSUnderlineStyleFromTextDecorationStyle( return NSUnderlinePatternDot | NSUnderlineStyleSingle; } } - -// TODO: this file has some duplicates method, we can remove it -inline static UIColor *_Nullable RCTUIColorFromSharedColor(const facebook::react::SharedColor &sharedColor) -{ - return RCTPlatformColorFromColor(*sharedColor); -}