@@ -76,7 +76,8 @@ - (instancetype)initWithFrame:(CGRect)frame {
7676 _props = defaultProps;
7777 [self setDefaults ];
7878 [self setupTextView ];
79- [self addSubview: textView];
79+ [self setupPlaceholderLabel ];
80+ self.contentView = textView;
8081 }
8182 return self;
8283}
@@ -264,14 +265,31 @@ - (void)setupTextView {
264265 textView.delegate = self;
265266 textView.input = self;
266267 textView.layoutManager .input = self;
267- textView.autoresizingMask =
268- UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
269268 textView.adjustsFontForContentSizeCategory = YES ;
270269 [textView addGestureRecognizer: [[TextBlockTapGestureRecognizer alloc ]
271270 initWithInput: self
272271 action: @selector (onTextBlockTap: )]];
273272}
274273
274+ - (void )setupPlaceholderLabel {
275+ _placeholderLabel = [[UILabel alloc ] initWithFrame: CGRectZero];
276+ _placeholderLabel.translatesAutoresizingMaskIntoConstraints = NO ;
277+ [textView addSubview: _placeholderLabel];
278+ [NSLayoutConstraint activateConstraints: @[
279+ [_placeholderLabel.leadingAnchor
280+ constraintEqualToAnchor: textView.leadingAnchor],
281+ [_placeholderLabel.widthAnchor
282+ constraintEqualToAnchor: textView.widthAnchor],
283+ [_placeholderLabel.topAnchor constraintEqualToAnchor: textView.topAnchor],
284+ [_placeholderLabel.bottomAnchor
285+ constraintEqualToAnchor: textView.bottomAnchor]
286+ ]];
287+ _placeholderLabel.lineBreakMode = NSLineBreakByTruncatingTail;
288+ _placeholderLabel.text = @" " ;
289+ _placeholderLabel.hidden = YES ;
290+ _placeholderLabel.adjustsFontForContentSizeCategory = YES ;
291+ }
292+
275293// MARK: - Props
276294
277295- (void )updateProps : (Props::Shared const &)props
@@ -717,6 +735,9 @@ - (void)updateProps:(Props::Shared const &)props
717735 [[NSParagraphStyle alloc ] init ];
718736 textView.typingAttributes = defaultTypingAttributes;
719737 textView.selectedRange = prevSelectedRange;
738+
739+ // update the placeholder as well
740+ [self refreshPlaceholderLabelStyles ];
720741 }
721742
722743 // editable
@@ -744,14 +765,24 @@ - (void)updateProps:(Props::Shared const &)props
744765
745766 // placeholderTextColor
746767 if (newViewProps.placeholderTextColor != oldViewProps.placeholderTextColor ) {
747- textView.placeholderColor =
748- RCTUIColorFromSharedColor (newViewProps.placeholderTextColor );
768+ // some real color
769+ if (isColorMeaningful (newViewProps.placeholderTextColor )) {
770+ _placeholderColor =
771+ RCTUIColorFromSharedColor (newViewProps.placeholderTextColor );
772+ } else {
773+ _placeholderColor = nullptr ;
774+ }
775+ [self refreshPlaceholderLabelStyles ];
749776 }
750777
751778 // placeholder
752779 if (newViewProps.placeholder != oldViewProps.placeholder ) {
753- [textView
754- setPlaceholderText: [NSString fromCppString: newViewProps.placeholder]];
780+ _placeholderLabel.text = [NSString fromCppString: newViewProps.placeholder];
781+ [self refreshPlaceholderLabelStyles ];
782+ // additionally show placeholder on first mount if it should be there
783+ if (isFirstMount && textView.text .length == 0 ) {
784+ [self setPlaceholderLabelShown: YES ];
785+ }
755786 }
756787
757788 // mention indicators
@@ -827,17 +858,75 @@ - (void)updateProps:(Props::Shared const &)props
827858 if (isFirstMount && newViewProps.autoFocus ) {
828859 [textView reactFocus ];
829860 }
830- [textView updatePlaceholderVisibility ];
831861}
832862
833- - (void )updateLayoutMetrics : (const LayoutMetrics &)layoutMetrics
834- oldLayoutMetrics : (const LayoutMetrics &)oldLayoutMetrics {
835- [super updateLayoutMetrics: layoutMetrics oldLayoutMetrics: oldLayoutMetrics];
863+ - (void )setPlaceholderLabelShown : (BOOL )shown {
864+ if (shown) {
865+ [self refreshPlaceholderLabelStyles ];
866+ _placeholderLabel.hidden = NO ;
867+ } else {
868+ _placeholderLabel.hidden = YES ;
869+ }
870+ }
871+
872+ - (void )refreshPlaceholderLabelStyles {
873+ NSMutableDictionary *newAttrs = [defaultTypingAttributes mutableCopy ];
874+ if (_placeholderColor != nullptr ) {
875+ newAttrs[NSForegroundColorAttributeName ] = _placeholderColor;
876+ }
877+ NSAttributedString *newAttrStr =
878+ [[NSAttributedString alloc ] initWithString: _placeholderLabel.text
879+ attributes: newAttrs];
880+ _placeholderLabel.attributedText = newAttrStr;
881+ }
882+
883+ // MARK: - Measuring and states
884+
885+ - (CGSize)measureSize : (CGFloat)maxWidth {
886+ // copy the the whole attributed string
887+ NSMutableAttributedString *currentStr = [[NSMutableAttributedString alloc ]
888+ initWithAttributedString: textView.textStorage];
889+
890+ // edge case: empty input should still be of a height of a single line, so we
891+ // add a mock "I" character
892+ if ([currentStr length ] == 0 ) {
893+ [currentStr
894+ appendAttributedString: [[NSAttributedString alloc ]
895+ initWithString: @" I"
896+ attributes: textView.typingAttributes]];
897+ }
898+
899+ // edge case: input with only a zero width space should still be of a height
900+ // of a single line, so we add a mock "I" character
901+ if ([currentStr length ] == 1 &&
902+ [[currentStr.string substringWithRange: NSMakeRange (0 , 1 )]
903+ isEqualToString: @" \u200B " ]) {
904+ [currentStr
905+ appendAttributedString: [[NSAttributedString alloc ]
906+ initWithString: @" I"
907+ attributes: textView.typingAttributes]];
908+ }
909+
910+ // edge case: trailing newlines aren't counted towards height calculations, so
911+ // we add a mock "I" character
912+ if (currentStr.length > 0 ) {
913+ unichar lastChar =
914+ [currentStr.string characterAtIndex: currentStr.length - 1 ];
915+ if ([[NSCharacterSet newlineCharacterSet ] characterIsMember: lastChar]) {
916+ [currentStr
917+ appendAttributedString: [[NSAttributedString alloc ]
918+ initWithString: @" I"
919+ attributes: defaultTypingAttributes]];
920+ }
921+ }
836922
837- textView.frame = UIEdgeInsetsInsetRect (
838- self.bounds , RCTUIEdgeInsetsFromEdgeInsets (layoutMetrics.borderWidth ));
839- textView.textContainerInset = RCTUIEdgeInsetsFromEdgeInsets (
840- layoutMetrics.contentInsets - layoutMetrics.borderWidth );
923+ CGRect boundingBox =
924+ [currentStr boundingRectWithSize: CGSizeMake (maxWidth, CGFLOAT_MAX)
925+ options: NSStringDrawingUsesLineFragmentOrigin |
926+ NSStringDrawingUsesFontLeading
927+ context: nullptr ];
928+
929+ return CGSizeMake (maxWidth, ceil (boundingBox.size .height ));
841930}
842931
843932// make sure the newest state is kept in _state property
@@ -850,42 +939,18 @@ - (void)updateState:(State::Shared const &)state
850939 // componentView) so we need to run a single height calculation for any
851940 // initial values
852941 if (oldState == nullptr ) {
853- [self commitSize: textView.textContainer.size ];
942+ [self tryUpdatingHeight ];
854943 }
855944}
856945
857- - (void )commitSize : (CGSize) size {
946+ - (void )tryUpdatingHeight {
858947 if (_state == nullptr ) {
859948 return ;
860949 }
861-
950+ _componentViewHeightUpdateCounter++;
862951 auto selfRef = wrapManagedObjectWeakly (self);
863- facebook::react::Size newSize{.width = size.width , .height = size.height };
864952 _state->updateState (
865- facebook::react::EnrichedTextInputViewState (newSize, selfRef));
866- }
867-
868- - (CGSize)measureInitialSizeWithMaxWidth : (CGFloat)maxWidth {
869- NSTextContainer *container = textView.textContainer ;
870- NSLayoutManager *layoutManager = textView.layoutManager ;
871-
872- container.size = CGSizeMake (maxWidth, CGFLOAT_MAX);
873-
874- [layoutManager ensureLayoutForTextContainer: container];
875-
876- CGRect used = [layoutManager usedRectForTextContainer: container];
877- CGFloat height = ceil (used.size .height );
878-
879- // Empty text fallback
880- if (textView.textStorage .length == 0 ) {
881- UIFont *font =
882- textView.typingAttributes [NSFontAttributeName ] ?: textView.font ;
883- if (font) {
884- height = ceil (font.lineHeight );
885- }
886- }
887-
888- return CGSizeMake (maxWidth, height);
953+ EnrichedTextInputViewState (_componentViewHeightUpdateCounter, selfRef));
889954}
890955
891956// MARK: - Active styles
@@ -1607,7 +1672,12 @@ - (void)anyTextMayHaveBeenModified {
16071672 }
16081673
16091674 // placholder management
1610- [textView updatePlaceholderVisibility ];
1675+ if (!_placeholderLabel.hidden && textView.textStorage .string .length > 0 ) {
1676+ [self setPlaceholderLabelShown: NO ];
1677+ } else if (textView.textStorage .string .length == 0 &&
1678+ _placeholderLabel.hidden ) {
1679+ [self setPlaceholderLabelShown: YES ];
1680+ }
16111681
16121682 if (![textView.textStorage.string isEqualToString: _recentInputString]) {
16131683 // modified words handling
@@ -1643,9 +1713,57 @@ - (void)anyTextMayHaveBeenModified {
16431713 }
16441714 }
16451715
1716+ // update height on each character change
1717+ [self tryUpdatingHeight ];
16461718 // update active styles as well
16471719 [self tryUpdatingActiveStyles ];
16481720 [self layoutAttachments ];
1721+ // update drawing - schedule debounced relayout
1722+ [self scheduleRelayoutIfNeeded ];
1723+ }
1724+
1725+ // Debounced relayout helper - coalesces multiple requests into one per runloop
1726+ // tick
1727+ - (void )scheduleRelayoutIfNeeded {
1728+ // Cancel any previously scheduled invocation to debounce
1729+ [NSObject cancelPreviousPerformRequestsWithTarget: self
1730+ selector: @selector (_performRelayout )
1731+ object: nil ];
1732+ // Schedule on next runloop cycle
1733+ [self performSelector: @selector (_performRelayout )
1734+ withObject: nil
1735+ afterDelay: 0 ];
1736+ }
1737+
1738+ - (void )_performRelayout {
1739+ if (!textView) {
1740+ return ;
1741+ }
1742+
1743+ dispatch_async (dispatch_get_main_queue (), ^{
1744+ NSRange wholeRange =
1745+ NSMakeRange (0 , self->textView.textStorage.string.length);
1746+ NSRange actualRange = NSMakeRange (0 , 0 );
1747+ [self ->textView.layoutManager
1748+ invalidateLayoutForCharacterRange: wholeRange
1749+ actualCharacterRange: &actualRange];
1750+ [self ->textView.layoutManager ensureLayoutForCharacterRange: actualRange];
1751+ [self ->textView.layoutManager
1752+ invalidateDisplayForCharacterRange: wholeRange];
1753+
1754+ // We have to explicitly set contentSize
1755+ // That way textView knows if content overflows and if should be scrollable
1756+ // We recall measureSize here because value returned from previous
1757+ // measureSize may not be up-to date at that point
1758+ CGSize measuredSize = [self measureSize: self ->textView.frame.size.width];
1759+ self->textView .contentSize = measuredSize;
1760+ });
1761+ }
1762+
1763+ - (void )didMoveToWindow {
1764+ [super didMoveToWindow ];
1765+ // used to run all lifecycle callbacks
1766+ [self anyTextMayHaveBeenModified ];
16491767}
16501768
16511769// MARK: - UITextView delegate methods
@@ -1821,7 +1939,7 @@ - (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection {
18211939 defaultTypingAttributes = newTypingAttrs;
18221940 textView.typingAttributes = defaultTypingAttributes;
18231941
1824- [textView refreshPlaceholder ];
1942+ [self refreshPlaceholderLabelStyles ];
18251943
18261944 NSRange prevSelectedRange = textView.selectedRange ;
18271945
0 commit comments