Skip to content

Commit 039f03c

Browse files
authored
fix: revert "fix(iOS): proper layout calculation #339" (#417)
Reverts #339 due to a iOS layout bug we found; The input sometimes becomes unscrollable when it's inside a scrollable bottom sheet (`@gorhom/bottom-sheet`), with a `KeyboardAwareScrollView` (`react-native-keyboard-controller`) turned into a proper bottom-sheet-scrollview component. The bug doesn't appear when testing a commit before the PR, but does happen on the exact merge commit of the said PR.
1 parent 3e3734b commit 039f03c

File tree

9 files changed

+240
-207
lines changed

9 files changed

+240
-207
lines changed

apps/example/ios/Podfile.lock

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2064,7 +2064,7 @@ EXTERNAL SOURCES:
20642064

20652065
SPEC CHECKSUMS:
20662066
FBLazyVector: c12d2108050e27952983d565a232f6f7b1ad5e69
2067-
hermes-engine: 57506c1404e8b80c5386de18743e0d02c8d4f681
2067+
hermes-engine: c50a496dd5a3974cde1b7d14a944bee1ee64ca0c
20682068
RCTDeprecation: 3280799c14232a56e5a44f92981a8ee33bc69fd9
20692069
RCTRequired: 9854a51b0f65ccf43ea0b744df4d70fce339db32
20702070
RCTSwiftUI: 96986e49a4fdc2c2103929dee2641e1b57edf33d
@@ -2073,7 +2073,7 @@ SPEC CHECKSUMS:
20732073
React: 7ef36630d07638043a134a7dd2ec17e0be10fc3c
20742074
React-callinvoker: af4e8fe1d60ab63dd8d74c2a68988064c2848954
20752075
React-Core: c0fb1df65eb0ed7a8633841831f05f93c3eb3aff
2076-
React-Core-prebuilt: cd92350bf2041dde22a9bc0b8984d9c70d179ca1
2076+
React-Core-prebuilt: 25c9d7a48b3bd78017d44e80de5fbb3eb139328d
20772077
React-CoreModules: 7dfe7962360355f1547c85ab52e1fc4b57f17127
20782078
React-cxxreact: 9e9c7f1710bc58abebf924813b5e825b99adb8e5
20792079
React-debug: 38389b86e3570558ec73dd4cbc0cd2f2eec47a51
@@ -2135,7 +2135,7 @@ SPEC CHECKSUMS:
21352135
ReactAppDependencyProvider: 625d2f6d9d5ef01acc9dfe2b5385504bbffd2ad0
21362136
ReactCodegen: 27937747ddc743fcb66a8dc19e8edf60188d94cc
21372137
ReactCommon: cc0e38600f82487c5fe5d29150abb6fa9d981986
2138-
ReactNativeDependencies: cebf665879bab2908201494cc5a9760dbdf0a637
2138+
ReactNativeDependencies: 8febfeb298c1be3a3699aac801aec086f0580694
21392139
ReactNativeEnriched: 790e857a207566a6dc34e7b45480b88bddf79bed
21402140
Yoga: 772166513f9cd2d61a6251d0dacbbfaa5b537479
21412141

ios/EnrichedTextInputView.h

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ NS_ASSUME_NONNULL_BEGIN
3131
@public
3232
BOOL blockEmitting;
3333
}
34+
- (CGSize)measureSize:(CGFloat)maxWidth;
3435
- (void)emitOnLinkDetectedEvent:(NSString *)text
3536
url:(NSString *)url
3637
range:(NSRange)range;
@@ -40,8 +41,6 @@ NS_ASSUME_NONNULL_BEGIN
4041
- (BOOL)handleStyleBlocksAndConflicts:(StyleType)type range:(NSRange)range;
4142
- (NSArray<NSNumber *> *)getPresentStyleTypesFrom:(NSArray<NSNumber *> *)types
4243
range:(NSRange)range;
43-
- (CGSize)measureInitialSizeWithMaxWidth:(CGFloat)maxWidth;
44-
- (void)commitSize:(CGSize)size;
4544
@end
4645

4746
NS_ASSUME_NONNULL_END

ios/EnrichedTextInputView.mm

Lines changed: 163 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -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

ios/inputTextView/InputTextView.h

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,4 @@
33

44
@interface InputTextView : UITextView
55
@property(nonatomic, weak) id input;
6-
@property(nonatomic, copy, nullable) NSString *placeholderText;
7-
@property(nonatomic, strong, nullable) UIColor *placeholderColor;
8-
- (void)updatePlaceholderVisibility;
9-
- (void)refreshPlaceholder;
106
@end

0 commit comments

Comments
 (0)