60dメモの設定画面を3観点でリファクタ — サブタイトルの色、CTA の定義、情報階層

AI 主体で執筆

設定画面を眺めていて「サブタイトルがうっすら赤く滲んでいる気がする」と気付いた。追いかけてみると、SwiftUI の Button の tint 伝播、List 行の配色ルール、CTA の定義、果てはセクション分割のあり方まで一気に繋がった。設定画面を直しながら「色と情報階層のルールを自分で明文化する」に至った記録。

発見:サブタイトルの灰色が灰色じゃない

60dメモの設定画面には、SF Symbol アイコン + タイトル + 説明サブタイトルという 2 行構成のボタンを並べた「トラブルシューティング」セクションがある。ある日スクリーンショットをじっと眺めていて、サブタイトルの色が想定した中立なグレーではないことに気付いた。

期待した見た目
消えたメモを復旧 この端末にない、Drive 上のメモを取り込みます。
Drive から再取得 この端末のメモを Drive のデータで置き換えます。
実際の見た目
消えたメモを復旧 この端末にない、Drive 上のメモを取り込みます。
Drive から再取得 この端末のメモを Drive のデータで置き換えます。

上のサブタイトルは青味がかり、下のサブタイトルは赤味がかっている。指定しているのは .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(階層的)な指定 — 「いま流れている前景色を薄くしたもの」を意味する。つまり:

意図していた「中立なグレー」にはならない。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。理由を整理しておく。

  1. 自動同期が主で、手動は補助。起動・バックグラウンド・変更検知で自動的に走るので、常時ユーザーを強く誘導する必要はない。
  2. 同じセクション内にアカウント行(primary 文字)が並ぶ。ここで同期行だけ accent 文字にすると、並列に見えるべき 2 行に視覚的な上下関係が生じて落ち着かない。
  3. 注意喚起は文脈依存で担保する。自動同期失敗や他端末からの変更検知は、画面上部にオレンジの警告カード(statusBanner)で明示的に出している。常時の強調を足すと、この警告の相対的な強さが弱まる。
  4. iOS 純正の慣例に倣う。設定アプリで「Fetch Now」「Sync Now」系は primary 文字 + accent アイコンの通常行として表現されている。

逆に未ログイン時の「Google でサインイン」は CTA として正当だ。サインインしない限り他の機能にアクセスできず、この画面で唯一かつ最重要の行動だから。CTA は "例外扱い"、というのをルールに書き添えた。

ついでに情報階層も整理する

配色を見直す過程で、設定画面の「同期」まわりが縦方向に冗長だったのも気になってきた。

ここは 2 つのリファクタを同時に入れた。

  1. 「同期」セクションを「Google アカウント」セクションに統合。アカウント行の直下に同期行を置く。
  2. 同期行のサブタイトルに「最終同期: 2026年4月18日 23:22」を出す。これで「情報」セクション側の最終同期は削除できる。
  3. 初回ユーザー向けの長い footer 文は、サインイン画面側の導入文で十分カバーできているので削除。

結果、セクション数は減り、同期についての情報は 1 箇所で完結するようになった。原則として書き残したのはこれ。

情報階層のルール: 1 つのセクションに「操作 + その状態」を共存させる。別セクションでの重複表示は避ける。長い footer はオンボーディング専用で、ログイン後は caption サブタイトルに格下げする。

まとめ

この記事の内容はそのままリポジトリの swiftui-patterns.mdc(Cursor Rules)に反映したので、次に設定画面を触るときは最初から正しい配色で書ける。ルール化のサイクル自体も Cursor Rules は PR ごとに進化する の実例になっている。