綴Photo の勉強ノートに戻る
開発記録2026.08.13

上限を上げるコストはゼロだと数えた。器が伸びる面だけ数え忘れていた

FlutterレイアウトTransform設計判断綴Photo

作っている最中に自分で使っていて出た不具合と、その直し方を決めるまでの記録です。

綴りの本文に 300 字の上限が入っていた。文字数を上げるか迷って、実際に自分が Claude に書いた内省テキストを1本当ててみたら 541 字あって、300 で切ると話が転回する直前で落ちる。フォト日記だと言っている以上これは短い、と判断して上げることにした。

上げる前に、影響が出そうな場所を数えた。

  • DB: TEXT 型(長さを決めずに文字を入れられる型)なので、上限を宣言しても容量は予約されない。書いた分しか使わない
  • サーバ側: 調べたら MEANING_TEXT_MAX = 5000 を最初から持っていた。300 はアプリだけの値だった
  • ホーム・本棚: どちらも本文そのものを出していない。ホームは気分の絵文字と日付だけ、本棚は背表紙に月と枚数だけ
  • 詳細の便箋面: SingleChildScrollView(中身が長ければ縦にスクロールできる箱)の中なので縦に伸びるだけ

4つとも移行作業なし・コード変更なし。上げるコストはゼロと結論して 5000 にした。

その日の夜、試しに長文を1本綴じてみたら、アルバム面で文字が左端から切れていた。

角度は同じでも、高さが伸びると横にはみ出す

アルバム面は綴りを付箋として縦に並べていて、1枚ずつわずかに傾けてある。紙を貼った感じを出すためのもので、角度はモバイルとデスクトップで違う。

dart
// モバイル
final angle = (isEven ? -3.8 : 3.2) * (math.pi / 180);
// デスクトップ
final angle = (isEven ? -2.0 : 1.8) * (math.pi / 180);

数度なので、短い付箋では傾いていることに気づくかどうかという程度に見える。

高さはレイアウトのメモリ。本文は端末 SQLite の meanings.body。角度は保存しない。短い付箋 200px親が見る箱中心片側 6.6px余白 20px の内側長い付箋 1100px中心切る片側 36px余白を越え切られるはみ出す量 = tan(角度) × 高さ ÷ 2。同じ 3.8 度。高いほど端だけ横へ出る。

厳密に中心回転から導くと tan ではなく sin になる。実装は tan で持っていて、tan は sin より必ず少し大きく出る(3.8 度なら 0.2% ほど)=多めに見積もる側なので、はみ出しを見逃す向きには外れない。

余白 20px を食い破り始めるのは高さが 600px を超えたあたり(20 ÷ 0.066 × 2)。エラーも警告も出ない。

テストで 1460 字の付箋を置いて実際の四隅を測ったら、モバイル面で 17.3px はみ出していた。デスクトップ面も同じ形で赤になった(テストが落ちた)。角度が小さく(2.0 度)余白も広い(24px)ぶん壊れ始めるのが遅いだけで、上限が無いことに変わりはない。

これは上限を上げて生えた欠陥ではない。 付箋の高さに天井が無いのは最初からだった。ただし 300 字では保存の時点で弾かれるので、ここまで背の高い付箋はそもそも作れなかった。穴は前から開いていて、上限を上げたことで初めてそこに手が届いた。

文字数で切るか、高さで切るか

どうするかは自分で決めて返した。「文字数に制限かけて角度変える。ある一定文字以上だとまっすぐにする」と。傾きを弱めるのではなく、傾く/まっすぐの2値にする。

2値にすると、長い付箋からは傾きが完全に消える。紙を貼った感じを出すために入れた角度が、長文のときだけ無くなるということだ。それで構わないと判断した。演出のために文字が切られるより、まっすぐでも最後まで読めるほうがいい。 高さに応じて角度を連続で弱めれば見た目は残せるが、そのぶん傾きが本文の長さの関数になって、並んだ付箋が一枚ずつ違う角度になる。2値なら、出てくる角度はモバイル/デスクトップそれぞれの基準値か 0 のどちらかで済む。

方針はそのまま通って、閾値だけ変わった。文字数ではなく高さで持つ形になった。

同じ 1000 字でも、モバイルとデスクトップでは幅もフォントサイズも違うので、出来上がる付箋の高さが変わる。文字数で閾値を持つと、面ごとに別の数字を置くことになって、片方だけ正しい状態が長く残る。高さで持てば、判定はどちらの面でも同じ式で済む。閾値は、人が読める単位ではなく壊れる条件そのもので持つほうが割れない。

判定そのものは関数1本に収まった。

dart
double stickyNoteTiltAngle({
  required double baseRadians,
  required double noteHeight,
  required double horizontalSlack,  // 親が横に持っている余白(面ごとに違う)
}) {
  final overhang = math.tan(baseRadians.abs()) * noteHeight / 2;
  return overhang <= horizontalSlack ? baseRadians : 0.0;
}

引っかかるのは、この判定に必要な noteHeight がレイアウトが終わるまで分からないことだった。レイアウトというのは、画面を描く前に「どの部品を・どこに・どれだけの大きさで置くか」を計算する工程のこと。

本文は端末 SQLite の meanings.body に残る。高さはレイアウトのメモリ。角度は保存しない。端末 SQLitemeanings.body閉じても残るwidget 組み立て高さはまだ無いメモリ。保存しない描画オブジェクトsize.height が乗るメモリ。保存しない交差 stickyNoteTiltAngle余白以内なら傾ける。超えたら 0置き場所ではない。メモリ。保存しないprefs にもファイルにも無い

本文を TextPainter(文字を描かずに幅と高さだけ測る道具)で先に測って高さを自分で計算する手もあった。ただそれをやると、「付箋の高さがいくつになるか」を決める場所が、実際に描く側と測る側の2箇所に生える。片方だけ余白やフォントを変えたときに静かにズレるので採らなかった。

そこで、Flutter が内部で回転を担当している部品(RenderTransform)を土台にして、子の高さが決まった直後に角度を計算し直す形にしてもらった。下のコードはその置き場所を示している。

dart
class RenderStickyNoteTilt extends RenderTransform {   // 既存の「回す担当」を土台に、1か所だけ差し替える
  @override
  void performLayout() {
    super.performLayout();          // ← ここで子の高さが確定する
    transform = Matrix4.rotationZ(  // 実際に回す指示。割り当てが終わったあとに適用される回転がこれ
      stickyNoteTiltAngle(
        baseRadians: _baseRadians,
        noteHeight: size.height,    // 確定した高さを使う
        horizontalSlack: _horizontalSlack,
      ),
    );
  }
}

horizontalSlack(振れてよい幅)は面ごとに違うので、共通部品側では決めずに呼び出し元から渡す。渡す値はスクロール領域の左右余白と同じ定数を指していて、余白を変えたときに片方だけ古くならないようにしてある。

見た目については「長くても付箋っぽくはないけど、まぁあの見た目は割と気に入ってるから大丈夫」と返した。

学び

「上限を上げるコストはゼロ」と数えたが、ゼロだったのは4つのうち3つだった。 残る本棚は本文を1文字も出していないのに、その月の本文の平均文字数が背表紙の高さに入っていて、長い綴りが増えると背が伸びて天井に張り付く。文字として出していないから「表示は変わらない」と数えてしまった。アルバム面に至ってはリストに入ってすらいなかった。

本文は端末 SQLite の meanings.body とサーバ photos_meaning.text に残る。ゼロだった場所端末 SQLitemeanings.bodyTEXT。予約しないゼロサーバphotos_meaning上限 5000 は既にあったゼロホーム気分と日付だけ本文を出さないゼロ便箋面縦スクロール伸びても切れないゼロ漏れた「伸びるか」本棚本文は出さない。平均文字数が背の高さメモリで計算。保存しない数えたが、伸びるアルバム傾きではみ出し、スクロール器が切る高さはメモリ。保存しないリストに無かった

上限や件数を上げるときは「入るか」だけでなく「伸びた器が周りを壊さないか」を数える項目として1つ足す。

装飾に付けた固定値は、中身が伸びると効き方が変わる。 角度ではなく中身の側が伸びて余白を食い破っている。見た目のために置く値は、中身が何倍になっても壊れない範囲まで見て決める。

上限を上げる変更は、欠陥を作らない。眠っていた欠陥の発火条件を満たすだけだ。 300 字という上限が、天井の無い高さをこちらに踏ませずに済ませていた。裏返すと「今まで壊れなかった」は壊れないことの証明ではなく、単に発火条件に届いていなかったという記録でしかない。制限を緩めるときは、緩めた先で何が増えるかより、その制限が何を隠していたかを先に見る。

この記事は2026年8月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。