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

写真が潰れていたのは表示じゃなくデコードだった — Flutter の画像サイズ指定と、写真メインアプリの cover/contain 判断

Flutter画像UI設計綴Photo

綴Photo の「写真の見せ方」を直した記録。デスクトップで一番大きく出す写真が縦に潰れていた原因と、写真メインのアプリで「枠に収める」より「全部見せる」をどう選んだか。

デスクトップ版の home 画面は、左半分に「最近綴った記憶」の写真を大きく出しています。使っていて、その写真が縦に間延びして見えることに気づいた。横長の風景写真が、ちょっとぽっちゃりして見える。最初は表示の BoxFit(写真を枠にどう収めるかの表示設定)の問題かと思ったけど、そこは cover(比率を保って枠を埋める=はみ出た端は切れる)になっていて、それなら歪まないはずだった。

原因は、枠のサイズをそのままデコードに渡していたこと

AI にコードを追ってもらったら、犯人は表示じゃなくデコードのほうだった。画像を読み込むとき、メモリ節約のために「このサイズで読み込んで」と指定しています。home パネルはそこに Size(w, w)=正方形を渡していた。

潰れるのはファイルではなくメモリのビットマップ。保存しない。ディスクの写真ファイル横長。比率は正しいカメラロール / ディスクアプリの DB ではない閉じても残るSize(w, w)メモリのビットマップ正方形に潰れている両軸指定の出力保存しない閉じると消えるcover表示の BoxFit潰れた絵を枠へ交差。置き場ではないもう手遅れ保存しない幅と高さの両方を渡すと、メモリ上でそのサイズちょうどに展開する。ファイルは触らない。片方だけ指定すれば比率は保つ。両方だと exact が比率を捨てる。
dart
// 横長写真を正方形サイズでデコード=この時点で縦に潰れる。
// 表示の cover は「潰れた正方形」を枠に合わせるだけで、もう手遅れ。
decodeLogicalSize: Size(w, w),  // ← 犯人

なぜ他の画面は無事だったのか

気になったのは、詳細画面も同じ部品で写真を出しているのに、そっちは潰れていないことだった。

渡すサイズはメモリのヒント。ファイルも DB も変えない。保存しない。home パネルSize(w, w) 正方形枠とも元写真とも違うメモリで縦に潰れる保存しない同じ部品でもここだけ詳細画面Size(幅, 幅/比率)元写真の比率から計算メモリでも潰れない保存しない同じ部品本棚のサムネ両軸固定 + cover同じ落とし穴メモリで軽く潰れる保存しない枠サイズをそのまま渡す潰れに効いていたのはデコードに渡すサイズだけ。表示の cover / contain は関係ない。詳細は元比率。home は正方形。本棚は両軸固定。いずれも画面の絵は保存しない。

ここで原因が腑に落ちた。本棚のサムネは見せ方(cover)を変えず、デコードサイズだけ一緒に直した。

比率を保つサイズを計算する

直す方向は決まった。デコードに渡すサイズを、元写真の比率を保ったまま枠を覆うサイズにする。これは純粋な計算なので、AI にテストから先に書いてもらった(テストを書いて、失敗を確認してから実装する流れ)。

dart
// cover 表示で枠を覆う、元写真の比率を保ったデコードサイズ。
Size? coverDecodeLogicalSize(Size frame, double? aspectRatio) {
  if (aspectRatio == null || aspectRatio <= 0) return null;
  final frameRatio = frame.width / frame.height;
  // 写真が枠より横長なら高さを枠に合わせる(幅がはみ出てクロップ)。
  return aspectRatio > frameRatio
      ? Size(frame.height * aspectRatio, frame.height)
      : Size(frame.width, frame.width / aspectRatio);
}

純関数なので「横長×正方形枠なら Size(200,100) が返る」みたいなテストで挙動を固められます。

同じ理屈で、枠に収める側(contain 用)も対で作った。並べてみると、条件式は同じで返す枝だけが入れ替わっています。

dart
// cover = 足りなくなる側の軸を枠にぴったり合わせる(もう片方がはみ出てクロップ)
return aspectRatio > frameRatio
    ? Size(frame.height * aspectRatio, frame.height)  // 枠より横長 → 高さを合わせる
    : Size(frame.width, frame.width / aspectRatio);   // 枠より縦長 → 幅を合わせる
 
// contain = はみ出す側の軸を枠にぴったり合わせる(もう片方に余白が出る)
return aspectRatio > frameRatio
    ? Size(frame.width, frame.width / aspectRatio)    // 枠より横長 → 幅を合わせる
    : Size(frame.height * aspectRatio, frame.height); // 枠より縦長 → 高さを合わせる

短い関数なのに、読むたびに「どっちがどっちだっけ」で一瞬止まります。条件の aspectRatio > frameRatio も「写真が横長か」ではなく「写真が枠より横長か」で、縦長写真でももっと縦長の枠に入れれば成立します。

対で作るとき片方から写すと、そのまま真逆になりえます。厄介なのは、取り違えても返る比率は正しいままなところです。潰れないので画面を見ても分かりません。cover 側を間違えれば枠を覆えず小さくてぼやける、contain 側なら必要以上に大きくデコードしてメモリを余分に食う——絵は合っているのに数字が違う、という出方になります。

本棚は cover、home は「全部見せる」contain

ここからが判断だった。潰れを消すだけなら、全部 cover(比率を保ってクロップ)で揃えてもいい。でも実機で見ていて、home の大きな写真パネルは違う気がした。綴Photo は写真に言葉を綴るアプリで、写真が主役です。cover は枠を埋めるかわりに写真の端を切ります。本棚みたいな小さいサムネは、整列のためにクロップでいい。でも home のメインの写真は、切らずに全部見せたいと思った。

なので home だけ contain(写真全体を枠に収める)に変えると決めた。問題は、縦長のパネルに横長写真を全体表示すると上下に余白が出ること。そこは、同じ写真を大きくぼかして背面に敷いて埋めることにした(Instagram の縦長投稿でよく見るやつ)。

元ファイルはディスク。画面の絵はメモリ。保存しない。本棚のサムネcover。端は切れる小さいので整列優先クロップで枠を埋めるビットマップはメモリ保存しないhome のメイン写真contain。全部見せる余白は同じ写真のぼかし背面もメモリのコピーファイルはディスクのまま保存しない整列させたい所は cover、主役は contain。同じ写真でも場所で判断が違う。どちらも元ファイルは触らない。展開した絵はメモリにだけある。
dart
// 背面: 同じ写真を cover で広げてぼかし、余白を埋める。
ImageFiltered(
  imageFilter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
  child: AppPhotoImage(source: photo, fit: BoxFit.cover /* ... */),
),
// 前面: 写真全体を contain で見せる(主役)。
AppPhotoImage(source: photo, fit: BoxFit.contain /* ... */),

実機で値を詰める

ぼかしの強さと、背面を暗くする量は理屈で決まらないので、実機で見ながら動かした。最初はぼかし強め・暗め(sigma 28・黒 38%)にしたら、写真の世界観が背面で死んでいた。段階的に弱め・明るめへ落として(ぼかし sigma 28→12、背面の黒 38%→18%)、前面の写真がちゃんと主役で、背面はうっすら色が回る程度に落ち着いた。この good / no-good は実機を見ないと判断できなかった。

学びポイント

  • 表示の fit が正しくても、デコードのサイズ指定で比率は壊れます。 幅・高さ両方を枠サイズで渡すと exact policy で潰れます。デコードに渡すサイズは「元写真の比率を保った値」にします。
  • cover と contain は、同じ条件式で返す枝が入れ替わっているだけ。 取り違えても比率は正しいまま返るので、画面を見ても気づけません(枠を覆えず小さい/必要以上に大きい、という形で出ます)。
  • 同じバグでも、画面ごとに出たり出なかったりします。 無事な画面との違い(デコードに渡すサイズが元比率か枠比率か)を並べると原因が絞れた。表示の cover/contain は関係なく、効いていたのはデコードサイズのほうだった。
  • 「潰れを直す」と「どう見せたいか」は別の判断。 整列させるサムネは cover、主役の写真は contain。アプリが何メインかで選びます。
  • 理屈で決まらない値(ぼかし・暗さ)は実機で動かします。 コードは AI が書けても、good / no-good の最終判定は自分の目だった。

おわりに

写真メインのアプリなのに、一番大きく出す写真が潰れて端も切れていた。直してみて、「枠に合わせる」のが当たり前じゃなく、「全部見せる」を選べる場面があると分かった。小さく整列させる所はクロップ、主役は全体表示。同じ写真の出し方でも、場所によって判断が違っていいです。

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