設定アプリで文字サイズを一番右まで動かし、自分のアプリを開いてみたことはありますでしょうか。
私はある日、Rork Max で組んだ設定画面をその状態で開いて、少しのあいだ黙ってしまいました。トグルのラベルが「バックグラウンドで…」で切れ、その右にあるはずのスイッチが画面の外へ押し出されていました。スイッチは存在しているのに、指が届きません。視覚的には完成していた画面が、文字サイズひとつで操作不能になっていたわけです。
Xcode のプレビューは緑。シミュレータも緑。ブラウザのライブシミュレータでも、当然ながら緑でした。既定の文字サイズと日本語で見るかぎり、その画面はどこも壊れていません。
個人開発では、この種の検証を誰も代わりにやってくれません。QA の担当者もいなければ、レビュー会もありません。緑を見て「できた」と思ったのは私自身であって、その判断を疑う役目もまた私自身に残っています。
この記事は、その一件のあと、Rork Max が生成した 23 画面すべてを 疑似ローカライズと最大文字サイズに通して、詰まる場所を機械的に洗い出した 記録です。どこから詰まったのか、どう直したのか、そして再生成のたびに同じ場所が戻ってしまう問題をどう捕まえているのかを、実際の数値とコードで残しておきます。
なぜ生成された画面は既定値でしか検証されないのか
Rork Max はプロンプトから純粋な Swift を出力します。React Native を経由しないぶん、SwiftUI のレイアウトシステムがそのまま効きます。これは利点ですが、同時に「SwiftUI のレイアウトが持つ弱点も、そのまま引き受ける」ということでもあります。
生成された画面を読むと、傾向がはっきり出ていました。
生成モデルが手を抜いているわけではありません。プロンプトに「文字サイズ 310% でも崩れないこと」と書かなければ、モデルは既定値で最も見栄えのする配置を選びます。それは指示に対して正しい応答です。問題は、私が既定値しか指示していなかったことのほうにありました。
そして厄介なのは、この種の詰まりが レビューにも届きにくい ことです。文字を大きくして使っている方は、アプリが使えなければ黙って消します。星がつくことすら稀です。
疑似ローカライズという、翻訳前にできる検査
翻訳を発注する前に、レイアウトが長い文言に耐えるかどうかは確かめられます。Xcode の疑似ローカライズ(Pseudolanguage)を使う方法です。
スキームの Run → Options → App Language に、通常の言語に混ざって次の項目があります。
Accented のほうは、レイアウトより先に「そもそもローカライズされていない文字列」を炙り出してくれます。囲みが付いていない文字列は、コード中にベタ書きされているということです。Rork Max の生成コードは String Catalog を使う指示を出していれば概ね拾ってくれますが、エラーメッセージやアクセシビリティラベルの取りこぼしは実際にありました。この辺りは Rork Max の Swift アプリを String Catalog で多言語運用する に整理してあります。
Double-Length のほうが、この記事の本題です。ドイツ語の実文を待たなくても、文字数がおよそ2倍になったときにどこが先に折れるかが分かります。実務上、ドイツ語は英語比でおよそ 1.3〜1.6 倍、フィンランド語やハンガリー語はさらに伸びます。2倍は少し厳しめですが、余白の設計としてはちょうど良い負荷でした。
23画面を4条件に通した結果
条件を4つに絞り、23画面すべてを手で開いて記録しました。全組み合わせを試すと現実的な時間に収まらないため、「壊れるとしたらここ」という組み合わせだけを残しています。
結果は次のとおりです。
条件 D で 23画面中9画面 に何らかの問題が出ました。うち4画面は「操作不能または意味の消失」です。基準では 23/23 が緑だったことを踏まえると、既定値のみの確認がいかに何も保証していないかがよく分かります。
詰まり方は3つに分類できました。
「無反応」の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行に潰されることがあります。付けるべき場所を選ぶ手当てとしては、地味ですが効果が大きい部類でした。
使い分けをまとめておきます。
再生成のたびに戻る問題を、スナップショットで捕まえる
ここからが、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段階を全部回しても、4条件と比べて追加で見つかるものはありませんでした。壊れるものは最悪条件で壊れます。一方 2条件まで削ると、「AX5 単独では出るが複合では別の壊れ方に隠れる」類の2件を取りこぼしました。4条件が私にとっての折り合いです。
画像の幅では捕まらない押し出しがあります
ここでひとつ、私の取りこぼしを書き残しておきます。
上のスナップショット検証を入れた直後、私は満足していました。幅超過が数値で出るのですから、押し出しはもう漏れないはずだと思っていたのです。ところが——この記事の発端になった設定画面そのものが、assertNoOverflow を 緑で通過しました 。
理由は単純でした。ImageRenderer は提案した幅でレンダリングします。親が .frame(width: 393) を持っている以上、その外へ出た子ビューは描かれずに切り落とされるだけで、画像の幅は 393pt のままです。つまり、あの検査が拾えるのは「押し出された結果、親のほうが広がった 」場合だけでした。クリップされて消える押し出しは、数値の上ではきれいに収まって見えます。
条件 D の9件を種類別に数え直すと、こうなっていました。
参照画像の差分はすべて拾います。ただし差分が教えてくれるのは「何かが変わりました」までで、それが直した結果なのか壊れた結果なのかは、結局こちらが目で判断することになります。再生成のたびに参照画像を撮り直す運用では、この判断が毎回発生します。判断を減らすには、壊れの形を名前のついた条件に落とすしかありません。
見えているかどうかではなく、指が届くかどうかを数えます。 発端の画面が教えてくれたのは、要するにこの一行でした。
そこで、触れる要素の矩形を計測して返す印を用意しました。
import SwiftUI
struct ControlFrame : Equatable {
let id: String
let rect: CGRect
}
struct ControlFrameKey : PreferenceKey {
static var defaultValue: [ControlFrame] = []
static func reduce ( value : inout [ControlFrame], nextValue : () -> [ControlFrame]) {
value. append ( contentsOf : nextValue ())
}
}
extension View {
/// タップできる要素にだけ付けて、計測座標系での矩形を報告させる
func trackHitArea ( _ id: String ) -> some View {
background (
GeometryReader { proxy in
Color.clear. preference (
key : ControlFrameKey. self ,
value : [ ControlFrame ( id : id, rect : proxy. frame ( in : . named ( "probe" )))]
)
}
)
}
}
印を付けるのは Toggle・Button・タップ可能な行だけです。23画面で 61 箇所になりました。装飾まで測ると、数字が増えるだけで判断材料は増えません。
計測側は UIHostingController にレイアウトを一度走らせて、集まった矩形を受け取ります。ImageRenderer は画像を返しますが、preference の値までは手元に残らないため、ここだけホスティング経由にしています。
import SwiftUI
import UIKit
@MainActor
func measureHitAreas < V : View >(
of view: V,
locale : Locale,
typeSize : DynamicTypeSize,
containerWidth : CGFloat
) -> [ControlFrame] {
final class Box { var frames: [ControlFrame] = [] }
let box = Box ()
let probe = view
. environment (\.locale, locale)
. environment (\.dynamicTypeSize, typeSize)
. frame ( width : containerWidth)
. coordinateSpace ( name : "probe" )
. onPreferenceChange (ControlFrameKey. self ) { box.frames = $0 }
let host = UIHostingController ( rootView : probe)
let fitted = host. sizeThatFits (
in : CGSize ( width : containerWidth, height : . greatestFiniteMagnitude )
)
host. view .frame = CGRect ( origin : .zero, size : fitted)
host. view . setNeedsLayout ()
host. view . layoutIfNeeded ()
return box.frames
}
判定は3つだけにしました。左右にはみ出していないか、幅が 44pt を割っていないか、高さが 44pt を割っていないか。
@MainActor
func assertHitAreasReachable < V : View >(
_ view: V,
named name: String ,
containerWidth : CGFloat = 393 ,
minimumSide : CGFloat = 44 ,
file : StaticString = #filePath ,
line : UInt = #line
) {
for condition in LayoutResilienceTests.conditions {
let frames = measureHitAreas (
of : view,
locale : condition.locale,
typeSize : condition.typeSize,
containerWidth : containerWidth
)
// 印の付け忘れを素通りさせない
XCTAssertFalse (
frames. isEmpty ,
" \( name ) / \( condition. name ) : trackHitArea の報告が 0 件です" ,
file : file, line : line
)
for frame in frames {
XCTAssertGreaterThanOrEqual (
frame.rect.minX, -0.5 ,
" \( name ) / \( condition. name ) : \( frame. id ) が左へ \( - frame. rect . minX ) pt はみ出しています" ,
file : file, line : line
)
XCTAssertLessThanOrEqual (
frame.rect.maxX, containerWidth + 0.5 ,
" \( name ) / \( condition. name ) : \( frame. id ) の右端 \( frame. rect . maxX ) pt が画面外です" ,
file : file, line : line
)
XCTAssertGreaterThanOrEqual (
frame.rect.width, minimumSide,
" \( name ) / \( condition. name ) : \( frame. id ) の幅 \( frame. rect . width ) pt が 44pt 未満です" ,
file : file, line : line
)
XCTAssertGreaterThanOrEqual (
frame.rect.height, minimumSide,
" \( name ) / \( condition. name ) : \( frame. id ) の高さ \( frame. rect . height ) pt が 44pt 未満です" ,
file : file, line : line
)
}
}
}
44pt は下限としてだけ見ています。@ScaledMetric で余白を追従させた画面では、AX5 でタップ領域のほうも一緒に広がります。等値で比較すると、正しく広がったことが失敗として報告されてしまいます。
実行時間は、4条件のスナップショット 2分40秒に対して +38秒 、合計 3分18秒になりました。61 箇所ぶんのレイアウトを4回走らせている割には、思ったより軽く済んでいます。
ひとつだけ注意点があります。onPreferenceChange はレイアウトが走った後にしか呼ばれないため、sizeThatFits と layoutIfNeeded() を省くと矩形が 0 件で返ります。上の判定で「報告が 0 件です」を最初に置いているのは、この失敗を成功と取り違えないためです。私は一度これで、何も測っていないテストを緑のまま2日ほど眺めておりました。
生成の入口を変える — プロンプトに条件を書く
検出の仕組みを作ったうえで、そもそも壊れにくいコードを出させる試みもしました。プロンプトに次の一節を足しています。
レイアウトの制約:
- 文字サイズ設定 AX5(310%)で操作不能にならないこと
- ラベルとコントロールの HStack は ViewThatFits で縦積みへ落とすこと
- コントロール側に layoutPriority(1) と fixedSize() を付けること
- .font(.system(size:)) の絶対指定を使わず、テキストスタイル基準で書くこと
- アイコン・余白の寸法は @ScaledMetric(relativeTo:) で宣言すること
- ボタンに .frame(width:) を使わず、minWidth と maxWidth で挟むこと
効果は、正直に言えば部分的でした。同じ画面を5回生成して比較したところ、次のようになりました。
1/5 から 4/5 は大きな改善ですが、5/5 にはなりません。画面が複雑になるほど、制約のいくつかが落ちます。つまり、プロンプトは 検証を減らす道具であって、検証を置き換える道具ではない ということです。
この結論は少し寂しくもありました。指示すれば毎回そのとおりに出てくれるなら、テストは要りません。ただ、5回のうち1回外すものを人間が目視で見つけられるかというと、それこそ無理な相談です。機械が出したものは、機械で確かめるほかありません。順当なところに落ち着いた気がしています。
導入の順序と、最初にやるべき一つ
同じことを始める方のために、順序を書いておきます。
疑似ローカライズを一度手で通す (所要 30分程度) — スキームの App Language を Double-Length にして全画面を開きます。ここで自分のアプリの弱点の傾向が掴めます
文字サイズ AX5 で同じことをする (所要 30分程度) — 設定 → アクセシビリティ → 画面表示とテキストサイズ → さらに大きな文字
詰まった画面だけをリスト化する — 全部直そうとせず、操作不能・意味の消失に絞ります
ViewThatFits + layoutPriority + fixedSize の3点セットを当てる — 押し出しの大半はこれで止まります
スナップショット検証を、直した画面だけに入れる — 全画面から始めると挫けます。触れる要素には trackHitArea の実測も一緒に足しておくと、クリップされる押し出しまで落ちずに済みます
プロンプトに制約を足す — 検証があってはじめて、この一手が意味を持ちます
最初にやるべき一つを選ぶなら、1番です。コードを1行も書かずに、30分で自分のアプリの現在地が分かります。私の場合、その30分が9画面分の宿題を出してきました。決して楽しい30分ではありませんでしたが、レビューで知らされるよりはずっと良かったと思っています。
個人開発の時間は有限です。だからこそ、30分で9件が出てくる検査は、費用対効果の面でも私は好きなほうに入ります。
VoiceOver まで含めた本番品質の詰め方は Rork で VoiceOver と Dynamic Type を本番品質に整える実装メモ に別途まとめてあります。あちらは React Native 側の話ですが、ラベル設計の考え方は Rork Max でもそのまま使えます。
文字サイズを一番右まで動かした画面は、私にとって、自分が誰を見落としていたかを教えてくれる場所になりました。次にアプリを開くとき、一度だけその設定を触ってみていただければと思います。お読みいただきありがとうございました。