設定アプリで文字サイズを一番右まで動かし、自分のアプリを開いてみたことはありますでしょうか。
私はある日、Rork Max で組んだ設定画面をその状態で開いて、少しのあいだ黙ってしまいました。トグルのラベルが「バックグラウンドで…」で切れ、その右にあるはずのスイッチが画面の外へ押し出されていました。スイッチは存在しているのに、指が届かない。視覚的には完成していた画面が、文字サイズひとつで操作不能になっていたわけです。
Xcode のプレビューは緑。シミュレータも緑。ブラウザのライブシミュレータでも、当然ながら緑でした。既定の文字サイズと日本語で見るかぎり、その画面はどこも壊れていません。
個人開発では、この種の検証を誰も代わりにやってくれません。QA の担当者もいなければ、レビュー会もない。緑を見て「できた」と思ったのは私自身であって、その判断を疑う役目もまた私自身に残っています。
この記事は、その一件のあと、Rork Max が生成した 23 画面すべてを 疑似ローカライズと最大文字サイズに通して、詰まる場所を機械的に洗い出した 記録です。どこから詰まったのか、どう直したのか、そして再生成のたびに同じ場所が戻ってしまう問題をどう捕まえているのかを、実際の数値とコードで残しておきます。
なぜ生成された画面は既定値でしか検証されないのか
Rork Max はプロンプトから純粋な Swift を出力します。React Native を経由しないぶん、SwiftUI のレイアウトシステムがそのまま効きます。これは利点ですが、同時に「SwiftUI のレイアウトが持つ弱点も、そのまま引き受ける」ということでもあります。
生成された画面を読むと、傾向がはっきり出ていました。
生成コードによく出る形 既定値での見え方 極端な条件での挙動
HStack にラベルとコントロールを直置き問題なし ラベルが伸びてコントロールが画面外へ
.frame(width: 120) の固定幅ボタンちょうど良い 文言が ... で省略され意味が消える
.font(.system(size: 15)) の絶対指定意図どおり 文字サイズ設定を無視して小さいまま
padding(.horizontal, 16) 前提の2カラム収まる 長い言語で1文字ずつ改行
Spacer() で押し切る右寄せ綺麗に右端 ラベルに負けて潰れる
生成モデルが手を抜いているわけではありません。プロンプトに「文字サイズ 310% でも崩れないこと」と書かなければ、モデルは既定値で最も見栄えのする配置を選びます。それは指示に対して正しい応答です。問題は、私が既定値しか指示していなかったことのほうにありました。
そして厄介なのは、この種の詰まりが レビューにも届きにくい ことです。文字を大きくして使っている方は、アプリが使えなければ黙って消します。星がつくことすら稀です。
疑似ローカライズという、翻訳前にできる検査
翻訳を発注する前に、レイアウトが長い文言に耐えるかどうかは確かめられます。Xcode の疑似ローカライズ(Pseudolanguage)を使う方法です。
スキームの Run → Options → App Language に、通常の言語に混ざって次の項目があります。
疑似言語 何をするか 見つかるもの
Double-Length Pseudolanguage 全文字列を2回繰り返す 長い言語での詰まり・省略・押し出し
Accented Pseudolanguage アクセント記号を付与し前後を [ ] で囲む ハードコードされた未ローカライズ文字列
Right-to-Left Pseudolanguage 擬似的に RTL 表示にする .leading 固定の非対称レイアウト
Bounded String Pseudolanguage 文字列の前後に境界を可視化 切り詰めが起きている箇所
Accented のほうは、レイアウトより先に「そもそもローカライズされていない文字列」を炙り出してくれます。囲みが付いていない文字列は、コード中にベタ書きされているということです。Rork Max の生成コードは String Catalog を使う指示を出していれば概ね拾ってくれますが、エラーメッセージやアクセシビリティラベルの取りこぼしは実際にありました。この辺りは Rork Max の Swift アプリを String Catalog で多言語運用する に整理してあります。
Double-Length のほうが、この記事の本題です。ドイツ語の実文を待たなくても、文字数がおよそ2倍になったときにどこが先に折れるかが分かります。実務上、ドイツ語は英語比でおよそ 1.3〜1.6 倍、フィンランド語やハンガリー語はさらに伸びます。2倍は少し厳しめですが、余白の設計としてはちょうど良い負荷でした。
23画面を4条件に通した結果
条件を4つに絞り、23画面すべてを手で開いて記録しました。全組み合わせを試すと現実的な時間に収まらないため、「壊れるとしたらここ」という組み合わせだけを残しています。
条件 設定 狙い
A: 基準 日本語 / Large(既定) 回帰の基準線
B: 文字最大 日本語 / AX5(310%) 縦の押し出し・タップ領域の消失
C: 文言最長 Double-Length / Large 横の詰まり・省略
D: 複合 Double-Length / AX5 最悪条件での破綻
結果は次のとおりです。
条件 問題なし 読みにくいが操作可能 操作不能・意味の消失
A: 基準 23 0 0
B: 文字最大 16 4 3
C: 文言最長 18 3 2
D: 複合 14 5 4
条件 D で 23画面中9画面 に何らかの問題が出ました。うち4画面は「操作不能または意味の消失」です。基準では 23/23 が緑だったことを踏まえると、既定値のみの確認がいかに何も保証していないかがよく分かります。
詰まり方は3つに分類できました。
類型 症状 件数(条件D) 根本原因
押し出し コントロールが画面外へ出て触れない 4 HStack の水平圧縮でコントロール側が負ける
省略 文言が ... になり意味が消える 3 固定幅 .frame(width:)
無反応 設定を上げても文字が大きくならない 2 .font(.system(size:)) の絶対指定
「無反応」の2件は、条件 B で初めて気づきました。壊れていないので緑に見えますが、文字サイズを上げている方から見れば、その画面だけが対応していないアプリです。壊れるより気づきにくい分、こちらのほうが根が深いと感じています。
私はこの2件を、9件のうち最初に直しました。押し出しは目に見えるので、いつか誰かが教えてくれるかもしれません。無反応は誰も教えてくれません。
押し出しを止める — layoutPriority と ViewThatFits
最も多かった「押し出し」から直します。生成コードは、設定行をこう書いていました。
// 生成された形。既定値では正しく動くが、AX5 でスイッチが画面外へ出る
struct SettingRow : View {
let title: String
@Binding var isOn: Bool
var body: some View {
HStack {
Text (title)
Spacer ()
Toggle ( "" , isOn : $isOn)
. labelsHidden ()
}
. padding (.horizontal, 16 )
}
}
HStack は水平方向に空間が足りなくなると、子ビューを圧縮していきます。Text は「圧縮されにくい」性質を持つため、Toggle のほうが先に負けます。Spacer() は残りを埋めるだけで、この綱引きには介入しません。結果としてスイッチが押し出されます。
修正は二段構えにしました。まず、コントロール側に優先度を明示します。
struct SettingRow : View {
let title: String
@Binding var isOn: Bool
// 文字サイズがアクセシビリティ帯に入ったかを見る
@Environment (\.dynamicTypeSize) private var typeSize
var body: some View {
// アクセシビリティ帯では横並びを諦めて縦に落とす
ViewThatFits ( in : .horizontal) {
horizontalLayout
verticalLayout
}
. padding (.horizontal, 16 )
. padding (.vertical, 8 )
}
private var horizontalLayout: some View {
HStack ( spacing : 12 ) {
Text (title)
. layoutPriority ( 0 ) // ラベルは譲る
Spacer ( minLength : 8 )
Toggle ( "" , isOn : $isOn)
. labelsHidden ()
. layoutPriority ( 1 ) // コントロールは死守
. fixedSize () // 圧縮そのものを拒否
}
}
private var verticalLayout: some View {
VStack ( alignment : .leading, spacing : 8 ) {
Text (title)
. fixedSize ( horizontal : false , vertical : true )
Toggle ( "" , isOn : $isOn)
. labelsHidden ()
}
. frame ( maxWidth : . infinity , alignment : .leading)
}
}
ViewThatFits は候補を順に試し、与えられた空間に収まる最初のものを採用します。横並びが収まらなければ、黙って縦積みに落ちます。条件分岐を自分で書くより堅いのは、判定が「実際に収まるかどうか」で行われる点です。typeSize > .accessibility1 のような閾値を自分で決めると、言語や端末幅が変わるたびに閾値が外れます。
Toggle 側の .fixedSize() が効きます。これがないと、layoutPriority を上げても Toggle は最小サイズまで縮む余地を残してしまいます。.fixedSize() は「私は理想サイズより小さくならない」という宣言で、押し出しの綱引きを終わらせます。
Spacer(minLength: 8) にしているのは、Spacer() の既定最小長がゼロではないためです。狭いときに 0 まで潰したい場面もありますが、ここでは触り分けのために 8pt を残しました。
省略を止める — 固定幅を捨てる判断
固定幅ボタンの3件は、直し方より「どこまで許すか」の線引きに時間がかかりました。
// 生成された形
Button ( "設定を保存" ) { save () }
. frame ( width : 120 , height : 44 )
. background (Color.accentColor)
. foregroundStyle (.white)
. clipShape ( RoundedRectangle ( cornerRadius : 8 ))
Double-Length では「設定を保存設定を保存」が 設定を保存設... になります。何のボタンか分かりません。
素直に幅を外すと、今度は画面いっぱいの巨大ボタンになります。デザイン上は避けたいところでした。最終的にこう落ち着いています。
Button {
save ()
} label : {
Text ( "settings.save" ) // String Catalog のキー
. lineLimit ( 2 ) // 2行までは許す
. multilineTextAlignment (.center)
. minimumScaleFactor ( 0.8 ) // 縮小は 80% まで
. frame ( minWidth : 120 ) // 下限だけ残す
. frame ( maxWidth : . infinity )
. padding (.vertical, 12 )
. padding (.horizontal, 16 )
}
. buttonStyle (.borderedProminent)
. frame ( maxWidth : 320 ) // 上限で巨大化を抑える
判断の順序を言葉にすると、こうなります。
幅の下限だけ残す — minWidth はタップ領域の確保として意味がある
上限で暴れを抑える — maxWidth: 320 で iPad の横幅に引き伸ばされることを防ぐ
2行までは折り返しを許す — 1行に固執すると省略か極小文字しか道がない
縮小は 80% まで — minimumScaleFactor を 0.5 などにすると、読めない文字が「読める」判定で通ってしまう
4番目は実際に失敗しました。当初 minimumScaleFactor(0.5) にしていたところ、Double-Length で文字が半分の大きさになり、レイアウトは崩れないので緑判定のまま通過していました。崩れないことと読めることは別です。0.8 に上げてからは、収まらないものはきちんと2行になります。
無反応を止める — ScaledMetric と font の相対指定
.font(.system(size: 15)) は文字サイズ設定に追従しません。.font(.body) などのテキストスタイルを使えば追従しますが、生成コードは「15pt」のような具体的な指示から絶対値を書きがちです。
// 追従しない
Text (label). font (. system ( size : 15 ))
// 追従する(テキストスタイル基準の相対指定)
Text (label). font (. system (.subheadline, design : .rounded))
// 15pt を起点にしつつ追従させたい場合
Text (label). font (. system ( size : 15 , weight : .medium))
. dynamicTypeSize ( ... DynamicTypeSize.accessibility3) // 上限だけ設ける
より効いたのは、文字以外の寸法を追従させることでした。アイコンや余白が固定のままだと、文字だけが大きくなって不格好に破綻します。
struct StatCard : View {
// 基準サイズを宣言すると、文字サイズ設定に比例して値が変わる
@ScaledMetric (relativeTo : .headline) private var iconSize: CGFloat = 28
@ScaledMetric (relativeTo : .body) private var cardPadding: CGFloat = 16
@ScaledMetric (relativeTo : .body) private var cornerRadius: CGFloat = 12
let title: String
let value: String
var body: some View {
HStack ( spacing : cardPadding * 0.75 ) {
Image ( systemName : "chart.bar.fill" )
. font (. system ( size : iconSize))
. frame ( width : iconSize, height : iconSize)
VStack ( alignment : .leading, spacing : 4 ) {
Text (title)
. font (.subheadline)
. foregroundStyle (.secondary)
. fixedSize ( horizontal : false , vertical : true )
Text (value)
. font (.headline)
. fixedSize ( horizontal : false , vertical : true )
}
Spacer ( minLength : 0 )
}
. padding (cardPadding)
. background (.regularMaterial, in : RoundedRectangle ( cornerRadius : cornerRadius))
}
}
@ScaledMetric(relativeTo:) は、指定したテキストスタイルの拡大率に比例して数値を返します。AX5 ではアイコンが 28pt から実測 62pt 前後まで伸び、余白も一緒に広がるため、比率が保たれたまま拡大されます。
.fixedSize(horizontal: false, vertical: true) を Text に付けているのは、縦方向の切り詰めを拒否するためです。これがないと、HStack の高さ計算の都合で複数行テキストが1行に潰されることがあります。付けるべき場所を選ぶ手当てとしては、地味ですが効果が大きい部類でした。
使い分けをまとめておきます。
道具 効く場面 使わないほうが良い場面
ViewThatFits横並びを縦に落としたい行 候補が3つ以上ある構造(判定コストが上がる)
layoutPriority + fixedSizeコントロールを死守したい HStack 両側がテキストの場合(綱引きが終わらない)
@ScaledMetricアイコン・余白・角丸 画面全体の幅など、拡大すべきでない寸法
dynamicTypeSize(...限界)ウィジェットなど拡大余地が構造的にない場所 本体アプリの本文(上限を設けると対応漏れになる)
minimumScaleFactor0.8 程度までの微調整 0.5 以下(読めないものが緑で通る)
再生成のたびに戻る問題を、スナップショットで捕まえる
ここからが、Rork Max を使っている以上どうしても必要になる部分です。
直したレイアウトは、プロンプトを変えて画面を再生成すると 元に戻ります 。モデルは私が手で入れた layoutPriority を知りません。再生成は Rork Max の中心的な価値なので、再生成を諦める選択は取りたくありませんでした。生成の境界をどう引くかは Rork Max 生成コードの再生成ゾーン設計 に書いたとおりですが、境界の内側は結局のところ「壊れたら検出する」しかありません。
そこでスナップショット検証を入れました。
import XCTest
import SwiftUI
@testable import MyApp
final class LayoutResilienceTests : XCTestCase {
/// 検証する4条件。全組み合わせは回さない。
struct Condition {
let name: String
let locale: Locale
let typeSize: DynamicTypeSize
}
static let conditions: [Condition] = [
. init ( name : "base" , locale : Locale ( identifier : "ja_JP" ), typeSize : .large),
. init ( name : "ax5" , locale : Locale ( identifier : "ja_JP" ), typeSize : .accessibility5),
. init ( name : "double" , locale : Locale ( identifier : "en_DOUBLE" ), typeSize : .large),
. init ( name : "worst" , locale : Locale ( identifier : "en_DOUBLE" ), typeSize : .accessibility5),
]
/// はみ出しと切り詰めを機械的に判定する
func assertNoOverflow < V : View >(
_ view: V,
named name: String ,
containerWidth : CGFloat = 393 , // iPhone 16 の論理幅
file : StaticString = #filePath ,
line : UInt = #line
) {
for condition in Self .conditions {
let hosted = view
. environment (\.locale, condition.locale)
. environment (\.dynamicTypeSize, condition.typeSize)
. frame ( width : containerWidth)
let renderer = ImageRenderer ( content : hosted)
renderer.proposedSize = ProposedViewSize ( width : containerWidth, height : nil )
guard let image = renderer.uiImage else {
XCTFail ( " \( name ) / \( condition. name ) : レンダリング失敗" , file : file, line : line)
continue
}
// 幅がコンテナを超えていれば水平方向の押し出し
let renderedWidth = image. size .width
XCTAssertLessThanOrEqual (
renderedWidth, containerWidth + 0.5 ,
" \( name ) / \( condition. name ) : 幅 \( renderedWidth ) pt がコンテナ \( containerWidth ) pt を超過" ,
file : file, line : line
)
// 参照画像との比較(初回は記録)
assertSnapshot (image, named : " \( name ) _ \( condition. name ) " , file : file, line : line)
}
}
func testSettingRow () {
assertNoOverflow (
SettingRow ( title : "settings.background_refresh" , isOn : . constant ( true )),
named : "SettingRow"
)
}
func testStatCard () {
assertNoOverflow (
StatCard ( title : "stats.total_views" , value : "128,400" ),
named : "StatCard"
)
}
}
ImageRenderer に proposedSize の高さを nil で渡すのが要点です。高さを固定すると、縦に伸びるべき内容が押し込まれて、はみ出しが検出できなくなります。幅だけ固定して高さを自由にすると、実際に必要な高さが返り、幅超過はそのまま数値で出ます。
疑似ロケール en_DOUBLE はテストターゲット側で定義します。実行時に Xcode のスキーム設定は効かないため、.strings を用意して倍長文字列を持たせる形にしました。
CI 実行時間の話も書いておきます。当初は全画面 × 全 DynamicTypeSize(12段階)× 3ロケールで回し、11分12秒 かかっていました。実行のたびに待つ時間としては現実的ではありません。
絞り込みの根拠は、実測の分布にありました。
絞り込み 検出できた問題 実行時間
全12段階 × 3ロケール 9件 11分12秒
4条件(base/ax5/double/worst) 9件 2分40秒
2条件(base/worst のみ) 7件 1分22秒
12段階を全部回しても、4条件と比べて追加で見つかるものはありませんでした。壊れるものは最悪条件で壊れます。一方 2条件まで削ると、「AX5 単独では出るが複合では別の壊れ方に隠れる」類の2件を取りこぼしました。4条件が私にとっての折り合いです。
生成の入口を変える — プロンプトに条件を書く
検出の仕組みを作ったうえで、そもそも壊れにくいコードを出させる試みもしました。プロンプトに次の一節を足しています。
レイアウトの制約:
- 文字サイズ設定 AX5(310%)で操作不能にならないこと
- ラベルとコントロールの HStack は ViewThatFits で縦積みへ落とすこと
- コントロール側に layoutPriority(1) と fixedSize() を付けること
- .font(.system(size:)) の絶対指定を使わず、テキストスタイル基準で書くこと
- アイコン・余白の寸法は @ScaledMetric(relativeTo:) で宣言すること
- ボタンに .frame(width:) を使わず、minWidth と maxWidth で挟むこと
効果は、正直に言えば部分的でした。同じ画面を5回生成して比較したところ、次のようになりました。
指示 5回中、条件Dを通った回数
制約なし 1 / 5
上記の制約あり 4 / 5
1/5 から 4/5 は大きな改善ですが、5/5 にはなりません。画面が複雑になるほど、制約のいくつかが落ちます。つまり、プロンプトは 検証を減らす道具であって、検証を置き換える道具ではない ということです。
この結論は少し寂しくもありました。指示すれば毎回そのとおりに出てくれるなら、テストは要りません。ただ、5回のうち1回外すものを人間が目視で見つけられるかというと、それこそ無理な相談です。機械が出すものは、機械で確かめる。順当なところに落ち着いた気がしています。
導入の順序と、最初にやるべき一つ
同じことを始める方のために、順序を書いておきます。
疑似ローカライズを一度手で通す (所要 30分程度) — スキームの App Language を Double-Length にして全画面を開く。ここで自分のアプリの弱点の傾向が掴めます
文字サイズ AX5 で同じことをする (所要 30分程度) — 設定 → アクセシビリティ → 画面表示とテキストサイズ → さらに大きな文字
詰まった画面だけをリスト化する — 全部直そうとせず、操作不能・意味の消失に絞る
ViewThatFits + layoutPriority + fixedSize の3点セットを当てる — 押し出しの大半はこれで止まります
スナップショット検証を、直した画面だけに入れる — 全画面から始めると挫けます
プロンプトに制約を足す — 検証があってはじめて、この一手が意味を持ちます
最初にやるべき一つを選ぶなら、1番です。コードを1行も書かずに、30分で自分のアプリの現在地が分かります。私の場合、その30分が9画面分の宿題を出してきました。決して楽しい30分ではありませんでしたが、レビューで知らされるよりはずっと良かったと思っています。
個人開発の時間は有限です。だからこそ、30分で9件が出てくる検査は、費用対効果の面でも私は好きなほうに入ります。
VoiceOver まで含めた本番品質の詰め方は Rork で VoiceOver と Dynamic Type を本番品質に整える実装メモ に別途まとめてあります。あちらは React Native 側の話ですが、ラベル設計の考え方は Rork Max でもそのまま使えます。
文字サイズを一番右まで動かした画面は、私にとって、自分が誰を見落としていたかを静かに教えてくれる場所になりました。次にアプリを開くとき、一度だけその設定を触ってみていただければと思います。お読みいただきありがとうございました。