Flutterで単一画面の入力フローを作る の次に固めたいのが、1 件の入力へ画像を複数添付する UI です。業務アプリでは、検品写真、現場写真、補助画像を 1 枚だけで済ませない場面がよくあります。この記事では image_picker を使い、ギャラリーからの複数選択、上限枚数の制御、サムネイル一覧、削除、差し替えまでを 1 画面で確認します。
1. ゴールと非対象
対象読者
- Flutter プロジェクトを作成して
flutter runした経験がある人 - Flutterで単一画面の入力フローを作る の次に、画像添付の UI を足したい人
- ファイル出力やアップロードの前に、まず添付一覧の扱いを固めたい人
この記事で到達する状態
image_pickerでギャラリーから複数画像を選択できる- 添付上限を超えた分を UI 側で抑止できる
- サムネイル一覧をグリッド表示し、各画像を削除できる
- 1 枚ずつ差し替えながら添付内容を整えられる
- 後続のファイル出力やアップロードへつなぎやすい一覧状態を保持できる
非対象
- カメラ起動による撮影
- Multipart での画像アップロード
- ローカルファイル出力や CSV 生成
permission_handlerによる明示的な権限要求- Riverpod などの状態管理ライブラリ
今回は「添付一覧をどう持つか」と「利用者がどう編集するか」に絞ります。撮影、保存、送信は後続の段階へ分けます。
2. 先に添付フローを整理する
今回の流れは次の通りです。
flowchart TD
A[登録対象レコードを確認する] --> B[画像を複数選択する]
B --> C{上限枚数を超えるか}
C -->|はい| D[残り枠に入る分だけ追加する]
C -->|いいえ| E[選択分をそのまま追加する]
D --> F[サムネイルグリッドへ反映する]
E --> F
F --> G{並びを見て修正が必要か}
G -->|削除| H[対象画像を削除する]
G -->|差し替え| I[1 枚だけ再選択する]
H --> F
I --> F
F --> J{最低枚数を満たすか}
J -->|いいえ| K[確定不可のまま待つ]
J -->|はい| L[添付内容を確定する]
ここで先に決めておきたいのは、添付一覧の責務です。
- 画像選択は「一覧へ追加する操作」に限定する
- 上限判定は選択後に UI 側で一元管理する
- 差し替えは「1 枚を別画像へ入れ替える操作」として分ける
- 確定時には現在の添付一覧をスナップショットとして保存する
この形にしておくと、次の記事で data.csv や画像ファイル出力を足す時も、どの時点の一覧を保存対象にするかが曖昧になりません。
3. プロジェクトを作成し、確認環境を用意する
3-1. Flutter の環境構築がまだなら先に済ませる
環境構築がまだの場合は Windows 11で始めるFlutter開発環境 を先に参照してください。
このサンプルは flutter run で確認できます。
3-2. Flutter プロジェクトを作成する
次のコマンドでプロジェクトを作成します。
flutter create my_multi_image_attachment_app
cd my_multi_image_attachment_app
3-3. エミュレーターを起動する
利用可能なエミュレーター一覧を確認します。
flutter emulators
表示された ID を指定して起動します。
flutter emulators --launch <emulator_id>
3-4. image_picker を追加する
プロジェクト直下で次のコマンドを実行します。
flutter pub add image_picker
今回はギャラリーからの選択に絞るため、追加パッケージは image_picker だけです。明示的な権限分岐や撮影は扱いません。
3-5. エミュレーターへサンプル画像を入れる
エミュレーターに画像が入っていない場合は、手元の JPG または PNG をエミュレーター画面へドラッグ&ドロップしてください。投入後に Photos または Files アプリで見えることを確認しておくと、後のギャラリー選択が止まりません。
検証用の画像をすぐ用意したい場合は、下のボタンからサンプル PNG を新しいタブで開きます。画像を保存し、エミュレーターへ入れてください。
端末側に複数枚を選べる画像ソースがあれば準備完了です。
サンプル PNG を入れたあと、Downloads に 4 枚並んでいれば準備完了です。
4. lib/main.dart に複数画像添付 UI を実装する
lib/main.dart は次の内容で作成します。
このファイルは、登録対象カード、複数画像の追加、上限制御、重複除外、差し替え、削除、確定履歴までを 1 画面で確認するサンプルです。注目点は _appendPickedImages() で上限と重複をまとめて扱い、_saveCurrentAttachments() で現在の一覧を別リストへ複写しているところにあります。
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Multi Image Attachment Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
useMaterial3: true,
),
home: const MultiImageAttachmentPage(),
);
}
}
class WorkRecord {
const WorkRecord({
required this.inspectionId,
required this.itemName,
required this.lotNumber,
required this.storageLane,
});
final String inspectionId;
final String itemName;
final String lotNumber;
final String storageLane;
}
class AttachmentItem {
const AttachmentItem({
required this.id,
required this.file,
required this.addedAt,
});
final String id;
final XFile file;
final DateTime addedAt;
String get fileName {
if (file.name.isNotEmpty) {
return file.name;
}
return file.path.split(RegExp(r'[\\/]')).last;
}
}
class SavedAttachmentBatch {
const SavedAttachmentBatch({
required this.record,
required this.items,
required this.savedAt,
});
final WorkRecord record;
final List<AttachmentItem> items;
final DateTime savedAt;
}
class MultiImageAttachmentPage extends StatefulWidget {
const MultiImageAttachmentPage({super.key});
@override
State<MultiImageAttachmentPage> createState() =>
_MultiImageAttachmentPageState();
}
class _MultiImageAttachmentPageState extends State<MultiImageAttachmentPage> {
static const int _minAttachmentCount = 1;
static const int _maxAttachmentCount = 4;
final ImagePicker _picker = ImagePicker();
final WorkRecord _record = const WorkRecord(
inspectionId: 'INSP-240315-001',
itemName: '冷蔵ケース部品 A',
lotNumber: 'LOT-240315-A',
storageLane: 'A-03',
);
List<AttachmentItem> _currentItems = <AttachmentItem>[];
final List<SavedAttachmentBatch> _savedBatches = <SavedAttachmentBatch>[];
bool _isPicking = false;
int get _remainingSlots => _maxAttachmentCount - _currentItems.length;
bool get _canSave => _currentItems.length >= _minAttachmentCount;
Future<void> _pickMultipleImages() async {
if (_remainingSlots <= 0) {
_showSnackBar('上限 $_maxAttachmentCount 枚に達しています。差し替えか削除を行ってください。');
return;
}
setState(() {
_isPicking = true;
});
try {
final List<XFile> picked = await _picker.pickMultiImage(imageQuality: 85);
if (picked.isEmpty) {
return;
}
_appendPickedImages(picked);
} finally {
if (!mounted) {
return;
}
setState(() {
_isPicking = false;
});
}
}
void _appendPickedImages(List<XFile> picked) {
final Set<String> existingPaths =
_currentItems.map((AttachmentItem item) => item.file.path).toSet();
final List<XFile> uniqueIncoming = <XFile>[];
for (final XFile file in picked) {
final bool isDuplicate =
existingPaths.contains(file.path) ||
uniqueIncoming.any((XFile incoming) => incoming.path == file.path);
if (isDuplicate) {
continue;
}
uniqueIncoming.add(file);
}
if (uniqueIncoming.isEmpty) {
_showSnackBar('追加できる新しい画像がありませんでした。');
return;
}
final List<XFile> accepted = uniqueIncoming.take(_remainingSlots).toList();
final DateTime now = DateTime.now();
setState(() {
_currentItems = <AttachmentItem>[
..._currentItems,
for (int index = 0; index < accepted.length; index++)
AttachmentItem(
id: '${now.microsecondsSinceEpoch}-$index',
file: accepted[index],
addedAt: now,
),
];
});
if (accepted.length < uniqueIncoming.length) {
_showSnackBar(
'上限 $_maxAttachmentCount 枚のため、${accepted.length} 枚だけ追加しました。',
);
return;
}
_showSnackBar('${accepted.length} 枚の画像を追加しました。');
}
Future<void> _replaceImage(int index) async {
final XFile? replacement = await _picker.pickImage(
source: ImageSource.gallery,
imageQuality: 85,
);
if (replacement == null) {
return;
}
final bool alreadyUsed = _currentItems
.asMap()
.entries
.any((MapEntry<int, AttachmentItem> entry) {
return entry.key != index && entry.value.file.path == replacement.path;
});
if (alreadyUsed) {
_showSnackBar('同じ画像がすでに添付されています。別の画像を選んでください。');
return;
}
final DateTime now = DateTime.now();
setState(() {
final AttachmentItem current = _currentItems[index];
_currentItems = <AttachmentItem>[
..._currentItems.sublist(0, index),
AttachmentItem(
id: current.id,
file: replacement,
addedAt: now,
),
..._currentItems.sublist(index + 1),
];
});
_showSnackBar('画像を差し替えました。');
}
void _removeImage(int index) {
final String fileName = _currentItems[index].fileName;
setState(() {
_currentItems = <AttachmentItem>[
..._currentItems.sublist(0, index),
..._currentItems.sublist(index + 1),
];
});
_showSnackBar('$fileName を削除しました。');
}
void _clearAllImages() {
if (_currentItems.isEmpty) {
return;
}
setState(() {
_currentItems = <AttachmentItem>[];
});
_showSnackBar('現在の添付一覧をクリアしました。');
}
void _saveCurrentAttachments() {
if (!_canSave) {
_showSnackBar('最低 1 枚の画像を追加してください。');
return;
}
final DateTime now = DateTime.now();
setState(() {
_savedBatches.insert(
0,
SavedAttachmentBatch(
record: _record,
items: List<AttachmentItem>.unmodifiable(_currentItems),
savedAt: now,
),
);
_currentItems = <AttachmentItem>[];
});
_showSnackBar('添付内容を確定しました。次の入力へ進めます。');
}
void _showSnackBar(String message) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(message)));
}
String _formatDateTime(DateTime value) {
String twoDigits(int number) => number.toString().padLeft(2, '0');
return '${value.year}-${twoDigits(value.month)}-${twoDigits(value.day)} '
'${twoDigits(value.hour)}:${twoDigits(value.minute)}';
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('複数画像添付 UI デモ'),
),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_RecordSummaryCard(record: _record),
const SizedBox(height: 16),
_buildAttachmentHeader(),
const SizedBox(height: 12),
_buildAttachmentBody(),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _canSave ? _saveCurrentAttachments : null,
icon: const Icon(Icons.task_alt),
label: const Text('添付内容を確定する'),
),
const SizedBox(height: 24),
Text(
'確定履歴',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
if (_savedBatches.isEmpty)
const Text('まだ確定履歴はありません。画像を 1 枚以上添付してから確定してください。')
else
Column(
children: _savedBatches.map(_buildSavedBatchCard).toList(),
),
],
),
),
),
);
}
Widget _buildAttachmentHeader() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'添付画像',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'最低 $_minAttachmentCount 枚、最大 $_maxAttachmentCount 枚まで添付できます。'
' 現在は ${_currentItems.length} 枚で、残り $_remainingSlots 枚です。',
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
FilledButton.icon(
onPressed: _isPicking ? null : _pickMultipleImages,
icon: const Icon(Icons.photo_library_outlined),
label: Text(_isPicking ? '画像を選択中...' : '画像を追加する'),
),
OutlinedButton.icon(
onPressed: _currentItems.isEmpty ? null : _clearAllImages,
icon: const Icon(Icons.clear_all),
label: const Text('一覧をクリアする'),
),
],
),
],
);
}
Widget _buildAttachmentBody() {
if (_currentItems.isEmpty) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.teal.shade50,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.teal.shade100),
),
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Icon(Icons.add_photo_alternate_outlined, size: 32),
SizedBox(height: 12),
Text(
'まだ画像は添付されていません。',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
SizedBox(height: 8),
Text(
'まずはギャラリーから複数画像を選び、必要に応じて削除や差し替えを行います。',
),
],
),
);
}
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _currentItems.length,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 0.78,
),
itemBuilder: (BuildContext context, int index) {
final AttachmentItem item = _currentItems[index];
return Card(
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Expanded(
child: Image.file(
File(item.file.path),
fit: BoxFit.cover,
errorBuilder: (BuildContext context, Object error, StackTrace? stackTrace) {
return const ColoredBox(
color: Color(0xFFE5E7EB),
child: Center(
child: Icon(Icons.broken_image_outlined, size: 36),
),
);
},
),
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
item.fileName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
Text(
'追加: ${_formatDateTime(item.addedAt)}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: Row(
children: <Widget>[
Expanded(
child: TextButton.icon(
onPressed: () => _replaceImage(index),
icon: const Icon(Icons.swap_horiz),
label: const Text('差し替え'),
),
),
IconButton(
onPressed: () => _removeImage(index),
icon: const Icon(Icons.delete_outline),
tooltip: '削除',
),
],
),
),
],
),
);
},
);
}
Widget _buildSavedBatchCard(SavedAttachmentBatch batch) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'${batch.record.inspectionId} を ${batch.items.length} 枚で確定',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
),
const SizedBox(height: 6),
Text('保存時刻: ${_formatDateTime(batch.savedAt)}'),
const SizedBox(height: 8),
Text(
batch.items.map((AttachmentItem item) => item.fileName).join(' / '),
),
],
),
),
);
}
}
class _RecordSummaryCard extends StatelessWidget {
const _RecordSummaryCard({required this.record});
final WorkRecord record;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'登録対象',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
_SummaryRow(label: '検品ID', value: record.inspectionId),
_SummaryRow(label: '品目', value: record.itemName),
_SummaryRow(label: 'ロット', value: record.lotNumber),
_SummaryRow(label: '保管レーン', value: record.storageLane),
],
),
),
);
}
}
class _SummaryRow extends StatelessWidget {
const _SummaryRow({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
SizedBox(
width: 88,
child: Text(
label,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey.shade700,
),
),
),
Expanded(
child: Text(
value,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
],
),
);
}
}
コードのポイント
① 上限枚数と重複除外を 1 か所で扱う
void _appendPickedImages(List<XFile> picked) {
final Set<String> existingPaths =
_currentItems.map((AttachmentItem item) => item.file.path).toSet();
final List<XFile> uniqueIncoming = <XFile>[];
for (final XFile file in picked) {
final bool isDuplicate =
existingPaths.contains(file.path) ||
uniqueIncoming.any((XFile incoming) => incoming.path == file.path);
if (isDuplicate) {
continue;
}
uniqueIncoming.add(file);
}
final List<XFile> accepted = uniqueIncoming.take(_remainingSlots).toList();
// accepted だけを一覧へ追加する
}
添付上限の判定と重複除外を別々の場所へ散らすと、後から挙動を追いにくくなります。ここでは「追加してよい画像だけを最終的に返す」形に寄せ、一覧更新前の判断を 1 か所へ集めています。
② 差し替えは 1 枚選択に分ける
Future<void> _replaceImage(int index) async {
final XFile? replacement = await _picker.pickImage(
source: ImageSource.gallery,
imageQuality: 85,
);
if (replacement == null) {
return;
}
// 他の添付画像と重複しないか確認してから index の要素だけ置き換える
}
複数選択と差し替えを同じ操作にすると、「どの画像を差し替えたのか」が利用者に伝わりにくくなります。差し替えだけ pickImage() に分けると、対象カードごとの再編集として理解しやすくなります。
③ 確定時は現在の一覧をスナップショットにする
SavedAttachmentBatch(
record: _record,
items: List<AttachmentItem>.unmodifiable(_currentItems),
savedAt: now,
)
そのまま同じリスト参照を持つと、確定後に現在の添付一覧を消した時、履歴側まで空になる危険があります。List.unmodifiable() でその時点の一覧を切り出しておくと、後続のファイル出力やアップロード処理でも扱いやすくなります。
5. flutter run で動かして確認するポイント
コードを貼り付けたら、次のコマンドでアプリを起動します。
flutter run
起動直後は、登録対象カード、添付画像の空状態、確定履歴の空状態が 1 画面に並びます。まずは追加前の見え方を確認しておくと、その後の差分を追いやすくなります。
Android Emulator で見るなら、次の順で試すと流れを追いやすくなります。
- まず 2 枚の画像を選び、サムネイルが 2 枚並ぶことを確認する
- さらに 3 枚以上選び、上限 4 枚を超えた分が追加されず、スナックバーで説明されることを確認する
- 1 枚を差し替え、カード内のファイル名と画像が更新されることを確認する
- 1 枚を削除し、残り枠が増えることを確認する
- 1 枚以上残した状態で確定し、履歴にファイル名一覧が残ることを確認する
image_picker がギャラリー選択まで通れば、それだけで十分というわけではありません。業務アプリで詰まりやすいのは、上限超過時にどう扱うか、差し替えをどの粒度でやるか、確定後にどの一覧を保存対象にするかのほうです。
上の 5 手順に対応するスクリーンショットは次の通りです。
- まず 2 枚を選ぶと、グリッドに 2 枚のカードが並びます。ここでは「複数選択で追加できたか」と「追加時刻が各カードに出るか」を確認します。
- さらに 3 枚以上を選ぶと、一覧は 4 枚で止まり、下部のスナックバーに上限到達の説明が出ます。追加できなかった分を黙って捨てず、利用者へ伝わることが重要です。
- 差し替えでは、対象カードだけが別画像に入れ替わり、スナックバーで操作結果が分かります。複数選択の再実行ではなく、1 枚単位の再編集として見えるかをここで確認します。
- 削除すると一覧枚数が減り、残り枠が戻ります。削除対象のファイル名がスナックバーに出ると、利用者はどれを消したかを見失いません。
- 1 枚以上残した状態で確定すると、現在の添付一覧は履歴カードへ残り、編集用の一覧は空に戻ります。履歴と現在編集中の一覧が混ざらないことを最後に確認します。
今回は明示的な権限分岐を扱っていません。まずは次に Flutterでデータをファイルに書き出す を読み、現在の添付一覧を data.csv と画像ファイルへどう出すかを固めるとつながります。画像選択やカメラ、Bluetooth の許可状態を UI に出し分けたい場合は、その後に permission_handler でAndroid権限を実践的に扱う(カメラ・ストレージ・Bluetooth) を読むと整理しやすくなります。
6. まとめ
複数画像添付 UI で先に固めておきたいのは、選択できることそのものより、一覧状態の持ち方です。上限、重複、差し替え、削除、確定時のスナップショットを分けておくと、次にファイル出力やアップロードへ進む時も処理の置き場がぶれません。
前段の入力フローをまだ見ていない場合は Flutterで単一画面の入力フローを作る から先に読むと入りやすくなります。添付した画像をローカルへ出したい場合は Flutterでデータをファイルに書き出す が次の導線です。権限分岐を整理したい場合は、その後に permission_handler でAndroid権限を実践的に扱う(カメラ・ストレージ・Bluetooth) を読むとつながります。