リリースノート

「押した」「終わった」を色で返す — こども向けUIの状態表現を設計する

こども向けのタスク管理アプリで、やること一覧の状態表現を作り直しました。扱ったのは「押している」「終わった」の2つの状態と、狭い画面での情報の置き場所です。

前のリリースで、やること行は「シール」の語彙に寄せました。こどもが4色から選んだ色を地に敷き、完了したら影を消して沈ませる。このとき 色は装飾であって状態ではない と決めています。こどもが選んだ色と、システムが決める状態が、同じ「色」という軸を奪い合うのを避けたかったからです。

今回はその線引きを引き直しました。何を根拠に引き直したかを書きます。

装飾の色と状態の色は、同じ軸を奪い合うのか

前提を疑うところから始めました。「色は装飾だから状態に使えない」という命題は、色を一次元のものとして扱ったときにだけ成り立ちます

色相・彩度・明度の3軸に分けると、話が変わります。こどもが選んでいるのは実質的に色相です。「ぼくのしゅくだいは青」という認識であって、「彩度70%の青」ではありません。だとすれば、色相を保ったまま彩度だけを動かせば、装飾としての選択を壊さずに状態を乗せられます。

採用したのはこの形です。

  • 未完了: こどもが選んだ色をそのまま使う
  • 完了: 色相を保ったまま彩度を大きく落とす

彩度を 0 にはしていません。完全な無彩色にすると「どのやることだったか」を色で辿れなくなります。わずかに残すことで、並べたときに元の選択の名残が見えます。

// 明度は保ち、彩度だけを落とす。
// 暗くすると「沈んだ」ではなく「影が乗った」に見え、
// 完了マークより先に目を引いてしまう。
Color desaturated(Color base, double saturation) =>
    HSLColor.fromColor(base).withSaturation(saturation).toColor();

明度を触らないのが要点です。暗くする方向で非活性を表現すると、行が重く見えて視線を集めてしまいます。狙いは逆で、終わったものは視界から引くことです。

一般化できる基準

「装飾と状態が同じ属性を奪い合う」と感じたときは、その属性がもっと細かい軸に分解できないかを先に確かめる。分解できるなら、片方が使っていない軸に状態を逃がせます。

同じ変換を全色に一律で当てられない場所がある

地の色を彩度で処理できたので、枠にも同じ変換を当てようとしました。ここは通りませんでした。

やること行の枠には、地に対してコントラスト比 3:1 以上という要件を課しています。枠が行の輪郭、つまりタップできる範囲を規定しているからです。この要件のもとで4色それぞれの枠を無彩化すると、結果が揃いません。

枠の色 変換前の 枠/地 一律に無彩化した場合
オレンジ系 3.09:1 4.35:1
ブルー系 3.06:1 3.17:1
グリーン系 3.05:1 3.83:1
ピンク系 3.00:1 2.56:1

ピンク系だけが基準を割ります。原因は相対輝度の計算にあります。輝度は RGB の各成分に異なる係数を掛けて求められ、赤の寄与は緑よりかなり小さい。赤みの強い色は、HSL 上の明度が同じ無彩色に置き換えると輝度が上がります。淡い地に対して枠が明るくなる方向へ動くので、差が縮むわけです。

対処として、枠は状態に応じて別のトークンへ差し替えることにしました。完了行の文字に使っている落ち着いたインク色を、枠にも使います。行全体が無彩色のインク一色でまとまり、コントラストは最悪でも 5:1 台を確保できます。

ここで「枠を弱くして後退させる」案は成立しませんでした。3:1 を満たす無彩色は、どれも元の枠より輝度が低くなります。淡い地に対して十分な差を作るには、ある程度暗くならざるを得ないからです。後退感は地の色が抜けることで作り、枠は可読性だけを担当すると役割を分けました。

一般化できる基準

コントラスト要件のある要素に、色変換を一律で適用しない。変換は色相ごとに違う結果を返すので、要件を持つ要素は変換ではなくトークンの差し替えで扱う

押下は自前の状態として持つ

行をタップしたときの反応も作り直しました。

Flutter で「押した感じ」を出す標準的な部品はインクの波紋ですが、これは祖先の Material の上に描かれます。その手前で子が不透明な地を塗ると、波紋は常に背面に隠れます。 地の色がデザインの中心にある画面では、この構造と噛み合いません。

選択肢は2つありました。

  1. 地の描画を Ink へ移し、波紋が地の上に出るようにする
  2. 押下状態を自前で保持し、地の色そのものを変える

採ったのは2です。理由は3つあります。

  • こども向けでは「押した」が一目で分かる必要がある。淡い波紋より、地の濃度が変わるほうが強い
  • 完了表現で既に「沈み込み」という語彙を使っている。押下も同じ語彙にすれば、画面全体の言語が1つに揃う
  • 波紋は指の位置から広がるので、行のどこを押したかで見え方が変わる。状態としての一貫性が出ない

実装は押下フラグを持ち、地の色と余白の配分を切り替えるだけです。

// 押下中の地は、行が持つ枠色を薄く混ぜて作る。
// 別のブランド色を足すと、こどもが選んだ4色のどれかに見えてしまい
// 「色が変わった」と誤解される。同じ色相のまま濃度だけを上げる。
final surface = pressed
    ? Color.alphaBlend(borderColor.withValues(alpha: 0.24), fill)
    : fill;

// 押下中は完了と同じ「沈む」表現にする。
// 完了の沈みとは滞留時間が違う(指を離せば戻る)ため、状態は混同されない。
// 上下の余白を入れ替えるので、沈んでも行の総高は変わらない。
final sunk = isChild && (isDone || pressed);

押下色を行自身の枠色から作るのが要点です。共通のグレーを重ねると、4色のうち特定の色に寄って見えます。行が持っている色を濃くする方向なら、どの色でも「同じ行が濃くなった」としか読めません。

触覚だけに頼らないことも決めました。触覚は補助であって、触覚を切っている端末では何も返らないからです。視覚・触覚のどちらか一方でも成立する状態にしています。

ジェスチャの競合は仕様として引き受ける

行は横スワイプの削除も持っているので、押下の確定はタップ判定の待ち時間を越えた後になります。押した瞬間ではなく、ごく短い保持のあとに色が変わる挙動です。

これは回避せず、そのまま受け入れました。ポインタが降りた瞬間に色を変えると、リストをスクロールしようとしただけの指にも反応してしまいます。スクロールの開始と押下を見分けるための待ち時間なので、削るべきものではありません。

固定した情報をスクロールへ逃がす

小さい画面では、縦の予算が足りていませんでした。4.7インチ級の端末で内訳を出すと、こう見えます。

要素 高さ
ステータスバー 20
アプリバー 56
キャラクターの表示領域 232
連続達成の表示 約52
下部のアクション 約80
やること一覧に残る領域 約227

画面の34%しか一覧に使えていません。行の高さが最低72に余白8なので、2.8行しか見えない計算です。

削れるものを探すと、連続達成の表示(「◯にち つづいてるよ!」)が固定の帯として52を占めていました。これをスクロール領域へ移せば、帯のぶんがそのまま一覧に回ります。

置き場所は3案を比較しました。

トーストにする案 は採りませんでした。トーストは「いつ出すか」を設計する必要があり、画面表示ごと・記録が伸びた瞬間・完了時のどれを選んでも、見逃しかうっとうしさとのトレードオフになります。加えて、ごほうびや進化の演出と表示タイミングが競合しうる。発火条件を持たない案があるなら、そちらのほうが単純です。

スクロールの先頭に置く案 も見送りました。動機づけの表示は、見えていること自体に意味があります。ただし一覧の先頭に置くと、まだ何もしていない状態で最初に目に入る。

採ったのは最下部 です。こどもは一覧を上から順に消していくので、最後の1件を終えた直後に連続日数が視界へ入ります。「全部できた → ◯にち つづいてるよ!」という順序が自然にできる。加えて、最初の1画面が丸ごとやること専用になります。

一覧に使える領域は約227から約279になり、見える行数は2.8から3.5へ増えました。

受け入れたトレードオフもあります。やることが多いと、連続日数は事実上見られません。これは操作ではなく環境情報なので、常時見えている必要はないと判断しました。

ロールの差は、引数を渡すか渡さないかで作る

この画面はこども向けとおとな向けの両方から使われています。おとな向けは縦に余裕があるので、帯のままで構いません。

一覧コンポーネントに「末尾に添える要素」を任意の引数として足し、渡すか渡さないかだけでロールの差を作りました。ロール判定のフラグも、画面サイズによる分岐も持っていません。

// こども向け: スクロール内に入れて縦を稼ぐ
TaskList(items: items, footer: StreakBadge(...));

// おとな向け: 渡さない。呼び出し元が従来どおり帯を描く
TaskList(items: items);

条件分岐をコンポーネントの内側に持たせると、「どのロールか」「どの画面幅か」という知識がコンポーネントへ漏れます。判断は呼び出し元に置き、コンポーネントは受け取ったものを配置するだけにしておくほうが、後から条件が増えたときに壊れません。

器が3種類あるときは、器ごとに置き方を決める

一覧の実体は3つに分岐しています。通常のリスト、並び替え可能なリスト、そして横幅の広い端末で使うグリッドです。

  • 並び替え可能なリスト: Flutter の ReorderableListView は末尾要素の引数を標準で持っています。しかも並び替えの対象外に置かれるので、ドラッグ中に掴めてしまう心配がありません
  • 通常のリスト: 要素数を1つ増やし、最後の1件として積みます。区切りの余白がそのまま効くので、やること同士と同じ間隔が空きます
  • グリッド: ここだけ帯のまま据え置きました

グリッドを揃えなかったのは、そもそも解こうとしている問題がそこには無いからです。横幅の広い端末は縦にも余裕があり、領域を稼ぐ必要がありません。加えて複数列のグリッドの末尾に1列ぶんの横長要素が挟まると、列の流れが途切れて読みにくくなります。

一貫性は目的ではなく手段です。同じ問題が存在しない場所まで同じ解を当てにいくと、その場所では副作用だけが残ります。

まとめ

  • 装飾と状態が同じ属性を奪い合うと感じたら、その属性を細かい軸に分解する。色相・彩度・明度に分ければ、片方が使っていない軸に状態を逃がせる
  • コントラスト要件のある要素に色変換を一律で当てない。変換の結果は色相ごとに違う。要件を持つ要素はトークンの差し替えで扱う
  • 押下の表現は、地の描画方式と噛み合う方法を選ぶ。不透明な地を塗る設計では、状態を自前で持って地の色で返すほうが素直になる
  • 触覚は補助として扱い、視覚だけでも成立させる
  • 発火条件を設計しなければならない案と、持たなくていい案があるなら、後者を先に検討する
  • ロールや画面幅による差は、コンポーネントの内側の条件分岐ではなく、呼び出し元が渡すものの差で表す
  • 同じ問題が存在しない場所には、同じ解を当てない