ほねっとのぶろぐ

アニメとAndroidが好きなほねっとのブログです。

Flutterでカメラアプリを作る〜撮影編〜

f:id:aftercider:20181116165525p:plain Flutterという、ワンソースでiOSアプリもAndroidアプリもビルドできるGoogle製フレームワークがありまして、そのFlutterを使ってカメラのプレビュー・撮影・保存までのやり方をまとめました。

以下の記事では、環境設定とプレビューまでをまとめました。

blog.aftercider.com

ちなみに本記事の次では、保存に関してまとめてあります。

前提

  • Flutter 0.11.3
  • MacBookPro(Mac Mojave)環境
  • Flutterは初めてさわる

撮影コード追加

package導入

まず、package.yamlにファイルパスをサポートしてくれるpath_providerを導入して、$ flutter packages getします。

name: hello_world_project
description: HelloWorldProject

version: 1.0.0+1

environment:
  sdk: ">=2.0.0-dev.68.0 <3.0.0"

dependencies:
  flutter:
    sdk: flutter

  cupertino_icons: ^0.1.2
  english_words: ^3.1.0
  camera: ^0.2.4
  path_provider: ^0.4.1

dev_dependencies:
  flutter_test:
    sdk: flutter

flutter:
  uses-material-design: true

プログラムを書く

プレビュー編で行なった通りcameraモジュールの動作要件に合わせ、AndroidのminSdkVersionは21にしておきましょう。

cameraモジュールのExampleにサンプルコードが掲載されているんですが、 静止画撮影・動画撮影の両方のコードが書いてあり、ボリュームも大きいので初心者にはちょっと難易度が高いです。

また、FlutterのUIに関するコードも結構な量あるため、どこがカメラ制御用のコードなのかわからないっていう難しさもあります。

ということで、今回はFlutterでカメラを使えるようになることを目的として、

  • 例外処理・UI装飾をできるだけなくして見通しをよくする
  • 録画関連・サムネイル表示のコードを削除
  • コメントを追加

といった対応を入れて、撮影・保存のみできるようにします。

main.dartを以下のようにしていきました。

import 'dart:async'; // 非同期処理(async/await)
import 'dart:io'; // ファイルの入出力

import 'package:camera/camera.dart'; // カメラモジュール
import 'package:flutter/material.dart'; // マテリアルデザイン
import 'package:path_provider/path_provider.dart'; // ファイルパスモジュール

List<CameraDescription> cameras; // 使用できるカメラのリスト

// ここから始まる
Future<Null> main() async {
  cameras = await availableCameras();
  runApp(CameraApp());
}

// 親玉のApp
class CameraApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: CameraWidget(),
    );
  }
}

// 親玉の中身
class CameraWidget extends StatefulWidget {
  @override
  _CameraWidgetState createState() {
    return _CameraWidgetState();
  }
}

// 実際はこれがやることやる
class _CameraWidgetState extends State<CameraWidget> {
  CameraController controller;
  final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
  String timestamp() =>
      DateTime.now().millisecondsSinceEpoch.toString(); // ファイル名にはタイムスタンプ入れる。
  void showInSnackBar(String message) => _scaffoldKey.currentState
      .showSnackBar(SnackBar(content: Text(message))); // SnackBarでメッセージ表示

  @override
  Widget build(BuildContext context) {
    Scaffold sc = Scaffold(
      key: _scaffoldKey,
      body: Column(
        children: <Widget>[
          Expanded(
            child: Container(
              child: Padding(
                padding: const EdgeInsets.all(1.0),
                child: Center(
                  child: _cameraPreviewWidget(), // カメラのプレビューを表示するWidget
                ),
              ),
            ),
          ),
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            mainAxisSize: MainAxisSize.max,
            children: <Widget>[
              IconButton(
                // カメラの撮影ボタン
                icon: const Icon(Icons.camera_alt),
                onPressed: controller != null && controller.value.isInitialized
                    ? onTakePictureButtonPressed // 撮影ボタンを押された時にコールバックされる関数
                    : null,
              ),
            ],
          )
        ],
      ),
    );

    // カメラのセットアップ。セットアップが終わったらもう一回buildが走るので、
    // controllerがnullかどうかで処理実施有無を判定。
    if (controller == null) {
      setUpCamera(cameras[0]);
    }
    return sc;
  }

  /// カメラプレビューを表示するWidget
  Widget _cameraPreviewWidget() {
    if (controller == null || !controller.value.isInitialized) {
      // カメラの準備ができるまではテキストを表示
      return const Text('Tap a camera');
    } else {
      // 準備ができたらプレビュー表示
      return AspectRatio(
        aspectRatio: controller.value.aspectRatio,
        child: CameraPreview(controller),
      );
    }
  }

  // カメラを準備する
  void setUpCamera(CameraDescription cameraDescription) async {
    if (controller != null) {
      await controller.dispose();
    }
    controller = CameraController(cameraDescription, ResolutionPreset.high);

    // カメラの情報が更新されたら呼ばれるリスナー設定
    controller.addListener(() {
      if (mounted) setState(() {}); // 準備終わったらbuildし直す。
      if (controller.value.hasError) {
        showInSnackBar('Camera error ${controller.value.errorDescription}');
      }
    });

    await controller.initialize();

    if (mounted) {
      setState(() {});
    }
  }

  // 撮影ボタンが押されたら撮影して、画像を保存する
  void onTakePictureButtonPressed() {
    takePicture().then((String filePath) {
      if (mounted) {
        setState(() {});
        if (filePath != null) showInSnackBar('Picture saved to $filePath');
      }
    });
  }

  // 画像保存処理
  Future<String> takePicture() async {
    if (!controller.value.isInitialized) {
      return null;
    }
    final Directory extDir = await getApplicationDocumentsDirectory();
    final String dirPath = '${extDir.path}/Pictures/flutter_test';
    await Directory(dirPath).create(recursive: true);
    final String filePath = '$dirPath/${timestamp()}.jpg';

    if (controller.value.isTakingPicture) {
      return null;
    }

    await controller.takePicture(filePath);
    return filePath;
  }
}

実機で実行

実機で実行すると、カメラプレビューと撮影ボタンがこんな感じで表示されます。

f:id:aftercider:20181116165705p:plain

課題

撮影してみるとわかるんですが、iOSもAndroidもtakePictureのなかで保存先ファイルパスをgetApplicationDocumentsDirectoryで取得していて、アプリ領域に画像を保存しているためため、ギャラリーアプリや写真アプリで撮影画像を見ることができません。

次回はギャラリーアプリや写真アプリで見れる領域に保存する方法をまとめます。

Android/iOSクロス開発フレームワーク Flutter入門

Android/iOSクロス開発フレームワーク Flutter入門