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..9b5f56bfec2310 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 *_defaultTypingAttributes; } #pragma mark - UIView overrides @@ -76,6 +83,7 @@ - (instancetype)initWithFrame:(CGRect)frame _ignoreNextTextInputCall = NO; _comingFromJS = NO; _didMoveToWindow = NO; + _defaultTypingAttributes = [_backedTextInputView.defaultTextAttributes copy]; [self addSubview:_backedTextInputView]; [self initializeReturnKeyType]; @@ -84,6 +92,20 @@ - (instancetype)initWithFrame:(CGRect)frame 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 +258,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 copy]; } if (newTextInputProps.selectionColor != oldTextInputProps.selectionColor) { @@ -418,6 +443,7 @@ - (void)textInputDidChange - (void)textInputDidChangeSelection { + [self _updateTypingAttributes]; if (_comingFromJS) { return; } @@ -674,9 +700,26 @@ - (void)_setAttributedString:(NSAttributedString *)attributedString [_backedTextInputView scrollRangeToVisible:NSMakeRange(offsetStart, 0)]; } [self _restoreTextSelection]; + [self _updateTypingAttributes]; _lastStringStateWasUpdatedWith = attributedString; } +// Ensure that newly typed text will inherit any custom attributes. We follow the logic of RN Android, where attributes +// to the left of the cursor are copied into new text, unless we are at the start of the field, in which case we will +// copy the attributes from text to the right. This allows consistency between backed input and new AttributedText +// https://github.com/facebook/react-native/blob/3102a58df38d96f3dacef0530e4dbb399037fcd2/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/SetSpanOperation.kt#L30 +- (void)_updateTypingAttributes +{ + if (_backedTextInputView.attributedText.length > 0) { + NSUInteger offsetStart = [_backedTextInputView offsetFromPosition:_backedTextInputView.beginningOfDocument + toPosition:_backedTextInputView.selectedTextRange.start]; + + NSUInteger samplePoint = offsetStart == 0 ? 0 : offsetStart - 1; + _backedTextInputView.typingAttributes = [_backedTextInputView.attributedText attributesAtIndex:samplePoint + effectiveRange:NULL]; + } +} + - (void)_setMultiline:(BOOL)multiline { [_backedTextInputView removeFromSuperview]; @@ -706,6 +749,10 @@ - (void)_setShowSoftInputOnFocus:(BOOL)showSoftInputOnFocus - (BOOL)_textOf:(NSAttributedString *)newText equals:(NSAttributedString *)oldText { + if (![newText.string isEqualToString:oldText.string]) { + return NO; + } + // When the dictation is running we can't update the attributed text on the backed up text view // because setting the attributed string will kill the dictation. This means that we can't impose // the settings on a dictation. @@ -732,10 +779,59 @@ - (BOOL)_textOf:(NSAttributedString *)newText equals:(NSAttributedString *)oldTe _backedTextInputView.markedTextRange || _backedTextInputView.isSecureTextEntry || fontHasBeenUpdatedBySystem; if (shouldFallbackToBareTextComparison) { - return ([newText.string isEqualToString:oldText.string]); + return YES; } else { - return ([newText isEqualToAttributedString:oldText]); - } + return [self _areAttributesEffectivelyEqual:oldText newText:newText]; + } +} + +- (BOOL)_areAttributesEffectivelyEqual:(NSAttributedString *)oldText newText:(NSAttributedString *)newText +{ + // We check that for every fragment in the old string + // 1. A fragment of the same range exists in the new string + // 2. The attributes of each matching fragment are the same, ignoring those which match the always set default typing + // attributes + __block BOOL areAttriubtesEqual = YES; + [oldText enumerateAttributesInRange:NSMakeRange(0, oldText.length) + options:0 + usingBlock:^(NSDictionary *attrs, NSRange range, BOOL *stop) { + [oldText enumerateAttributesInRange:range + options:0 + usingBlock:^( + NSDictionary *innerAttrs, + NSRange innerRange, + BOOL *innerStop) { + if (!NSEqualRanges(range, innerRange)) { + areAttriubtesEqual = NO; + *innerStop = YES; + *stop = YES; + return; + } + + NSMutableDictionary *normAttrs = + [attrs mutableCopy]; + NSMutableDictionary *normInnerAttrs = + [innerAttrs mutableCopy]; + + for (NSAttributedStringKey key in _defaultTypingAttributes) { + id defaultAttr = _defaultTypingAttributes[key]; + if ([normAttrs[key] isEqual:defaultAttr]) { + [normAttrs removeObjectForKey:key]; + } + if ([normInnerAttrs[key] isEqual:normInnerAttrs]) { + [normInnerAttrs removeObjectForKey:key]; + } + } + + if (![normAttrs isEqualToDictionary:normInnerAttrs]) { + areAttriubtesEqual = NO; + *innerStop = YES; + *stop = YES; + } + }]; + }]; + + return areAttriubtesEqual; } - (SubmitBehavior)getSubmitBehavior 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..d39093382cb28f 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); /* 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..f8aef48ba6ad16 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)