From 1676a218d67f09f3cedbd74d41567e9b7a4ad799 Mon Sep 17 00:00:00 2001 From: IvanIhnatsiuk Date: Sun, 28 Dec 2025 10:45:06 +0100 Subject: [PATCH 01/11] feat: proper layout caclulation --- ios/EnrichedTextInputView.h | 1 - ios/EnrichedTextInputView.mm | 206 ++++-------------- ios/extensions/LayoutManagerExtension.mm | 8 +- ios/inputTextView/InputTextView.h | 3 + ios/inputTextView/InputTextView.mm | 63 +++++- .../EnrichedTextInputViewShadowNode.h | 11 +- .../EnrichedTextInputViewShadowNode.mm | 94 +------- ios/internals/EnrichedTextInputViewState.cpp | 10 - ios/internals/EnrichedTextInputViewState.h | 20 +- 9 files changed, 130 insertions(+), 286 deletions(-) delete mode 100644 ios/internals/EnrichedTextInputViewState.cpp diff --git a/ios/EnrichedTextInputView.h b/ios/EnrichedTextInputView.h index 4ba4759cf..28a321e71 100644 --- a/ios/EnrichedTextInputView.h +++ b/ios/EnrichedTextInputView.h @@ -31,7 +31,6 @@ NS_ASSUME_NONNULL_BEGIN @public BOOL blockEmitting; } -- (CGSize)measureSize:(CGFloat)maxWidth; - (void)emitOnLinkDetectedEvent:(NSString *)text url:(NSString *)url range:(NSRange)range; diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index 28a806890..d14749826 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -46,6 +46,7 @@ @implementation EnrichedTextInputView { UIColor *_placeholderColor; BOOL _emitFocusBlur; BOOL _emitTextChange; + BOOL _isMeasuringSize; } // MARK: - Component utils @@ -72,8 +73,8 @@ - (instancetype)initWithFrame:(CGRect)frame { _props = defaultProps; [self setDefaults]; [self setupTextView]; - [self setupPlaceholderLabel]; - self.contentView = textView; + [self addSubview:textView]; + _isMeasuringSize = NO; } return self; } @@ -249,26 +250,9 @@ - (void)setupTextView { textView.delegate = self; textView.input = self; textView.layoutManager.input = self; - textView.adjustsFontForContentSizeCategory = YES; -} - -- (void)setupPlaceholderLabel { - _placeholderLabel = [[UILabel alloc] initWithFrame:CGRectZero]; - _placeholderLabel.translatesAutoresizingMaskIntoConstraints = NO; - [textView addSubview:_placeholderLabel]; - [NSLayoutConstraint activateConstraints:@[ - [_placeholderLabel.leadingAnchor - constraintEqualToAnchor:textView.leadingAnchor], - [_placeholderLabel.widthAnchor - constraintEqualToAnchor:textView.widthAnchor], - [_placeholderLabel.topAnchor constraintEqualToAnchor:textView.topAnchor], - [_placeholderLabel.bottomAnchor - constraintEqualToAnchor:textView.bottomAnchor] - ]]; - _placeholderLabel.lineBreakMode = NSLineBreakByTruncatingTail; - _placeholderLabel.text = @""; - _placeholderLabel.hidden = YES; - _placeholderLabel.adjustsFontForContentSizeCategory = YES; + textView.autoresizingMask = + UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + textView.adjustsFontForContentSizeCategory = YES; } // MARK: - Props @@ -685,9 +669,6 @@ - (void)updateProps:(Props::Shared const &)props [[NSParagraphStyle alloc] init]; textView.typingAttributes = defaultTypingAttributes; textView.selectedRange = prevSelectedRange; - - // update the placeholder as well - [self refreshPlaceholderLabelStyles]; } // editable @@ -715,24 +696,14 @@ - (void)updateProps:(Props::Shared const &)props // placeholderTextColor if (newViewProps.placeholderTextColor != oldViewProps.placeholderTextColor) { - // some real color - if (isColorMeaningful(newViewProps.placeholderTextColor)) { - _placeholderColor = - RCTUIColorFromSharedColor(newViewProps.placeholderTextColor); - } else { - _placeholderColor = nullptr; - } - [self refreshPlaceholderLabelStyles]; + textView.placeholderColor = + RCTUIColorFromSharedColor(newViewProps.placeholderTextColor); } // placeholder if (newViewProps.placeholder != oldViewProps.placeholder) { - _placeholderLabel.text = [NSString fromCppString:newViewProps.placeholder]; - [self refreshPlaceholderLabelStyles]; - // additionally show placeholder on first mount if it should be there - if (isFirstMount && textView.text.length == 0) { - [self setPlaceholderLabelShown:YES]; - } + [textView + setPlaceholderText:[NSString fromCppString:newViewProps.placeholder]]; } // mention indicators @@ -808,75 +779,17 @@ - (void)updateProps:(Props::Shared const &)props if (isFirstMount && newViewProps.autoFocus) { [textView reactFocus]; } + [textView updatePlaceholderVisibility]; } -- (void)setPlaceholderLabelShown:(BOOL)shown { - if (shown) { - [self refreshPlaceholderLabelStyles]; - _placeholderLabel.hidden = NO; - } else { - _placeholderLabel.hidden = YES; - } -} +- (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics + oldLayoutMetrics:(const LayoutMetrics &)oldLayoutMetrics { + [super updateLayoutMetrics:layoutMetrics oldLayoutMetrics:oldLayoutMetrics]; -- (void)refreshPlaceholderLabelStyles { - NSMutableDictionary *newAttrs = [defaultTypingAttributes mutableCopy]; - if (_placeholderColor != nullptr) { - newAttrs[NSForegroundColorAttributeName] = _placeholderColor; - } - NSAttributedString *newAttrStr = - [[NSAttributedString alloc] initWithString:_placeholderLabel.text - attributes:newAttrs]; - _placeholderLabel.attributedText = newAttrStr; -} - -// MARK: - Measuring and states - -- (CGSize)measureSize:(CGFloat)maxWidth { - // copy the the whole attributed string - NSMutableAttributedString *currentStr = [[NSMutableAttributedString alloc] - initWithAttributedString:textView.textStorage]; - - // edge case: empty input should still be of a height of a single line, so we - // add a mock "I" character - if ([currentStr length] == 0) { - [currentStr - appendAttributedString:[[NSAttributedString alloc] - initWithString:@"I" - attributes:textView.typingAttributes]]; - } - - // edge case: input with only a zero width space should still be of a height - // of a single line, so we add a mock "I" character - if ([currentStr length] == 1 && - [[currentStr.string substringWithRange:NSMakeRange(0, 1)] - isEqualToString:@"\u200B"]) { - [currentStr - appendAttributedString:[[NSAttributedString alloc] - initWithString:@"I" - attributes:textView.typingAttributes]]; - } - - // edge case: trailing newlines aren't counted towards height calculations, so - // we add a mock "I" character - if (currentStr.length > 0) { - unichar lastChar = - [currentStr.string characterAtIndex:currentStr.length - 1]; - if ([[NSCharacterSet newlineCharacterSet] characterIsMember:lastChar]) { - [currentStr - appendAttributedString:[[NSAttributedString alloc] - initWithString:@"I" - attributes:defaultTypingAttributes]]; - } - } - - CGRect boundingBox = - [currentStr boundingRectWithSize:CGSizeMake(maxWidth, CGFLOAT_MAX) - options:NSStringDrawingUsesLineFragmentOrigin | - NSStringDrawingUsesFontLeading - context:nullptr]; - - return CGSizeMake(maxWidth, ceil(boundingBox.size.height)); + textView.frame = UIEdgeInsetsInsetRect( + self.bounds, RCTUIEdgeInsetsFromEdgeInsets(layoutMetrics.borderWidth)); + textView.textContainerInset = RCTUIEdgeInsetsFromEdgeInsets( + layoutMetrics.contentInsets - layoutMetrics.borderWidth); } // make sure the newest state is kept in _state property @@ -889,18 +802,32 @@ - (void)updateState:(State::Shared const &)state // componentView) so we need to run a single height calculation for any // initial values if (oldState == nullptr) { - [self tryUpdatingHeight]; + [self measureSize]; } } -- (void)tryUpdatingHeight { - if (_state == nullptr) { +- (void)measureSize { + if (_state == nullptr || _isMeasuringSize) { return; } - _componentViewHeightUpdateCounter++; - auto selfRef = wrapManagedObjectWeakly(self); - _state->updateState( - EnrichedTextInputViewState(_componentViewHeightUpdateCounter, selfRef)); + + [textView.layoutManager ensureLayoutForTextContainer:textView.textContainer]; + + CGRect used = + [textView.layoutManager usedRectForTextContainer:textView.textContainer]; + CGSize size = used.size; + + // Empty text fallback + if (textView.textStorage.length == 0) { + UIFont *font = + textView.typingAttributes[NSFontAttributeName] ?: textView.font; + if (font) { + size.height = ceil(font.lineHeight); + } + } + + facebook::react::Size newSize{.width = size.width, .height = size.height}; + _state->updateState(facebook::react::EnrichedTextInputViewState(newSize)); } // MARK: - Active styles @@ -1610,12 +1537,7 @@ - (void)anyTextMayHaveBeenModified { } // placholder management - if (!_placeholderLabel.hidden && textView.textStorage.string.length > 0) { - [self setPlaceholderLabelShown:NO]; - } else if (textView.textStorage.string.length == 0 && - _placeholderLabel.hidden) { - [self setPlaceholderLabelShown:YES]; - } + [textView updatePlaceholderVisibility]; if (![textView.textStorage.string isEqualToString:_recentInputString]) { // modified words handling @@ -1652,55 +1574,9 @@ - (void)anyTextMayHaveBeenModified { } // update height on each character change - [self tryUpdatingHeight]; + [self measureSize]; // update active styles as well [self tryUpdatingActiveStyles]; - // update drawing - schedule debounced relayout - [self scheduleRelayoutIfNeeded]; -} - -// Debounced relayout helper - coalesces multiple requests into one per runloop -// tick -- (void)scheduleRelayoutIfNeeded { - // Cancel any previously scheduled invocation to debounce - [NSObject cancelPreviousPerformRequestsWithTarget:self - selector:@selector(_performRelayout) - object:nil]; - // Schedule on next runloop cycle - [self performSelector:@selector(_performRelayout) - withObject:nil - afterDelay:0]; -} - -- (void)_performRelayout { - if (!textView) { - return; - } - - dispatch_async(dispatch_get_main_queue(), ^{ - NSRange wholeRange = - NSMakeRange(0, self->textView.textStorage.string.length); - NSRange actualRange = NSMakeRange(0, 0); - [self->textView.layoutManager - invalidateLayoutForCharacterRange:wholeRange - actualCharacterRange:&actualRange]; - [self->textView.layoutManager ensureLayoutForCharacterRange:actualRange]; - [self->textView.layoutManager - invalidateDisplayForCharacterRange:wholeRange]; - - // We have to explicitly set contentSize - // That way textView knows if content overflows and if should be scrollable - // We recall measureSize here because value returned from previous - // measureSize may not be up-to date at that point - CGSize measuredSize = [self measureSize:self->textView.frame.size.width]; - self->textView.contentSize = measuredSize; - }); -} - -- (void)didMoveToWindow { - [super didMoveToWindow]; - // used to run all lifecycle callbacks - [self anyTextMayHaveBeenModified]; } // MARK: - UITextView delegate methods diff --git a/ios/extensions/LayoutManagerExtension.mm b/ios/extensions/LayoutManagerExtension.mm index 2b011bddc..869999d59 100644 --- a/ios/extensions/LayoutManagerExtension.mm +++ b/ios/extensions/LayoutManagerExtension.mm @@ -317,11 +317,11 @@ - (void)drawLists:(EnrichedTextInputView *)typedInput CGFloat bulletSize = [typedInput->config unorderedListBulletSize]; - CGFloat bulletX = usedRect.origin.x - - gapWidth - - bulletSize / 2; + CGFloat bulletX = + origin.x + usedRect.origin.x - + bulletSize / 2 - gapWidth; CGFloat centerY = - CGRectGetMidY(usedRect); + CGRectGetMidY(usedRect) + origin.y; CGContextRef context = UIGraphicsGetCurrentContext(); diff --git a/ios/inputTextView/InputTextView.h b/ios/inputTextView/InputTextView.h index 6ddac2e91..498ae68b3 100644 --- a/ios/inputTextView/InputTextView.h +++ b/ios/inputTextView/InputTextView.h @@ -3,4 +3,7 @@ @interface InputTextView : UITextView @property(nonatomic, weak) id input; +@property(nonatomic, copy, nullable) NSString *placeholderText; +@property(nonatomic, strong, nullable) UIColor *placeholderColor; +- (void)updatePlaceholderVisibility; @end diff --git a/ios/inputTextView/InputTextView.mm b/ios/inputTextView/InputTextView.mm index 2487c90c7..27848acc6 100644 --- a/ios/inputTextView/InputTextView.mm +++ b/ios/inputTextView/InputTextView.mm @@ -4,7 +4,23 @@ #import "TextInsertionUtils.h" #import -@implementation InputTextView +@implementation InputTextView { + UILabel *placeholderView; +}; + +- (instancetype)initWithFrame:(CGRect)frame { + if ((self = [super initWithFrame:frame])) { + placeholderView = [[UILabel alloc] initWithFrame:self.bounds]; + placeholderView.isAccessibilityElement = NO; + placeholderView.numberOfLines = 0; + [self addSubview:placeholderView]; + + self.textContainer.lineFragmentPadding = 0; + self.scrollEnabled = YES; + self.scrollsToTop = NO; + } + return self; +} - (void)copy:(id)sender { EnrichedTextInputView *typedInput = (EnrichedTextInputView *)_input; @@ -153,4 +169,49 @@ - (void)cut:(id)sender { [typedInput anyTextMayHaveBeenModified]; } +- (void)updatePlaceholderVisibility { + BOOL shouldShow = + self.placeholderText.length > 0 && self.textStorage.length == 0; + + placeholderView.hidden = !shouldShow; +} + +- (void)setText:(NSString *)text { + [super setText:text]; + [self updatePlaceholderVisibility]; +} + +- (void)setAttributedText:(NSAttributedString *)attributedText { + [super setAttributedText:attributedText]; + [self updatePlaceholderVisibility]; +} + +- (void)setPlaceholderText:(NSString *)newPlaceholderText { + EnrichedTextInputView *typedInput = (EnrichedTextInputView *)_input; + if (typedInput == nullptr) { + return; + } + _placeholderText = newPlaceholderText; + BOOL hasPlaceholder = newPlaceholderText && newPlaceholderText.length > 0; + NSString *placeholderText = hasPlaceholder ? newPlaceholderText : @""; + NSMutableDictionary *attributes = + [typedInput->defaultTypingAttributes mutableCopy]; + attributes[NSForegroundColorAttributeName] = _placeholderColor; + placeholderView.attributedText = + [[NSAttributedString alloc] initWithString:placeholderText + attributes:attributes]; + [self setNeedsLayout]; +} + +- (void)layoutSubviews { + [super layoutSubviews]; + + CGRect textFrame = + UIEdgeInsetsInsetRect(self.bounds, self.textContainerInset); + CGFloat placeholderHeight = + [placeholderView sizeThatFits:textFrame.size].height; + textFrame.size.height = MIN(placeholderHeight, textFrame.size.height); + placeholderView.frame = textFrame; +} + @end diff --git a/ios/internals/EnrichedTextInputViewShadowNode.h b/ios/internals/EnrichedTextInputViewShadowNode.h index 9789e9b76..d34debb2a 100644 --- a/ios/internals/EnrichedTextInputViewShadowNode.h +++ b/ios/internals/EnrichedTextInputViewShadowNode.h @@ -17,14 +17,12 @@ class EnrichedTextInputViewShadowNode : public ConcreteViewShadowNode< EnrichedTextInputViewComponentName, EnrichedTextInputViewProps, EnrichedTextInputViewEventEmitter, EnrichedTextInputViewState> { + public: using ConcreteViewShadowNode::ConcreteViewShadowNode; - EnrichedTextInputViewShadowNode(const ShadowNodeFragment &fragment, - const ShadowNodeFamily::Shared &family, - ShadowNodeTraits traits); - EnrichedTextInputViewShadowNode(const ShadowNode &sourceShadowNode, - const ShadowNodeFragment &fragment); + void dirtyLayoutIfNeeded(); + Size measureContent(const LayoutContext &layoutContext, const LayoutConstraints &layoutConstraints) const override; @@ -37,8 +35,7 @@ class EnrichedTextInputViewShadowNode } private: - int localForceHeightRecalculationCounter_; - id setupMockTextInputView_() const; + mutable Size _prevContentSize{}; }; } // namespace facebook::react diff --git a/ios/internals/EnrichedTextInputViewShadowNode.mm b/ios/internals/EnrichedTextInputViewShadowNode.mm index 6d560564e..7c9ed650d 100644 --- a/ios/internals/EnrichedTextInputViewShadowNode.mm +++ b/ios/internals/EnrichedTextInputViewShadowNode.mm @@ -1,103 +1,25 @@ -#import "EnrichedTextInputViewShadowNode.h" -#import "CoreText/CoreText.h" -#import -#import -#import -#import +#include "EnrichedTextInputViewShadowNode.h" namespace facebook::react { extern const char EnrichedTextInputViewComponentName[] = "EnrichedTextInputView"; -EnrichedTextInputViewShadowNode::EnrichedTextInputViewShadowNode( - const ShadowNodeFragment &fragment, const ShadowNodeFamily::Shared &family, - ShadowNodeTraits traits) - : ConcreteViewShadowNode(fragment, family, traits) { - localForceHeightRecalculationCounter_ = 0; -} - -// mock input is used for the first measure calls that need to be done when the -// real input isn't defined yet -id EnrichedTextInputViewShadowNode::setupMockTextInputView_() const { - // it's rendered far away from the viewport - const int veryFarAway = 20000; - const int mockSize = 1000; - EnrichedTextInputView *mockTextInputView_ = [[EnrichedTextInputView alloc] - initWithFrame:(CGRectMake(veryFarAway, veryFarAway, mockSize, mockSize))]; - const auto props = this->getProps(); - mockTextInputView_->blockEmitting = YES; - [mockTextInputView_ updateProps:props oldProps:nullptr]; - return mockTextInputView_; -} - -EnrichedTextInputViewShadowNode::EnrichedTextInputViewShadowNode( - const ShadowNode &sourceShadowNode, const ShadowNodeFragment &fragment) - : ConcreteViewShadowNode(sourceShadowNode, fragment) { - dirtyLayoutIfNeeded(); -} - void EnrichedTextInputViewShadowNode::dirtyLayoutIfNeeded() { - const auto state = this->getStateData(); - const int receivedCounter = state.getForceHeightRecalculationCounter(); + const auto &state = getStateData(); - if (receivedCounter > localForceHeightRecalculationCounter_) { - localForceHeightRecalculationCounter_ = receivedCounter; + auto nextSize = state.getContentSize(); + if (nextSize != _prevContentSize) { + _prevContentSize = nextSize; YGNodeMarkDirty(&yogaNode_); } } Size EnrichedTextInputViewShadowNode::measureContent( - const LayoutContext &layoutContext, - const LayoutConstraints &layoutConstraints) const { - const auto state = this->getStateData(); - const auto componentRef = state.getComponentViewRef(); - RCTInternalGenericWeakWrapper *weakWrapper = - (RCTInternalGenericWeakWrapper *)unwrapManagedObject(componentRef); - - if (weakWrapper != nullptr) { - id componentObject = weakWrapper.object; - EnrichedTextInputView *typedComponentObject = - (EnrichedTextInputView *)componentObject; - - if (typedComponentObject != nullptr) { - __block CGSize estimatedSize; - - // synchronously dispatch to main thread if needed - if ([NSThread isMainThread]) { - estimatedSize = [typedComponentObject - measureSize:layoutConstraints.maximumSize.width]; - } else { - dispatch_sync(dispatch_get_main_queue(), ^{ - estimatedSize = [typedComponentObject - measureSize:layoutConstraints.maximumSize.width]; - }); - } - - return {estimatedSize.width, - MIN(estimatedSize.height, layoutConstraints.maximumSize.height)}; - } - } else { - __block CGSize estimatedSize; - - // synchronously dispatch to main thread if needed - if ([NSThread isMainThread]) { - EnrichedTextInputView *mockTextInputView = setupMockTextInputView_(); - estimatedSize = - [mockTextInputView measureSize:layoutConstraints.maximumSize.width]; - } else { - dispatch_sync(dispatch_get_main_queue(), ^{ - EnrichedTextInputView *mockTextInputView = setupMockTextInputView_(); - estimatedSize = - [mockTextInputView measureSize:layoutConstraints.maximumSize.width]; - }); - } - - return {estimatedSize.width, - MIN(estimatedSize.height, layoutConstraints.maximumSize.height)}; - } + const LayoutContext &, const LayoutConstraints &constraints) const { - return Size(); + const auto size = getStateData().getContentSize(); + return constraints.clamp(size); } } // namespace facebook::react diff --git a/ios/internals/EnrichedTextInputViewState.cpp b/ios/internals/EnrichedTextInputViewState.cpp deleted file mode 100644 index 6d0eea37f..000000000 --- a/ios/internals/EnrichedTextInputViewState.cpp +++ /dev/null @@ -1,10 +0,0 @@ -#include "EnrichedTextInputViewState.h" - -namespace facebook::react { - int EnrichedTextInputViewState::getForceHeightRecalculationCounter() const { - return forceHeightRecalculationCounter_; - } - std::shared_ptr EnrichedTextInputViewState::getComponentViewRef() const { - return componentViewRef_; - } -} // namespace facebook::react diff --git a/ios/internals/EnrichedTextInputViewState.h b/ios/internals/EnrichedTextInputViewState.h index 3da93fe50..c7b48df41 100644 --- a/ios/internals/EnrichedTextInputViewState.h +++ b/ios/internals/EnrichedTextInputViewState.h @@ -1,22 +1,18 @@ -#pragma once -#include +#include namespace facebook::react { class EnrichedTextInputViewState { public: - EnrichedTextInputViewState() - : forceHeightRecalculationCounter_(0), componentViewRef_(nullptr) {} - EnrichedTextInputViewState(int counter, std::shared_ptr ref) { - forceHeightRecalculationCounter_ = counter; - componentViewRef_ = ref; - } - int getForceHeightRecalculationCounter() const; - std::shared_ptr getComponentViewRef() const; + EnrichedTextInputViewState() = default; + + explicit EnrichedTextInputViewState(Size contentSize) + : contentSize_(contentSize) {} + + const Size &getContentSize() const { return contentSize_; } private: - int forceHeightRecalculationCounter_{}; - std::shared_ptr componentViewRef_{}; + Size contentSize_{}; }; } // namespace facebook::react From a0d838a43c2421df9e19020fb67fd6b339d5424f Mon Sep 17 00:00:00 2001 From: IvanIhnatsiuk Date: Sun, 28 Dec 2025 16:11:42 +0100 Subject: [PATCH 02/11] fix: return mock view --- ios/EnrichedTextInputView.h | 1 + ios/EnrichedTextInputView.mm | 45 ++++++++-- .../EnrichedTextInputViewShadowNode.h | 9 +- .../EnrichedTextInputViewShadowNode.mm | 86 +++++++++++++++++-- ios/internals/EnrichedTextInputViewState.h | 11 ++- 5 files changed, 135 insertions(+), 17 deletions(-) diff --git a/ios/EnrichedTextInputView.h b/ios/EnrichedTextInputView.h index 28a321e71..d54c596dc 100644 --- a/ios/EnrichedTextInputView.h +++ b/ios/EnrichedTextInputView.h @@ -39,6 +39,7 @@ NS_ASSUME_NONNULL_BEGIN - (BOOL)handleStyleBlocksAndConflicts:(StyleType)type range:(NSRange)range; - (NSArray *)getPresentStyleTypesFrom:(NSArray *)types range:(NSRange)range; +- (CGSize)measureInitialSizeWithMaxWidth:(CGFloat)maxWidth; @end NS_ASSUME_NONNULL_END diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index d14749826..137fc300c 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -802,15 +802,14 @@ - (void)updateState:(State::Shared const &)state // componentView) so we need to run a single height calculation for any // initial values if (oldState == nullptr) { - [self measureSize]; + [self measureAndCommitSize]; } } -- (void)measureSize { - if (_state == nullptr || _isMeasuringSize) { +- (void)measureAndCommitSize { + if (_state == nullptr) { return; } - [textView.layoutManager ensureLayoutForTextContainer:textView.textContainer]; CGRect used = @@ -825,9 +824,41 @@ - (void)measureSize { size.height = ceil(font.lineHeight); } } - + auto selfRef = wrapManagedObjectWeakly(self); facebook::react::Size newSize{.width = size.width, .height = size.height}; - _state->updateState(facebook::react::EnrichedTextInputViewState(newSize)); + _state->updateState( + facebook::react::EnrichedTextInputViewState(newSize, selfRef)); +} + +- (CGSize)measureInitialSizeWithMaxWidth:(CGFloat)maxWidth { + [textView.layoutManager ensureLayoutForTextContainer:textView.textContainer]; + CGRect usedRect = + [textView.layoutManager usedRectForTextContainer:textView.textContainer]; + CGSize size = usedRect.size; + + // Empty text fallback + if (textView.textStorage.length == 0) { + UIFont *font = + textView.typingAttributes[NSFontAttributeName] ?: textView.font; + + if (font) { + size.height = ceil(font.lineHeight); + } + } + NSString *currentStr = [[textView.textStorage string] copy]; + // Bounding rect fallback / final height calculation + NSString *string = currentStr ?: @""; + CGRect boundingBox = + [string boundingRectWithSize:CGSizeMake(maxWidth, CGFLOAT_MAX) + options:NSStringDrawingUsesLineFragmentOrigin | + NSStringDrawingUsesFontLeading + attributes:@{ + NSFontAttributeName : textView.font + ?: [UIFont systemFontOfSize:17] + } + context:nil]; + + return CGSizeMake(maxWidth, ceil(boundingBox.size.height)); } // MARK: - Active styles @@ -1574,7 +1605,7 @@ - (void)anyTextMayHaveBeenModified { } // update height on each character change - [self measureSize]; + [self measureAndCommitSize]; // update active styles as well [self tryUpdatingActiveStyles]; } diff --git a/ios/internals/EnrichedTextInputViewShadowNode.h b/ios/internals/EnrichedTextInputViewShadowNode.h index d34debb2a..dc9a6d5de 100644 --- a/ios/internals/EnrichedTextInputViewShadowNode.h +++ b/ios/internals/EnrichedTextInputViewShadowNode.h @@ -20,7 +20,11 @@ class EnrichedTextInputViewShadowNode public: using ConcreteViewShadowNode::ConcreteViewShadowNode; - + EnrichedTextInputViewShadowNode(const ShadowNodeFragment &fragment, + const ShadowNodeFamily::Shared &family, + ShadowNodeTraits traits); + EnrichedTextInputViewShadowNode(const ShadowNode &sourceShadowNode, + const ShadowNodeFragment &fragment); void dirtyLayoutIfNeeded(); Size @@ -35,7 +39,8 @@ class EnrichedTextInputViewShadowNode } private: - mutable Size _prevContentSize{}; + mutable Size _prevContentSize{NAN, NAN}; + id setupMockTextInputView_() const; }; } // namespace facebook::react diff --git a/ios/internals/EnrichedTextInputViewShadowNode.mm b/ios/internals/EnrichedTextInputViewShadowNode.mm index 7c9ed650d..2736d2352 100644 --- a/ios/internals/EnrichedTextInputViewShadowNode.mm +++ b/ios/internals/EnrichedTextInputViewShadowNode.mm @@ -1,4 +1,9 @@ -#include "EnrichedTextInputViewShadowNode.h" +#import "EnrichedTextInputViewShadowNode.h" + +#import +#import +#import +#import namespace facebook::react { @@ -7,19 +12,88 @@ void EnrichedTextInputViewShadowNode::dirtyLayoutIfNeeded() { const auto &state = getStateData(); + const auto nextSize = state.getContentSize(); - auto nextSize = state.getContentSize(); - if (nextSize != _prevContentSize) { - _prevContentSize = nextSize; + if (_prevContentSize != nextSize) { YGNodeMarkDirty(&yogaNode_); } + + _prevContentSize = nextSize; +} + +id EnrichedTextInputViewShadowNode::setupMockTextInputView_() const { + // it's rendered far away from the viewport + const int veryFarAway = 20000; + const int mockSize = 1000; + EnrichedTextInputView *mockTextInputView_ = [[EnrichedTextInputView alloc] + initWithFrame:(CGRectMake(veryFarAway, veryFarAway, mockSize, mockSize))]; + const auto props = this->getProps(); + mockTextInputView_->blockEmitting = YES; + [mockTextInputView_ updateProps:props oldProps:nullptr]; + return mockTextInputView_; +} + +EnrichedTextInputViewShadowNode::EnrichedTextInputViewShadowNode( + const ShadowNode &source, const ShadowNodeFragment &fragment) + : ConcreteViewShadowNode(source, fragment) { + + const auto &oldState = + static_cast(source) + .getStateData(); + + const auto &newState = getStateData(); + + const auto &oldSize = oldState.getContentSize(); + const auto &newSize = newState.getContentSize(); + + if (newSize != oldSize) { + YGNodeMarkDirty(&yogaNode_); + } +} + +EnrichedTextInputViewShadowNode::EnrichedTextInputViewShadowNode( + const ShadowNodeFragment &fragment, const ShadowNodeFamily::Shared &family, + ShadowNodeTraits traits) + : ConcreteViewShadowNode(fragment, family, traits) { + _prevContentSize = {}; } Size EnrichedTextInputViewShadowNode::measureContent( const LayoutContext &, const LayoutConstraints &constraints) const { + const auto state = this->getStateData(); + const auto componentRef = state.getComponentViewRef(); + RCTInternalGenericWeakWrapper *weakWrapper = + (RCTInternalGenericWeakWrapper *)unwrapManagedObject(componentRef); + if (weakWrapper != nullptr) { + id componentObject = weakWrapper.object; + EnrichedTextInputView *typedComponentObject = + (EnrichedTextInputView *)componentObject; + + if (typedComponentObject != nullptr) { + auto size = state.getContentSize(); + _prevContentSize = size; + return constraints.clamp(size); + } + } else { + __block CGSize estimatedSize; + // synchronously dispatch to main thread if needed + if ([NSThread isMainThread]) { + EnrichedTextInputView *mockTextInputView = setupMockTextInputView_(); + estimatedSize = [mockTextInputView + measureInitialSizeWithMaxWidth:constraints.maximumSize.width]; + } else { + dispatch_sync(dispatch_get_main_queue(), ^{ + EnrichedTextInputView *mockTextInputView = setupMockTextInputView_(); + estimatedSize = [mockTextInputView + measureInitialSizeWithMaxWidth:constraints.maximumSize.width]; + }); + } + + return {estimatedSize.width, + MIN(estimatedSize.height, constraints.maximumSize.height)}; + } - const auto size = getStateData().getContentSize(); - return constraints.clamp(size); + return Size(); } } // namespace facebook::react diff --git a/ios/internals/EnrichedTextInputViewState.h b/ios/internals/EnrichedTextInputViewState.h index c7b48df41..d32926b39 100644 --- a/ios/internals/EnrichedTextInputViewState.h +++ b/ios/internals/EnrichedTextInputViewState.h @@ -1,3 +1,4 @@ +#pragma once #include namespace facebook::react { @@ -6,13 +7,19 @@ class EnrichedTextInputViewState { public: EnrichedTextInputViewState() = default; - explicit EnrichedTextInputViewState(Size contentSize) - : contentSize_(contentSize) {} + explicit EnrichedTextInputViewState(Size contentSize, + std::shared_ptr ref) + : contentSize_(contentSize), componentViewRef_(std::move(ref)) {} const Size &getContentSize() const { return contentSize_; } + const std::shared_ptr getComponentViewRef() const { + return componentViewRef_; + } + private: Size contentSize_{}; + std::shared_ptr componentViewRef_{}; }; } // namespace facebook::react From 1428329f78ed2bdd0c51220a2e02d92e2af81b41 Mon Sep 17 00:00:00 2001 From: IvanIhnatsiuk Date: Sun, 28 Dec 2025 16:44:51 +0100 Subject: [PATCH 03/11] fix: remove code duplication --- ios/EnrichedTextInputView.mm | 57 +++++++++++++----------------------- 1 file changed, 20 insertions(+), 37 deletions(-) diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index 137fc300c..4e635ddb3 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -806,24 +806,34 @@ - (void)updateState:(State::Shared const &)state } } -- (void)measureAndCommitSize { - if (_state == nullptr) { - return; - } - [textView.layoutManager ensureLayoutForTextContainer:textView.textContainer]; +- (CGSize)_measureSizeWithMaxWidth:(CGFloat)maxWidth { + NSTextContainer *container = textView.textContainer; + NSLayoutManager *layoutManager = textView.layoutManager; + + container.size = CGSizeMake(maxWidth, CGFLOAT_MAX); + + [layoutManager ensureLayoutForTextContainer:container]; - CGRect used = - [textView.layoutManager usedRectForTextContainer:textView.textContainer]; - CGSize size = used.size; + CGRect used = [layoutManager usedRectForTextContainer:container]; + CGFloat height = ceil(used.size.height); // Empty text fallback if (textView.textStorage.length == 0) { UIFont *font = textView.typingAttributes[NSFontAttributeName] ?: textView.font; if (font) { - size.height = ceil(font.lineHeight); + height = ceil(font.lineHeight); } } + + return CGSizeMake(maxWidth, height); +} + +- (void)measureAndCommitSize { + if (_state == nullptr) { + return; + } + CGSize size = [self _measureSizeWithMaxWidth:textView.bounds.size.width]; auto selfRef = wrapManagedObjectWeakly(self); facebook::react::Size newSize{.width = size.width, .height = size.height}; _state->updateState( @@ -831,34 +841,7 @@ - (void)measureAndCommitSize { } - (CGSize)measureInitialSizeWithMaxWidth:(CGFloat)maxWidth { - [textView.layoutManager ensureLayoutForTextContainer:textView.textContainer]; - CGRect usedRect = - [textView.layoutManager usedRectForTextContainer:textView.textContainer]; - CGSize size = usedRect.size; - - // Empty text fallback - if (textView.textStorage.length == 0) { - UIFont *font = - textView.typingAttributes[NSFontAttributeName] ?: textView.font; - - if (font) { - size.height = ceil(font.lineHeight); - } - } - NSString *currentStr = [[textView.textStorage string] copy]; - // Bounding rect fallback / final height calculation - NSString *string = currentStr ?: @""; - CGRect boundingBox = - [string boundingRectWithSize:CGSizeMake(maxWidth, CGFLOAT_MAX) - options:NSStringDrawingUsesLineFragmentOrigin | - NSStringDrawingUsesFontLeading - attributes:@{ - NSFontAttributeName : textView.font - ?: [UIFont systemFontOfSize:17] - } - context:nil]; - - return CGSizeMake(maxWidth, ceil(boundingBox.size.height)); + return [self _measureSizeWithMaxWidth:maxWidth]; } // MARK: - Active styles From 5402e2e5162a6d06a4457cd357072f5da78f3e15 Mon Sep 17 00:00:00 2001 From: IvanIhnatsiuk Date: Sun, 28 Dec 2025 18:23:01 +0100 Subject: [PATCH 04/11] fix: remove unused ivars --- ios/EnrichedTextInputView.mm | 30 +++++++++++++----------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index 4e635ddb3..c9f1f0081 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -74,7 +74,6 @@ - (instancetype)initWithFrame:(CGRect)frame { [self setDefaults]; [self setupTextView]; [self addSubview:textView]; - _isMeasuringSize = NO; } return self; } @@ -806,7 +805,19 @@ - (void)updateState:(State::Shared const &)state } } -- (CGSize)_measureSizeWithMaxWidth:(CGFloat)maxWidth { +- (void)measureAndCommitSize { + if (_state == nullptr) { + return; + } + CGSize size = + [self measureInitialSizeWithMaxWidth:textView.bounds.size.width]; + auto selfRef = wrapManagedObjectWeakly(self); + facebook::react::Size newSize{.width = size.width, .height = size.height}; + _state->updateState( + facebook::react::EnrichedTextInputViewState(newSize, selfRef)); +} + +- (CGSize)measureInitialSizeWithMaxWidth:(CGFloat)maxWidth { NSTextContainer *container = textView.textContainer; NSLayoutManager *layoutManager = textView.layoutManager; @@ -829,21 +840,6 @@ - (CGSize)_measureSizeWithMaxWidth:(CGFloat)maxWidth { return CGSizeMake(maxWidth, height); } -- (void)measureAndCommitSize { - if (_state == nullptr) { - return; - } - CGSize size = [self _measureSizeWithMaxWidth:textView.bounds.size.width]; - auto selfRef = wrapManagedObjectWeakly(self); - facebook::react::Size newSize{.width = size.width, .height = size.height}; - _state->updateState( - facebook::react::EnrichedTextInputViewState(newSize, selfRef)); -} - -- (CGSize)measureInitialSizeWithMaxWidth:(CGFloat)maxWidth { - return [self _measureSizeWithMaxWidth:maxWidth]; -} - // MARK: - Active styles - (void)tryUpdatingActiveStyles { From da5c6a156240d08d46140cb9ed6a7fd23d839aee Mon Sep 17 00:00:00 2001 From: IvanIhnatsiuk Date: Sun, 28 Dec 2025 23:34:30 +0100 Subject: [PATCH 05/11] feat: use usedRect in layout subviews --- ios/EnrichedTextInputView.h | 1 + ios/EnrichedTextInputView.mm | 9 +++------ ios/inputTextView/InputTextView.mm | 21 +++++++++++++++++++++ 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/ios/EnrichedTextInputView.h b/ios/EnrichedTextInputView.h index d54c596dc..346f1d36c 100644 --- a/ios/EnrichedTextInputView.h +++ b/ios/EnrichedTextInputView.h @@ -40,6 +40,7 @@ NS_ASSUME_NONNULL_BEGIN - (NSArray *)getPresentStyleTypesFrom:(NSArray *)types range:(NSRange)range; - (CGSize)measureInitialSizeWithMaxWidth:(CGFloat)maxWidth; +- (void)commitSize:(CGSize)size; @end NS_ASSUME_NONNULL_END diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index c9f1f0081..c310cd72e 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -801,16 +801,15 @@ - (void)updateState:(State::Shared const &)state // componentView) so we need to run a single height calculation for any // initial values if (oldState == nullptr) { - [self measureAndCommitSize]; + [self commitSize:textView.textContainer.size]; } } -- (void)measureAndCommitSize { +- (void)commitSize:(CGSize)size { if (_state == nullptr) { return; } - CGSize size = - [self measureInitialSizeWithMaxWidth:textView.bounds.size.width]; + auto selfRef = wrapManagedObjectWeakly(self); facebook::react::Size newSize{.width = size.width, .height = size.height}; _state->updateState( @@ -1583,8 +1582,6 @@ - (void)anyTextMayHaveBeenModified { } } - // update height on each character change - [self measureAndCommitSize]; // update active styles as well [self tryUpdatingActiveStyles]; } diff --git a/ios/inputTextView/InputTextView.mm b/ios/inputTextView/InputTextView.mm index 27848acc6..17dd71171 100644 --- a/ios/inputTextView/InputTextView.mm +++ b/ios/inputTextView/InputTextView.mm @@ -4,8 +4,19 @@ #import "TextInsertionUtils.h" #import +// for some reason UIKit may produce different size with the same text +// the difference is always ~0.5 +static const CGFloat Epsilon = 0.5; + +static inline BOOL CGSizeAlmostEqual(CGSize firstSize, CGSize secondSize, + CGFloat epsilon) { + return fabs(firstSize.width - secondSize.width) < epsilon && + fabs(firstSize.height - secondSize.height) < epsilon; +} + @implementation InputTextView { UILabel *placeholderView; + CGSize _lastCommittedSize; }; - (instancetype)initWithFrame:(CGRect)frame { @@ -18,6 +29,7 @@ - (instancetype)initWithFrame:(CGRect)frame { self.textContainer.lineFragmentPadding = 0; self.scrollEnabled = YES; self.scrollsToTop = NO; + _lastCommittedSize = CGSizeZero; } return self; } @@ -212,6 +224,15 @@ - (void)layoutSubviews { [placeholderView sizeThatFits:textFrame.size].height; textFrame.size.height = MIN(placeholderHeight, textFrame.size.height); placeholderView.frame = textFrame; + CGSize newSize = + [self.layoutManager usedRectForTextContainer:self.textContainer].size; + if (CGSizeAlmostEqual(newSize, _lastCommittedSize, Epsilon)) { + return; + } + + _lastCommittedSize = newSize; + + [_input commitSize:newSize]; } @end From f4acdfa13daebf09b3c44d58e7a7c4a23e79b953 Mon Sep 17 00:00:00 2001 From: IvanIhnatsiuk Date: Tue, 30 Dec 2025 11:52:25 +0100 Subject: [PATCH 06/11] fix: set _prevSize before making yoga node dirty --- .../EnrichedTextInputViewShadowNode.mm | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/ios/internals/EnrichedTextInputViewShadowNode.mm b/ios/internals/EnrichedTextInputViewShadowNode.mm index 2736d2352..5a4e1cf18 100644 --- a/ios/internals/EnrichedTextInputViewShadowNode.mm +++ b/ios/internals/EnrichedTextInputViewShadowNode.mm @@ -15,10 +15,9 @@ const auto nextSize = state.getContentSize(); if (_prevContentSize != nextSize) { + _prevContentSize = nextSize; YGNodeMarkDirty(&yogaNode_); } - - _prevContentSize = nextSize; } id EnrichedTextInputViewShadowNode::setupMockTextInputView_() const { @@ -74,26 +73,23 @@ _prevContentSize = size; return constraints.clamp(size); } + } + __block CGSize estimatedSize; + // synchronously dispatch to main thread if needed + if ([NSThread isMainThread]) { + EnrichedTextInputView *mockTextInputView = setupMockTextInputView_(); + estimatedSize = [mockTextInputView + measureInitialSizeWithMaxWidth:constraints.maximumSize.width]; } else { - __block CGSize estimatedSize; - // synchronously dispatch to main thread if needed - if ([NSThread isMainThread]) { + dispatch_sync(dispatch_get_main_queue(), ^{ EnrichedTextInputView *mockTextInputView = setupMockTextInputView_(); estimatedSize = [mockTextInputView measureInitialSizeWithMaxWidth:constraints.maximumSize.width]; - } else { - dispatch_sync(dispatch_get_main_queue(), ^{ - EnrichedTextInputView *mockTextInputView = setupMockTextInputView_(); - estimatedSize = [mockTextInputView - measureInitialSizeWithMaxWidth:constraints.maximumSize.width]; - }); - } - - return {estimatedSize.width, - MIN(estimatedSize.height, constraints.maximumSize.height)}; + }); } - return Size(); + return {estimatedSize.width, + MIN(estimatedSize.height, constraints.maximumSize.height)}; } } // namespace facebook::react From cc0c84f94a193bc0e8c54f6e8bdfb501fbacdad5 Mon Sep 17 00:00:00 2001 From: IvanIhnatsiuk Date: Fri, 9 Jan 2026 14:27:36 +0100 Subject: [PATCH 07/11] fix: properly handle placeholder insets --- ios/inputTextView/InputTextView.mm | 31 ++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/ios/inputTextView/InputTextView.mm b/ios/inputTextView/InputTextView.mm index 17dd71171..b959004dc 100644 --- a/ios/inputTextView/InputTextView.mm +++ b/ios/inputTextView/InputTextView.mm @@ -15,16 +15,16 @@ static inline BOOL CGSizeAlmostEqual(CGSize firstSize, CGSize secondSize, } @implementation InputTextView { - UILabel *placeholderView; + UILabel *_placeholderView; CGSize _lastCommittedSize; }; - (instancetype)initWithFrame:(CGRect)frame { if ((self = [super initWithFrame:frame])) { - placeholderView = [[UILabel alloc] initWithFrame:self.bounds]; - placeholderView.isAccessibilityElement = NO; - placeholderView.numberOfLines = 0; - [self addSubview:placeholderView]; + _placeholderView = [[UILabel alloc] initWithFrame:self.bounds]; + _placeholderView.isAccessibilityElement = NO; + _placeholderView.numberOfLines = 0; + [self addSubview:_placeholderView]; self.textContainer.lineFragmentPadding = 0; self.scrollEnabled = YES; @@ -185,7 +185,7 @@ - (void)updatePlaceholderVisibility { BOOL shouldShow = self.placeholderText.length > 0 && self.textStorage.length == 0; - placeholderView.hidden = !shouldShow; + _placeholderView.hidden = !shouldShow; } - (void)setText:(NSString *)text { @@ -209,7 +209,7 @@ - (void)setPlaceholderText:(NSString *)newPlaceholderText { NSMutableDictionary *attributes = [typedInput->defaultTypingAttributes mutableCopy]; attributes[NSForegroundColorAttributeName] = _placeholderColor; - placeholderView.attributedText = + _placeholderView.attributedText = [[NSAttributedString alloc] initWithString:placeholderText attributes:attributes]; [self setNeedsLayout]; @@ -220,13 +220,20 @@ - (void)layoutSubviews { CGRect textFrame = UIEdgeInsetsInsetRect(self.bounds, self.textContainerInset); + CGFloat placeholderHeight = - [placeholderView sizeThatFits:textFrame.size].height; + [_placeholderView sizeThatFits:textFrame.size].height; + textFrame.size.height = MIN(placeholderHeight, textFrame.size.height); - placeholderView.frame = textFrame; - CGSize newSize = - [self.layoutManager usedRectForTextContainer:self.textContainer].size; - if (CGSizeAlmostEqual(newSize, _lastCommittedSize, Epsilon)) { + + _placeholderView.frame = textFrame; + + CGRect usedRect = + [self.layoutManager usedRectForTextContainer:self.textContainer]; + + CGSize newSize = usedRect.size; + + if (CGSizeAlmostEqual(newSize, _lastCommittedSize, 0.5)) { return; } From 75a6cc40c82431da18def777c71f9b4a29e83b86 Mon Sep 17 00:00:00 2001 From: IvanIhnatsiuk Date: Fri, 9 Jan 2026 16:14:35 +0100 Subject: [PATCH 08/11] fix: remove epsilon comprassion --- ios/inputTextView/InputTextView.mm | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/ios/inputTextView/InputTextView.mm b/ios/inputTextView/InputTextView.mm index b959004dc..096654085 100644 --- a/ios/inputTextView/InputTextView.mm +++ b/ios/inputTextView/InputTextView.mm @@ -4,16 +4,6 @@ #import "TextInsertionUtils.h" #import -// for some reason UIKit may produce different size with the same text -// the difference is always ~0.5 -static const CGFloat Epsilon = 0.5; - -static inline BOOL CGSizeAlmostEqual(CGSize firstSize, CGSize secondSize, - CGFloat epsilon) { - return fabs(firstSize.width - secondSize.width) < epsilon && - fabs(firstSize.height - secondSize.height) < epsilon; -} - @implementation InputTextView { UILabel *_placeholderView; CGSize _lastCommittedSize; @@ -233,7 +223,7 @@ - (void)layoutSubviews { CGSize newSize = usedRect.size; - if (CGSizeAlmostEqual(newSize, _lastCommittedSize, 0.5)) { + if (CGSizeEqualToSize(newSize, _lastCommittedSize)) { return; } From 20f955da32c9f7a60cc3246d2b4803cb8f22155d Mon Sep 17 00:00:00 2001 From: IvanIhnatsiuk Date: Tue, 13 Jan 2026 02:14:11 +0100 Subject: [PATCH 09/11] fix: rebase with latest main --- ios/EnrichedTextInputView.mm | 4 ++-- ios/inputTextView/InputTextView.h | 1 + ios/inputTextView/InputTextView.mm | 23 ++++++++++++++++++----- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index c310cd72e..350fd6024 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -251,7 +251,7 @@ - (void)setupTextView { textView.layoutManager.input = self; textView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; - textView.adjustsFontForContentSizeCategory = YES; + textView.adjustsFontForContentSizeCategory = YES; } // MARK: - Props @@ -1731,7 +1731,7 @@ - (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection { defaultTypingAttributes = newTypingAttrs; textView.typingAttributes = defaultTypingAttributes; - [self refreshPlaceholderLabelStyles]; + [textView refreshPlaceholder]; NSRange prevSelectedRange = textView.selectedRange; diff --git a/ios/inputTextView/InputTextView.h b/ios/inputTextView/InputTextView.h index 498ae68b3..a3e82eec1 100644 --- a/ios/inputTextView/InputTextView.h +++ b/ios/inputTextView/InputTextView.h @@ -6,4 +6,5 @@ @property(nonatomic, copy, nullable) NSString *placeholderText; @property(nonatomic, strong, nullable) UIColor *placeholderColor; - (void)updatePlaceholderVisibility; +- (void)refreshPlaceholder; @end diff --git a/ios/inputTextView/InputTextView.mm b/ios/inputTextView/InputTextView.mm index 096654085..1dfaf0fa9 100644 --- a/ios/inputTextView/InputTextView.mm +++ b/ios/inputTextView/InputTextView.mm @@ -14,6 +14,7 @@ - (instancetype)initWithFrame:(CGRect)frame { _placeholderView = [[UILabel alloc] initWithFrame:self.bounds]; _placeholderView.isAccessibilityElement = NO; _placeholderView.numberOfLines = 0; + _placeholderView.adjustsFontForContentSizeCategory = YES; [self addSubview:_placeholderView]; self.textContainer.lineFragmentPadding = 0; @@ -189,19 +190,31 @@ - (void)setAttributedText:(NSAttributedString *)attributedText { } - (void)setPlaceholderText:(NSString *)newPlaceholderText { + _placeholderText = newPlaceholderText; + [self refreshPlaceholder]; +} + +- (void)refreshPlaceholder { EnrichedTextInputView *typedInput = (EnrichedTextInputView *)_input; if (typedInput == nullptr) { return; } - _placeholderText = newPlaceholderText; - BOOL hasPlaceholder = newPlaceholderText && newPlaceholderText.length > 0; - NSString *placeholderText = hasPlaceholder ? newPlaceholderText : @""; + NSMutableDictionary *attributes = [typedInput->defaultTypingAttributes mutableCopy]; - attributes[NSForegroundColorAttributeName] = _placeholderColor; + + if (_placeholderColor) { + attributes[NSForegroundColorAttributeName] = _placeholderColor; + } + + NSString *placeholder = _placeholderText ?: @""; + _placeholderView.attributedText = - [[NSAttributedString alloc] initWithString:placeholderText + [[NSAttributedString alloc] initWithString:placeholder attributes:attributes]; + + [self updatePlaceholderVisibility]; + [self setNeedsLayout]; } From e73c4684fee0d0a7aa4b4a06bb8559cdd5f97dcc Mon Sep 17 00:00:00 2001 From: IvanIhnatsiuk Date: Tue, 13 Jan 2026 11:43:57 +0100 Subject: [PATCH 10/11] fix: remove unused var --- ios/EnrichedTextInputView.mm | 1 - 1 file changed, 1 deletion(-) diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index 350fd6024..10bc6dfb1 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -46,7 +46,6 @@ @implementation EnrichedTextInputView { UIColor *_placeholderColor; BOOL _emitFocusBlur; BOOL _emitTextChange; - BOOL _isMeasuringSize; } // MARK: - Component utils From a4fb0530257e237806eaaeca964f71a17220852f Mon Sep 17 00:00:00 2001 From: IvanIhnatsiuk Date: Tue, 13 Jan 2026 16:00:31 +0100 Subject: [PATCH 11/11] fix: always bounce vertically --- ios/inputTextView/InputTextView.mm | 1 + 1 file changed, 1 insertion(+) diff --git a/ios/inputTextView/InputTextView.mm b/ios/inputTextView/InputTextView.mm index 1dfaf0fa9..b5ed89176 100644 --- a/ios/inputTextView/InputTextView.mm +++ b/ios/inputTextView/InputTextView.mm @@ -20,6 +20,7 @@ - (instancetype)initWithFrame:(CGRect)frame { self.textContainer.lineFragmentPadding = 0; self.scrollEnabled = YES; self.scrollsToTop = NO; + self.alwaysBounceVertical = YES; _lastCommittedSize = CGSizeZero; } return self;