器を広げた後で、中身を守るものを数え忘れていた記録です。
言葉を綴るシートの文字数上限を、300字から5000字に上げた。実際に書きたかった文章が541字で、300字だと話が転回する直前で切れていた。上げるコストを面ごとに数えたら全部ゼロだったので、その場で上げた。
そのすぐ後に、長い付箋が画面からはみ出す方を踏んで直した(上限を上げるコストはゼロだと数えた。器が伸びる面だけ数え忘れていた)。
さらに2日後、もう1面あることに気づいた。長く書けるようにしたのに、その長文を守っているものが何も無かった。
自分で見つけたわけではない。判断待ちを棚卸ししてもらったら、4件のうちの1つとして上がってきた。
書きかけを守るものが1つも無かった
綴りシートは、画面の下からせり上がってきて、後ろの画面を覆う形で出る(モーダル=後ろを触れなくして、手前だけを操作させる出し方)。上には「保存」ボタンと「✕」ボタンがあり、✕ を押すと閉じる。付けてくれと言った覚えがあるのはここだけで、配線した(ボタンと「閉じる処理」を繋いだ)のは AI だ。
閉じ方が他にもあることを、あまり意識していなかった。
4つのうち3つは、showModalBottomSheet(下からシートを出す Flutter の呼び出し)を実行した時点で標準で付いてくる。システムの戻るは Android の話で、iOS だと ✕ / 外側タップ / 下へドラッグ の3つになる。なお同じシェルはデスクトップだと showDialog で出していて、そちらはドラッグで閉じる経路が無い。数が変わるのはモバイルだけの話。
300字の頃は、閉じても失うのは数行だった。5000字にした以上、1回閉じたときに消える量だけが増えている。
コードを探してもらったら、PopScope は1つも無かった。4つの経路のうち、確認が挟まるものはゼロ。
3つを1枚で受け止めた、つもりだった
やったことは、PopScope を1枚だけ置くこと。✕ 以外の3つは pop を通るので、そこで全部受け止まる ── そう数えて置いた。この数え方が1つだけ間違っていたことは、後で測って分かった(末尾の追記)。
// ✕ 以外の3経路は Navigator の pop を通るので PopScope 1枚で受け止める(つもりだった)
return PopScope(
canPop: !hasUnsavedInput, // 書きかけがある間だけ、閉じるのを止める
onPopInvokedWithResult: (didPop, _) async {
if (didPop) return; // 止めずに閉じた時(=書きかけ無し)は何もしない
if (await _confirmDiscard(context)) onClose(); // 確認を1枚出し、捨てるを選んだ時だけ閉じる
},
child: shell, // シートの中身
);✕ は maybePop を経由せず Navigator.pop で直接閉じるので、同じ判断を通す関数へ寄せた。
/// ✕ から閉じる。pop 経路と同じ判断を通す=入口が違っても結果は同じ。
Future<void> _handleClose(BuildContext context) async {
if (!hasUnsavedInput) {
onClose();
return;
}
if (await _confirmDiscard(context)) onClose();
}確認の出し方は「閉じる前に1枚挟む」を選んだ。他に「聞かずに下書きとして保存しておいて、同じ写真で開き直したら戻す」という案もあって、書いた分を黙って持っておく方が、綴る手を止めないぶん本来は近い。確認を1枚挟む形は、書きかけを守るために書きかけの手を一度止めることになる。ただそっちは下書きをいつまで持つか・復元したことをどう伝えるか・消し方をどうするかを決める必要が出てくる。今回は確認を1枚挟む方にした。
文言は4つとも定数にして仮置きしてある(定数=文字列に名前をつけて1箇所に置いたもの。使う側は中身ではなくその名前を見に行くので、1箇所を書き換えれば呼んでいる全部が同時に変わる)。書きかけを捨てるかどうかを聞く4行は、綴る手が止まる場所に出る。言い回しは後で自分で書き直すので、いまは仮のまま置いた。
// 仮=本人上書き前提(2026-08-14)。ここを唯一の正本にしてテストからも参照する
const kDiscardDraftTitle = '書きかけの言葉があります';
const kDiscardDraftMessage = 'このまま閉じると、綴った分は残りません。';
const kDiscardDraftKeepLabel = '書き続ける';
const kDiscardDraftDiscardLabel = '閉じる';テストもこの定数を参照するようにした。言い回しを後から変えても検査は落ちない。守っているのは「確認が挟まる」という性質であって、文言ではないので。
測れたのは2経路だけだった
書いたテストは5本で、通っている。ただ4つの経路のうち、この5本で踏めているのは2つだけだった。
ここで書いているテストは、実機に画面を出すのではなく、コードの中で部品を組み立てて、指の操作を真似して動かすもの。だから「部品として組み立てれば起きる操作」は踏めて、「シートとして下から出さないと起きない操作」は踏んでいない。
pop は NavigatorState.maybePop()(「1枚閉じてみて」とコードから直接頼む呼び出し)で起こしている。
ドラッグには気になる点があった。Flutter のモーダルシートは canPop を見ていて、false の間はドラッグ自体を跳ね返す実装になっているかもしれない。そうだとすると、下へ引っ張っても確認が出ずに「閉じないだけ」になる。外れるとしても軽い側だと見積もった ── 閉じないだけなら、守りたかった書きかけはそのまま残っている。
実機で触るまで分からないと思って、確認手順の方に「下スワイプも試す」と書いて渡した。テストで塞げなかった穴は、塞げなかったと書いて渡すしかない。
学び
入口を数える時は、頼んでいないのに付いているものから数える。 ✕ は頼んだ覚えがあるので真っ先に思い浮かぶ。危ないのは残りの3つの方で、フレームワークが親切に用意してくれている分、頼んでいない=意識に無いになる。「この画面を閉じる方法を全部挙げて」と問い直すと出てくる。
上限を上げると、壊れる場所と、壊れないまま損害だけ増える場所の2種類が出る。 2日前に踏んだのは前者で、伸びた付箋が画面からはみ出した=表示が実際に壊れた。今回は後者だった。閉じる4経路は5000字にする前も後も同じコードで、動き方は何も変わっていない。変わったのは1回閉じたときに消える量だけだ。前者は画面を見れば分かるが、後者は画面の上では何も起きていないので、2日気づかなかった。上限は入る量を決めているように見えて、一度に失える量も同じだけ決めている。
入口を1つずつ塞ぐと、5つ目が生えた時にまた1つ塞ぎ忘れる。 判断を1か所に寄せて全部そこを通す形にしておけば、入口が増えても塞ぎ忘れる場所そのものが無い。
追記 — ドラッグだけは通っていなかった
テストの中で showModalBottomSheet を実際に開けば、残る2経路もそのまま測れた。実機を待つ必要はなかった。測ったらこうだった(Flutter 3.44.0)。
跳ね返されるどころか、逆だった。Navigator.pop は canPop を見ない。見ているのは maybePop の方で、canPop: false のときに「pop しない」と決めて onPopInvokedWithResult を呼ぶのはそこにしかない。モーダルシートのドラッグは、閉じ始めた時に Navigator.pop を直接呼ぶ作りなので、PopScope の前を素通りする。
「pop を通るから1枚で受け止まる」という数え方が間違っていた。 受け止まるのは maybePop を通る経路だけで、pop を直接呼ぶ経路は、同じ「閉じる」でも別の入口だった。外れ方も、軽い側だという見積もりの方ではなかった。
8/22 に、下へ引っ張って閉じる道そのものを切った(enableDrag: false)。「書きかけがある時だけ切る」はできなかった。引っ張って閉じられるかどうかはシートを開いた時に一度だけ決まり、後から書き始めても変わらないので。残った閉じ方は、どれも確認を通る。テストには「引っ張っても閉じない」と並べて、「確認を挟まない普通のシートなら、同じ操作で閉じる」も置いた。こちらが緑でなければ、引っ張る操作がそもそも届いていない。
この記事は2026年8月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。