一覧画面の10行目だけ、文字が極端に小さくなっています。並び順を入れ替えても、アプリを再起動しても、縮むのは10行目です。同じ文字列を9行目に置くと、何事もなかったように普通の大きさで表示されます。
こうなると、まずデータを疑い、次に文字列を疑い、OS のバージョンを疑って、半日が過ぎてしまいます。この記事は、そうした症状を Zenn で共有されていた事例(異常に小さい文字サイズを追いかけたら、丸め誤差問題だった話)で知り、React Native と Yoga の公開ソースを読み、手元の Node で計算を写して確かめた記録です。
先にお伝えしておくと、実機での再現は行っていません。確かめたのは「ソースがそう書かれていること」と「その式を float32 で計算するとどれくらいの頻度でズレるか」までです。そのうえで、疑う順番と直し方を整理しました。
「1行だけ」「位置で決まる」と思ったら、最初にすること
文字列やデータを疑う前に、次の3つを上から順に確かめます。どれも数分で終わります。
- 同じ文字列を別の行に置きます。 9行目に置いて正常、10行目に置くと縮むなら、原因は文字列ではなく位置です
onLayoutの高さを小数の末尾まで出します。 丸めて出すと11に見えます。toFixed(6)で見てください- 枠の高さと
lineHeightが同じ値になっていないかを見ます。 22pt の枠にlineHeight: 11の2行、という作りが典型です
2番で 10.999969 のように、期待値にわずかに届かない値が出たら、この記事の話です。出なければ、別の原因(文字サイズ設定の拡大、Fabric のビュー再利用など)を探す段階に進んでください。
なぜ枠の高さが、ほんの少し足りなくなるのか
Yoga は、レイアウトの結果を画面のピクセル格子に揃えます。PixelGrid.cpp を読むと、ノードの高さは次の形で出されています。
- 下端の絶対座標を格子に丸めた値(
float) - 上端の絶対座標を格子に丸めた値(
float) - その2つの差
つまり高さを直接丸めているのではなく、上端と下端をそれぞれ float32 に落としてから引いているわけです。絶対座標が大きいほど float32 の刻みは粗くなります。256〜512pt のあたりで刻みは 2^-15(約0.00003pt)、512〜1024pt で 2^-14 です。
テキストのノードには「サイズを切り下げない」ための配慮(forceCeil / forceFloor)が入っています。それでも、引き算の結果が元の高さにぴったり戻る保証まではありません。
次に、縮小するかどうかを決める側です。iOS の adjustsFontSizeToFit は NSTextStorage+FontScaling.m が担当していて、収まるかどうかをこう判定します。
BOOL fitsSize = size.width >= measuredSize.width && size.height >= measuredSize.height;許容誤差がありません。枠が 10.99997、使った高さが 11 なら「収まらない」です。さらに、縮小の探索が変えるのは文字サイズだけで、段落の lineHeight には触れません(ファイルの中にそれを変える処理がありません)。lineHeight が11で固定なら、どれだけ縮めても使う高さは11のままです。探索は一度も「収まった」を記録できず、lastRatioWhichFits の初期値 0.02 が適用されて、下限のサイズに落ちます。
整理すると、次の3段が重なったときだけ起きます。
| 段 | 何が起きるか | 確かめる場所 |
|---|---|---|
| 1 | ある行の枠の高さが、期待値より float32 1刻み分だけ小さくなる | onLayout の高さを小数6桁で |
| 2 | 使った高さが、許容誤差なしの比較で「収まらない」と判定される | 枠の高さと lineHeight × 行数 が同値か |
| 3 | 縮めても lineHeight が変わらず、永久に収まらない | lineHeight を固定しているか |
3段のうち1つでも外せば、症状は出ません。直し方の考え方は、そこから決まります。
手元で数える:どの端末で、どれくらいの頻度でズレるのか
「位置で決まる」というなら、位置を変えて数えれば頻度が分かります。PixelGrid.cpp の丸め処理を JavaScript に写し、Math.fround で float32 を再現して、上端を0〜900ptの範囲で動かしました。次のスクリプトは、そのまま保存して node probe.mjs で動きます。
// probe.mjs — Yoga の roundLayoutResultsToPixelGrid(Text ノードの高さ)を float32 で写した検証
// 前提: facebook/react-native main の PixelGrid.cpp の式を写したもの。実機の再現ではありません
const inexactEquals = (a, b) => Math.abs(a - b) < 0.0001;
function roundValue(value, scale, forceCeil, forceFloor) {
let s = value * scale;
let frac = s % 1;
if (frac < 0) frac += 1;
if (inexactEquals(frac, 0)) s -= frac;
else if (inexactEquals(frac, 1)) s = s - frac + 1;
else if (forceCeil) s = s - frac + 1;
else if (forceFloor) s -= frac;
else s = s - frac + (frac >= 0.5 ? 1 : 0);
return Math.fround(s / scale); // C++ 側の (float) キャスト
}
function textHeight(top, h, scale) {
const fractional = !inexactEquals(Math.round(h * scale), h * scale);
const bottom = roundValue(top + h, scale, fractional, !fractional);
const topRounded = roundValue(top, scale, false, true);
return Math.fround(bottom - topRounded); // float 同士の引き算
}
const heights = [11, 14, 16, 18, 20, 22, 24, 33];
for (const scale of [2, 3]) {
for (const h of heights) {
let n = 0, short = 0, worst = 0;
for (let k = 0; k < 5400; k++) { // 0〜900pt を 1/6pt 刻み
const top = k / 6;
n++;
const r = textHeight(top, h, scale);
if (r < h) { short++; worst = Math.max(worst, h - r); }
}
console.log(`scale=${scale} height=${h} positions=${n} short=${short} worst=${worst.toExponential(2)}`);
}
}結果は次のとおりでした(上端0〜900pt、1/6pt刻みの2,700位置ずつを数えた値です。表はスケール別に整理しています)。
| 画面の倍率 | 枠の高さ(pt) | 期待値に届かない位置 | 最大の不足 |
|---|---|---|---|
| 2x | 11〜33 の8通り | すべて 0 件 | 0 |
| 3x | 11 | 104 件(約3.9%) | 約 1.5e-5 pt |
| 3x | 22 | 164 件(約6.1%) | 約 1.5e-5 pt |
| 3x | 33 | 196 件(約7.3%) | 約 1.5e-5 pt |
読み取れることは2つあります。
ひとつ目は、倍率で結果がまったく違うことです。2x では1件も出ず、3x だけで4〜7%の位置にズレが出ました。3x は現行の iPhone の標準的な倍率で、たとえば iPhone 13 や 17 Pro がそうです。開発に使っているシミュレータや端末が2xだと、この症状は見えないまま出荷してしまう可能性があります。
ふたつ目は、4〜7%という頻度です。1画面に行がたくさん並ぶ一覧なら、どこかの1行は当たる計算になります。しかも当たる行は絶対座標で決まるので、同じ画面構成なら毎回同じ行です。「再起動しても同じ行」「別の画面に移すと消える」という報告は、この性質で説明できます。
なお、私の計算での最大の不足は約 1.5e-5pt で、共有されていた事例の 2^-15(約 3.1e-5)とは一致しません。私は上端を1/6pt刻みの厳密な値で与えていますが、実際の画面では親の位置が float32 で積み上がるため、条件が少し違うのだと考えています。大事なのは正確な不足量ではなく、「不足がゼロにならない位置が、現実に存在する」ことです。
直し方は3つ。どれを選ぶかは、手元の制約で決めます
案1:枠の高さに、ごく小さな余白を持たせる
縮小の判定が size.height >= measuredSize.height なら、枠を使う高さより少しだけ大きくしておけば、float32 の1刻み程度のズレでは負けません。同じ走査で余白を足してみると、次のようになりました。
| 余白 | 走査した位置(2x・3x、8通りの高さ) | 枠が使う高さを下回った数 |
|---|---|---|
| 0pt | 43,200 | 1,216 |
| 0.01pt | 43,200 | 0 |
| 0.05pt 以上(0.25・0.5 も確認) | 43,200 | 0 |
私なら、見た目に出ない範囲で余裕を取るため、0.1pt を選びます。3x では約0.3物理ピクセルで、さらに Yoga が切り上げるので、視覚的な違いはほとんど出ないはずです。float32 の刻みは1,000pt付近でも約0.00006ptなので、1,000倍以上の余裕があります。
// fit-slack.ts — 枠の高さを「使う高さ + 余白」で決める
// 目的: adjustsFontSizeToFit の許容誤差なしの比較で、縮み切るのを避ける
const FIT_SLACK_PT = 0.1;
export function fitBoxHeight(lineHeight: number, lines: number): number {
return lineHeight * lines + FIT_SLACK_PT;
}// PlayerName.tsx
import { Text } from 'react-native';
import { fitBoxHeight } from './fit-slack';
const LINE_HEIGHT = 11;
export function PlayerName({ name }: { name: string }) {
return (
<Text
numberOfLines={2}
adjustsFontSizeToFit
minimumFontScale={0.7}
style={{
fontSize: 10,
lineHeight: LINE_HEIGHT,
height: fitBoxHeight(LINE_HEIGHT, 2),
}}
>
{name}
</Text>
);
}後ろの説明です。余白を足す場所を height にしたのは、判定に使われるのが枠のサイズだからです。lineHeight を小さくする方向でも同じ効果は出ますが、行間が変わって見た目に影響します。枠を広げるほうが、見た目に触れずに済みます。
この案は、ソースの比較式と計算結果から導いたもので、実機では試していません。採用する場合は、症状が出ていた行で縮まなくなったことを、3x の実機で確かめてください。
案2:adjustsFontSizeToFit をやめて、字数から文字サイズを決める
共有されていた事例では、この方法が選ばれていました。描画側の「収まるか」の判定を通らないので、丸めのズレに左右されません。代わりに、字幅は見ないので、半角の英数字が多い名前などでは収まり方が粗くなります。症状の再現が難しく、余白の効果を実機で確かめる時間が取れないときの、堅実な選択です。
案3:numberOfLines と省略に任せる
縮小よりも「…」で省略されることを許容できるなら、adjustsFontSizeToFit を外すだけで済みます。表示名のように、末尾が欠けても意味が通じる文字列に向いています。選手名や商品名のように、最後まで読めないと困るものには向きません。
迷ったときは、次の順で選ぶのがよいと考えています。縮小を残したいなら案1、再現確認の時間が取れないなら案2、省略で足りるなら案3です。
例外と注意
この症状は、React Native の版によって見え方が変わります。私が確認した main ブランチの RCTTextLayoutManager.mm では、minimumFontScale が指定されていれば MAX(minimumFontScale × 最大サイズ, 4.0) を下限にしています。この場合、縮み切っても minimumFontScale の割合までで止まります。一方、共有されていた事例では4ptまで落ちており、お使いの版が minimumFontScale を読んでいるかどうかで、症状の重さは変わるはずです。気になる方は、node_modules/react-native 内の同名ファイルをご覧ください。
もうひとつ、端末の文字サイズ設定を大きくしたときに全員が縮む症状は、別の原因(拡大された lineHeight に枠が足りない)です。仕組みの3段目は共通ですが、直し方は枠の高さを文字サイズに追従させる方向になります。こちらは Rork で VoiceOver と Dynamic Type を本番品質に整える実装メモ で扱っている領域です。
次にやること
まず、縮む行がある画面で onLayout の高さを toFixed(6) で出してください。次のような確認用の部品を、開発中だけ噛ませる形にしておくと、気づかないまま出荷する事態を減らせます。
// ProbedText.tsx — 開発中だけ、枠の高さが期待値に届いていない Text を警告する
import { useCallback } from 'react';
import { Text, type LayoutChangeEvent, type TextProps } from 'react-native';
type Props = TextProps & { expectedHeight: number; label: string };
export function ProbedText({ expectedHeight, label, onLayout, ...rest }: Props) {
const handleLayout = useCallback(
(e: LayoutChangeEvent) => {
if (__DEV__) {
const h = e.nativeEvent.layout.height;
const gap = expectedHeight - h;
if (gap > 0) {
console.warn(`[fit-probe] ${label} height=${h.toFixed(6)} short by ${gap.toExponential(2)}pt`);
}
}
onLayout?.(e);
},
[expectedHeight, label, onLayout],
);
return <Text {...rest} onLayout={handleLayout} />;
}表示後の実際の文字サイズを確かめたいときは、onTextLayout で行ごとの高さを取る方法が使えます。考え方は 「続きを読む」を出すべきか、テキストの実測で決める — Rork(Expo)の折りたたみ表示設計 にまとめています。3x の端末が多い現在は、Rork で作った iOS アプリを iPhone Air と 17 Pro シリーズに対応させる — 2026年の解像度ごとのレイアウト調整パターン の画面サイズ表とあわせて、手元の確認端末の倍率を見直しておくと安心です。
この記事の元になった事例を共有してくださった方に、感謝しています。再現が難しい症状ほど、原因を言葉と数字で残してもらえることが、あとから同じ場所で立ち止まる人の助けになります。