Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

custom fonts support The Android Way #24595

Closed
wants to merge 13 commits into from
1 change: 1 addition & 0 deletions RNTester/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
</intent-filter>
</activity>
<activity android:name="com.facebook.react.devsupport.DevSettingsActivity" />
<meta-data android:name="rn_fonts" android:resource="@array/fonts" />
</application>

</manifest>
5 changes: 5 additions & 0 deletions RNTester/android/app/src/main/res/font/srisakdi.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<font-family xmlns:app="http://schemas.android.com/apk/res-auto">
<font app:fontStyle="normal" app:fontWeight="400" app:font="@font/srisakdi_regular"/>
<font app:fontStyle="normal" app:fontWeight="700" app:font="@font/srisakdi_bold" />
</font-family>
Binary file not shown.
Binary file not shown.
6 changes: 6 additions & 0 deletions RNTester/android/app/src/main/res/values/arrays.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<array name="fonts">
<item>@font/srisakdi</item>
</array>
</resources>
8 changes: 8 additions & 0 deletions RNTester/js/TextExample.android.js
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,14 @@ class TextExample extends React.Component<{}> {
<Text style={{fontFamily: 'notoserif', fontStyle: 'italic'}}>
NotoSerif Italic (Missing Font file)
</Text>
<Text style={{fontFamily: 'srisakdi'}}>Srisakdi Regular</Text>
<Text
style={{
fontFamily: 'srisakdi',
fontWeight: 'bold',
}}>
Srisakdi Bold
</Text>
</View>
</View>
</RNTesterBlock>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,16 @@

package com.facebook.react.views.text;

import javax.annotation.Nullable;

import android.content.Context;
import android.content.res.AssetManager;
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;

public class CustomStyleSpan extends MetricAffectingSpan implements ReactSpan {

/**
Expand All @@ -29,31 +31,30 @@ public class CustomStyleSpan extends MetricAffectingSpan implements ReactSpan {
* Fonts are retrieved and cached using the {@link ReactFontManager}
*/

private final AssetManager mAssetManager;

private final int mStyle;
private final int mWeight;
private final @Nullable String mFontFamily;
private final Context mContext;

public CustomStyleSpan(
int fontStyle,
int fontWeight,
@Nullable String fontFamily,
AssetManager assetManager) {
@NonNull Context context) {
mStyle = fontStyle;
mWeight = fontWeight;
mFontFamily = fontFamily;
mAssetManager = assetManager;
mContext = context;
}

@Override
public void updateDrawState(TextPaint ds) {
apply(ds, mStyle, mWeight, mFontFamily, mAssetManager);
apply(ds, mStyle, mWeight, mFontFamily, mContext);
}

@Override
public void updateMeasureState(TextPaint paint) {
apply(paint, mStyle, mWeight, mFontFamily, mAssetManager);
public void updateMeasureState(@NonNull TextPaint paint) {
apply(paint, mStyle, mWeight, mFontFamily, mContext);
}

/**
Expand Down Expand Up @@ -82,7 +83,7 @@ private static void apply(
int style,
int weight,
@Nullable String family,
AssetManager assetManager) {
Context context) {
int oldStyle;
Typeface typeface = paint.getTypeface();
if (typeface == null) {
Expand All @@ -103,7 +104,7 @@ private static void apply(
}

if (family != null) {
typeface = ReactFontManager.getInstance().getTypeface(family, want, assetManager);
typeface = ReactFontManager.getInstance().getTypeface(family, want, context);
} else if (typeface != null) {
// TODO(t9055065): Fix custom fonts getting applied to text children with different style
typeface = Typeface.create(typeface, want);
Expand All @@ -116,5 +117,4 @@ private static void apply(
}
paint.setSubpixelText(true);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ private static void buildSpannedFromShadowNode(
textShadowNode.mFontStyle,
textShadowNode.mFontWeight,
textShadowNode.mFontFamily,
textShadowNode.getThemedContext().getAssets())));
textShadowNode.getThemedContext())));
}
if (textShadowNode.mIsUnderlineTextDecorationSet) {
ops.add(new SetSpanOperation(start, end, new ReactUnderlineSpan()));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,23 @@

package com.facebook.react.views.text;

import javax.annotation.Nullable;

import java.util.HashMap;
import java.util.Map;

import android.content.Context;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.content.res.AssetManager;
import android.content.res.Resources;
import android.content.res.TypedArray;
import android.graphics.Typeface;
import android.os.Bundle;
import android.util.SparseArray;

import androidx.annotation.Nullable;
import androidx.core.content.res.ResourcesCompat;


Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove extra newline

/**
* 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
Expand All @@ -37,9 +45,12 @@ public class ReactFontManager {
private static ReactFontManager sReactFontManagerInstance;

private Map<String, FontFamily> mFontCache;
private Map<String, Typeface> mTypeCache;
private boolean mTypeCacheLoaded = false;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think a better set of names is:

  • mCustomFontCache
  • mHasCustomFont

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or:

  • mCustomTypefaceCache
  • mHasCustomTypeface


private ReactFontManager() {
mFontCache = new HashMap<>();
mTypeCache = new HashMap<>();
}

public static ReactFontManager getInstance() {
Expand All @@ -49,8 +60,7 @@ public static ReactFontManager getInstance() {
return sReactFontManagerInstance;
}

public
@Nullable Typeface getTypeface(
private @Nullable Typeface getTypeface(
String fontFamilyName,
int style,
AssetManager assetManager) {
Expand All @@ -71,6 +81,49 @@ public static ReactFontManager getInstance() {
return typeface;
}

public @Nullable Typeface getTypeface(
String fontFamilyName,
int style,
Context context) {

if (!mTypeCacheLoaded) {
try {
ApplicationInfo app = context.getPackageManager().getApplicationInfo(context.getPackageName(), PackageManager.GET_META_DATA);
Bundle bundle = app.metaData;
int fontsId = bundle.getInt("rn_fonts", 0);
Copy link

@hey99xx hey99xx Apr 26, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be namespaced like com.facebook.react.font.resources instead of rn_fonts?

if (fontsId != 0) {
Resources resources = context.getResources();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, I'm not sure if accessing anything dynamically from Resources is fast, because it needs to crack them open from the APK.

Can we make this not do any resource lookup by default and only perform this lookup if the hosting app configures it? You don't really need to change it to use AndroidManifest, using font resource is fine, but I'd like:

  • no lookup by default
  • do custom lookup with any impl for now only if the hosting app enables it explicitly

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think @dulmandakh is trying to find a way where non-native developers don't have to touch java code to customize these things, that's why he switched to using AndroidManifest that's supposed to be a cheaper alternative to resource lookups.

If an app like FB does not provide "rn_fonts" in AndroidManifest.xml, there will never be any look up (besides looking to AndroidManifest itself), isn't that sufficient?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

got it. I'll figure out another solution :D

TypedArray fonts = resources.obtainTypedArray(fontsId);
for (int i = 0; i < fonts.length(); i++) {
int fontId = fonts.getResourceId(i, 0);
if (fontId != 0) {
Typeface font = ResourcesCompat.getFont(context, fontId);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How expensive is loading Typeface instances for every single font of the app into the cache and calling resources.getResourceEntryName at startup?

If it is also taking too long (for FB scale), although it's verbose you might do something like:

<array name="fonts">
    
    <item>srisakdi</item>
    <item>@font/srisakdi</item>
    
    <item>anotherfont</item>
    <item>@font/anotherfont</item>

</array>

This way you can iterate the array in increments of 2, and get away with only caching fontFamily->fontId mapping at startup, and only inflate fontId->Typeface still lazily.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also this should allow font file names to not strictly match font family names, ex:

<item>Open Sans</item>
<item>@font/open_sans</item>

Native devs will be more familiar underscored naming R.font.open_sans whereas web developers will be more familiar with human readable naming fontFamily: "Open Sans".

This will allow sharing cross platform styling code with iOS, where font family name is already retrieved from font file content, not from file names.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to mimic iOS feature, where we only list font files, but also make typo-proof. Also I couldn't find anything about performance impact of getResourceEntryName, and hope it won't have impact on performance.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

iOS works a bit differently, it reads font family name from the file content, Android in your implementation reads from the file name.
https://developer.apple.com/documentation/uikit/text_display_and_fonts/adding_a_custom_font_to_your_app
https://stackoverflow.com/questions/16788330/how-do-i-get-the-font-name-from-an-otf-or-ttf-file

IMO this will force developers to write strange JS code like:
{fontFamily: Platform.OS == 'ios' ? 'Open Sans' : 'open_sans'}
I dont know why typo-proof is such an important thing, you only need to match with what you use in JS code, you could make a mistake in font file name too.


Just looking at getResourceEntryName implementation in AOSP repo, it's implemented in C++ so you will at least jump between JNI bridge for each call.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, I found that Android doesn't allow to use font family name, but resource filename without extension. So difference is inevitable.

With this change we can use various font weights like medium easily. Currently, you have to write something like which makes text style sharing difficult.

{
   fontFamily: Platform.OS == 'ios' ? 'Open Sans' : 'open_sans_medium'
   fontWeight: Platform.OS == 'ios' ? '500' : 'normal'
}

once PR lands you will write

{
   fontFamily: Platform.OS == 'ios' ? 'Open Sans' : 'open_sans'
   fontWeight: '500'
}

Copy link

@hey99xx hey99xx Apr 26, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can currently name your file "Open Sans.ttf", "Open Sans_bold.ttf"... etc though, so the code can already be cross platform fontFamily: "Open Sans". Can you leave a space in these font resource file names and reuse that resource in Java? IMO it's not good practice to enforce android resource naming conventions into JS land.

if (font != null) {
String fontFamily = resources.getResourceEntryName(fontId);
mTypeCache.put(fontFamily, font);
}
}
}
fonts.recycle();
}
} catch (NullPointerException | PackageManager.NameNotFoundException | Resources.NotFoundException e) {
// do nothing
}
mTypeCacheLoaded = true;
}

Typeface font = mTypeCache.get(fontFamilyName);

if (font != null) {
return Typeface.create(
font,
style
);
}

return getTypeface(fontFamilyName, style, context.getAssets());
}

/**
* Add additional font family, or replace the exist one in the font memory cache.
* @param style
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ private static void buildSpannableFromFragment(
textAttributes.mFontStyle,
textAttributes.mFontWeight,
textAttributes.mFontFamily,
context.getAssets())));
context)));
}
if (textAttributes.mIsUnderlineTextDecorationSet) {
ops.add(new SetSpanOperation(start, end, new ReactUnderlineSpan()));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ public void setFontFamily(ReactEditText view, String fontFamily) {
Typeface newTypeface = ReactFontManager.getInstance().getTypeface(
fontFamily,
style,
view.getContext().getAssets());
view.getContext());
view.setTypeface(newTypeface);
}

Expand Down