Implement `adjustsFontSizeToFit` on Android (#26389)

Summary:
This adds support for `adjustsFontSizeToFit` and `minimumFontScale` on Android. The implementation tries to match closely the behaviour on iOS (hardcoded 4px min size for example). It uses a simpler linear algorithm for now, opened to improving it now if it is a deal breaker or in a follow up.

See https://twitter.com/janicduplessis/status/1171147709979516929 for a more detailed thread about the implementation

## Changelog

[Android] [Added] - Implement `adjustsFontSizeToFit` on Android
Pull Request resolved: https://github.com/facebook/react-native/pull/26389

Test Plan: Tested by adding the existing `adjustsFontSizeToFit` example from the iOS text page to android. Also added a case for limiting size by using `maxHeight` instead of `numberOfLines`.

Reviewed By: mdvacca

Differential Revision: D17285473

Pulled By: JoshuaGross

fbshipit-source-id: 43dbdb05e2d6418e9a390d11f921518bfa58e697
This commit is contained in:
Janic Duplessis 2020-02-10 14:57:28 -08:00 коммит произвёл Facebook Github Bot
Родитель 9f8e4accfa
Коммит 2c1913f0b3
7 изменённых файлов: 280 добавлений и 86 удалений

Просмотреть файл

@ -16,7 +16,7 @@ const React = require('react');
const TextInlineView = require('../../components/TextInlineView');
const TextLegend = require('../../components/TextLegend');
const {StyleSheet, Text, View} = require('react-native');
const {LayoutAnimation, StyleSheet, Text, View} = require('react-native');
class Entity extends React.Component<{|children: React.Node|}> {
render() {
@ -70,10 +70,137 @@ class AttributeToggler extends React.Component<{...}, $FlowFixMeState> {
}
}
type AdjustingFontSizeProps = $ReadOnly<{||}>;
type AdjustingFontSizeState = {|
dynamicText: string,
shouldRender: boolean,
|};
class AdjustingFontSize extends React.Component<
AdjustingFontSizeProps,
AdjustingFontSizeState,
> {
state = {
dynamicText: '',
shouldRender: true,
};
reset = () => {
LayoutAnimation.easeInEaseOut();
this.setState({
shouldRender: false,
});
setTimeout(() => {
LayoutAnimation.easeInEaseOut();
this.setState({
dynamicText: '',
shouldRender: true,
});
}, 300);
};
addText = () => {
this.setState({
dynamicText:
this.state.dynamicText +
(Math.floor((Math.random() * 10) % 2) ? ' foo' : ' bar'),
});
};
removeText = () => {
this.setState({
dynamicText: this.state.dynamicText.slice(
0,
this.state.dynamicText.length - 4,
),
});
};
render() {
if (!this.state.shouldRender) {
return <View />;
}
return (
<View>
<Text
ellipsizeMode="tail"
numberOfLines={1}
style={{fontSize: 36, marginVertical: 6}}>
Truncated text is baaaaad.
</Text>
<Text
numberOfLines={1}
adjustsFontSizeToFit={true}
style={{fontSize: 40, marginVertical: 6}}>
Shrinking to fit available space is much better!
</Text>
<Text
adjustsFontSizeToFit={true}
numberOfLines={1}
style={{fontSize: 30, marginVertical: 6}}>
{'Add text to me to watch me shrink!' + ' ' + this.state.dynamicText}
</Text>
<Text
adjustsFontSizeToFit={true}
numberOfLines={4}
style={{fontSize: 20, marginVertical: 6}}>
{'Multiline text component shrinking is supported, watch as this reeeeaaaally loooooong teeeeeeext grooooows and then shriiiinks as you add text to me! ioahsdia soady auydoa aoisyd aosdy ' +
' ' +
this.state.dynamicText}
</Text>
<Text
adjustsFontSizeToFit={true}
style={{fontSize: 20, marginVertical: 6, maxHeight: 50}}>
{'Text limited by height, watch as this reeeeaaaally loooooong teeeeeeext grooooows and then shriiiinks as you add text to me! ioahsdia soady auydoa aoisyd aosdy ' +
' ' +
this.state.dynamicText}
</Text>
<Text
adjustsFontSizeToFit={true}
numberOfLines={1}
style={{marginVertical: 6}}>
<Text style={{fontSize: 14}}>
{'Differently sized nested elements will shrink together. '}
</Text>
<Text style={{fontSize: 20}}>
{'LARGE TEXT! ' + this.state.dynamicText}
</Text>
</Text>
<View
style={{
flexDirection: 'row',
justifyContent: 'space-around',
marginTop: 5,
marginVertical: 6,
}}>
<Text style={{backgroundColor: '#ffaaaa'}} onPress={this.reset}>
Reset
</Text>
<Text style={{backgroundColor: '#aaaaff'}} onPress={this.removeText}>
Remove Text
</Text>
<Text style={{backgroundColor: '#aaffaa'}} onPress={this.addText}>
Add Text
</Text>
</View>
</View>
);
}
}
class TextExample extends React.Component<{...}> {
render(): React.Node {
return (
<RNTesterPage title="<Text>">
<RNTesterBlock title="Dynamic Font Size Adjustment">
<AdjustingFontSize />
</RNTesterBlock>
<RNTesterBlock title="Wrap">
<Text>
The text should wrap if it goes on multiple lines. See, this is

Просмотреть файл

@ -222,6 +222,14 @@ class AdjustingFontSize extends React.Component<
this.state.dynamicText}
</Text>
<Text
adjustsFontSizeToFit={true}
style={{fontSize: 20, marginVertical: 6, maxHeight: 50}}>
{'Text limited by height, watch as this reeeeaaaally loooooong teeeeeeext grooooows and then shriiiinks as you add text to me! ioahsdia soady auydoa aoisyd aosdy ' +
' ' +
this.state.dynamicText}
</Text>
<Text
adjustsFontSizeToFit={true}
numberOfLines={1}

Просмотреть файл

@ -90,6 +90,8 @@ public class ViewProps {
public static final String NEEDS_OFFSCREEN_ALPHA_COMPOSITING = "needsOffscreenAlphaCompositing";
public static final String NUMBER_OF_LINES = "numberOfLines";
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";
public static final String ON = "on";
public static final String RESIZE_MODE = "resizeMode";
public static final String RESIZE_METHOD = "resizeMethod";

Просмотреть файл

@ -337,7 +337,6 @@ public abstract class ReactBaseTextShadowNode extends LayoutShadowNode {
(Build.VERSION.SDK_INT < Build.VERSION_CODES.M) ? 0 : Layout.HYPHENATION_FREQUENCY_NONE;
protected int mJustificationMode =
(Build.VERSION.SDK_INT < Build.VERSION_CODES.O) ? 0 : Layout.JUSTIFICATION_MODE_NONE;
protected TextTransform mTextTransform = TextTransform.UNSET;
protected float mTextShadowOffsetDx = 0;
protected float mTextShadowOffsetDy = 0;
@ -347,6 +346,8 @@ public abstract class ReactBaseTextShadowNode extends LayoutShadowNode {
protected boolean mIsUnderlineTextDecorationSet = false;
protected boolean mIsLineThroughTextDecorationSet = false;
protected boolean mIncludeFontPadding = true;
protected boolean mAdjustsFontSizeToFit = false;
protected float mMinimumFontScale = 0;
/**
* mFontStyle can be {@link Typeface#NORMAL} or {@link Typeface#ITALIC}. mFontWeight can be {@link
@ -627,4 +628,20 @@ public abstract class ReactBaseTextShadowNode extends LayoutShadowNode {
}
markUpdated();
}
@ReactProp(name = ViewProps.ADJUSTS_FONT_SIZE_TO_FIT)
public void setAdjustFontSizeToFit(boolean adjustsFontSizeToFit) {
if (adjustsFontSizeToFit != mAdjustsFontSizeToFit) {
mAdjustsFontSizeToFit = adjustsFontSizeToFit;
markUpdated();
}
}
@ReactProp(name = ViewProps.MINIMUM_FONT_SCALE)
public void setMinimumFontScale(float minimumFontScale) {
if (minimumFontScale != mMinimumFontScale) {
mMinimumFontScale = minimumFontScale;
markUpdated();
}
}
}

Просмотреть файл

@ -60,6 +60,11 @@ public abstract class ReactTextAnchorViewManager<T extends View, C extends React
}
}
@ReactProp(name = ViewProps.ADJUSTS_FONT_SIZE_TO_FIT)
public void setAdjustFontSizeToFit(ReactTextView view, boolean adjustsFontSizeToFit) {
view.setAdjustFontSizeToFit(adjustsFontSizeToFit);
}
@ReactProp(name = ViewProps.TEXT_ALIGN_VERTICAL)
public void setTextAlignVertical(ReactTextView view, @Nullable String textAlignVertical) {
if (textAlignVertical == null || "auto".equals(textAlignVertical)) {

Просмотреть файл

@ -25,6 +25,7 @@ import com.facebook.react.bridge.ReactSoftException;
import com.facebook.react.bridge.WritableArray;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.uimanager.NativeViewHierarchyOptimizer;
import com.facebook.react.uimanager.PixelUtil;
import com.facebook.react.uimanager.ReactShadowNode;
import com.facebook.react.uimanager.Spacing;
import com.facebook.react.uimanager.ThemedReactContext;
@ -66,96 +67,40 @@ public class ReactTextShadowNode extends ReactBaseTextShadowNode {
YogaMeasureMode widthMode,
float height,
YogaMeasureMode heightMode) {
// TODO(5578671): Handle text direction (see View#getTextDirectionHeuristic)
TextPaint textPaint = sTextPaintInstance;
textPaint.setTextSize(mTextAttributes.getEffectiveFontSize());
Layout layout;
Spanned text =
Spannable text =
Assertions.assertNotNull(
mPreparedSpannableText,
"Spannable element has not been prepared in onBeforeLayout");
BoringLayout.Metrics boring = BoringLayout.isBoring(text, textPaint);
float desiredWidth = boring == null ? Layout.getDesiredWidth(text, textPaint) : Float.NaN;
// technically, width should never be negative, but there is currently a bug in
boolean unconstrainedWidth = widthMode == YogaMeasureMode.UNDEFINED || width < 0;
Layout layout = measureSpannedText(text, width, widthMode);
Layout.Alignment alignment = Layout.Alignment.ALIGN_NORMAL;
switch (getTextAlign()) {
case Gravity.LEFT:
alignment = Layout.Alignment.ALIGN_NORMAL;
break;
case Gravity.RIGHT:
alignment = Layout.Alignment.ALIGN_OPPOSITE;
break;
case Gravity.CENTER_HORIZONTAL:
alignment = Layout.Alignment.ALIGN_CENTER;
break;
}
if (mAdjustsFontSizeToFit) {
int initialFontSize = mTextAttributes.getEffectiveFontSize();
int currentFontSize = mTextAttributes.getEffectiveFontSize();
// Minimum font size is 4pts to match the iOS implementation.
int minimumFontSize =
(int) Math.max(mMinimumFontScale * initialFontSize, PixelUtil.toPixelFromDIP(4));
while (currentFontSize > minimumFontSize
&& (mNumberOfLines != UNSET && layout.getLineCount() > mNumberOfLines
|| heightMode != YogaMeasureMode.UNDEFINED && layout.getHeight() > height)) {
// TODO: We could probably use a smarter algorithm here. This will require 0(n)
// measurements
// based on the number of points the font size needs to be reduced by.
currentFontSize = currentFontSize - (int) PixelUtil.toPixelFromDIP(1);
if (boring == null
&& (unconstrainedWidth
|| (!YogaConstants.isUndefined(desiredWidth) && desiredWidth <= width))) {
// Is used when the width is not known and the text is not boring, ie. if it contains
// unicode characters.
int hintWidth = (int) Math.ceil(desiredWidth);
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
layout =
new StaticLayout(
text, textPaint, hintWidth, alignment, 1.f, 0.f, mIncludeFontPadding);
} else {
StaticLayout.Builder builder =
StaticLayout.Builder.obtain(text, 0, text.length(), textPaint, hintWidth)
.setAlignment(alignment)
.setLineSpacing(0.f, 1.f)
.setIncludePad(mIncludeFontPadding)
.setBreakStrategy(mTextBreakStrategy)
.setHyphenationFrequency(mHyphenationFrequency);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
builder.setJustificationMode(mJustificationMode);
float ratio = (float) currentFontSize / (float) initialFontSize;
ReactAbsoluteSizeSpan[] sizeSpans =
text.getSpans(0, text.length(), ReactAbsoluteSizeSpan.class);
for (ReactAbsoluteSizeSpan span : sizeSpans) {
text.setSpan(
new ReactAbsoluteSizeSpan(
(int) Math.max((span.getSize() * ratio), minimumFontSize)),
text.getSpanStart(span),
text.getSpanEnd(span),
text.getSpanFlags(span));
text.removeSpan(span);
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
builder.setUseLineSpacingFromFallbacks(true);
}
layout = builder.build();
}
} else if (boring != null && (unconstrainedWidth || boring.width <= width)) {
// Is used for single-line, boring text when the width is either unknown or bigger
// than the width of the text.
layout =
BoringLayout.make(
text,
textPaint,
boring.width,
alignment,
1.f,
0.f,
boring,
mIncludeFontPadding);
} else {
// Is used for multiline, boring text and the width is known.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
layout =
new StaticLayout(
text, textPaint, (int) width, alignment, 1.f, 0.f, mIncludeFontPadding);
} else {
StaticLayout.Builder builder =
StaticLayout.Builder.obtain(text, 0, text.length(), textPaint, (int) width)
.setAlignment(alignment)
.setLineSpacing(0.f, 1.f)
.setIncludePad(mIncludeFontPadding)
.setBreakStrategy(mTextBreakStrategy)
.setHyphenationFrequency(mHyphenationFrequency);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
builder.setUseLineSpacingFromFallbacks(true);
}
layout = builder.build();
layout = measureSpannedText(text, width, widthMode);
}
}
@ -201,6 +146,89 @@ public class ReactTextShadowNode extends ReactBaseTextShadowNode {
}
}
private Layout measureSpannedText(Spannable text, float width, YogaMeasureMode widthMode) {
// TODO(5578671): Handle text direction (see View#getTextDirectionHeuristic)
TextPaint textPaint = sTextPaintInstance;
textPaint.setTextSize(mTextAttributes.getEffectiveFontSize());
Layout layout;
BoringLayout.Metrics boring = BoringLayout.isBoring(text, textPaint);
float desiredWidth = boring == null ? Layout.getDesiredWidth(text, textPaint) : Float.NaN;
// technically, width should never be negative, but there is currently a bug in
boolean unconstrainedWidth = widthMode == YogaMeasureMode.UNDEFINED || width < 0;
Layout.Alignment alignment = Layout.Alignment.ALIGN_NORMAL;
switch (getTextAlign()) {
case Gravity.LEFT:
alignment = Layout.Alignment.ALIGN_NORMAL;
break;
case Gravity.RIGHT:
alignment = Layout.Alignment.ALIGN_OPPOSITE;
break;
case Gravity.CENTER_HORIZONTAL:
alignment = Layout.Alignment.ALIGN_CENTER;
break;
}
if (boring == null
&& (unconstrainedWidth
|| (!YogaConstants.isUndefined(desiredWidth) && desiredWidth <= width))) {
// Is used when the width is not known and the text is not boring, ie. if it contains
// unicode characters.
int hintWidth = (int) Math.ceil(desiredWidth);
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
layout =
new StaticLayout(text, textPaint, hintWidth, alignment, 1.f, 0.f, mIncludeFontPadding);
} else {
StaticLayout.Builder builder =
StaticLayout.Builder.obtain(text, 0, text.length(), textPaint, hintWidth)
.setAlignment(alignment)
.setLineSpacing(0.f, 1.f)
.setIncludePad(mIncludeFontPadding)
.setBreakStrategy(mTextBreakStrategy)
.setHyphenationFrequency(mHyphenationFrequency);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
builder.setJustificationMode(mJustificationMode);
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
builder.setUseLineSpacingFromFallbacks(true);
}
layout = builder.build();
}
} else if (boring != null && (unconstrainedWidth || boring.width <= width)) {
// Is used for single-line, boring text when the width is either unknown or bigger
// than the width of the text.
layout =
BoringLayout.make(
text, textPaint, boring.width, alignment, 1.f, 0.f, boring, mIncludeFontPadding);
} else {
// Is used for multiline, boring text and the width is known.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
layout =
new StaticLayout(
text, textPaint, (int) width, alignment, 1.f, 0.f, mIncludeFontPadding);
} else {
StaticLayout.Builder builder =
StaticLayout.Builder.obtain(text, 0, text.length(), textPaint, (int) width)
.setAlignment(alignment)
.setLineSpacing(0.f, 1.f)
.setIncludePad(mIncludeFontPadding)
.setBreakStrategy(mTextBreakStrategy)
.setHyphenationFrequency(mHyphenationFrequency);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
builder.setUseLineSpacingFromFallbacks(true);
}
layout = builder.build();
}
}
return layout;
}
// Return text alignment according to LTR or RTL style
private int getTextAlign() {
int textAlign = mTextAlign;

Просмотреть файл

@ -51,6 +51,7 @@ public class ReactTextView extends AppCompatTextView implements ReactCompoundVie
private int mTextAlign = Gravity.NO_GRAVITY;
private int mNumberOfLines = ViewDefaults.NUMBER_OF_LINES;
private TextUtils.TruncateAt mEllipsizeLocation = TextUtils.TruncateAt.END;
private boolean mAdjustsFontSizeToFit = false;
private int mLinkifyMaskType = 0;
private boolean mNotifyOnInlineViewLayout;
@ -474,6 +475,10 @@ public class ReactTextView extends AppCompatTextView implements ReactCompoundVie
setMaxLines(mNumberOfLines);
}
public void setAdjustFontSizeToFit(boolean adjustsFontSizeToFit) {
mAdjustsFontSizeToFit = adjustsFontSizeToFit;
}
public void setEllipsizeLocation(TextUtils.TruncateAt ellipsizeLocation) {
mEllipsizeLocation = ellipsizeLocation;
}
@ -485,7 +490,9 @@ public class ReactTextView extends AppCompatTextView implements ReactCompoundVie
public void updateView() {
@Nullable
TextUtils.TruncateAt ellipsizeLocation =
mNumberOfLines == ViewDefaults.NUMBER_OF_LINES ? null : mEllipsizeLocation;
mNumberOfLines == ViewDefaults.NUMBER_OF_LINES || mAdjustsFontSizeToFit
? null
: mEllipsizeLocation;
setEllipsize(ellipsizeLocation);
}