diff --git a/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js b/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js index faec7943692d86..68ee3811c59385 100644 --- a/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js +++ b/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js @@ -179,6 +179,13 @@ export type NativeProps = $ReadOnly<{| */ numberOfLines?: ?Int32, + /** + * Sets the maximum number of lines for a `TextInput`. Use it with multiline set to + * `true` to be able to fill the lines. + * @platform android + */ + maximumNumberOfLines?: ?Int32, + /** * When `false`, if there is a small amount of space available around a text input * (e.g. landscape orientation on a phone), the OS may choose to have the user edit diff --git a/Libraries/Components/TextInput/RCTTextInputViewConfig.js b/Libraries/Components/TextInput/RCTTextInputViewConfig.js index d49c7a494e0b60..159fdcdcfee68f 100644 --- a/Libraries/Components/TextInput/RCTTextInputViewConfig.js +++ b/Libraries/Components/TextInput/RCTTextInputViewConfig.js @@ -138,6 +138,8 @@ const RCTTextInputViewConfig = { placeholder: true, autoCorrect: true, multiline: true, + numberOfLines: true, + maximumNumberOfLines: true, textContentType: true, maxLength: true, autoCapitalize: true, diff --git a/Libraries/Components/TextInput/TextInput.d.ts b/Libraries/Components/TextInput/TextInput.d.ts index 0b40b29146397f..3f0159701e00eb 100644 --- a/Libraries/Components/TextInput/TextInput.d.ts +++ b/Libraries/Components/TextInput/TextInput.d.ts @@ -422,12 +422,6 @@ export interface TextInputAndroidProps { */ inlineImagePadding?: number | undefined; - /** - * Sets the number of lines for a TextInput. - * Use it with multiline set to true to be able to fill the lines. - */ - numberOfLines?: number | undefined; - /** * Sets the return key to the label. Use it instead of `returnKeyType`. * @platform android @@ -617,11 +611,29 @@ export interface TextInputProps */ maxLength?: number | undefined; + /** + * Sets the maximum number of lines for a TextInput. + * Use it with multiline set to true to be able to fill the lines. + */ + maximumNumberOfLines?: number | undefined; + /** * If true, the text input can be multiple lines. The default value is false. */ multiline?: boolean | undefined; + /** + * Sets the number of lines for a TextInput. + * Use it with multiline set to true to be able to fill the lines. + */ + numberOfLines?: number | undefined; + + /** + * Sets the number of rows for a TextInput. + * Use it with multiline set to true to be able to fill the lines. + */ + rows?: number | undefined; + /** * Callback that is called when the text input is blurred */ diff --git a/Libraries/Components/TextInput/TextInput.flow.js b/Libraries/Components/TextInput/TextInput.flow.js index a37e9ef03e85d8..42594c7aa13ef5 100644 --- a/Libraries/Components/TextInput/TextInput.flow.js +++ b/Libraries/Components/TextInput/TextInput.flow.js @@ -488,26 +488,12 @@ type AndroidProps = $ReadOnly<{| */ inlineImagePadding?: ?number, - /** - * Sets the number of lines for a `TextInput`. Use it with multiline set to - * `true` to be able to fill the lines. - * @platform android - */ - numberOfLines?: ?number, - /** * Sets the return key to the label. Use it instead of `returnKeyType`. * @platform android */ returnKeyLabel?: ?string, - /** - * Sets the number of rows for a `TextInput`. Use it with multiline set to - * `true` to be able to fill the lines. - * @platform android - */ - rows?: ?number, - /** * When `false`, it will prevent the soft keyboard from showing when the field is focused. * Defaults to `true`. @@ -656,6 +642,12 @@ export type Props = $ReadOnly<{| */ keyboardType?: ?KeyboardType, + /** + * Sets the maximum number of lines for a `TextInput`. Use it with multiline set to + * `true` to be able to fill the lines. + */ + maximumNumberOfLines?: ?number, + /** * Specifies largest possible scale a font can reach when `allowFontScaling` is enabled. * Possible values: @@ -677,6 +669,12 @@ export type Props = $ReadOnly<{| */ multiline?: ?boolean, + /** + * Sets the number of lines for a `TextInput`. Use it with multiline set to + * `true` to be able to fill the lines. + */ + numberOfLines?: ?number, + /** * Callback that is called when the text input is blurred. */ @@ -838,6 +836,12 @@ export type Props = $ReadOnly<{| */ returnKeyType?: ?ReturnKeyType, + /** + * Sets the number of rows for a `TextInput`. Use it with multiline set to + * `true` to be able to fill the lines. + */ + rows?: ?number, + /** * If `true`, the text input obscures the text entered so that sensitive text * like passwords stay secure. The default value is `false`. Does not work with 'multiline={true}'. diff --git a/Libraries/Components/TextInput/TextInput.js b/Libraries/Components/TextInput/TextInput.js index 6ccd8cde8720e7..764e2392a5ca67 100644 --- a/Libraries/Components/TextInput/TextInput.js +++ b/Libraries/Components/TextInput/TextInput.js @@ -524,7 +524,6 @@ type AndroidProps = $ReadOnly<{| /** * Sets the number of lines for a `TextInput`. Use it with multiline set to * `true` to be able to fill the lines. - * @platform android */ numberOfLines?: ?number, @@ -537,10 +536,14 @@ type AndroidProps = $ReadOnly<{| /** * Sets the number of rows for a `TextInput`. Use it with multiline set to * `true` to be able to fill the lines. - * @platform android */ rows?: ?number, + /** + * Sets the maximum number of lines the TextInput can have. + */ + maximumNumberOfLines?: ?number, + /** * When `false`, it will prevent the soft keyboard from showing when the field is focused. * Defaults to `true`. @@ -1082,6 +1085,12 @@ const emptyFunctionThatReturnsTrue = () => true; * */ function InternalTextInput(props: Props): React.Node { + const { + rows, + numberOfLines, + ...otherProps + } = props; + const inputRef = useRef>>(null); // Android sends a "onTextChanged" event followed by a "onSelectionChanged" event, for @@ -1428,7 +1437,7 @@ function InternalTextInput(props: Props): React.Node { textInput = ( = 0)) { console.error( `'numberOfLines' in must be a non-negative number, received: ${numberOfLines}. The value will be set to 0.`, ); - numberOfLines = 0; + numberOfLinesValue = 0; } const hasTextAncestor = useContext(TextAncestor); @@ -233,7 +234,7 @@ const Text: React.AbstractComponent< isPressable={isPressable} selectable={_selectable} nativeID={id ?? nativeID} - numberOfLines={numberOfLines} + maximumNumberOfLines={numberOfLinesValue} selectionColor={selectionColor} style={flattenedStyle} ref={forwardedRef} @@ -259,7 +260,7 @@ const Text: React.AbstractComponent< ellipsizeMode={ellipsizeMode ?? 'tail'} isHighlighted={isHighlighted} nativeID={id ?? nativeID} - numberOfLines={numberOfLines} + maximumNumberOfLines={numberOfLinesValue} selectionColor={selectionColor} style={flattenedStyle} ref={forwardedRef} diff --git a/Libraries/Text/Text/RCTTextViewManager.m b/Libraries/Text/Text/RCTTextViewManager.m index 7fec0b7b58b010..7b53c80d557acc 100644 --- a/Libraries/Text/Text/RCTTextViewManager.m +++ b/Libraries/Text/Text/RCTTextViewManager.m @@ -26,7 +26,7 @@ @implementation RCTTextViewManager { RCT_EXPORT_MODULE(RCTText) -RCT_REMAP_SHADOW_PROPERTY(numberOfLines, maximumNumberOfLines, NSInteger) +RCT_EXPORT_SHADOW_PROPERTY(maximumNumberOfLines, NSInteger) RCT_REMAP_SHADOW_PROPERTY(ellipsizeMode, lineBreakMode, NSLineBreakMode) RCT_REMAP_SHADOW_PROPERTY(adjustsFontSizeToFit, adjustsFontSizeToFit, BOOL) RCT_REMAP_SHADOW_PROPERTY(minimumFontScale, minimumFontScale, CGFloat) diff --git a/Libraries/Text/TextInput/Multiline/RCTMultilineTextInputViewManager.m b/Libraries/Text/TextInput/Multiline/RCTMultilineTextInputViewManager.m index e729bb7a222a78..7d019df9b50778 100644 --- a/Libraries/Text/TextInput/Multiline/RCTMultilineTextInputViewManager.m +++ b/Libraries/Text/TextInput/Multiline/RCTMultilineTextInputViewManager.m @@ -7,6 +7,8 @@ #import #import +#import +#import @implementation RCTMultilineTextInputViewManager @@ -17,8 +19,21 @@ - (UIView *)view return [[RCTMultilineTextInputView alloc] initWithBridge:self.bridge]; } +- (RCTShadowView *)shadowView +{ + RCTBaseTextInputShadowView *shadowView = (RCTBaseTextInputShadowView *)[super shadowView]; + + shadowView.maximumNumberOfLines = 0; + shadowView.exactNumberOfLines = 0; + + return shadowView; +} + #pragma mark - Multiline (aka TextView) specific properties RCT_REMAP_VIEW_PROPERTY(dataDetectorTypes, backedTextInputView.dataDetectorTypes, UIDataDetectorTypes) +RCT_EXPORT_SHADOW_PROPERTY(maximumNumberOfLines, NSInteger) +RCT_REMAP_SHADOW_PROPERTY(numberOfLines, exactNumberOfLines, NSInteger) + @end diff --git a/Libraries/Text/TextInput/RCTBaseTextInputShadowView.h b/Libraries/Text/TextInput/RCTBaseTextInputShadowView.h index 8f4cf7eb1b5f47..6238ebc600927f 100644 --- a/Libraries/Text/TextInput/RCTBaseTextInputShadowView.h +++ b/Libraries/Text/TextInput/RCTBaseTextInputShadowView.h @@ -16,6 +16,7 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, copy, nullable) NSString *text; @property (nonatomic, copy, nullable) NSString *placeholder; @property (nonatomic, assign) NSInteger maximumNumberOfLines; +@property (nonatomic, assign) NSInteger exactNumberOfLines; @property (nonatomic, copy, nullable) RCTDirectEventBlock onContentSizeChange; - (void)uiManagerWillPerformMounting; diff --git a/Libraries/Text/TextInput/RCTBaseTextInputShadowView.m b/Libraries/Text/TextInput/RCTBaseTextInputShadowView.m index 04d2446f86d9b3..9d777436763f24 100644 --- a/Libraries/Text/TextInput/RCTBaseTextInputShadowView.m +++ b/Libraries/Text/TextInput/RCTBaseTextInputShadowView.m @@ -218,7 +218,22 @@ - (NSAttributedString *)measurableAttributedText - (CGSize)sizeThatFitsMinimumSize:(CGSize)minimumSize maximumSize:(CGSize)maximumSize { - NSAttributedString *attributedText = [self measurableAttributedText]; + NSMutableAttributedString *attributedText = [[self measurableAttributedText] mutableCopy]; + + /* + * The block below is responsible for setting the exact height of the view in lines + * Unfortunatelly, iOS doesn't export any easy way to do it. So we set maximumNumberOfLines + * prop and then add random lines at the front. However, they are only used for layout + * so they are not visible on the screen. + */ + if (self.exactNumberOfLines) { + NSMutableString *newLines = [NSMutableString stringWithCapacity:self.exactNumberOfLines]; + for (NSUInteger i = 0UL; i < self.exactNumberOfLines; ++i) { + [newLines appendString:@"\n"]; + } + [attributedText insertAttributedString:[[NSAttributedString alloc] initWithString:newLines attributes:self.textAttributes.effectiveTextAttributes] atIndex:0]; + _maximumNumberOfLines = self.exactNumberOfLines; + } if (!_textStorage) { _textContainer = [NSTextContainer new]; diff --git a/Libraries/Text/TextInput/Singleline/RCTSinglelineTextInputViewManager.m b/Libraries/Text/TextInput/Singleline/RCTSinglelineTextInputViewManager.m index 413ac42238783a..56d039ce2518f9 100644 --- a/Libraries/Text/TextInput/Singleline/RCTSinglelineTextInputViewManager.m +++ b/Libraries/Text/TextInput/Singleline/RCTSinglelineTextInputViewManager.m @@ -19,6 +19,7 @@ - (RCTShadowView *)shadowView RCTBaseTextInputShadowView *shadowView = (RCTBaseTextInputShadowView *)[super shadowView]; shadowView.maximumNumberOfLines = 1; + shadowView.exactNumberOfLines = 0; return shadowView; } diff --git a/Libraries/Text/TextNativeComponent.js b/Libraries/Text/TextNativeComponent.js index 0d5990455b5be0..812f334806eb73 100644 --- a/Libraries/Text/TextNativeComponent.js +++ b/Libraries/Text/TextNativeComponent.js @@ -18,6 +18,7 @@ import {type TextProps} from './TextProps'; type NativeTextProps = $ReadOnly<{ ...TextProps, + maximumNumberOfLines?: ?number, isHighlighted?: ?boolean, selectionColor?: ?ProcessedColorValue, onClick?: ?(event: PressEvent) => mixed, @@ -31,7 +32,7 @@ const textViewConfig = { validAttributes: { isHighlighted: true, isPressable: true, - numberOfLines: true, + maximumNumberOfLines: true, ellipsizeMode: true, allowFontScaling: true, dynamicTypeRamp: true, diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewDefaults.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewDefaults.java index 8cab4071bda2ef..ad5fa969d38db4 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewDefaults.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewDefaults.java @@ -12,5 +12,6 @@ public class ViewDefaults { public static final float FONT_SIZE_SP = 14.0f; public static final int LINE_HEIGHT = 0; - public static final int NUMBER_OF_LINES = Integer.MAX_VALUE; + public static final int NUMBER_OF_LINES = -1; + public static final int MAXIMUM_NUMBER_OF_LINES = Integer.MAX_VALUE; } diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.java index 462fd25ee28949..53231a5baf6765 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.java @@ -96,6 +96,7 @@ public class ViewProps { public static final String LETTER_SPACING = "letterSpacing"; public static final String NEEDS_OFFSCREEN_ALPHA_COMPOSITING = "needsOffscreenAlphaCompositing"; public static final String NUMBER_OF_LINES = "numberOfLines"; + public static final String MAXIMUM_NUMBER_OF_LINES = "maximumNumberOfLines"; public static final String ELLIPSIZE_MODE = "ellipsizeMode"; public static final String ADJUSTS_FONT_SIZE_TO_FIT = "adjustsFontSizeToFit"; public static final String MINIMUM_FONT_SCALE = "minimumFontScale"; diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java b/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java index cbf2967d9755ab..9bca263cf0ab9d 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java @@ -327,6 +327,7 @@ protected Spannable spannedFromShadowNode( protected boolean mIsAccessibilityLink = false; protected int mNumberOfLines = UNSET; + protected int mMaxNumberOfLines = UNSET; protected int mTextAlign = Gravity.NO_GRAVITY; protected int mTextBreakStrategy = (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) ? 0 : Layout.BREAK_STRATEGY_HIGH_QUALITY; @@ -411,6 +412,12 @@ public void setNumberOfLines(int numberOfLines) { markUpdated(); } + @ReactProp(name = ViewProps.MAXIMUM_NUMBER_OF_LINES, defaultInt = UNSET) + public void setMaxNumberOfLines(int numberOfLines) { + mMaxNumberOfLines = numberOfLines == 0 ? UNSET : numberOfLines; + markUpdated(); + } + @ReactProp(name = ViewProps.LINE_HEIGHT, defaultFloat = Float.NaN) public void setLineHeight(float lineHeight) { mTextAttributes.setLineHeight(lineHeight); diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextAnchorViewManager.java b/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextAnchorViewManager.java index dc874023c58425..e914938d1c8ec2 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextAnchorViewManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextAnchorViewManager.java @@ -54,6 +54,12 @@ public void setNumberOfLines(ReactTextView view, int numberOfLines) { view.setNumberOfLines(numberOfLines); } + // maxLines can only be set in master view (block), doesn't really make sense to set in a span + @ReactProp(name = ViewProps.MAXIMUM_NUMBER_OF_LINES, defaultInt = ViewDefaults.NUMBER_OF_LINES) + public void setMaxNumberOfLines(ReactTextView view, int numberOfLines) { + view.setNumberOfLines(numberOfLines); + } + @ReactProp(name = ViewProps.ELLIPSIZE_MODE) public void setEllipsizeMode(ReactTextView view, @Nullable String ellipsizeMode) { if (ellipsizeMode == null || ellipsizeMode.equals("tail")) { diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextShadowNode.java b/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextShadowNode.java index 4e67070aca4bd0..ef67cbb48da70e 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextShadowNode.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextShadowNode.java @@ -82,7 +82,7 @@ public long measure( int minimumFontSize = (int) Math.max(mMinimumFontScale * initialFontSize, PixelUtil.toPixelFromDIP(4)); while (currentFontSize > minimumFontSize - && (mNumberOfLines != UNSET && layout.getLineCount() > mNumberOfLines + && (mMaxNumberOfLines != UNSET && layout.getLineCount() > mMaxNumberOfLines || heightMode != YogaMeasureMode.UNDEFINED && layout.getHeight() > height)) { // TODO: We could probably use a smarter algorithm here. This will require 0(n) // measurements @@ -124,9 +124,9 @@ public long measure( } final int lineCount = - mNumberOfLines == UNSET + mMaxNumberOfLines == UNSET ? layout.getLineCount() - : Math.min(mNumberOfLines, layout.getLineCount()); + : Math.min(mMaxNumberOfLines, layout.getLineCount()); // Instead of using `layout.getWidth()` (which may yield a significantly larger width for // text that is wrapping), compute width using the longest line. diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.java b/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.java index 964152d93df5b8..97e9b09e7b8d25 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.java @@ -18,6 +18,7 @@ import android.text.Spanned; import android.text.StaticLayout; import android.text.TextPaint; +import android.text.TextUtils; import android.util.LayoutDirection; import android.util.LruCache; import android.view.View; @@ -65,6 +66,7 @@ public class TextLayoutManager { private static final String TEXT_BREAK_STRATEGY_KEY = "textBreakStrategy"; private static final String HYPHENATION_FREQUENCY_KEY = "android_hyphenationFrequency"; private static final String MAXIMUM_NUMBER_OF_LINES_KEY = "maximumNumberOfLines"; + private static final String NUMBER_OF_LINES_KEY = "numberOfLines"; private static final LruCache sSpannableCache = new LruCache<>(spannableCacheSize); private static final ConcurrentHashMap sTagToSpannableCache = @@ -385,6 +387,47 @@ public static long measureText( ? paragraphAttributes.getInt(MAXIMUM_NUMBER_OF_LINES_KEY) : UNSET; + int numberOfLines = + paragraphAttributes.hasKey(NUMBER_OF_LINES_KEY) + ? paragraphAttributes.getInt(NUMBER_OF_LINES_KEY) + : UNSET; + + int lines = layout.getLineCount(); + if (numberOfLines != UNSET && numberOfLines != 0 && numberOfLines >= lines && text.length() > 0) { + int numberOfEmptyLines = numberOfLines - lines; + SpannableStringBuilder ssb = new SpannableStringBuilder(); + + // for some reason a newline on end causes issues with computing height so we add a character + if (text.toString().endsWith("\n")) { + ssb.append("A"); + } + + for (int i = 0; i < numberOfEmptyLines; ++i) { + ssb.append("\nA"); + } + + Object[] spans = text.getSpans(0, 0, Object.class); + for (Object span : spans) { // It's possible we need to set exl-exl + ssb.setSpan(span, 0, ssb.length(), text.getSpanFlags(span)); + }; + + text = new SpannableStringBuilder(TextUtils.concat(text, ssb)); + boring = null; + layout = createLayout( + text, + boring, + width, + widthYogaMeasureMode, + includeFontPadding, + textBreakStrategy, + hyphenationFrequency); + } + + + if (numberOfLines != UNSET && numberOfLines != 0) { + maximumNumberOfLines = numberOfLines; + } + int calculatedLineCount = maximumNumberOfLines == UNSET || maximumNumberOfLines == 0 ? layout.getLineCount() diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManagerMapBuffer.java b/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManagerMapBuffer.java index e2a8c05f2f0a9a..cff95d677e2223 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManagerMapBuffer.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManagerMapBuffer.java @@ -18,6 +18,7 @@ import android.text.Spanned; import android.text.StaticLayout; import android.text.TextPaint; +import android.text.TextUtils; import android.util.LayoutDirection; import android.util.LruCache; import android.view.View; @@ -61,6 +62,7 @@ public class TextLayoutManagerMapBuffer { public static final short PA_KEY_ADJUST_FONT_SIZE_TO_FIT = 3; public static final short PA_KEY_INCLUDE_FONT_PADDING = 4; public static final short PA_KEY_HYPHENATION_FREQUENCY = 5; + public static final short PA_KEY_NUMBER_OF_LINES = 6; private static final boolean ENABLE_MEASURE_LOGGING = ReactBuildConfig.DEBUG && false; @@ -405,6 +407,46 @@ public static long measureText( ? paragraphAttributes.getInt(PA_KEY_MAX_NUMBER_OF_LINES) : UNSET; + int numberOfLines = + paragraphAttributes.contains(PA_KEY_NUMBER_OF_LINES) + ? paragraphAttributes.getInt(PA_KEY_NUMBER_OF_LINES) + : UNSET; + + int lines = layout.getLineCount(); + if (numberOfLines != UNSET && numberOfLines != 0 && numberOfLines > lines && text.length() > 0) { + int numberOfEmptyLines = numberOfLines - lines; + SpannableStringBuilder ssb = new SpannableStringBuilder(); + + // for some reason a newline on end causes issues with computing height so we add a character + if (text.toString().endsWith("\n")) { + ssb.append("A"); + } + + for (int i = 0; i < numberOfEmptyLines; ++i) { + ssb.append("\nA"); + } + + Object[] spans = text.getSpans(0, 0, Object.class); + for (Object span : spans) { // It's possible we need to set exl-exl + ssb.setSpan(span, 0, ssb.length(), text.getSpanFlags(span)); + }; + + text = new SpannableStringBuilder(TextUtils.concat(text, ssb)); + boring = null; + layout = createLayout( + text, + boring, + width, + widthYogaMeasureMode, + includeFontPadding, + textBreakStrategy, + hyphenationFrequency); + } + + if (numberOfLines != UNSET && numberOfLines != 0) { + maximumNumberOfLines = numberOfLines; + } + int calculatedLineCount = maximumNumberOfLines == UNSET || maximumNumberOfLines == 0 ? layout.getLineCount() diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java index c150abc1500dca..8b062c2d7990ec 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java @@ -477,7 +477,13 @@ public void setInputType(int type) { * href='https://android.googlesource.com/platform/frameworks/base/+/jb-release/core/java/android/widget/TextView.java'>TextView.java} */ if (isMultiline()) { + // we save max lines as setSingleLines overwrites it + // https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/widget/TextView.java#10671 + int maxLines = getMaxLines(); setSingleLine(false); + if (maxLines != -1) { + setMaxLines(maxLines); + } } // We override the KeyListener so that all keys on the soft input keyboard as well as hardware diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputLocalData.java b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputLocalData.java index a85051068d2974..c59be1de38e761 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputLocalData.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputLocalData.java @@ -41,9 +41,9 @@ public ReactTextInputLocalData(EditText editText) { public void apply(EditText editText) { editText.setText(mText); editText.setTextSize(TypedValue.COMPLEX_UNIT_PX, mTextSize); + editText.setInputType(mInputType); editText.setMinLines(mMinLines); editText.setMaxLines(mMaxLines); - editText.setInputType(mInputType); editText.setHint(mPlaceholder); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { editText.setBreakStrategy(mBreakStrategy); diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java index 85fb317a2a9d9d..35c31e9c75d57d 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java @@ -723,9 +723,16 @@ public void setEditable(ReactEditText view, boolean editable) { @ReactProp(name = ViewProps.NUMBER_OF_LINES, defaultInt = 1) public void setNumLines(ReactEditText view, int numLines) { + view.setInputType(view.getInputType() | InputType.TYPE_TEXT_FLAG_MULTI_LINE); view.setLines(numLines); } + @ReactProp(name = ViewProps.MAXIMUM_NUMBER_OF_LINES, defaultInt = 1) + public void setMaxNumLines(ReactEditText view, int numLines) { + view.setInputType(view.getInputType() | InputType.TYPE_TEXT_FLAG_MULTI_LINE); + view.setMaxLines(numLines); + } + @ReactProp(name = "maxLength") public void setMaxLength(ReactEditText view, @Nullable Integer maxLength) { InputFilter[] currentFilters = view.getFilters(); diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputShadowNode.java b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputShadowNode.java index e621e1b585f9be..cf2079aee053ce 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputShadowNode.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputShadowNode.java @@ -117,6 +117,10 @@ public long measure( if (mNumberOfLines != UNSET) { editText.setLines(mNumberOfLines); + } else { + if (mMaxNumberOfLines != UNSET) { + editText.setMaxLines(mMaxNumberOfLines); + } } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M diff --git a/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.cpp b/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.cpp index cec544ea1f76c0..38c6be4ee8e572 100644 --- a/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.cpp +++ b/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.cpp @@ -16,6 +16,7 @@ namespace facebook::react { bool ParagraphAttributes::operator==(const ParagraphAttributes &rhs) const { return std::tie( + numberOfLines, maximumNumberOfLines, ellipsizeMode, textBreakStrategy, @@ -23,6 +24,7 @@ bool ParagraphAttributes::operator==(const ParagraphAttributes &rhs) const { includeFontPadding, android_hyphenationFrequency) == std::tie( + rhs.numberOfLines, rhs.maximumNumberOfLines, rhs.ellipsizeMode, rhs.textBreakStrategy, @@ -42,6 +44,7 @@ bool ParagraphAttributes::operator!=(const ParagraphAttributes &rhs) const { #if RN_DEBUG_STRING_CONVERTIBLE SharedDebugStringConvertibleList ParagraphAttributes::getDebugProps() const { return { + debugStringConvertibleItem("numberOfLines", numberOfLines), debugStringConvertibleItem("maximumNumberOfLines", maximumNumberOfLines), debugStringConvertibleItem("ellipsizeMode", ellipsizeMode), debugStringConvertibleItem("textBreakStrategy", textBreakStrategy), diff --git a/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.h b/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.h index c71909b89ad15f..b709634e89a67b 100644 --- a/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.h +++ b/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.h @@ -30,6 +30,11 @@ class ParagraphAttributes : public DebugStringConvertible { public: #pragma mark - Fields + /* + * Number of lines which paragraph takes. + */ + int numberOfLines{}; + /* * Maximum number of lines which paragraph can take. * Zero value represents "no limit". @@ -92,6 +97,7 @@ struct hash { const facebook::react::ParagraphAttributes &attributes) const { return folly::hash::hash_combine( 0, + attributes.numberOfLines, attributes.maximumNumberOfLines, attributes.ellipsizeMode, attributes.textBreakStrategy, diff --git a/ReactCommon/react/renderer/attributedstring/conversions.h b/ReactCommon/react/renderer/attributedstring/conversions.h index f768de8cd82d2f..480da1492ae01e 100644 --- a/ReactCommon/react/renderer/attributedstring/conversions.h +++ b/ReactCommon/react/renderer/attributedstring/conversions.h @@ -836,12 +836,18 @@ inline ParagraphAttributes convertRawProp( ParagraphAttributes const &defaultParagraphAttributes) { auto paragraphAttributes = ParagraphAttributes{}; - paragraphAttributes.maximumNumberOfLines = convertRawProp( + paragraphAttributes.numberOfLines = convertRawProp( context, rawProps, "numberOfLines", - sourceParagraphAttributes.maximumNumberOfLines, - defaultParagraphAttributes.maximumNumberOfLines); + sourceParagraphAttributes.numberOfLines, + defaultParagraphAttributes.numberOfLines); + paragraphAttributes.maximumNumberOfLines = convertRawProp( + context, + rawProps, + "maximumNumberOfLines", + sourceParagraphAttributes.maximumNumberOfLines, + defaultParagraphAttributes.maximumNumberOfLines); paragraphAttributes.ellipsizeMode = convertRawProp( context, rawProps, @@ -914,6 +920,7 @@ inline std::string toString(AttributedString::Range const &range) { inline folly::dynamic toDynamic( const ParagraphAttributes ¶graphAttributes) { auto values = folly::dynamic::object(); + values("numberOfLines", paragraphAttributes.numberOfLines); values("maximumNumberOfLines", paragraphAttributes.maximumNumberOfLines); values("ellipsizeMode", toString(paragraphAttributes.ellipsizeMode)); values("textBreakStrategy", toString(paragraphAttributes.textBreakStrategy)); @@ -1119,6 +1126,7 @@ constexpr static MapBuffer::Key PA_KEY_TEXT_BREAK_STRATEGY = 2; constexpr static MapBuffer::Key PA_KEY_ADJUST_FONT_SIZE_TO_FIT = 3; constexpr static MapBuffer::Key PA_KEY_INCLUDE_FONT_PADDING = 4; constexpr static MapBuffer::Key PA_KEY_HYPHENATION_FREQUENCY = 5; +constexpr static MapBuffer::Key PA_KEY_NUMBER_OF_LINES = 6; inline MapBuffer toMapBuffer(const ParagraphAttributes ¶graphAttributes) { auto builder = MapBufferBuilder(); @@ -1136,6 +1144,8 @@ inline MapBuffer toMapBuffer(const ParagraphAttributes ¶graphAttributes) { builder.putString( PA_KEY_HYPHENATION_FREQUENCY, toString(paragraphAttributes.android_hyphenationFrequency)); + builder.putInt( + PA_KEY_NUMBER_OF_LINES, paragraphAttributes.numberOfLines); return builder.build(); } diff --git a/ReactCommon/react/renderer/components/textinput/androidtextinput/react/renderer/components/androidtextinput/AndroidTextInputProps.cpp b/ReactCommon/react/renderer/components/textinput/androidtextinput/react/renderer/components/androidtextinput/AndroidTextInputProps.cpp index c8eaf160ba1ca3..90aebcffd56d37 100644 --- a/ReactCommon/react/renderer/components/textinput/androidtextinput/react/renderer/components/androidtextinput/AndroidTextInputProps.cpp +++ b/ReactCommon/react/renderer/components/textinput/androidtextinput/react/renderer/components/androidtextinput/AndroidTextInputProps.cpp @@ -56,6 +56,10 @@ AndroidTextInputProps::AndroidTextInputProps( "numberOfLines", sourceProps.numberOfLines, {0})), + maximumNumberOfLines(CoreFeatures::enablePropIteratorSetter? sourceProps.maximumNumberOfLines : convertRawProp(context, rawProps, + "maximumNumberOfLines", + sourceProps.maximumNumberOfLines, + {0})), disableFullscreenUI(CoreFeatures::enablePropIteratorSetter? sourceProps.disableFullscreenUI : convertRawProp(context, rawProps, "disableFullscreenUI", sourceProps.disableFullscreenUI, @@ -281,6 +285,12 @@ void AndroidTextInputProps::setProp( value, paragraphAttributes, maximumNumberOfLines, + "maximumNumberOfLines"); + REBUILD_FIELD_SWITCH_CASE( + paDefaults, + value, + paragraphAttributes, + numberOfLines, "numberOfLines"); REBUILD_FIELD_SWITCH_CASE( paDefaults, value, paragraphAttributes, ellipsizeMode, "ellipsizeMode"); @@ -326,6 +336,7 @@ void AndroidTextInputProps::setProp( RAW_SET_PROP_SWITCH_CASE_BASIC(autoComplete, {}); RAW_SET_PROP_SWITCH_CASE_BASIC(returnKeyLabel, {}); RAW_SET_PROP_SWITCH_CASE_BASIC(numberOfLines, 0); + RAW_SET_PROP_SWITCH_CASE_BASIC(maximumNumberOfLines, 0); RAW_SET_PROP_SWITCH_CASE_BASIC(disableFullscreenUI, false); RAW_SET_PROP_SWITCH_CASE_BASIC(textBreakStrategy, {}); RAW_SET_PROP_SWITCH_CASE_BASIC(underlineColorAndroid, {}); @@ -419,6 +430,7 @@ void AndroidTextInputProps::setProp( // TODO T53300085: support this in codegen; this was hand-written folly::dynamic AndroidTextInputProps::getDynamic() const { folly::dynamic props = folly::dynamic::object(); + props["maximumNumberOfLines"] = maximumNumberOfLines; props["autoComplete"] = autoComplete; props["returnKeyLabel"] = returnKeyLabel; props["numberOfLines"] = numberOfLines; diff --git a/ReactCommon/react/renderer/components/textinput/androidtextinput/react/renderer/components/androidtextinput/AndroidTextInputProps.h b/ReactCommon/react/renderer/components/textinput/androidtextinput/react/renderer/components/androidtextinput/AndroidTextInputProps.h index 33b34adff99198..6847f6d4997afb 100644 --- a/ReactCommon/react/renderer/components/textinput/androidtextinput/react/renderer/components/androidtextinput/AndroidTextInputProps.h +++ b/ReactCommon/react/renderer/components/textinput/androidtextinput/react/renderer/components/androidtextinput/AndroidTextInputProps.h @@ -118,6 +118,7 @@ class AndroidTextInputProps final : public ViewProps, public BaseTextProps { std::string autoComplete{}; std::string returnKeyLabel{}; int numberOfLines{0}; + int maximumNumberOfLines{0}; bool disableFullscreenUI{false}; std::string textBreakStrategy{}; SharedColor underlineColorAndroid{}; diff --git a/ReactCommon/react/renderer/textlayoutmanager/platform/ios/RCTTextLayoutManager.mm b/ReactCommon/react/renderer/textlayoutmanager/platform/ios/RCTTextLayoutManager.mm index 844fcb228510a2..056619c990ed0c 100644 --- a/ReactCommon/react/renderer/textlayoutmanager/platform/ios/RCTTextLayoutManager.mm +++ b/ReactCommon/react/renderer/textlayoutmanager/platform/ios/RCTTextLayoutManager.mm @@ -170,26 +170,50 @@ - (LinesMeasurements)getLinesForAttributedString:(facebook::react::AttributedStr return paragraphLines; } -- (NSTextStorage *)_textStorageAndLayoutManagerWithAttributesString:(NSAttributedString *)attributedString +- (NSTextStorage *)_textStorageAndLayoutManagerWithAttributesString:(NSAttributedString *)inputAttributedString paragraphAttributes:(ParagraphAttributes)paragraphAttributes size:(CGSize)size { - NSTextContainer *textContainer = [[NSTextContainer alloc] initWithSize:size]; - - textContainer.lineFragmentPadding = 0.0; // Note, the default value is 5. - textContainer.lineBreakMode = paragraphAttributes.maximumNumberOfLines > 0 - ? RCTNSLineBreakModeFromEllipsizeMode(paragraphAttributes.ellipsizeMode) - : NSLineBreakByClipping; - textContainer.maximumNumberOfLines = paragraphAttributes.maximumNumberOfLines; + NSMutableAttributedString *attributedString = [inputAttributedString mutableCopy]; + + /* + * The block below is responsible for setting the exact height of the view in lines + * Unfortunatelly, iOS doesn't export any easy way to do it. So we set maximumNumberOfLines + * prop and then add random lines at the front. However, they are only used for layout + * so they are not visible on the screen. This method is used for drawing only for Paragraph component + * but we set exact height in lines only on TextInput that doesn't use it. + */ + if (paragraphAttributes.numberOfLines) { + paragraphAttributes.maximumNumberOfLines = paragraphAttributes.numberOfLines; + NSMutableString *newLines = [NSMutableString stringWithCapacity: paragraphAttributes.numberOfLines]; + for (NSUInteger i = 0UL; i < paragraphAttributes.numberOfLines; ++i) { + // K is added on purpose. New line seems to be not enough for NTtextContainer + [newLines appendString:@"K\n"]; + } + NSDictionary * attributesOfFirstCharacter = [inputAttributedString attributesAtIndex:0 effectiveRange:NULL]; + + + [attributedString insertAttributedString:[[NSAttributedString alloc] initWithString:newLines attributes:attributesOfFirstCharacter] atIndex:0]; + } + + NSTextContainer *textContainer = [NSTextContainer new]; + NSLayoutManager *layoutManager = [NSLayoutManager new]; layoutManager.usesFontLeading = NO; [layoutManager addTextContainer:textContainer]; - - NSTextStorage *textStorage = [[NSTextStorage alloc] initWithAttributedString:attributedString]; - + NSTextStorage *textStorage = [NSTextStorage new]; [textStorage addLayoutManager:layoutManager]; + textContainer.lineFragmentPadding = 0.0; // Note, the default value is 5. + textContainer.lineBreakMode = paragraphAttributes.maximumNumberOfLines > 0 + ? RCTNSLineBreakModeFromEllipsizeMode(paragraphAttributes.ellipsizeMode) + : NSLineBreakByClipping; + textContainer.size = size; + textContainer.maximumNumberOfLines = paragraphAttributes.maximumNumberOfLines; + + [textStorage replaceCharactersInRange:(NSRange){0, textStorage.length} withAttributedString:attributedString]; + if (paragraphAttributes.adjustsFontSizeToFit) { CGFloat minimumFontSize = !isnan(paragraphAttributes.minimumFontSize) ? paragraphAttributes.minimumFontSize : 4.0; CGFloat maximumFontSize = !isnan(paragraphAttributes.maximumFontSize) ? paragraphAttributes.maximumFontSize : 96.0; diff --git a/packages/rn-tester/js/examples/TextInput/TextInputExample.android.js b/packages/rn-tester/js/examples/TextInput/TextInputExample.android.js index dd228211744f3a..91a00da0e29d45 100644 --- a/packages/rn-tester/js/examples/TextInput/TextInputExample.android.js +++ b/packages/rn-tester/js/examples/TextInput/TextInputExample.android.js @@ -383,36 +383,6 @@ exports.examples = ([ ); }, }, - { - title: 'Fixed number of lines', - platform: 'android', - render: function (): React.Node { - return ( - - - - - - - ); - }, - }, { title: 'Auto-expanding', render: function (): React.Node { diff --git a/packages/rn-tester/js/examples/TextInput/TextInputSharedExamples.js b/packages/rn-tester/js/examples/TextInput/TextInputSharedExamples.js index e5b1c4f580bfcb..07ca76f24ac6b6 100644 --- a/packages/rn-tester/js/examples/TextInput/TextInputSharedExamples.js +++ b/packages/rn-tester/js/examples/TextInput/TextInputSharedExamples.js @@ -80,6 +80,12 @@ const styles = StyleSheet.create({ fontSize: 13, padding: 4, }, + textInputLines: { + borderWidth: 1, + borderColor: 'black', + padding: 0, + textAlignVertical: Platform.OS === 'android' ? 'top' : undefined, + }, }); class WithLabel extends React.Component<$FlowFixMeProps> { @@ -871,4 +877,50 @@ module.exports = ([ return ; }, }, + { + title: 'Height in rows/lines', + name: 'rows', + render: function (): React.Node { + return ( + + + + + + + + + ); + }, + }, ]: Array);