Achieving Precise Vertical Text Centering in Flutter: Font Metrics Analysis and Android‑Based Solution
This article investigates why text in Flutter buttons often fails to vertically center, analyzes Android font metrics and NotoSansCJK details, explores native Android solutions like includeFontPadding and Paint.getTextBounds, and proposes a Flutter implementation using minikin layout to achieve precise vertical centering.
When designing button UI in Flutter we expect the label text to be vertically centered, but in practice the text is often offset and developers resort to manually adjusting padding . Because font size, screen density and the underlying font metrics vary, a systematic solution is required.
The default Android fallback font for Chinese is NotoSansCJK-Regular.ttc (also known as 思源黑体). The mapping is defined in /system/etc/fonts.xml and the actual font files reside in /system/fonts . An excerpt of the XML configuration is:
<family lang="zh-Hans">
<font weight="400" style="normal" index="2">NotoSansCJK-Regular.ttc</font>
<font weight="400" style="normal" index="2" fallbackFor="serif">NotoSerifCJK-Regular.ttc</font>
</family>Using the font-line tool we can extract the font’s vertical metrics, for example:
[head] Units per Em: 1000
[head] yMax: 1808
[head] yMin: -1048
[OS/2] CapHeight: 733
[OS/2] xHeight: 543
[OS/2] TypoAscender: 880
[OS/2] TypoDescender: -120
[OS/2] WinAscent: 1160
[OS/2] WinDescent: 320
[hhea] Ascent: 1160
[hhea] Descent: -320
[hhea] LineGap: 0
[OS/2] TypoLineGap: 0From these values we identify three critical lines: baseline , Ascent and Descent . In the glyph of the Chinese character “中”, the top point is at y=837 and the bottom point at y=‑76, giving a glyph height of 913. Normalizing by the Units per Em (1000) yields a height factor of 0.913. With a fontSize of 10 sp on a device with density 3, the glyph height becomes 27 px while the text‑box height (ascent + descent) is 44 px.
The vertical center of the glyph (380) does not coincide with the center of the ascent/descent box (420), producing a 1 px offset at fontSize 10 and larger offsets at bigger sizes. Therefore simple padding cannot guarantee perfect centering.
Android provides two native ways to address this:
Set TextView attribute includeFontPadding="false" so that layout uses Ascent and Descent instead of top and bottom .
Create a custom view and call Paint.getTextBounds() to obtain the exact glyph bounds.
The relevant source snippets are:
void init(CharSequence source, TextPaint paint, Alignment align,
BoringLayout.Metrics metrics, boolean includePad, boolean trustWidth) {
// ...
// if includePad is true use bottom/top, else use ascent/descent
if (includePad) {
spacing = metrics.bottom - metrics.top;
mDesc = metrics.bottom;
} else {
spacing = metrics.descent - metrics.ascent;
mDesc = metrics.descent;
}
// ...
} public int getFontMetricsInt(FontMetricsInt fmi) {
return nGetFontMetricsInt(mNativePaint, fmi);
}Experiments on a Pixel emulator (fontSize 40dp, dpi 420) show different FontMetricsInt results for the system default (Roboto), the system‑matched NotoSans, and a manually set NotoSans font, confirming that the layout engine uses the font of the android:fontFamily attribute.
To bring the same logic to Flutter we traced the layout call chain:
ParagraphTxt::Layout()
-> Layout::doLayout()
-> Layout::doLayoutRunCached()
-> Layout::doLayoutWord()
-> LayoutCacheKey::doLayout()
-> Layout::doLayoutRun()
-> MinikinFont::GetBounds()
-> FontSkia::GetBounds()
-> SkFont::getWidths()
-> SkFont::getWidthsBounds()The Skia function that finally returns glyph bounds is:
void SkFont::getWidthsBounds(const SkGlyphID glyphIDs[], int count,
SkScalar widths[], SkRect bounds[],
const SkPaint* paint) const {
SkStrikeSpec strikeSpec = SkStrikeSpec::MakeCanonicalized(*this, paint);
SkBulkGlyphMetrics metrics{strikeSpec};
SkSpan
glyphs = metrics.glyphs(SkMakeSpan(glyphIDs, count));
SkScalar scale = strikeSpec.strikeToSourceRatio();
if (bounds) {
SkMatrix scaleMat = SkMatrix::Scale(scale, scale);
SkRect* cursor = bounds;
for (auto glyph : glyphs) {
scaleMat.mapRectScaleTranslate(cursor++, glyph->rect());
}
}
if (widths) {
SkScalar* cursor = widths;
for (auto glyph : glyphs) {
*cursor++ = glyph->advanceX() * scale;
}
}
}By calling Layout::getBounds(MinikinRect* bounds) we can retrieve the same precise metrics that Paint.getTextBounds() provides on Android. The implementation must also compensate for Flutter’s use of logical fontSize (without density) which introduces a 1‑density‑pixel error, and for rounding performed in ParagraphTxt::Layout (e.g., round(max_accent + max_descent) ) and the floating‑point hack applied in Dart.
We added two new parameters to the Flutter Text widget:
drawMinHeight : forces the minimum height of the text box.
forceVerticalCenter : keeps all existing layout logic but forces the glyph to be vertically centered within its line.
The changes are demonstrated in screenshots comparing normal mode, drawMinHeight , and forceVerticalCenter across font sizes 8‑26. The implementation is available in a private fork of the engine (see PR #27278 for the upstream forceVerticalCenter feature).
In summary, by dissecting Android’s font metrics, understanding the mismatch between glyph center and ascent/descent box, and reusing the minikin layout path in Flutter, we can achieve reliable vertical centering of text in Flutter buttons without ad‑hoc padding.
References:
Android font, 字体全攻略 – https://www.jianshu.com/p/35328f7ac54a
Meaning of top, ascent, baseline, descent, bottom, and leading in Android's FontMetrics – https://stackoverflow.com/questions/27631736/meaning-of-top-ascent-baseline-descent-bottom-and-leading-in-androids-font
fontEditor – https://github.com/ecomfe/fonteditor
思源黑体 – https://baike.baidu.com/item/思源黑体/14919098
字体排印学 – https://zh.wikipedia.org/wiki/Em_(字体排印学)
Android source – http://source.bytedance.net/android/
Vertical metrics guide – https://glyphsapp.com/learn/vertical-metrics
ByteDance Dali Intelligent Technology Team
Technical practice sharing from the ByteDance Dali Intelligent Technology Team
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.