RN: Unify Typeface Logic (Android)

Summary:
Refactors how `Typeface` style and weight are applied in React Native on Android.

- Unifies all style and weight normalization logic into a new `TypefaceStyle` class.
  - Fixes font weight support for the Fabric renderer.
  - De-duplicates code with `TextAttributeProps`.
  - Simplified normalization logic.
- Fixes a rare crash due to `Typeface.sDefaultTypeface` (Android SDK) being `null`.
- Adds a new example to test font weights in `TextInput`.
- Adds missing `Nullsafe` and `Nullable` annotations.
- Clean up a bunch of obsolete inline comments.

Changelog:
[Android][Fixed] - Fixed a rare crash due to `Typeface.sDefaultTypeface` (Android SDK) being `null`.
[Android][Fixed] - Fixed font weight support for the Fabric renderer.
[Android][Added] - Added a new example to test font weights in `TextInput`.

Reviewed By: JoshuaGross

Differential Revision: D29631134

fbshipit-source-id: 3f227d84253104fa828a5561b77ba7a9cbc030c4
This commit is contained in:
Tim Yung 2021-07-12 22:16:00 -07:00 коммит произвёл Facebook GitHub Bot
Родитель 3e2bb331fc
Коммит 9d2fedc6e2
7 изменённых файлов: 214 добавлений и 184 удалений

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

@ -12,9 +12,10 @@ import android.graphics.Paint;
import android.graphics.Typeface;
import android.text.TextPaint;
import android.text.style.MetricAffectingSpan;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.facebook.infer.annotation.Nullsafe;
@Nullsafe(Nullsafe.Mode.LOCAL)
public class CustomStyleSpan extends MetricAffectingSpan implements ReactSpan {
/**
@ -40,7 +41,7 @@ public class CustomStyleSpan extends MetricAffectingSpan implements ReactSpan {
int fontWeight,
@Nullable String fontFeatureSettings,
@Nullable String fontFamily,
@NonNull AssetManager assetManager) {
AssetManager assetManager) {
mStyle = fontStyle;
mWeight = fontWeight;
mFeatureSettings = fontFeatureSettings;
@ -54,21 +55,18 @@ public class CustomStyleSpan extends MetricAffectingSpan implements ReactSpan {
}
@Override
public void updateMeasureState(@NonNull TextPaint paint) {
public void updateMeasureState(TextPaint paint) {
apply(paint, mStyle, mWeight, mFeatureSettings, mFontFamily, mAssetManager);
}
/** Returns {@link Typeface#NORMAL} or {@link Typeface#ITALIC}. */
public int getStyle() {
return (mStyle == ReactTextShadowNode.UNSET ? 0 : mStyle);
return mStyle == ReactBaseTextShadowNode.UNSET ? Typeface.NORMAL : mStyle;
}
/** Returns {@link Typeface#NORMAL} or {@link Typeface#BOLD}. */
public int getWeight() {
return (mWeight == ReactTextShadowNode.UNSET ? 0 : mWeight);
return mWeight == ReactBaseTextShadowNode.UNSET ? TypefaceStyle.NORMAL : mWeight;
}
/** Returns the font family set for this StyleSpan. */
public @Nullable String getFontFamily() {
return mFontFamily;
}

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

@ -10,31 +10,39 @@ package com.facebook.react.views.text;
import android.content.Context;
import android.content.res.AssetManager;
import android.graphics.Typeface;
import android.os.Build;
import android.util.SparseArray;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.res.ResourcesCompat;
import com.facebook.infer.annotation.Nullsafe;
import java.util.HashMap;
import java.util.Map;
/**
* Class responsible to load and cache Typeface objects. It will first try to load typefaces inside
* the assets/fonts folder and if it doesn't find the right Typeface in that folder will fall back
* on the best matching system Typeface The supported custom fonts extensions are .ttf and .otf. For
* each font family the bold, italic and bold_italic variants are supported. Given a "family" font
* family the files in the assets/fonts folder need to be family.ttf(.otf) family_bold.ttf(.otf)
* family_italic.ttf(.otf) and family_bold_italic.ttf(.otf)
* Responsible for loading and caching Typeface objects.
*
* <p>This will first try to load a typeface from the assets/fonts folder. If one is not found in
* that folder, this will fallback to the best matching system typeface.
*
* <p>Custom fonts support the extensions `.ttf` and `.otf` and the variants `bold`, `italic`, and
* `bold_italic`. For example, given a font named "ExampleFontFamily", the following are supported:
*
* <ul>
* <li>ExampleFontFamily.ttf (or .otf)
* <li>ExampleFontFamily_bold.ttf (or .otf)
* <li>ExampleFontFamily_italic.ttf (or .otf)
* <li>ExampleFontFamily_bold_italic.ttf (or .otf)
*/
@Nullsafe(Nullsafe.Mode.LOCAL)
public class ReactFontManager {
// NOTE: Indices in `EXTENSIONS` correspond to the `TypeFace` style constants.
private static final String[] EXTENSIONS = {"", "_bold", "_italic", "_bold_italic"};
private static final String[] FILE_EXTENSIONS = {".ttf", ".otf"};
private static final String FONTS_ASSET_PATH = "fonts/";
private static ReactFontManager sReactFontManagerInstance;
private final Map<String, FontFamily> mFontCache;
private final Map<String, AssetFontFamily> mFontCache;
private final Map<String, Typeface> mCustomTypefaceCache;
private ReactFontManager() {
@ -49,36 +57,43 @@ public class ReactFontManager {
return sReactFontManagerInstance;
}
public @Nullable Typeface getTypeface(
String fontFamilyName, int style, AssetManager assetManager) {
return getTypeface(fontFamilyName, style, 0, assetManager);
public Typeface getTypeface(String fontFamilyName, int style, AssetManager assetManager) {
return getTypeface(fontFamilyName, new TypefaceStyle(style), assetManager);
}
public @Nullable Typeface getTypeface(
public Typeface getTypeface(
String fontFamilyName, int weight, boolean italic, AssetManager assetManager) {
return getTypeface(fontFamilyName, new TypefaceStyle(weight, italic), assetManager);
}
public Typeface getTypeface(
String fontFamilyName, int style, int weight, AssetManager assetManager) {
return getTypeface(fontFamilyName, new TypefaceStyle(style, weight), assetManager);
}
public Typeface getTypeface(
String fontFamilyName, TypefaceStyle typefaceStyle, AssetManager assetManager) {
if (mCustomTypefaceCache.containsKey(fontFamilyName)) {
Typeface typeface = mCustomTypefaceCache.get(fontFamilyName);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && weight >= 100 && weight <= 1000) {
return Typeface.create(typeface, weight, (style & Typeface.ITALIC) != 0);
}
return Typeface.create(typeface, style);
// Apply `typefaceStyle` because custom fonts configure variants using `app:fontStyle` and
// `app:fontWeight` in their resource XML configuration file.
return typefaceStyle.apply(mCustomTypefaceCache.get(fontFamilyName));
}
FontFamily fontFamily = mFontCache.get(fontFamilyName);
if (fontFamily == null) {
fontFamily = new FontFamily();
mFontCache.put(fontFamilyName, fontFamily);
AssetFontFamily assetFontFamily = mFontCache.get(fontFamilyName);
if (assetFontFamily == null) {
assetFontFamily = new AssetFontFamily();
mFontCache.put(fontFamilyName, assetFontFamily);
}
Typeface typeface = fontFamily.getTypeface(style);
if (typeface == null) {
typeface = createTypeface(fontFamilyName, style, assetManager);
if (typeface != null) {
fontFamily.setTypeface(style, typeface);
}
}
int style = typefaceStyle.getNearestStyle();
return typeface;
Typeface assetTypeface = assetFontFamily.getTypefaceForStyle(style);
if (assetTypeface == null) {
assetTypeface = createAssetTypeface(fontFamilyName, style, assetManager);
assetFontFamily.setTypefaceForStyle(style, assetTypeface);
}
// Do not apply `typefaceStyle` because asset font files already incorporate the style.
return assetTypeface;
}
/*
@ -88,7 +103,7 @@ public class ReactFontManager {
*
* ReactFontManager.getInstance().addCustomFont(this, "Srisakdi", R.font.srisakdi);
*/
public void addCustomFont(@NonNull Context context, @NonNull String fontFamily, int fontId) {
public void addCustomFont(Context context, String fontFamily, int fontId) {
Typeface font = ResourcesCompat.getFont(context, fontId);
if (font != null) {
mCustomTypefaceCache.put(fontFamily, font);
@ -106,16 +121,16 @@ public class ReactFontManager {
*/
public void setTypeface(String fontFamilyName, int style, Typeface typeface) {
if (typeface != null) {
FontFamily fontFamily = mFontCache.get(fontFamilyName);
if (fontFamily == null) {
fontFamily = new FontFamily();
mFontCache.put(fontFamilyName, fontFamily);
AssetFontFamily assetFontFamily = mFontCache.get(fontFamilyName);
if (assetFontFamily == null) {
assetFontFamily = new AssetFontFamily();
mFontCache.put(fontFamilyName, assetFontFamily);
}
fontFamily.setTypeface(style, typeface);
assetFontFamily.setTypefaceForStyle(style, typeface);
}
}
private static @Nullable Typeface createTypeface(
private static Typeface createAssetTypeface(
String fontFamilyName, int style, AssetManager assetManager) {
String extension = EXTENSIONS[style];
for (String fileExtension : FILE_EXTENSIONS) {
@ -129,27 +144,27 @@ public class ReactFontManager {
try {
return Typeface.createFromAsset(assetManager, fileName);
} catch (RuntimeException e) {
// unfortunately Typeface.createFromAsset throws an exception instead of returning null
// if the typeface doesn't exist
// If the typeface asset does not exist, try another extension.
continue;
}
}
return Typeface.create(fontFamilyName, style);
}
private static class FontFamily {
/** Responsible for caching typefaces for each custom font family. */
private static class AssetFontFamily {
private SparseArray<Typeface> mTypefaceSparseArray;
private FontFamily() {
private AssetFontFamily() {
mTypefaceSparseArray = new SparseArray<>(4);
}
public Typeface getTypeface(int style) {
public @Nullable Typeface getTypefaceForStyle(int style) {
return mTypefaceSparseArray.get(style);
}
public void setTypeface(int style, Typeface typeface) {
public void setTypefaceForStyle(int style, Typeface typeface) {
mTypefaceSparseArray.put(style, typeface);
}
}

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

@ -9,38 +9,54 @@ package com.facebook.react.views.text;
import android.content.res.AssetManager;
import android.graphics.Typeface;
import android.os.Build;
import android.text.TextUtils;
import androidx.annotation.Nullable;
import com.facebook.common.logging.FLog;
import com.facebook.infer.annotation.Nullsafe;
import com.facebook.react.bridge.ReadableArray;
import java.util.ArrayList;
import java.util.List;
@Nullsafe(Nullsafe.Mode.LOCAL)
public class ReactTypefaceUtils {
private static final String TAG = "ReactTypefaceUtils";
public static final int UNSET = -1;
public static int parseFontWeight(@Nullable String fontWeightString) {
int fontWeightNumeric =
fontWeightString != null ? parseNumericFontWeight(fontWeightString) : UNSET;
int fontWeight = fontWeightNumeric != UNSET ? fontWeightNumeric : Typeface.NORMAL;
if ("bold".equals(fontWeightString)) fontWeight = Typeface.BOLD;
else if ("normal".equals(fontWeightString)) fontWeight = Typeface.NORMAL;
return fontWeight;
if (fontWeightString != null) {
switch (fontWeightString) {
case "100":
return 100;
case "200":
return 200;
case "300":
return 300;
case "normal":
case "400":
return 400;
case "500":
return 500;
case "600":
return 600;
case "bold":
case "700":
return 700;
case "800":
return 800;
case "900":
return 900;
}
}
return ReactBaseTextShadowNode.UNSET;
}
public static int parseFontStyle(@Nullable String fontStyleString) {
int fontStyle = UNSET;
if ("italic".equals(fontStyleString)) {
fontStyle = Typeface.ITALIC;
} else if ("normal".equals(fontStyleString)) {
fontStyle = Typeface.NORMAL;
if (fontStyleString != null) {
if ("italic".equals(fontStyleString)) {
return Typeface.ITALIC;
}
if ("normal".equals(fontStyleString)) {
return Typeface.NORMAL;
}
}
return fontStyle;
return ReactBaseTextShadowNode.UNSET;
}
public static @Nullable String parseFontVariant(@Nullable ReadableArray fontVariantArray) {
@ -80,67 +96,14 @@ public class ReactTypefaceUtils {
@Nullable Typeface typeface,
int style,
int weight,
@Nullable String family,
@Nullable String fontFamilyName,
AssetManager assetManager) {
int oldStyle;
if (typeface == null) {
oldStyle = Typeface.NORMAL;
TypefaceStyle typefaceStyle = new TypefaceStyle(style, weight);
if (fontFamilyName == null) {
return typefaceStyle.apply(typeface == null ? Typeface.DEFAULT : typeface);
} else {
oldStyle = typeface.getStyle();
return ReactFontManager.getInstance()
.getTypeface(fontFamilyName, typefaceStyle, assetManager);
}
int newStyle = oldStyle;
boolean italic = false;
if (weight == UNSET) weight = Typeface.NORMAL;
if (style == Typeface.ITALIC) italic = true;
boolean UNDER_SDK_28 = Build.VERSION.SDK_INT < Build.VERSION_CODES.P;
boolean applyNumericValues = !(weight < (Typeface.BOLD_ITALIC + 1) || family != null);
boolean numericBold = UNDER_SDK_28 && weight > 699 && applyNumericValues;
boolean numericNormal = UNDER_SDK_28 && weight < 700 && applyNumericValues;
if (weight == Typeface.BOLD) {
newStyle = (newStyle == Typeface.ITALIC) ? Typeface.BOLD_ITALIC : Typeface.BOLD;
typeface = Typeface.create(typeface, newStyle);
}
if (weight == Typeface.NORMAL) {
typeface = Typeface.create(typeface, Typeface.NORMAL);
newStyle = Typeface.NORMAL;
}
if (style == Typeface.ITALIC) {
newStyle = (newStyle == Typeface.BOLD) ? Typeface.BOLD_ITALIC : Typeface.ITALIC;
typeface = Typeface.create(typeface, newStyle);
}
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.O_MR1 && weight > Typeface.BOLD_ITALIC) {
typeface = Typeface.create(typeface, weight, italic);
}
if (family != null && UNDER_SDK_28 && weight > Typeface.BOLD_ITALIC) {
FLog.d(
TAG,
"Support for numeric font weight numeric values with custom fonts under Android API 28 Pie is not yet supported in ReactNative.");
}
if (family != null) {
typeface = ReactFontManager.getInstance().getTypeface(family, newStyle, weight, assetManager);
}
if (numericBold || numericNormal) {
newStyle = numericBold ? Typeface.BOLD : Typeface.NORMAL;
typeface = Typeface.create(typeface, newStyle);
FLog.d(
TAG,
"Support for numeric font weight numeric values available only from Android API 28 Pie. Android device lower then API 28 will use normal or bold.");
}
return typeface;
}
/**
* Return -1 if the input string is not a valid numeric fontWeight (100, 200, ..., 900), otherwise
* return the weight.
*/
private static int parseNumericFontWeight(String fontWeightString) {
// This should be much faster than using regex to verify input and Integer.parseInt
return fontWeightString.length() == 3
&& fontWeightString.endsWith("00")
&& fontWeightString.charAt(0) <= '9'
&& fontWeightString.charAt(0) >= '1'
? 100 * (fontWeightString.charAt(0) - '0')
: UNSET;
}
}

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

@ -7,7 +7,6 @@
package com.facebook.react.views.text;
import android.graphics.Typeface;
import android.os.Build;
import android.text.Layout;
import android.text.TextUtils;
@ -104,12 +103,7 @@ public class TextAttributeProps {
protected @Nullable ReactAccessibilityDelegate.AccessibilityRole mAccessibilityRole = null;
protected boolean mIsAccessibilityRoleSet = false;
/**
* mFontStyle can be {@link Typeface#NORMAL} or {@link Typeface#ITALIC}. mFontWeight can be {@link
* Typeface#NORMAL} or {@link Typeface#BOLD}.
*/
protected int mFontStyle = UNSET;
protected int mFontWeight = UNSET;
/**
* NB: If a font family is used that does not have a style in a certain Android version (ie.
@ -460,39 +454,12 @@ public class TextAttributeProps {
mFontFeatureSettings = TextUtils.join(", ", features);
}
/**
* /* This code is duplicated in ReactTextInputManager /* TODO: Factor into a common place they
* can both use
*/
private void setFontWeight(@Nullable String fontWeightString) {
int fontWeightNumeric =
fontWeightString != null ? parseNumericFontWeight(fontWeightString) : -1;
int fontWeight = UNSET;
if (fontWeightNumeric >= 500 || "bold".equals(fontWeightString)) {
fontWeight = Typeface.BOLD;
} else if ("normal".equals(fontWeightString)
|| (fontWeightNumeric != -1 && fontWeightNumeric < 500)) {
fontWeight = Typeface.NORMAL;
}
if (fontWeight != mFontWeight) {
mFontWeight = fontWeight;
}
mFontWeight = ReactTypefaceUtils.parseFontWeight(fontWeightString);
}
/**
* /* This code is duplicated in ReactTextInputManager /* TODO: Factor into a common place they
* can both use
*/
private void setFontStyle(@Nullable String fontStyleString) {
int fontStyle = UNSET;
if ("italic".equals(fontStyleString)) {
fontStyle = Typeface.ITALIC;
} else if ("normal".equals(fontStyleString)) {
fontStyle = Typeface.NORMAL;
}
if (fontStyle != mFontStyle) {
mFontStyle = fontStyle;
}
mFontStyle = ReactTypefaceUtils.parseFontStyle(fontStyleString);
}
private void setIncludeFontPadding(boolean includepad) {
@ -601,21 +568,4 @@ public class TextAttributeProps {
}
return androidTextBreakStrategy;
}
/**
* Return -1 if the input string is not a valid numeric fontWeight (100, 200, ..., 900), otherwise
* return the weight.
*
* <p>This code is duplicated in ReactTextInputManager TODO: Factor into a common place they can
* both use
*/
private static int parseNumericFontWeight(String fontWeightString) {
// This should be much faster than using regex to verify input and Integer.parseInt
return fontWeightString.length() == 3
&& fontWeightString.endsWith("00")
&& fontWeightString.charAt(0) <= '9'
&& fontWeightString.charAt(0) >= '1'
? 100 * (fontWeightString.charAt(0) - '0')
: -1;
}
}

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

@ -0,0 +1,72 @@
/*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
package com.facebook.react.views.text;
import android.graphics.Typeface;
import android.os.Build;
import com.facebook.infer.annotation.Nullsafe;
/** Responsible for normalizing style and numeric weight for backward compatibility. */
@Nullsafe(Nullsafe.Mode.LOCAL)
class TypefaceStyle {
public static final int BOLD = 700;
public static final int NORMAL = 400;
private static final int MIN_WEIGHT = 1;
private static final int MAX_WEIGHT = 1000;
private final boolean mItalic;
private final int mWeight;
public TypefaceStyle(int weight, boolean italic) {
mItalic = italic;
mWeight = weight == ReactBaseTextShadowNode.UNSET ? NORMAL : weight;
}
public TypefaceStyle(int style) {
if (style == ReactBaseTextShadowNode.UNSET) {
style = Typeface.NORMAL;
}
mItalic = (style & Typeface.ITALIC) != 0;
mWeight = (style & Typeface.BOLD) != 0 ? BOLD : NORMAL;
}
/**
* If `weight` is supplied, it will be combined with the italic bit from `style`. Otherwise, any
* existing weight bit in `style` will be used.
*/
public TypefaceStyle(int style, int weight) {
if (style == ReactBaseTextShadowNode.UNSET) {
style = Typeface.NORMAL;
}
mItalic = (style & Typeface.ITALIC) != 0;
mWeight =
weight == ReactBaseTextShadowNode.UNSET
? (style & Typeface.BOLD) != 0 ? BOLD : NORMAL
: weight;
}
public int getNearestStyle() {
if (mWeight < BOLD) {
return mItalic ? Typeface.ITALIC : Typeface.NORMAL;
} else {
return mItalic ? Typeface.BOLD_ITALIC : Typeface.BOLD;
}
}
public Typeface apply(Typeface typeface) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
return Typeface.create(typeface, getNearestStyle());
} else {
return Typeface.create(typeface, mWeight, mItalic);
}
}
}

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

@ -107,8 +107,8 @@ public class ReactEditText extends AppCompatEditText
private TextAttributes mTextAttributes;
private boolean mTypefaceDirty = false;
private @Nullable String mFontFamily = null;
private int mFontWeight = ReactTypefaceUtils.UNSET;
private int mFontStyle = ReactTypefaceUtils.UNSET;
private int mFontWeight = UNSET;
private int mFontStyle = UNSET;
private boolean mAutoFocus = false;
private boolean mDidAttachToWindow = false;

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

@ -200,6 +200,38 @@ exports.examples = ([
);
},
},
{
title: 'Font Weight',
render: function(): React.Node {
return (
<View>
<TextInput
defaultValue="Font Weight (default)"
style={[styles.singleLine]}
/>
{[
'normal',
'bold',
'900',
'800',
'700',
'600',
'500',
'400',
'300',
'200',
'100',
].map(fontWeight => (
<TextInput
defaultValue={`Font Weight (${fontWeight})`}
key={fontWeight}
style={[styles.singleLine, {fontWeight}]}
/>
))}
</View>
);
},
},
{
title: 'Text input, themes and heights',
render: function(): React.Node {