跪拜 Guibai
← All articles
Flutter · Android · iOS

Font Metric Mismatch Breaks Vertical Centering in Flutter on iOS and Android

By Jewel105 ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

Cross-platform apps inherit whichever font metric each OS trusts, so a font that looks centered on iOS shifts on Android, and a line-height multiplier that fixes Latin text breaks Chinese and Japanese. Teams shipping multilingual Flutter UIs need font-level normalization and auto line heights, not per-platform tweaks.

Summary

Font vertical metrics decide how much space a glyph occupies above and below its baseline, and Flutter exposes the differences. iOS reads the hhea table (the font in question reported ascent 1050, descent -274, line gap 0), while Android reads OS/2 typo (750, -250, 324). The same font lands on different baselines with different whitespace on each platform, so a layout vertically centered on iOS sits off-center on Android. The fix is font-level: unify hhea and OS/2 typo to the same ascent, descent, and line gap (normalized here to 1029 / -295 / 0), calibrate the baseline, and leave glyphs, widths, and total line height untouched.

A second failure mode comes from mixing scripts. English, Chinese, and Japanese fonts carry different natural line heights, so one hard-coded line-height multiplier cannot center all of them. Flutter's Material theme does not default to auto; bodyMedium, for instance, applies a line height of 1.43. Setting a fixed height for English centers Latin text but changes the line box and whitespace for Chinese, pushing it off-center again. The proposed global remedy is to set the theme height to kTextHeightNone (value 0), which means native line height, not zero height, letting each language use its own natural metrics.

Takeaways
— Vertical metrics consist of ascent, descent, and line gap, defining the vertical space around the baseline.
— iOS uses the hhea metric table; Android uses the OS/2 typo metric table, and the two can disagree for the same font.
— The problematic font had hhea metrics of 1050 / -274 / 0 and OS/2 typo metrics of 750 / -250 / 324; after the fix both are 1029 / -295 / 0.
— Flutter Material theme applies default line-height multipliers such as bodyMedium's 1.43; leaving height unset is not the same as auto.
— Hard-coding a line height for English changes the line box and whitespace for Chinese, so fixed ratios do not work across languages.
— Unify hhea and OS/2 typo ascent, descent, and line gap, calibrate the baseline, and preserve glyphs, widths, and total line height.
— Setting global theme height to kTextHeightNone (value 0) makes Flutter use each font's native line height, not zero height.
Conclusions

One font file can render correctly on iOS and incorrectly on Android because the two platforms read different metadata, so widget-level tweaks treat a symptom.

Hard-coding one line-height multiplier only ever fixes one script; native line heights remove the need to tune each language separately.

Normalizing font metrics changes only vertical spacing metadata while leaving glyph shapes and widths intact, which is why the fix holds across platforms.

Concepts & terms
hhea
A font table storing horizontal layout metrics, including ascent, descent, and line gap; iOS uses these values when laying out text.
OS/2 typo
The typographic ascent, descent, and line gap values in the OS/2 font table; Android reads these, and they can differ from hhea for the same font.
kTextHeightNone
A Flutter constant with value 0 that tells text styles to use the font's native line height instead of a fixed multiplier; it is not a zero-height setting.
From the discussion
Featured comments
booboosui

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.

See top comments, translated →
Source: juejin.cn ↗ Google Translate ↗ Backup ↗