【Flutter】簡単!カメラ機能の追加方法

対象者

  • Flutterの基本を理解している開発者
  • アプリにカメラ撮影画面を追加したい開発者
  • カメラ撮影時のエラーハンドリング方法が知りたい開発者

はじめに

この記事では、Flutterを使用して、iOS、Androidのカメラ機能を簡単に組み込む方法をステップバイステップで解説します。初心者でも理解しやすいように、基本的な設定から始め、カメラのプレビュー表示、写真撮影、撮影した写真の表示、さらにはズーム機能の切り替えまで、具体的なコード例を交えながら丁寧に説明していきます。

image_pickerとcameraパッケージの比較

Flutterでカメラ機能をアプリに組み込む際、主にimage_pickercameraの二つのパッケージが利用されます。これらは似ているようでいて、実装の柔軟性や使用目的において異なる特徴を持っています。

image_pickerの特徴

  • 手軽さ: image_pickerは、デフォルトのカメラUIを使用して、写真やビデオを簡単に撮影または選択できます。特に、追加の設定なしで直接デバイスのギャラリーやカメラにアクセスできるため、初心者にも扱いやすいです。

cameraパッケージの特徴

  • カスタマイズ性: cameraパッケージは、カメラのプレビュー画面や撮影機能をアプリ内で完全にカスタマイズできます。特定のUIデザインや機能を実装したい場合に適しています。
  • 詳細なカメラ制御: ズーム、露出、フォーカスポイントの設定など、カメラの細かな制御が可能です。高度なカメラ機能をアプリに組み込みたい開発者にとって有利です。
  • リアルタイム画像処理: カメラからのストリームを直接受け取り、リアルタイムで画像処理を行うことができます。ARアプリケーションやフィルター機能など、特定の処理をリアルタイムに適用したい場合に役立ちます。

使い分け

image_pickerは、簡単に写真やビデオを撮影・選択する機能をアプリに追加したい場合に最適です。一方で、cameraパッケージは、カメラのカスタマイズや詳細な制御、リアルタイム画像処理が必要な高度なアプリケーション開発に適しています。開発するアプリの要件に応じて、適切なパッケージを選択することが重要です。

準備

Flutterの設定

まず、cameraパッケージをプロジェクトに追加する必要があります。pubspec.yamlファイルに以下の依存関係を追加してください。

dependencies:
  flutter:
    sdk: flutter
  camera: ^0.9.4+19

次に、コマンドラインでflutter pub getを実行して、パッケージをインストールします。

Androidの設定

android/app/build.gradleファイルを開き、minSdkVersionを21以上に設定します。

android {
    defaultConfig {
        minSdkVersion 21
    }
}

iOSの設定

iOSでカメラとマイクを使用するには、Info.plistファイルに使用理由を説明する必要があります。ios/Runner/Info.plistに以下のキーを追加してください。

<key>NSCameraUsageDescription</key>
<string>カメラを使用する理由をここに記述します</string>
<key>NSMicrophoneUsageDescription</key>
<string>マイクを使用する理由をここに記述します</string>

cameraの実装

カメラコントローラの初期化

TakePicturePageStateクラスのinitStateメソッドで、利用可能なカメラを取得し、CameraControllerを初期化します。
カメラの初期化が終わったことを示すため、_cameraLoadedを設定してます。

  @override
  void initState() {
    super.initState();

    availableCameras().then((cameras) {
      _controller = CameraController(cameras.first, ResolutionPreset.medium)
        ..initialize().then((_) {
          setState(() {
            _cameraLoaded.complete(true);
          });
        });
    });
  }

カメラのプレビューエリアの実装

カメラのプレビューはCameraPreviewウィジェットを使用して表示されます。
ただ、表示はカメラの初期化を待たなければなりません。_cameraLoadedが完了するまでCircularProgressIndicatorを表示します。初期化が完了したら、カメラのプレビューエリアが表示されます。

                FutureBuilder<bool>(
                  future: _cameraLoaded.future,
                  builder: (context, snapshot) {
                    if (snapshot.hasError) {
                      return Text('Error: ${snapshot.error!}');
                    } else if (!snapshot.hasData) {
                      return const CircularProgressIndicator();
                    }
                    return CameraPreview(_controller);
                  },
                ),

カメラ撮影の実装

onTakePictureでは、実際にユーザーが写真を撮影するための機能を提供します。
具体的には、カメラコントローラー(_controller)を使用して写真を撮影し、その写真をファイルとして保存するプロセスを担います。

  Future<void> onTakePicture(BuildContext context) async {
    final image = await _controller.takePicture();
    setState(() {
      _imageFile = File(image.path);
    });
    Future.delayed(const Duration(seconds: 3)).then((_) {
      setState(() {
        _imageFile = null;
      });
    });
  }

写真の撮影

_controller.takePictureを呼び出しています。takePictureメソッドが完了すると、撮影された写真の情報を含むXFileオブジェクトがimage変数に格納されます。

撮影した写真の表示

setStateメソッドを使用して、撮影した写真のファイルパス(image.path)からFileオブジェクトを作成し、それを_imageFile変数に代入します。この操作により、ウィジェットが再描画され、UI上で撮影した写真が表示されるようになります。

撮影した写真の一時的表示

Future.delayedを使用して3秒間の遅延を設定しています。3秒経過後、setStateが再度呼び出され、_imageFile変数をnullに設定します。これにより、UIから写真が消去され、撮影した写真が一時的にのみ表示されるようになります

撮影した画像の表示

上記で見たように _imageFile には撮影した画像のパスが取得できます。以下のようにして、撮影した画像を表示します。
撮影した瞬間表示され、3秒後に_imageFileはnullになるので、自動で非表示になります。

            if (_imageFile != null)
              Padding(
                padding: const EdgeInsets.all(32.0),
                child: Image.file(_imageFile!),
              ),

ズームの切り替え

ズーム機能は、CameraControllersetZoomLevelメソッドを使用して制御されます。ユーザーがスイッチを切り替えると、ズームレベルが変更されます。

void zoomingChanged(bool? value) {
  if (value == null) return;
  setState(() {
    _isZooming = value;
  });
  _controller.setZoomLevel(_isZooming ? 2.0 : 1.0);
}

フラッシュモードの設定

フラッシュモード(フラッシュを使用するか)は、CameraControllersetFlashModeメソッドを使用して設定します。以下に、FlashModeは以下のような設定ができます

  • FlashMode.off – フラッシュをオフにします。
  • FlashMode.auto – 自動的にフラッシュを制御します。
  • FlashMode.always – 常にフラッシュを使用します。
  • FlashMode.torch – フラッシュをトーチモード(常時点灯)にします。

iPadにてsetFlashModeメソッドを使用すると、フラッシュがないため例外が発生しました。これを無視しても問題ありません。なお、フラッシュの有無をcameraパッケージでは判別できません、多分。

try {
  await _controller.setFlashMode(FlashMode.off);
} on CameraException catch (_) {
  // フラッシュモードの設定中にエラーが発生しても無視します
}

エラーハンドリングの重要性と実装方法

Flutterでカメラ機能を実装する際、ユーザーにとって快適なアプリ体験を提供するためには、発生する可能性のあるエラーを適切にハンドリングすることが非常に重要です。カメラアクセス時には様々なエラーが発生する可能性があり、これらを適切に処理することで、アプリの信頼性とユーザビリティを向上させることができます。

一般的なエラーケースとそのハンドリング方法

  1. カメラアクセス拒否: ユーザーがカメラの使用を許可しない場合、アプリはカメラを使用できません。この場合、ユーザーに対してカメラへのアクセスが許可されていないことを明確に伝え、設定からカメラの使用を許可するよう案内します。

  2. カメラアクセス不可: デバイスにカメラが存在しない、または他のアプリケーションがカメラを使用中でアクセスできない場合があります。このような状況では、ユーザーにカメラが使用不可であることを通知し、可能であれば代替機能を提供します。

  3. カメラ初期化失敗: カメラの初期化中に技術的な問題が発生することがあります。エラーが発生した場合は、ユーザーに問題が発生したことを伝え、後ほど再試行するよう促します。

  4. 撮影時のエラー: 写真やビデオを撮影する際にエラーが発生することがあります。撮影に失敗した場合は、ユーザーにエラーが発生したことを通知し、再度撮影を試みるオプションを提供します。

実際のエラーハンドリングの実装例

以下のコードスニペットは、cameraパッケージを使用してカメラを初期化し、エラーハンドリングを行う方法を示しています。

availableCameras().then((cameras) {
  _controller = CameraController(cameras.first, ResolutionPreset.medium)
    ..initialize().then((_) {
      setState(() {
        _cameraLoaded.complete(true);
      });
    }).onError((CameraException error, stackTrace) {
      if (error.code == 'CameraAccessDenied') {
        showDialog(
          context: context,
          builder: (context) => const SimpleDialog(
            children: [Text('カメラへのアクセス許可を出してください')],
          ),
        );
      }
    });
});

この例では、availableCameras()関数を使用して利用可能なカメラのリストを取得し、最初のカメラを使用してCameraControllerを初期化しています。初期化プロセス中にエラーが発生した場合(この場合、カメラアクセスが拒否された場合)、onErrorコールバックがトリガーされ、ユーザーに対してダイアログを表示してカメラへのアクセス許可を求めます。

このようにエラーハンドリングを実装することで、ユーザーが直面する可能性のある問題に対して適切なフィードバックを提供し、より良いユーザーエクスペリエンスを実現することができます。

まとめ

この記事を通じて、Flutterを使用したiOSおよびAndroidのカメラ機能の組み込み方について学びました。image_pickerとcameraパッケージの比較から始まり、Flutterの設定、カメラの実装方法、そしてエラーハンドリングに至るまで、ステップバイステップで理解を深めることができました。

  • image_pickerは手軽にデフォルトのカメラUIを使用して写真やビデオを撮影・選択する機能を提供します。一方、cameraパッケージはカメラのカスタマイズや詳細な制御、リアルタイム画像処理が必要な場合に適しています。
  • Flutterの設定では、cameraパッケージをプロジェクトに追加し、iOSおよびAndroidでの特定の設定を行う必要があります。
  • カメラの実装では、カメラコントローラの初期化、プレビューエリアの表示、写真撮影の実装方法を学びました。
  • エラーハンドリングでは、カメラアクセス時に発生する可能性のあるエラーと、それらをユーザーにとって快適な方法で処理する方法について学びました。

この記事を読んだことで、Flutterを使用してカメラ機能をアプリに組み込む方法について理解しました。また、エラーハンドリングの重要性と具体的な実装方法についても学び、ユーザーにとってより良いアプリ体験を提供するための知識を得ることができました。

参考

ソース(main.dartにコピペして動作確認用)

import 'dart:async';
import 'dart:io';

import 'package:camera/camera.dart';
import 'package:flutter/material.dart';

Future<void> main() async {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: 'Flutter Camera Example',
      home: TakePicturePage(),
    );
  }
}

class TakePicturePage extends StatefulWidget {
  const TakePicturePage({
    Key? key,
  }) : super(key: key);

  @override
  TakePicturePageState createState() => TakePicturePageState();
}

class TakePicturePageState extends State<TakePicturePage> {
  late CameraController _controller;
  final _cameraLoaded = Completer<bool>();
  var _isZooming = false;
  var _isTakingPicture = false;

  File? _imageFile;

  @override
  void initState() {
    super.initState();

    availableCameras().then((cameras) {
      _controller = CameraController(cameras.first, ResolutionPreset.medium)
        ..initialize().then((_) {
          setState(() {
            _cameraLoaded.complete(true);
          });
        }).onError((CameraException error, stackTrace) {
          if (error.code == 'CameraAccessDenied') {
            showDialog(
              context: context,
              builder: (context) => const SimpleDialog(
                children: [Text('カメラに撮影許可を出してください')],
              ),
            );
          }
        });
    });
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Stack(
          children: [
            Column(
              children: [
                FutureBuilder<bool>(
                  future: _cameraLoaded.future,
                  builder: (context, snapshot) {
                    if (snapshot.hasError) {
                      return Text('Error: ${snapshot.error!}');
                    } else if (!snapshot.hasData) {
                      return const CircularProgressIndicator();
                    }
                    return CameraPreview(_controller);
                  },
                ),
                Expanded(
                  child: Container(
                    width: double.infinity,
                    color: Colors.grey,
                    child: Stack(
                      children: [
                        Row(
                          children: [
                            const Text('2倍ズーム'),
                            Switch(
                              value: _isZooming,
                              onChanged: zoomingChanged,
                            ),
                          ],
                        ),
                        Center(
                          child: FilledButton(
                            onPressed:
                                _cameraLoaded.isCompleted && !_isTakingPicture
                                    ? () {
                                        setState(() {
                                          _isTakingPicture = true;
                                        });
                                        onTakePicture(context).then((_) {
                                          setState(() {
                                            _isTakingPicture = false;
                                          });
                                        });
                                      }
                                    : null,
                            child: const Text('撮影'),
                          ),
                        ),
                      ],
                    ),
                  ),
                ),
              ],
            ),
            if (_imageFile != null)
              Padding(
                padding: const EdgeInsets.all(32.0),
                child: Image.file(_imageFile!),
              ),
          ],
        ),
      ),
    );
  }

  Future<void> onTakePicture(BuildContext context) async {
    final image = await _controller.takePicture();
    setState(() {
      _imageFile = File(image.path);
    });
    Future.delayed(const Duration(seconds: 3)).then((_) {
      setState(() {
        _imageFile = null;
      });
    });
  }

  void zoomingChanged(bool? value) {
    if (value == null) {
      return;
    }
    setState(() {
      _isZooming = value;
    });
    _controller.setZoomLevel(_isZooming ? 2.0 : 1.0);
  }
}