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:
The Android screenshot has a yellow background; the Figma screenshot has a blue one. The following problems appear when comparing Figma with Android behavior:
- The line heights are different (Android bigger than Figma, although slightly)
- The placement inside the text box is different (Android is a little more "down" than Figma)
- 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):
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:
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.
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")
}
}
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
}
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
)
)
}
}
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()
}
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
}
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:
- How much offset from the top of the line does Figma use? We will call this
centerOffset
- 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()
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
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()
)
}
}
The Result
Let's put the result near the Figma text again:
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.
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!
Top comments (4)
great article!
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 topmost point of all glyphs in the font.
you meant bottom: is the bottommost?
Also includeFontPadding has been rollbacked to false by default to allow a smoother transition with Compose 1.2
Amazing!!!
@canyudev I was wondering if you updated your snippet for the updated font metrics with compose 1.2.0
I'm using latest compose 1.5.1 and still seeing different font paddings between compose typography and figma specs.
Waiting for 1.2.0 a worth shot!