Android and Figma Typography and how to achieve 100% fidelity

canyudev

Can Yumusak

Posted on March 29, 2022

Android and Figma Typography and how to achieve 100% fidelity

TL;DR: If you want to attain 100% Figma fidelity wait for the new Text API to be released with Compose 1.2.0 or scroll down to the gist at the end if you need it earlier.

I have been working on Android UI for quite some time now. Getting the typography right compared to a Figma design was always something I winged without getting a more profound understanding as to how and why one is different from the other. Welp, since I got to work on setting up the typo yet another time, I decided it should have a little more foundation this time.

The Problem

Let's quickly pick up the Font "Nationale", draw a Text in Jetpack Compose and the same Text in Figma and compare them:

Image description

The Android screenshot has a yellow background; the Figma screenshot has a blue one. The following problems appear when comparing Figma with Android behavior:

  1. The line heights are different (Android bigger than Figma, although slightly)
  2. The placement inside the text box is different (Android is a little more "down" than Figma)
  3. The line-height specified in Android is only considered for texts with more than one line. The result is having two lines resulting in a total size of "line-height + text height" (instead of 2x line-height).

There is this article from Eduardo Pratti, which suggests comparing the placement and adding paddings to the top and bottom of the Android view (via "firstBaselineToTopHeight") to make up for the different arrangements. This advice is fantastic and probably satisfies the need of most.

However, I had a use-case where we wanted arbitrary line-heights and font sizes derived from shared design tokens. I didn't want to admit to the other platforms that we needed two more tokens in platform-agnostic code per text style to fix a shortcoming on the Android side.

I wanted to get to the bottom of the differences and systematically understand what both platforms are doing to fix the issue. Let's try to understand the behaviors of each first to achieve our goal!

How does Android places text?

This iconic article from 2017 lays the foundation to understand Android typography. Essentially the Android OS extracts "FontMetrics" from each font, which describes the following values:

top: This value is computed by Skia and is the topmost point of all glyphs in the font.

bottom: This value is computed by Skia and is the bottommost point of all glyphs in the font.

ascent: This value is chosen by the font designer and represents the recommended distance above the baseline for any text.

descent: This value is chosen by the font designer and represents the recommended distance below the baseline for any text.

leading: This value is chosen by the font designer and represents the recommended distance between two lines.

Note that these metrics are a property of the font, thus independent of the string!
Skia is the underlying graphics engine. Essentially the Android Canvas is a wrapper for the Skia Canvas. So if you call "Paint.fontMetrics" you essentially ask Skia to give you the Font Metrics. See this Skia documentation of FontMetrics. I use an OpenType font, and other font formats may lead to different results.

Let's have a look at the values of the "Nationale" font on Android (I wrote a small tool to visualize these, you can get it here):

FontAnalyzer

The yellow background shows the area of the text composable or the size of the TextView. That area extends between the "top" and the "bottom" line.

This means that the height of our "TextView" / "Text Composeable" for a single line of text will be ceil(bottom - top), in our case this amounts to ceil(93,94 + 381,15) = 476. The main reason for this is Androids includeFontPadding property which can't be turned off in Compose currently.

How does Figma places text?

Let's render the text in Figma:

Image description

The visual comparison suggests that Figma uses "ascent" and "descent" as the limits of the text box. A quick calculation confirms that numbers add up as well: 115,50 - (-346,50) = 462,0; 462 is exactly the line-height chosen by Figma.

So that's a great success! We are now aware of how Android and Figma place text and can account for that. But your designer might want to have a larger line-height than what Figma picks.

Image description

So let's increase the Figma line-height to 1000 and look at the result: Figma adds 269 points - which is (1000 - 462) / 2 - to the top and the bottom (adding more to the bottom when the amount is not even). We can safely conclude that Figma centers the area between ascent and descent inside the line-height.

That's is excellent! We now know how Figma places text inside its available space and can emulate this in Compose.

The Solution

So let's recap what we need to do: Make sure that line-heights are also respected for single-line text and shift the placement of the text so that it imitates Figmas behavior.

Figma-like Line Height

Let's reason about the first issue: We could wrap our text in a Box and give it the correct height. Unfortunately, we are not aware of how many lines the text will have when placing our Box, thus making it difficult to specify the correct height. However, we could leverage Composes feature of using layout modifiers which will let us modify the height and the placement. Let's try that approach:



public class FigmaTextModifier : LayoutModifier {
    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints
    ): MeasureResult {
        TODO("Not yet implemented")
    }
}


Enter fullscreen mode Exit fullscreen mode

When developers implement a Figma design, they usually have use defined line-height. So let's assume that line-height is a constant which is correctly set in its TextStyle. Our first challenge is computing the number of lines the text has. We cannot access the text computation results, but we have the total intended height. Using our knowledge that all lines after the first are a multiple of lineHeight and the fact that compose gives us alignment lines, we can infer our count:



  private val lineHeight: TextUnit
    get() = textStyle.lineHeight

private fun Density.lineCount(placeable: Placeable): Int {
    val firstToLast = (placeable[LastBaseline] - placeable[FirstBaseline]).toFloat()
    return (firstToLast / lineHeight.toPx()).roundToInt() + 1
}


Enter fullscreen mode Exit fullscreen mode

So we know the line-height and the number of lines! Consequently, we can compute the height we would like to achieve. Let's assume we want to center the text in the available space for now. The measure method equates to this:



override fun MeasureScope.measure(
    measurable: Measurable,
    constraints: Constraints
): MeasureResult {
    val placeable = measurable.measure(constraints)
    val lineCount = lineCount(placeable)
    val fullHeight = (lineHeight.toPx() * lineCount).roundToInt()
    return layout(width = placeable.width, height = fullHeight) {
        placeable.placeRelative(
            x = 0,
            y = Alignment.CenterVertically.align(
                placeable.height,
                fullHeight
            )
        )
    }
}


Enter fullscreen mode Exit fullscreen mode

While this is a win in our books. However, Compose has a mechanism called "Intrinsics". They allow getting the minimum and maximum sizes of a layout node before it is measured. Intrinsics are very useful to avoid two measurement passes (which are not simply not allowed and will crash in Compose).
Since we do not have access to alignment lines when computing intrinsics, we need to find a different way to calculate the correct height. One naive way is just rounding the size to the next multiple of lineHeight. This assumption is only valid if the specified lineHeight is not smaller than the text height. We could get to a more elaborate calculation that correctly behaves when that's the case, but let's keep things simple.



    override fun IntrinsicMeasureScope.maxIntrinsicHeight(
        measurable: IntrinsicMeasurable,
        width: Int
    ): Int {
        return ceilToLineHeight(measurable.maxIntrinsicHeight(width))
    }

    override fun IntrinsicMeasureScope.minIntrinsicHeight(
        measurable: IntrinsicMeasurable,
        width: Int
    ): Int {
        return ceilToLineHeight(measurable.minIntrinsicHeight(width))
    }

    override fun IntrinsicMeasureScope.minIntrinsicWidth(
        measurable: IntrinsicMeasurable,
        height: Int
    ): Int {
        return measurable.minIntrinsicWidth(height)
    }

    override fun IntrinsicMeasureScope.maxIntrinsicWidth(
        measurable: IntrinsicMeasurable,
        height: Int
    ): Int {
        return measurable.maxIntrinsicWidth(height)
    }

    private fun Density.ceilToLineHeight(value: Int): Int {
        val lineHeightPx = lineHeight.toPx()
        return (ceil(value.toFloat() / lineHeightPx) * lineHeightPx).roundToInt()
    }


Enter fullscreen mode Exit fullscreen mode

That's it! We achieved the same line heights as Figma and now need to account for the placement.

Figma-like Text Placement

Our knowledge of Figmas and Android text placement rests on font metrics. So the first thing we need is getting access to the FontMetrics. We thus need to build a Paint object with the typeface and textSize values correctly set. It may be easy to build such a Paint instance if you have complete knowledge of your typography. If you do not, you will need to duplicate the TypefaceAdapter which Compose UI for Android uses internally. Let's skip the computation to shorten this already long article. TypefaceAdapter handles font matching based on the given font attributes and caches the result to get quicker access on subsequent calls. If you want to read more on how Compose paints text on the Canvas AndroidParagraphIntrinsics may be a good entry point.

Let's assume that we know our font and just detail on the FontMetrics part. For my use-case I decided to wrap TextStyle with a custom object which brings knowledge about the font:



private fun Density.fontMetrics(context: Context, textStyle: WaveTextStyle): Paint.FontMetrics {
    val fontResourceId = textStyle.fonts[textStyle.fontWeight]!!
    val font = ResourcesCompat.getFont(context, fontResourceId)
    val paint = Paint().also {
        it.typeface = font
        it.textSize = textStyle.fontSize.toPx()
    }

    return paint.fontMetrics
}


Enter fullscreen mode Exit fullscreen mode

Once we have the FontMetrics object, all we need to do is recall the difference between Figma and our Text: Figma centers between "ascent" and "descent" while Android centers between "top" and "bottom". All we need to do is adjust for that difference. How? Let's do some math.

We need to find out how much we need to vertically offset our text for it to match the Figma placement. In other words, we need to find out two things:

  1. How much offset from the top of the line does Figma use? We will call this centerOffset
  2. How much do I need to adjust for the differences between Android and Figma? We will call this figmaOffset

For the centerOffset we take our line-height, substract our ascent-descent distance and divide the rest by two. Also we need to floor that amount - remember that Figma favors the bottom when partitioning space? Thus the calculation will need to be in DP. The result looks a bit scary but:



val centerOffset = floor((lineHeight.toPx().toDp() - fontMetrics.descent.toDp() + fontMetrics.ascent.toDp()).value / 2f).dp.toPx().toInt()


Enter fullscreen mode Exit fullscreen mode

Thankfully the figmaOffset is much easier to calculate. All we need to do is take the difference between aligning from "top" and aligning from "ascent" into account:



val figmaOffset = fontMetrics.ascent - fontMetrics.top


Enter fullscreen mode Exit fullscreen mode

In sum, this means that our placement logic looks like this:



override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints
): MeasureResult {
val placeable = measurable.measure(constraints)
val lineCount = lineCount(placeable)
val fullHeight = (lineHeight.toPx() * lineCount).roundToInt()
val fontMetrics = fontMetrics(context, style)
val centerOffset = floor((lineHeight.toPx().toDp() - fontMetrics.descent.toDp() + fontMetrics.ascent.toDp()).value / 2f).dp.toPx().toInt()
val figmaOffset = fontMetrics.ascent - fontMetrics.top
return layout(width = placeable.width, height = fullHeight) {
// Alignment lines are recorded with the parents automatically.
placeable.placeRelative(
x = 0,
y = (centerOffset - figmaOffset).toInt()
)
}
}

Enter fullscreen mode Exit fullscreen mode




The Result

Let's put the result near the Figma text again:

Image description

A perfect match! Since putting together all the snippets is tedious work, I have built a little gist that captures the essence of the adjustments needed.

The final code as gist

Compose 1.2.0

In other news, Google currently works on massively improving the Compose Text API.
Adjustments of line-height are happening on this tracker. There is even a finished prototype implementation for the line-height adjustment.
The difference of text placement between Figma and Compose will be fixed with Compose 1.2.0 as well, since includeFontPadding is set to false for all texts with this PR - it is already part of Compose 1.2.0-alpha05, see release notes for more information.

So if you are not in a hurry, you can wait for Compose 1.2.0 to include the new LineHeightBehaviour API. With the new text API, fidelity should be attainable with only the tools Compose provides.

I will follow up with a post-"Compose 1.2.0" guide once it hits beta status.

Acknowledgements

I want to thank Helios Alonso from the Square team at Block, who had put a lot of groundwork together and assisted me, especially with the maths!

💖 💪 🙅 🚩
canyudev
Can Yumusak

Posted on March 29, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related