60dメモの設定画面を3観点でリファクタ — サブタイトルの色、CTA の定義、情報階層
AI 主体で執筆設定画面を眺めていて「サブタイトルがうっすら赤く滲んでいる気がする」と気付いた。追いかけてみると、SwiftUI の Button の tint 伝播、List 行の配色ルール、CTA の定義、果てはセクション分割のあり方まで一気に繋がった。設定画面を直しながら「色と情報階層のルールを自分で明文化する」に至った記録。
発見:サブタイトルの灰色が灰色じゃない
60dメモの設定画面には、SF Symbol アイコン + タイトル + 説明サブタイトルという 2 行構成のボタンを並べた「トラブルシューティング」セクションがある。ある日スクリーンショットをじっと眺めていて、サブタイトルの色が想定した中立なグレーではないことに気付いた。
上のサブタイトルは青味がかり、下のサブタイトルは赤味がかっている。指定しているのは .foregroundStyle(.secondary) だけなのに。
原因:Button の tint 伝播 + hierarchical secondary
コードはこうだった。
Button(role: .destructive) {
showRebuildFromDriveConfirm = true
} label: {
VStack(alignment: .leading, spacing: 2) {
Text("Drive から再取得")
.foregroundStyle(isDestructive ? Color.red : Color.primary)
Text("この端末のメモを Drive のデータで置き換えます。")
.font(.caption)
.foregroundStyle(.secondary) // ← これが赤く滲む
}
}
SwiftUI の Button は、配下のコンテンツに tint を伝播させる。通常の Button ならアクセントカラー(青)、role: .destructive なら赤が、子孫要素の "現在の前景色" として設定される。
そこに .foregroundStyle(.secondary) をあてると、これは具体的な色ではなく hierarchical(階層的)な指定 — 「いま流れている前景色を薄くしたもの」を意味する。つまり:
- 通常の Button 内 → accent を薄めた色 → 青寄りの灰色
- destructive Button 内 → 赤を薄めた色 → 赤寄りの灰色(ピンクグレー)
意図していた「中立なグレー」にはならない。NavigationLink 配下の Label がうまく描画されるのは、List 標準行が独自にスタイルを組み立てていて tint 伝播に乗らないからで、Button で自前ラベルを書いた瞬間にこの挙動を踏み抜く。
この "テキストの方" の現象は、破壊的ボタンの SF Symbol が赤くならない件(SwiftUI の Button(role: .destructive) は List 内で SF Symbol を赤くしない)と表裏一体で、どちらも「List 内で Button が子要素の色を意図せず支配する/しない」という同じ根の挙動から来ている。
解決:tint 伝播を断ち、色を明示する
対処は 2 段構えで行う。
1. .buttonStyle(.plain) で tint 伝播を断つ
List 内の Button に .buttonStyle(.plain) を付けると、tint の流し込みが止まり、配下のテキストやアイコンは指定した色そのままで描画される。タップ領域は .contentShape(Rectangle()) で明示しておく。
2. 色は hierarchical ではなく明示値を指定
.secondary のような hierarchical ではなく、Color.secondary / Color.primary / Color.red / Color.accentColor を直接あてる。こうすれば将来どこかで tint を流し込まれても色は動かない。
Button(role: .destructive) {
showRebuildFromDriveConfirm = true
} label: {
VStack(alignment: .leading, spacing: 2) {
Text("Drive から再取得")
.foregroundStyle(Color.red)
Text("この端末のメモを Drive のデータで置き換えます。")
.font(.caption)
.foregroundStyle(Color.secondary) // 明示値
}
}
.buttonStyle(.plain)
.contentShape(Rectangle())
「フィードバックを送る」のように Label を使う行も、同じ問題で全文字が青く染まっていた。こちらは Label の明示初期化子を使って各要素に色を振る。
Button { openFeedbackEmail() } label: {
Label {
Text("フィードバックを送る")
.foregroundStyle(Color.primary)
} icon: {
Image(systemName: "envelope")
.foregroundStyle(Color.accentColor)
}
}
.buttonStyle(.plain)
.contentShape(Rectangle())
配色ルールを明文化する
同じ問題を踏まないために、List 行の色分けを 4 パターンに分類してルール化した。
| 行の種類 | アイコン色 | タイトル色 | 例 |
|---|---|---|---|
| 通常のアクション/ナビゲーション | accent | primary | フィードバックを送る、今すぐ同期、エクスポート、消えたメモを復旧 |
| 破壊的操作 | red | red | Drive から再取得、サインアウト |
| 強い CTA(唯一かつ最重要の誘導) | accent | accent | 未ログイン時の「Google でサインイン」 |
| 無効化中 | 上記 + .opacity(0.4) |
同期中のトラブルシューティング行 | |
判断指針: 色は意味のシグナル。多用すると「本当に注意を引きたい赤」や「強調したい CTA」が相対的に埋もれる。迷ったら iOS 純正の設定アプリの表現を見るとだいたい primary 文字 + accent アイコンに落ち着く。
応用:「今すぐ同期」は CTA か
ルール化の直後、「じゃあ『今すぐ同期』ボタンも accent 全面色にすべきでは?」という疑問が出た。結論は NO。理由を整理しておく。
- 自動同期が主で、手動は補助。起動・バックグラウンド・変更検知で自動的に走るので、常時ユーザーを強く誘導する必要はない。
- 同じセクション内にアカウント行(primary 文字)が並ぶ。ここで同期行だけ accent 文字にすると、並列に見えるべき 2 行に視覚的な上下関係が生じて落ち着かない。
- 注意喚起は文脈依存で担保する。自動同期失敗や他端末からの変更検知は、画面上部にオレンジの警告カード(
statusBanner)で明示的に出している。常時の強調を足すと、この警告の相対的な強さが弱まる。 - iOS 純正の慣例に倣う。設定アプリで「Fetch Now」「Sync Now」系は primary 文字 + accent アイコンの通常行として表現されている。
逆に未ログイン時の「Google でサインイン」は CTA として正当だ。サインインしない限り他の機能にアクセスできず、この画面で唯一かつ最重要の行動だから。CTA は "例外扱い"、というのをルールに書き添えた。
ついでに情報階層も整理する
配色を見直す過程で、設定画面の「同期」まわりが縦方向に冗長だったのも気になってきた。
- 「同期」セクション = ヘッダ + ボタン + 2 行ある説明 footer
- 「情報」セクション = 最終同期日時 + メモ数 + バージョン
- つまり "同期" に関する情報が 2 セクションに分散していて、footer と "最終同期" は似たことを別の言い方で伝えている
ここは 2 つのリファクタを同時に入れた。
- 「同期」セクションを「Google アカウント」セクションに統合。アカウント行の直下に同期行を置く。
- 同期行のサブタイトルに「最終同期: 2026年4月18日 23:22」を出す。これで「情報」セクション側の最終同期は削除できる。
- 初回ユーザー向けの長い footer 文は、サインイン画面側の導入文で十分カバーできているので削除。
結果、セクション数は減り、同期についての情報は 1 箇所で完結するようになった。原則として書き残したのはこれ。
情報階層のルール: 1 つのセクションに「操作 + その状態」を共存させる。別セクションでの重複表示は避ける。長い footer はオンボーディング専用で、ログイン後は caption サブタイトルに格下げする。
まとめ
- SwiftUI の
Buttonは List 内で子孫の色を支配する。.foregroundStyle(.secondary)と組むとサブタイトルに色が透ける。 - 対処は
.buttonStyle(.plain)+.contentShape(Rectangle())+ 明示色指定(Color.primary/Color.secondaryなど)。 - List 行の配色は 4 パターンに分類して明文化(通常 / 破壊的 / CTA / 無効化)。色は意味のシグナルなので多用しない。
- CTA 扱いは稀。「自動処理が主で手動は補助」の操作は通常行に留める。
- 情報階層は「操作 + 状態」を同じセクションに共存させ、重複表示を消す。
この記事の内容はそのままリポジトリの swiftui-patterns.mdc(Cursor Rules)に反映したので、次に設定画面を触るときは最初から正しい配色で書ける。ルール化のサイクル自体も Cursor Rules は PR ごとに進化する の実例になっている。