TextAppearanceSpan with custom font on min SDK 21

Lets say we want to use a font that is not part of the ones provided by the system.

First we create a family:

<font-family xmlns:android="http://schemas.android.com/apk/res/android">
<font
android:font="@font/acme"
android:fontStyle="normal" />
</font-family>

then we add the family in a text appearance style:

<style name="AcmeText" parent="TextAppearance.MaterialComponents.Body1">
<item name="android:fontFamily">@font/acme_family</item>
<item name="fontFamily">@font/acme_family</item>
<item name="android:textSize">20sp</item>
</style>

and finally we use the style either in our layout’s XML or through a TextAppearanceSpan:

textView.text = buildSpannedString {
inSpans(TextAppearanceSpan(context, R.style.AcmeText)) {
append(context.getString(R.string.le0nidas_gr))
}
}

Everything renders correctly as long as your min SDK is 26 since this is when the fonts in xml was introduced to the framework:

min SDK<26

When having a min SDK lower than 26 then the first thing that we need to change is our font family file. In particular we must use the app namespace instead of the android one:

<font-family xmlns:app="http://schemas.android.com/apk/res-auto">
<font
app:font="@font/acme"
app:fontStyle="normal" />
</font-family>

Unfortunately this stops our TextAppearanceSpan from working properly:

it renders the text with all the expected properties except the needed fonts!

Why is that?

TextAppearanceSpan, upon its construction, tries to create a typeface based on the provided font family:

// TextAppearanceSpan:
public TextAppearanceSpan(Context context, int appearance, int colorList) {
// …
mTypeface = a.getFont(com.android.internal.R.styleable.TextAppearance_fontFamily)
// …
}
// TypedArray:
public Typeface getFont(@StyleableRes int index) {
// …
return mResources.getFont(value, value.resourceId);
// …
}

getFont is added in SDK 26 and if you follow it down to resources you’ll see that it ends up in FontResourcesParser where there is a readFont:

private static FontFileResourceEntry readFont(XmlPullParser parser, Resources resources)
throws XmlPullParserException, IOException {
AttributeSet attrs = Xml.asAttributeSet(parser);
TypedArray array = resources.obtainAttributes(attrs, R.styleable.FontFamilyFont);
// …
String filename = array.getString(R.styleable.FontFamilyFont_font);
// …
}

that tries to get the font’s name by using R.styleable.FontFamilyFont and this is where the namespace makes the difference.

Family name

So what happens when the span cannot create a typeface? In the constructor you’ll see that it loads the provided font family but only its name:

public TextAppearanceSpan(Context context, int appearance, int colorList) {
// …
if (mTypeface != null) {
mFamilyName = null;
} else {
String family = a.getString(com.android.internal.R.styleable.TextAppearance_fontFamily);
if (family != null) {
mFamilyName = family;
}
// …
}

and that name is being used, when needed, to create a typeface:

// TextAppearanceSpan
public void updateMeasureState(TextPaint ds) {
// …
if (mFamilyName != null) {
styledTypeface = Typeface.create(mFamilyName, style);
}
// …
}

The thing is that Typeface.create loads a font only if it is a systemic one:

// Typeface
public static Typeface create(String familyName, @Style int style) {
return create(getSystemDefaultTypeface(familyName), style);
}
private static Typeface getSystemDefaultTypeface(@NonNull String familyName) {
Typeface tf = sSystemFontMap.get(familyName);
return tf == null ? Typeface.DEFAULT : tf;
}

so anything custom does not get found and a default is being used instead.

FontAwareTextAppearanceSpan

So, at this point we know which font we want to load and we need to find a way to use the supported way of getting it.

class FontAwareTextAppearanceSpan(
private val context: Context,
appearance: Int
) : TextAppearanceSpan(context, appearance) {
override fun updateMeasureState(ds: TextPaint) {
super.updateMeasureState(ds)
val font = getFont() ?: Typeface.DEFAULT
val oldStyle = ds.typeface?.style ?: 0
ds.typeface = Typeface.create(font, oldStyle)
}
private fun getFont(): Typeface? {
val cleanFamilyName = family.removePrefix("res/font/").removeSuffix(".xml")
val appPackageName = context.applicationContext.packageName
val id = context.resources.getIdentifier(cleanFamilyName, "font", appPackageName)
return ResourcesCompat.getFont(context, id)
}
}

This is one way to go. Since we know that all families live in res/font and have a .xml suffix we can clean up the name and use Resource‘s getIdentifier to figure out the font’s resource id.
Now we can get the font by calling compat’s function.

What do we gain with this? We can simply replace TextAppearanceSpan with FontAwareTextAppearanceSpan and have our entire project use the new font with the minimum number of changes:

textView.text = buildSpannedString {
inSpans(FontAwareTextAppearanceSpan(context, R.style.AcmeText)) {
append(context.getString(R.string.le0nidas_gr))
}
}

One thought on “TextAppearanceSpan with custom font on min SDK 21

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s