diff --git a/ios/EnrichedTextInputView.h b/ios/EnrichedTextInputView.h index 4ba4759cf..346f1d36c 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; @@ -40,6 +39,8 @@ NS_ASSUME_NONNULL_BEGIN - (BOOL)handleStyleBlocksAndConflicts:(StyleType)type range:(NSRange)range; - (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 b4d608b6f..6f4599a3b 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -72,8 +72,7 @@ - (instancetype)initWithFrame:(CGRect)frame { _props = defaultProps; [self setDefaults]; [self setupTextView]; - [self setupPlaceholderLabel]; - self.contentView = textView; + [self addSubview:textView]; } return self; } @@ -249,28 +248,11 @@ - (void)setupTextView { textView.delegate = self; textView.input = self; textView.layoutManager.input = self; + textView.autoresizingMask = + UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; 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; -} - // MARK: - Props - (void)updateProps:(Props::Shared const &)props @@ -685,9 +667,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 +694,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 +777,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 +800,42 @@ - (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 commitSize:textView.textContainer.size]; } } -- (void)tryUpdatingHeight { +- (void)commitSize:(CGSize)size { if (_state == nullptr) { return; } - _componentViewHeightUpdateCounter++; + auto selfRef = wrapManagedObjectWeakly(self); + facebook::react::Size newSize{.width = size.width, .height = size.height}; _state->updateState( - EnrichedTextInputViewState(_componentViewHeightUpdateCounter, selfRef)); + facebook::react::EnrichedTextInputViewState(newSize, selfRef)); +} + +- (CGSize)measureInitialSizeWithMaxWidth:(CGFloat)maxWidth { + NSTextContainer *container = textView.textContainer; + NSLayoutManager *layoutManager = textView.layoutManager; + + container.size = CGSizeMake(maxWidth, CGFLOAT_MAX); + + [layoutManager ensureLayoutForTextContainer:container]; + + 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) { + height = ceil(font.lineHeight); + } + } + + return CGSizeMake(maxWidth, height); } // MARK: - Active styles @@ -1617,12 +1552,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 @@ -1658,56 +1588,8 @@ - (void)anyTextMayHaveBeenModified { } } - // update height on each character change - [self tryUpdatingHeight]; // 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 @@ -1874,7 +1756,7 @@ - (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection { defaultTypingAttributes = newTypingAttrs; textView.typingAttributes = defaultTypingAttributes; - [self refreshPlaceholderLabelStyles]; + [textView refreshPlaceholder]; NSRange prevSelectedRange = textView.selectedRange; 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..a3e82eec1 100644 --- a/ios/inputTextView/InputTextView.h +++ b/ios/inputTextView/InputTextView.h @@ -3,4 +3,8 @@ @interface InputTextView : UITextView @property(nonatomic, weak) id input; +@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 2487c90c7..b5ed89176 100644 --- a/ios/inputTextView/InputTextView.mm +++ b/ios/inputTextView/InputTextView.mm @@ -4,7 +4,27 @@ #import "TextInsertionUtils.h" #import -@implementation InputTextView +@implementation InputTextView { + 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; + _placeholderView.adjustsFontForContentSizeCategory = YES; + [self addSubview:_placeholderView]; + + self.textContainer.lineFragmentPadding = 0; + self.scrollEnabled = YES; + self.scrollsToTop = NO; + self.alwaysBounceVertical = YES; + _lastCommittedSize = CGSizeZero; + } + return self; +} - (void)copy:(id)sender { EnrichedTextInputView *typedInput = (EnrichedTextInputView *)_input; @@ -153,4 +173,77 @@ - (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 { + _placeholderText = newPlaceholderText; + [self refreshPlaceholder]; +} + +- (void)refreshPlaceholder { + EnrichedTextInputView *typedInput = (EnrichedTextInputView *)_input; + if (typedInput == nullptr) { + return; + } + + NSMutableDictionary *attributes = + [typedInput->defaultTypingAttributes mutableCopy]; + + if (_placeholderColor) { + attributes[NSForegroundColorAttributeName] = _placeholderColor; + } + + NSString *placeholder = _placeholderText ?: @""; + + _placeholderView.attributedText = + [[NSAttributedString alloc] initWithString:placeholder + attributes:attributes]; + + [self updatePlaceholderVisibility]; + + [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; + + CGRect usedRect = + [self.layoutManager usedRectForTextContainer:self.textContainer]; + + CGSize newSize = usedRect.size; + + if (CGSizeEqualToSize(newSize, _lastCommittedSize)) { + return; + } + + _lastCommittedSize = newSize; + + [_input commitSize:newSize]; +} + @end diff --git a/ios/internals/EnrichedTextInputViewShadowNode.h b/ios/internals/EnrichedTextInputViewShadowNode.h index 9789e9b76..dc9a6d5de 100644 --- a/ios/internals/EnrichedTextInputViewShadowNode.h +++ b/ios/internals/EnrichedTextInputViewShadowNode.h @@ -17,6 +17,7 @@ class EnrichedTextInputViewShadowNode : public ConcreteViewShadowNode< EnrichedTextInputViewComponentName, EnrichedTextInputViewProps, EnrichedTextInputViewEventEmitter, EnrichedTextInputViewState> { + public: using ConcreteViewShadowNode::ConcreteViewShadowNode; EnrichedTextInputViewShadowNode(const ShadowNodeFragment &fragment, @@ -25,6 +26,7 @@ class EnrichedTextInputViewShadowNode EnrichedTextInputViewShadowNode(const ShadowNode &sourceShadowNode, const ShadowNodeFragment &fragment); void dirtyLayoutIfNeeded(); + Size measureContent(const LayoutContext &layoutContext, const LayoutConstraints &layoutConstraints) const override; @@ -37,7 +39,7 @@ class EnrichedTextInputViewShadowNode } private: - int localForceHeightRecalculationCounter_; + mutable Size _prevContentSize{NAN, NAN}; id setupMockTextInputView_() const; }; diff --git a/ios/internals/EnrichedTextInputViewShadowNode.mm b/ios/internals/EnrichedTextInputViewShadowNode.mm index 6d560564e..5a4e1cf18 100644 --- a/ios/internals/EnrichedTextInputViewShadowNode.mm +++ b/ios/internals/EnrichedTextInputViewShadowNode.mm @@ -1,5 +1,5 @@ #import "EnrichedTextInputViewShadowNode.h" -#import "CoreText/CoreText.h" + #import #import #import @@ -10,15 +10,16 @@ extern const char EnrichedTextInputViewComponentName[] = "EnrichedTextInputView"; -EnrichedTextInputViewShadowNode::EnrichedTextInputViewShadowNode( - const ShadowNodeFragment &fragment, const ShadowNodeFamily::Shared &family, - ShadowNodeTraits traits) - : ConcreteViewShadowNode(fragment, family, traits) { - localForceHeightRecalculationCounter_ = 0; +void EnrichedTextInputViewShadowNode::dirtyLayoutIfNeeded() { + const auto &state = getStateData(); + const auto nextSize = state.getContentSize(); + + if (_prevContentSize != nextSize) { + _prevContentSize = nextSize; + YGNodeMarkDirty(&yogaNode_); + } } -// 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; @@ -32,72 +33,63 @@ } EnrichedTextInputViewShadowNode::EnrichedTextInputViewShadowNode( - const ShadowNode &sourceShadowNode, const ShadowNodeFragment &fragment) - : ConcreteViewShadowNode(sourceShadowNode, fragment) { - dirtyLayoutIfNeeded(); -} + const ShadowNode &source, const ShadowNodeFragment &fragment) + : ConcreteViewShadowNode(source, fragment) { -void EnrichedTextInputViewShadowNode::dirtyLayoutIfNeeded() { - const auto state = this->getStateData(); - const int receivedCounter = state.getForceHeightRecalculationCounter(); + const auto &oldState = + static_cast(source) + .getStateData(); + + const auto &newState = getStateData(); - if (receivedCounter > localForceHeightRecalculationCounter_) { - localForceHeightRecalculationCounter_ = receivedCounter; + 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 &layoutContext, - const LayoutConstraints &layoutConstraints) const { + 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) { - __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)}; + auto size = state.getContentSize(); + _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 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)}; + estimatedSize = [mockTextInputView + measureInitialSizeWithMaxWidth:constraints.maximumSize.width]; + }); } - return Size(); + return {estimatedSize.width, + MIN(estimatedSize.height, constraints.maximumSize.height)}; } } // 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..d32926b39 100644 --- a/ios/internals/EnrichedTextInputViewState.h +++ b/ios/internals/EnrichedTextInputViewState.h @@ -1,21 +1,24 @@ #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; + EnrichedTextInputViewState() = default; + + 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_; } - int getForceHeightRecalculationCounter() const; - std::shared_ptr getComponentViewRef() const; private: - int forceHeightRecalculationCounter_{}; + Size contentSize_{}; std::shared_ptr componentViewRef_{}; };