並び順をどこに持つか、機能にどうたどり着かせるか — ぽんっ! 1.1.1
こども向けの「やること」アプリ「ぽんっ!」(旧称:でいりんぐ)1.1.1 で、やることの並び替えを実装しました。行の左にあるハンドルをつまんで上下にドラッグすると、順番を入れかえられます。
見た目は素朴な機能ですが、決めることは 2 つありました。並び順をデータのどこに持つかと、画面に出ているリストがフィルタ済みのときに並び替えをどう成立させるかです。あわせて、同じリリースで手を入れた「機能の到達性」の設計についても書きます。
並び順を専用フィールドで持たない
やることは、その日ぶんのカードに配列として入っています。表示はこの配列の順です。
並び替えを実装するとき、最初に浮かぶのは各要素に order のような並び順フィールドを足す形です。今回はそれを採らず、配列そのものの順序を権威にしました。
理由は 3 つあります。
1. 表示順の権威が二重にならない。配列順と order の両方が存在すると、「どちらが正しいのか」を読み出し箇所ごとに決めることになります。既存のコードは配列順で表示していたので、そこに別の真実を持ち込むと、片方だけ更新された状態が生まれます。
2. 移行が不要になる。既存のカードには order がありません。フィールドを足すなら、無いときの既定値をどう決めるか、いつ埋めるかを設計する必要があります。配列順はすでに全データが持っている情報なので、その必要がありません。
3. 翌日への繰り越しに自然に乗る。このアプリは日付が変わると、直近のカードのやることを未完了に戻して当日のカードを作ります。この複製は配列をそのままコピーするので、並び順は何もしなくても翌日以降へ引き継がれます。order を持つ設計なら、繰り越し処理でも順序を維持する実装が必要でした。
「新しいフィールドを足す」より「既にある構造に意味を認める」ほうが、触る箇所が少なく済みます。
フィルタ済みのリストで並び替えを成立させる
厄介だったのはここです。画面に出ているやることは、繰り返し設定でフィルタされた結果です。曜日を指定していれば平日だけ、「毎月X日」を指定していればその日だけ表示されます。
つまり、ユーザーが並び替えているのは配列の一部でしかありません。UI が知っているのは「今日出ている 3 件をこの順にした」ということだけで、非表示の要素をどこに置くべきかは知りません。
設計として決めたのは、表示中の要素が占めていた位置(枠)だけを詰め替えるという規則です。
// 表示中の要素が配列内で占めている位置を集める
final slots = <int>[];
for (var i = 0; i < tasks.length; i++) {
if (requestedIds.contains(tasks[i].id)) slots.add(i);
}
// その枠に、指定された順で埋め直す。枠に入らない要素は動かさない
for (var i = 0; i < slots.length; i++) {
tasks[slots[i]] = orderedTasks[i];
}
たとえば配列が [A, B, C] で B が非表示のとき、A と C を入れかえると結果は [C, B, A] になります。B は 1 番目のまま動きません。
この規則にした理由は、非表示の要素の相対位置が保たれることです。別の案として「表示中の要素を先頭に集めて、非表示を末尾に追いやる」形も考えましたが、それだと土日にしか出ないやることが、平日に並び替えるたびに配列の末尾へ押し出されます。ユーザーには見えないところで順序が壊れていくので採りませんでした。
もう 1 つ決めたのは、枠の数と並び替え後の件数が合わないときは何もしないことです。
// 取得したカードが UI の前提とずれている状態(他端末での同時追加・削除など)。
// 順序を壊すより、何もしないほうが安全。
if (orderedTasks.length != slots.length) return;
このアプリはおとな端末とこども端末が同じデータを共有します。並び替えの操作中に相手が 1 件追加していれば、UI が前提にしていた配列と実際の配列が食い違います。そこで無理に書き込むと、意図しない位置に要素が飛びます。判断できないときは書かないほうが、あとから説明できる状態になります。
ロールごとに、並び替えを使えるかを分ける
並び替えは、おとな画面では常に使えます。こども画面では設定でオンにしたときだけにしました。既定はオフです。
こども画面を既定オフにしたのは、ドラッグとスクロールが同じ指の動きで競合するからです。やることが多い日はリストがスクロールします。低年齢のユーザーが縦にスワイプしたときに、意図せず順番が変わってしまうのは避けたい挙動です。
一方で「こどもは使えない」と決め打ちもしませんでした。順番を自分で決められることは、このアプリが大事にしている「自分でやる」感覚に効くはずだからです。既定はオフにしつつ、使いたい家庭は設定でオンにできる形にしました。機能を削るのではなく、既定値で守るという選択です。
ドラッグの起点をハンドルだけに限る
行はもともと、タップで完了トグル、長押しで削除メニューという操作を持っています。ここに「長押しでドラッグ開始」を足すと、長押しの意味が 2 つになります。
そこで、並び替えリストの既定のドラッグハンドル生成を切り、ハンドルの領域からだけドラッグが始まるようにしました。
ReorderableListView.builder(
// 行全体をドラッグ開始点にすると既存の長押し削除と衝突する
buildDefaultDragHandles: false,
itemBuilder: (context, i) => TaskRow(
dragHandle: DragHandle(index: i), // この領域からのみ引ける
// タップ=完了トグル、長押し=削除メニューは従来どおり
),
)
ハンドルは行の左端、カテゴリアイコンよりさらに左に置きました。触りやすさの下限(48dp)を満たす幅を確保しています。既存のジェスチャに意味を追加するのではなく、新しい操作には新しい当たり判定を与えるほうが、どちらも壊れません。
なお、横幅が広い端末のグリッド表示は並び替えの対象外にしました。並び替えリストは縦一列を前提にした仕組みで、グリッドに当てるには別の実装が必要になります。タブレット横画面では従来の一覧のままで、ハンドルも出していません。対応しないことを決めて、その範囲では見た目も変えないという切り方です。
機能があることと、たどり着けることは別
同じリリースで、こどもが「ふだんのスタンプ」(やることを完了したときに押される絵柄)を選べる導線を設定画面に追加しました。
もともとスタンプを選ぶ画面はあり、無料でも既定の絵柄群から選べる作りになっていました。ただ、こども側の入口が別機能の画面の中にありました。ごほうびカードのシートを開くと、その中に「スタンプをかえる」ボタンがあるという構造です。
この形だと、入口が別機能の利用状況に従属します。ごほうびカードを使っていない家庭では、シートを開く導線自体が現れないので、スタンプ選択画面へ到達できません。機能は完成していて権限も通っているのに、たどり着く経路がない状態です。
設計として直したのは 2 点です。
- 設定画面に独立した入口を置く。設定は「その機能を使いたいと思ったときに探す場所」で、他機能の利用状況に依存しません
- 空の状態にも到達できるようにする。ごほうびカードが 0 枚でもシートを開けるようにしました。これまで表示されることのなかった「まだカードがありません」という案内が、ここで初めて機能します
ここから引き出せる判断基準は、入口を別機能の内側に置くと、その機能の利用有無が前提条件になるということです。ある機能が単独で意味を持つなら、入口も単独で存在すべきでした。実装の完成度と、ユーザーから見た利用可能性は別に確認する必要があります。
到達性は、機能一覧を眺めていても分かりません。「この機能に、いま初めて使う人はどうたどり着くか」を経路として辿ると見えてきます。
検証で決めたこと
並び替えは、順序が保存されて次の日も保たれることが要件の中心です。そこで検証の重点を次のように置きました。
- 保存層のユニットテスト: 指定順の反映、非表示要素の位置保持、件数不一致で書き換えない、完了状態が変わらないこと
- 画面のウィジェットテスト: ハンドルの出し分け(ロール・設定による)、ドラッグで意図した順序が保存層に渡ること
- 実機の E2E: ハンドルをドラッグして、保存されたデータの順序が実際に入れかわること
E2E を 1 本足したのは、ドラッグは実機のジェスチャ処理を通らないと確からしさが出ないためです。逆にロール別の出し分けはウィジェットテストで十分なので、E2E には入れていません。実行コストの高い層には、そこでしか確認できないものだけを置く方針です。
まとめ
- 並び順は既にある構造(配列順)に意味を認めると、権威が二重にならず移行も要らない
- フィルタ済みのリストでの並び替えは、表示中の要素が占める枠だけを詰め替える。非表示の相対位置を壊さない
- 前提とデータが食い違ったときは書かずに諦める。判断できない状態で書くと、あとから説明できなくなる
- 既存のジェスチャに意味を足すのではなく、新しい操作には新しい当たり判定を与える
- 対応しない範囲は決めてよい。ただしその範囲では見た目も変えない
- 機能の完成度と到達可能性は別に確認する。入口を別機能の内側に置くと、その利用有無が前提条件になる
素朴に見える機能ほど、データの持ち方と入口の置き方で後々の触りやすさが変わる、という手応えがありました。