◉RORK LABEN
●GPT-6.1 SOL — Rork のモデルメニューに追加(09-29)。Low/Medium/High を選べ、Pro・Max に含まれます●SONNET 5.5 — UI づくりが得意で、Sonnet 5 より出力が速いモデルが選べます(09-28)●SDK 58 — Beta が 09/15 から。stable の案内はまだなく、Expo Go でのプロジェクト実行は 09/03 からログインが必要です●WIDGET — iOS のウィジェットが本番ビルドだけ真っ黒になる原因を追った報告が出ています●OBSERVE — EAS Observe がネイティブクラッシュも記録します(SDK 57.0.21 以降・10/07)●NEW — 起動広告が、背面から戻った回にしか出ていませんでした。「前面に戻った」合図が、…●GPT-6.1 SOL — Rork のモデルメニューに追加(09-29)。Low/Medium/High を選べ、Pro・Max に含まれます●SONNET 5.5 — UI づくりが得意で、Sonnet 5 より出力が速いモデルが選べます(09-28)●SDK 58 — Beta が 09/15 から。stable の案内はまだなく、Expo Go でのプロジェクト実行は 09/03 からログインが必要です●WIDGET — iOS のウィジェットが本番ビルドだけ真っ黒になる原因を追った報告が出ています●OBSERVE — EAS Observe がネイティブクラッシュも記録します(SDK 57.0.21 以降・10/07)●NEW — 起動広告が、背面から戻った回にしか出ていませんでした。「前面に戻った」合図が、…
記事一覧/アプリ開発
◇ アプリ開発/2026-10-15中級

adjustsFontSizeToFit で特定の1行だけ極小になるとき、疑うのは文字列ではなく枠の高さです

一覧の10行目だけ文字が4ptまで縮む。データも文字列も正常なのに起きる iOS の症状を、Yoga の丸めと縮小判定のソースで確かめ、Node で再現率を数えた記録です。

React Native239Expo218adjustsFontSizeToFitYogaiOS115レイアウト3トラブルシューティング85

一覧画面の10行目だけ、文字が極端に小さくなっています。並び順を入れ替えても、アプリを再起動しても、縮むのは10行目です。同じ文字列を9行目に置くと、何事もなかったように普通の大きさで表示されます。

こうなると、まずデータを疑い、次に文字列を疑い、OS のバージョンを疑って、半日が過ぎてしまいます。この記事は、そうした症状を Zenn で共有されていた事例(異常に小さい文字サイズを追いかけたら、丸め誤差問題だった話)で知り、React Native と Yoga の公開ソースを読み、手元の Node で計算を写して確かめた記録です。

先にお伝えしておくと、実機での再現は行っていません。確かめたのは「ソースがそう書かれていること」と「その式を float32 で計算するとどれくらいの頻度でズレるか」までです。そのうえで、疑う順番と直し方を整理しました。

「1行だけ」「位置で決まる」と思ったら、最初にすること

文字列やデータを疑う前に、次の3つを上から順に確かめます。どれも数分で終わります。

  1. 同じ文字列を別の行に置きます。 9行目に置いて正常、10行目に置くと縮むなら、原因は文字列ではなく位置です
  2. onLayout の高さを小数の末尾まで出します。 丸めて出すと 11 に見えます。toFixed(6) で見てください
  3. 枠の高さと 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)期待値に届かない位置最大の不足
2x11〜33 の8通りすべて 0 件0
3x11104 件(約3.9%)約 1.5e-5 pt
3x22164 件(約6.1%)約 1.5e-5 pt
3x33196 件(約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通りの高さ)枠が使う高さを下回った数
0pt43,2001,216
0.01pt43,2000
0.05pt 以上(0.25・0.5 も確認)43,2000

私なら、見た目に出ない範囲で余裕を取るため、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年の解像度ごとのレイアウト調整パターン の画面サイズ表とあわせて、手元の確認端末の倍率を見直しておくと安心です。

この記事の元になった事例を共有してくださった方に、感謝しています。再現が難しい症状ほど、原因を言葉と数字で残してもらえることが、あとから同じ場所で立ち止まる人の助けになります。

シェア

お読みいただきありがとうございます

Rork Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • ✦コピー&ペーストで使える実装コード付き
  • ✦毎日新しい上級ガイドを追加
  • ✦¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

◇ アプリ開発2026-10-15
EAS Build だけ「No matching variant」で落ちるとき、まず疑う手元の android/build
手元の Gradle は通るのに、EAS Build の Android だけが「No matching variant of project」で止まる。GitHub の報告を読み比べ、Windows で生成した autolinking のキャッシュが Linux のビルド機へ渡っていた経路と、ビルドを投げる前に確かめる診断スクリプトをまとめました。
◇ アプリ開発2026-10-14
App Review で「Guideline 2.1・白い画面」と言われたとき、Expo のリリースビルドで疑う3か所
TestFlight では開くのに、審査では「起動後に白い画面のまま」と差し戻される。Expo + EAS のリリースビルドで、環境変数の environment、secret の可視性、審査員の環境差の3点を順に切り分ける手順を、確認コマンドと診断コードつきでまとめました。
◇ アプリ開発2026-08-25
iOS 27 が正式版になる前に、予備の iPhone で公開中の Rork アプリを触っておく
iOS 27 の正式版は9月に来ます。開発者ベータが7回目まで進んだ今のうちに、予備の iPhone へベータを入れて公開中のアプリを触る手順と、見る順番、JS 側で先に潰せる箇所をまとめました。