Font Metric Mismatch Breaks Vertical Centering in Flutter on iOS and Android
Title: Why text is never centered! Documenting a bug Author: Jewel105 Tags: Flutter, Android, iOS
The design recently switched to a new font package. After using it, vertical centering is never right; changing line height works until the language changes. iOS is fixed, Android still isn't.
1. Problem
1. iOS centered, Android not centered
Cause: the font's vertical metrics are inconsistent. The English font's hhea and OS/2 typo metric sets allocate upper and lower space differently. Different platforms use different metric sets, so the same layout produces different baselines and whitespace, causing visual offset.
| Platform | Metric set | Original: ascent / descent / line gap | After fix |
|---|---|---|---|
| iOS | hhea | 1050 / -274 / 0 | 1029 / -295 / 0 |
| Android | OS/2 typo | 750 / -250 / 324 | 1029 / -295 / 0 |
What are font vertical metrics?
Font vertical metrics determine the vertical layout space of text, mainly including three indicators:
- Ascent: distance from the baseline to the top of the font.
- Descent: distance from the baseline to the bottom of the font.
- Line Gap: extra space the font recommends between lines.
2. Chinese centered, English not centered
Cause: the font package's English, Chinese, and Japanese fonts have different natural line heights, so a fixed line-height multiplier cannot work across languages.
- Flutter's Material theme has default line heights; not setting one is not Auto, for example
bodyMediumis 1.43. - Hard-coding an English line height does center English, but it changes the Chinese line box and whitespace, so Chinese becomes offset again.
2. Solutions
1. Unify English font metrics
Unify the ascent, descent, and line gap of hhea and OS/2 typo, and calibrate the baseline; preserve glyphs, widths, and total line height.
2. Use Auto as the global theme default, adapting to different languages
In the global theme, set height: kTextHeightNone by default so each language uses its font's natural line height. Its value 0 means native line height, not zero height.
Top 1 from juejin.cn, machine-translated. The original thread is authoritative.
Adding two metrics that aren't in your table: besides sTypo, OS/2 also has usWinAscent/usWinDescent as a third set, and when bit 7 of fsSelection (USE_TYPO_METRICS) is not set, the spec calls for exactly usWin, not sTypo. Aligning hhea and sTypo without setting that bit will still cause drift when you switch to another layout engine (in fonttools, it's font["OS/2"].fsSelection |= 1<<7). Another number: all three of your groups sum to 1324 (750+250+324, 1050+274+0, 1029+295+0). That means the line height never changed; the issue is purely the baseline's position within the line box. The fix essentially moves Android's baseline from 750 to 1029 — a downward shift of 0.279em relative to the original Android, still 21 units short of iOS's 1050. So this move isn't 'centering', it's 'pushing the text down' — places that were previously aligned via padding will now be off by 0.279em downward. To completely avoid the font's own metrics, setting a global Auto height isn't enough: when falling back to system fonts, their metrics are yet another set. In Flutter, the targeted solution is strut; with forceStrutHeight set to true, the line box only respects (height+leading)×fontSize, regardless of which font is rendering.