10
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

multi_image_pickerとsqfliteを使って選択した画像をアプリに保存するFlutterアプリを作った

Last updated at Posted at 2020-06-14

この記事の要点

  • アプリ内の画像から複数の画像を選択して何かしたい時は、multi_image_pickerが便利
  • アプリ内に画像など比較的大きいデータや同じ構造のデータを多数保存するには、 sqfliteが便利
    (追記: 画像はsqliteに入れずに、path_providerなどを使ってアプリ内の領域に保存して、そのパスをsqliteに保存するのが良さそうです。)
  • 写真などを格子状に並べるには、GridViewWidgetを使う
  • ListViewGridViewなどでListの値を動的に表示する際、元々あるListaddinsertなどを実行してListの要素を変えても状態変化は反映されない(再描画されない)。かわりに、新しいListを作って代入する必要がある。

成果物

手順メモ

※ iOSでしか試してません。

まずはflutter create appname

流れ

  1. パッケージのインストール
  2. レイアウトの大枠を作る
  3. multi_image_pickerを使う
  4. sqliteに画像を保存する

1. パッケージのインストール

  • multi_image_picker: 今回の主役。アプリ内の写真を複数選択するための便利UIを提供してくれる。
  • sqflite: 今回の主役2。Flutterでsqliteを使うための便利パッケージ。
  • path_provider: DB作るパスを探すか何かでsqflite使うのに必要。
  • provider: Providerパターンで状態管理するときに必要な影の主役。
pubspec.yaml
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に以下を追加。

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でできるカウンターアプリのMyHomePageWidgetをStatelessWidgetにし、余計な部分を取り除いてFloatingActionButtonを設定する。

main.dart
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: () {},
      ),
    );
  }
}

続いて、ScaffoldbodyとなるWidgetを追加する。
album.dartを作ってStatelessWidgetAlbumWidgetを作成する。
今の段階では、ネット上にある画像をランダムにいくつか表示する。
画像は Free-Photos | Pixabay のものを拝借。

album.dart
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,
    );
  });
}
MyHomePage()
class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
+     body: Album(),
      floatingActionButton: FloatingActionButton(
        child: Icon(Icons.add_photo_alternate),
        onPressed: () {},
      ),
    );
  }
}

こんな感じ。
今後はAlbumwidgetの_photosをsqliteから取ってきたり、MyHomePagewidgetのonPressedに複数画像を選択するViewを表示する機構を用意していく。

3. multi_image_pickerを使う

次は、以下のように実装する。

  • FloatingActionButtonを押すと、アプリ内の画像を選択するViewが表示される。
  • 画像を選択すると、選択した画像がGridViewに表示される。

multi_image_pickerについて

パッケージの説明に載っている例を見たり、いろいろ試すと、以下のようなことがわかった。

  • await MultiImagePicker.pickImages()で画像選択UIを表示し、画像の選択を待ち受ける。
  • 戻り値はList<Asset>Assetはパッケージ固有のクラス
  • Assetは、通常のImagewidgetではなく、パッケージ固有のAssetThumbWidgetで表示する。

リファレンスによると、

このメソッドは、Assetオブジェクトのリストを返します。これらは画像自体ではなく、画像への実際の識別子を含む単なるプレースホルダーであるため、一度に何千もの画像を選択でき、パフォーマンスを低下させることはありません。元の画像またはサムをリクエストする方法は、Assetクラスのドキュメントを参照できます。

らしい。

また、ImageWidgetで表示できないか調査したところ、以下のようにして表示できることがわかった。
(AssetThumbwidgetでも同様のことが行われているっぽい)

  1. AssetクラスのFuture<ByteData> getByteData()ByteDataに変換する。
  2. ByteData().buffer.asUint8List()Uint8Listに変換する。
  3. Uint8ListImage.memory(Uint8List bytes)で表示できるので、Image.memory()で表示する。

表示するときは、AssetThumbで、sqliteに保存する時は、Uint8ListBLOB型として保存すると良さそう。

状態の追加

multi_image_pickerを使う前に、現在のアプリでは選んだ画像に応じて表示を切り替えることができないため、アプリに状態を持たせて動的にViewを表示できるようにする、
今回は、MyHomePageWidgetのFloatingActionButtonと、AlbumWidgetから状態にアクセスする必要があるので、StatefulWidgetによる状態管理ではなく、Providerパターンで状態管理を行う。

状態クラスの作成

ChangeNotifierクラスを拡張し、状態を保持するクラスを作成する。
album_model.dartを作成し、パッケージの例を参考に以下のように実装。

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();
  }
}

_photosList<Asset>にしたので、Albumを書き換え。

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の設定

MyApphomeChangeNotifierProviderを設定する。

MyApp()
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押下時の設定

MyHomePagewidgetのFloatingActionButtononPressedを修正して、AlbumModelloadAssets()を呼び出すようにする。

MyHomePage
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();
        },
      ),
    );
  }
}

こんな感じ。

4. sqliteに画像を保存する

次は、選択した画像をsqliteに保存して、アプリを再起動しても保存した画像が表示されるようにする。
最初はUint8ListBLOBで保存していたが、Asset.identifierなどを保存した方が軽そうなのでAssetの情報を保存することにする。(アプリ内の画像が削除された場合への対処は必要になる。)

まずは、DB操作系の処理をまとめたDBHelperクラスを作る。

db_helper.dart
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に保存する
album_model.dart
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つ目の課題は、AssetThumbWidgetが内部的にエラーを投げているので、対処しづらい。さらにエラーを投げている関数がFuture型のgetThumbByteData関数なので、if文とかで条件分岐するのもめんどくさそう。
いっそ最初からsqliteに画像データを入れておいて、毎回それを表示するようにした方が良いかもしれない。(今回は断念)

2つ目の課題は、アプリの要件によってどうするべきか変わるので、今回は対処しない。

(おまけ)画像選択Viewの日本語化

画像を選択する際のViewの上部が(キャンセル、カテゴリ、完了)が英語になっていたので日本語にする方法を調査。

ios/Runner.xcodeproj/ をXcodeで開き、

  1. プロジェクトファイル(Runner)を選択
  2. PROJECTのRunnerを選択
  3. 上部のメニューからInfoを選択
  4. ▼Localizations の + を押して、Japaneseを追加する

※ 当然デバイスの設定は日本語である必要があります。

10
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?