この記事の要点
- アプリ内の画像から複数の画像を選択して何かしたい時は、multi_image_pickerが便利
- アプリ内に画像など比較的大きいデータや同じ構造のデータを多数保存するには、 sqfliteが便利
(追記: 画像はsqliteに入れずに、path_provider
などを使ってアプリ内の領域に保存して、そのパスをsqliteに保存するのが良さそうです。) - 写真などを格子状に並べるには、
GridView
Widgetを使う -
ListView
やGridView
などでList
の値を動的に表示する際、元々あるList
にadd
やinsert
などを実行してList
の要素を変えても状態変化は反映されない(再描画されない)。かわりに、新しいList
を作って代入する必要がある。
成果物
multi_image_picker x sqflite pic.twitter.com/S6WUhd2X3Z
— かーにゃ (@popy1017) June 14, 2020
手順メモ
※ iOSでしか試してません。
まずはflutter create appname
流れ
- パッケージのインストール
- レイアウトの大枠を作る
- multi_image_pickerを使う
- sqliteに画像を保存する
1. パッケージのインストール
- multi_image_picker: 今回の主役。アプリ内の写真を複数選択するための便利UIを提供してくれる。
- sqflite: 今回の主役2。Flutterでsqliteを使うための便利パッケージ。
- path_provider: DB作るパスを探すか何かでsqflite使うのに必要。
- provider: Providerパターンで状態管理するときに必要な影の主役。
dependencies:
flutter:
sdk: flutter
multi_image_picker: ^4.6.7
sqflite: ^1.3.0
path_provider: ^1.6.10
provider: ^4.1.3
追加設定 for iOS
iOSでmulti_image_picker
を使うためには、追加の設定が必要。
ios/Runner/Info.plist
に以下を追加。
<key>NSPhotoLibraryUsageDescription</key>
<string>Example usage description</string>
<key>NSCameraUsageDescription</key>
<string>Example usage description</string>
それぞれ写真・カメラへのアクセス権限を確認するときに表示されるダイアログに表示されるメッセージ。
続いて、ios/Podfile
の先頭行に、platform :ios, '9.0'
を追記。
たぶんデフォルトで全く同じコードがコメントアウトされているので、コメントアウトを外せばOK。
以上でパッケージのインストール・設定は終了。
2. レイアウトの大枠を作る
保存した画像を並べて表示するViewをGridView
で、画像を選択するシートを表示するトリガーとなるボタンをFloatingActionButton
で作成する。
まずは、flutter create
でできるカウンターアプリのMyHomePage
WidgetをStatelessWidget
にし、余計な部分を取り除いてFloatingActionButton
を設定する。
import 'package:flutter/material.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: MyHomePage(),
);
}
}
class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
child: Icon(Icons.add_photo_alternate),
onPressed: () {},
),
);
}
}
続いて、Scaffold
のbody
となるWidgetを追加する。
album.dart
を作ってStatelessWidget
のAlbum
Widgetを作成する。
今の段階では、ネット上にある画像をランダムにいくつか表示する。
画像は Free-Photos | Pixabay のものを拝借。
import 'dart:math';
import 'package:flutter/material.dart';
class Album extends StatelessWidget {
final List<Widget> _photos = getPhotos(20);
@override
Widget build(BuildContext context) {
return GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
),
itemCount: _photos.length,
itemBuilder: (BuildContext context, int index) => Padding(
padding: const EdgeInsets.all(1),
child: _photos[index],
));
}
}
final List<String> _photoList = <String>[
'https://cdn.pixabay.com/photo/2015/03/26/09/47/sky-690293__340.jpg',
'https://cdn.pixabay.com/photo/2015/09/09/16/05/forest-931706__340.jpg',
'https://cdn.pixabay.com/photo/2016/03/09/09/22/workplace-1245776__340.jpg',
'https://cdn.pixabay.com/photo/2016/02/19/11/19/office-1209640__340.jpg',
'https://cdn.pixabay.com/photo/2014/12/15/17/16/pier-569314__340.jpg'
];
List<Widget> getPhotos(int count) {
final Random _rnd = Random();
return List<Widget>.generate(count, (int index) {
final int _id = _rnd.nextInt(_photoList.length - 1);
return Image.network(
_photoList[_id],
fit: BoxFit.cover,
);
});
}
class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
+ body: Album(),
floatingActionButton: FloatingActionButton(
child: Icon(Icons.add_photo_alternate),
onPressed: () {},
),
);
}
}
こんな感じ。
今後はAlbum
widgetの_photos
をsqliteから取ってきたり、MyHomePage
widgetのonPressed
に複数画像を選択するViewを表示する機構を用意していく。

3. multi_image_pickerを使う
次は、以下のように実装する。
- FloatingActionButtonを押すと、アプリ内の画像を選択するViewが表示される。
- 画像を選択すると、選択した画像がGridViewに表示される。
multi_image_pickerについて
パッケージの説明に載っている例を見たり、いろいろ試すと、以下のようなことがわかった。
-
await MultiImagePicker.pickImages()
で画像選択UIを表示し、画像の選択を待ち受ける。 - 戻り値は
List<Asset>
でAsset
はパッケージ固有のクラス -
Asset
は、通常のImage
widgetではなく、パッケージ固有のAssetThumb
Widgetで表示する。
リファレンスによると、
このメソッドは、Assetオブジェクトのリストを返します。これらは画像自体ではなく、画像への実際の識別子を含む単なるプレースホルダーであるため、一度に何千もの画像を選択でき、パフォーマンスを低下させることはありません。元の画像またはサムをリクエストする方法は、Assetクラスのドキュメントを参照できます。
らしい。
また、Image
Widgetで表示できないか調査したところ、以下のようにして表示できることがわかった。
(AssetThumb
widgetでも同様のことが行われているっぽい)
-
Asset
クラスのFuture<ByteData> getByteData()
でByteData
に変換する。 -
ByteData().buffer.asUint8List()
でUint8List
に変換する。 -
Uint8List
はImage.memory(Uint8List bytes)
で表示できるので、Image.memory()
で表示する。
表示するときは、AssetThumb
で、sqliteに保存する時は、Uint8List
でBLOB
型として保存すると良さそう。
状態の追加
multi_image_pickerを使う前に、現在のアプリでは選んだ画像に応じて表示を切り替えることができないため、アプリに状態を持たせて動的にViewを表示できるようにする、
今回は、MyHomePage
WidgetのFloatingActionButton
と、Album
Widgetから状態にアクセスする必要があるので、StatefulWidget
による状態管理ではなく、Providerパターンで状態管理を行う。
状態クラスの作成
ChangeNotifier
クラスを拡張し、状態を保持するクラスを作成する。
album_model.dart
を作成し、パッケージの例を参考に以下のように実装。
import 'package:flutter/material.dart';
import 'package:multi_image_picker/multi_image_picker.dart';
class AlbumModel extends ChangeNotifier {
List<Asset> _photos = <Asset>[];
List<Asset> get photos => _photos;
Future<void> loadAssets() async {
List<Asset> _resultList;
try {
_resultList = await MultiImagePicker.pickImages(
maxImages: 50,
enableCamera: true,
cupertinoOptions: const CupertinoOptions(
selectionFillColor: '#ff11ab',
selectionTextColor: '#ffffff',
),
materialOptions: const MaterialOptions(
actionBarColor: '#abcdef',
actionBarTitle: 'Example App',
allViewTitle: 'All Photos',
useDetailsView: true,
selectCircleStrokeColor: '#000000',
),
);
} on NoImagesSelectedException catch (e) {
print(e);
return;
} on Exception catch (e) {
print(e);
}
// 新しいリストを代入しないと再描画されない。
// NG: _photos.add() or _photos.insert()
_photos = _resultList;
notifyListeners();
}
}
_photos
をList<Asset>
にしたので、Album
を書き換え。
class Album extends StatelessWidget {
@override
Widget build(BuildContext context) {
final List<Asset> _photos =
context.select((AlbumModel model) => model.photos);
return GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
),
itemCount: _photos.length,
itemBuilder: (BuildContext context, int index) => Padding(
padding: const EdgeInsets.all(1),
child: AssetThumb(
key: Key(_photos[index].identifier),
asset: _photos[index],
width: _photos[index].originalWidth,
height: _photos[index].originalHeight,
),
));
}
}
MultiImagePicker.pickImagesのオプション
Key | Value type | 説明 |
---|---|---|
maxImages | int | 必須パラメータ。選択可能な画像の枚数 |
enableCamera | bool | ギャラリー起動中にカメラ起動を有効にする。デフォルトはfalse |
selectedAssets | List | 選択中の画像を記憶するための配列を指定する。 |
cupertinoOptions | CupertinoOptions | iOS用のカスタマイズオプション。背景色や選択中の画像につけるアイコンの色などを設定できる。 詳しくはこちら。(CupertinoOptions) |
materialOptions | MaterialOptions | Android用のカスタマイズオプション。iOSより設定できる項目が多い。 |
※ selectedAssets
は、選択した順番が変わってしまうバグがある。(Asset.identifierの昇順ソートになっているっぽい。)
Providerの設定
MyApp
のhome
にChangeNotifierProvider
を設定する。
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: ChangeNotifierProvider<AlbumModel>(
create: (_) => AlbumModel(), child: MyHomePage()),
);
}
}
FloatingActionButton押下時の設定
MyHomePage
widgetのFloatingActionButton
のonPressed
を修正して、AlbumModel
のloadAssets()
を呼び出すようにする。
class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Album(),
floatingActionButton: FloatingActionButton(
child: Icon(Icons.add_photo_alternate),
onPressed: () {
context.read<AlbumModel>().loadAssets();
},
),
);
}
}
こんな感じ。
multi_image_picker pic.twitter.com/YufimmH88C
— かーにゃ (@popy1017) June 14, 2020
4. sqliteに画像を保存する
次は、選択した画像をsqliteに保存して、アプリを再起動しても保存した画像が表示されるようにする。
最初はUint8List
をBLOB
で保存していたが、Asset.identifierなどを保存した方が軽そうなのでAssetの情報を保存することにする。(アプリ内の画像が削除された場合への対処は必要になる。)
まずは、DB操作系の処理をまとめたDBHelper
クラスを作る。
import 'dart:async';
import 'dart:io';
import 'package:multi_image_picker/multi_image_picker.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
// AssetクラスにtoMapメソッドを追加
// sqliteに保存する際、Map<String, dynamic>に変換する必要がある。
extension on Asset {
Map<String, dynamic> toMap() {
return <String, dynamic>{
'identifier': identifier,
'name': name,
'originalWidth': originalWidth,
'originalHeight': originalHeight
};
}
}
class DBHelper {
static const String tableName = 'MultiImagePickerSample';
Database _db;
Future<Database> get database async {
if (_db == null) {
final Directory documentDirectory =
await getApplicationDocumentsDirectory();
final String path = join(documentDirectory.path, 'multi_photo_sample.db');
_db = await openDatabase(path, version: 2,
onCreate: (Database newDb, int version) {
newDb.execute('''
CREATE TABLE $tableName
(
identifier TEXT PRIMARY KEY,
name TEXT,
originalWidth INTEGER,
originalHeight INTEGER
)
''');
});
}
return _db;
}
Future<void> insertAsset(Asset asset) async {
final Database db = await database;
await db.insert(
tableName,
asset.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
Future<List<Asset>> assets() async {
final Database db = await database;
final List<Map<String, dynamic>> maps = await db.query(tableName);
return List<Asset>.generate(maps.length, (int i) {
final String identifier = maps[i]['identifier'] as String;
final String name = maps[i]['name'] as String;
final int originalWidth = maps[i]['originalWidth'] as int;
final int originalHeight = maps[i]['originalHeight'] as int;
return Asset(identifier, name, originalWidth, originalHeight);
});
}
Future<void> close() async => _db.close();
}
次に、AlbumModel
を以下のように変更する。
- 起動時にsqliteに保存してある画像を読み込む
- 画像選択が完了したら、選択した画像の情報をsqliteに保存する
class AlbumModel extends ChangeNotifier {
List<Asset> get photos => _photos;
List<Asset> _photos = <Asset>[];
+ AlbumModel() {
+ loadAssetsFromDB();
+ }
+ final DBHelper _dbHelper = DBHelper();
+ Future<void> loadAssetsFromDB() async {
+ _photos = await _dbHelper.assets();
+ notifyListeners();
+ }
Future<void> loadAssets() async {
List<Asset> _resultList;
try {
_resultList = await MultiImagePicker.pickImages(
maxImages: 50,
enableCamera: true,
cupertinoOptions: const CupertinoOptions(
selectionFillColor: '#ff11ab',
selectionTextColor: '#ffffff',
),
materialOptions: const MaterialOptions(
actionBarColor: '#abcdef',
actionBarTitle: 'Example App',
allViewTitle: 'All Photos',
useDetailsView: true,
selectCircleStrokeColor: '#000000',
),
);
} on NoImagesSelectedException catch (e) {
print(e);
return;
} on Exception catch (e) {
print(e);
}
+ for (final Asset asset in _resultList) {
+ await _dbHelper.insertAsset(asset);
+ }
+ loadAssetsFromDB();
}
}
とりあえず上記で画像情報を保存することができたが、以下のような課題がある。
- 保存されたAssetの情報に対する画像がデバイス上にないとエラーになる
- Sqliteの設定がIDが重複した際に置換する設定(
conflictAlgorithm: ConflictAlgorithm.replace
)になっているので、同じ画像を複数枚保存できない
1つ目の課題は、AssetThumb
Widgetが内部的にエラーを投げているので、対処しづらい。さらにエラーを投げている関数がFuture
型のgetThumbByteData
関数なので、if文とかで条件分岐するのもめんどくさそう。
いっそ最初からsqliteに画像データを入れておいて、毎回それを表示するようにした方が良いかもしれない。(今回は断念)
2つ目の課題は、アプリの要件によってどうするべきか変わるので、今回は対処しない。
(おまけ)画像選択Viewの日本語化
画像を選択する際のViewの上部が(キャンセル、カテゴリ、完了)が英語になっていたので日本語にする方法を調査。
ios/Runner.xcodeproj/ をXcodeで開き、
- プロジェクトファイル(Runner)を選択
- PROJECTのRunnerを選択
- 上部のメニューからInfoを選択
- ▼Localizations の + を押して、Japaneseを追加する
※ 当然デバイスの設定は日本語である必要があります。