Loading
BLOG 開発者ブログ

2022年12月17日

Flutterで写真・動画撮影が可能なカメラ機能を実装しよう!

今回はFlutterを使ってiOS・Androidの両OSで動作する写真撮影・動画撮影の両方が可能なカメラ機能を実装してみたのでご紹介します。

こんにちは。
モバイルソリューショングループのokochi.shinyaです。

この記事は アイソルート Advent Calendar 2022 の17日目の記事です。
昨日は fujinami.m さんの【swift】UI変更・表示切り替え・仕様変更に強いUIの作り方 でした。ぜひこちらもご覧ください。

目次

本記事でやろうとしていること

今回実装するアプリは、撮影画面右下のボタンをタップすると写真を撮影、長押しすると押している間の映像が撮影でき、撮影後プレビュー画面で写真・動画が表示・再生されるすごくシンプルなアプリを実装します。

使用するパッケージ

実装で必要になるパッケージは以下の通りです。

  • camera:デバイスのカメラを操作するために必要
  • path_provider:画像の保存場所を見つけるために必要
  • path:画像の保存先を作成するために必要
  • video_player:ビデオを再生するために必要

以下コマンドで必要なパッケージのインストールを行います。

$ flutter pub add camera 
$ flutter pub add path_provider 
$ flutter pub add path 
$ flutter pub add video_player

iOS・Androidの個別設定

次に、OS毎に必要な設定を行います。

Flutterを活用することで、iOS・Androidの両OSで動作するアプリを1つのプログラミング言語で開発が可能であるとはいえ、使用するパッケージや実装する機能によって、OS毎に個別の設定や実装が必要な場合もあります。

今回使用するcameraプラグインを正常に動作させるには、以下を設定する必要があります。
iOS:カメラとマイクを使用するための許諾ダイアログの文言を設定
Android:Android SDK の最小バージョンを 21 より最新に設定

※video_playerプラグインで表示する動画をURLで指定する場合、iOSではApp Transport Security設定、Androidではネットワークのアクセス許可の設定が必要になりますが、今回は使用しないため割愛します。

それでは、それぞれ設定していきます。

iOS:カメラとマイクを使用するための許諾ダイアログの文言を設定

以下の設定をios/Runner/Info.plistに追加します。

<key>NSCameraUsageDescription</key>
<string>your usage description here</string>
<key>NSMicrophoneUsageDescription</key>
<string>your usage description here</string>

Android:Android SDK の最小バージョンを 21 より最新に設定

cameraプラグインは、Android 5.0以前のバージョンには対応していないため、android/app/build.gradle の minSdkVersionを21以上に設定します。

minSdkVersion 21

利用可能なカメラの取得

それではコードを書いていきます。
端末のカメラを取得するには、availableCamerasメソッドを使用します。

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

Future<void> main() async { 
    // main 関数内で非同期処理を呼び出すための設定 
    WidgetsFlutterBinding.ensureInitialized(); 
    // デバイスで使用可能なカメラを全て取得 
    final cameras = await availableCameras(); 
    runApp(SampleApp(camera: cameras.first)); 
}

撮影画面の表示

カメラを表示するには、写真撮影・動画撮影開始・動画撮影終了などのメソッドを持つCameraControllerクラスの初期化が必要になります。

撮影画面をStatefulWidgetで定義し、initState内で利用可能なカメラと解像度を指定し、CameraControllerの作成・初期化を行います。

そして、CameraPreviewウィジェットにCameraControllerを渡すことでカメラを表示させることが可能になります。

※iOSのSimulatorではカメラを扱うことが不可能なため、エラーが発生してしまいます。AndoroidのエミュレータもしくはiOS・Androidの実機で動作確認を行うようにしてください。

// カメラ画面
class Camera extends StatefulWidget {
  const Camera({
    Key? key,
    required this.camera,
  }) : super(key: key);

  final CameraDescription camera;

  @override
  State<Camera> createState() => _CameraState();
}

class _CameraState extends State<Camera> {
  late CameraController _controller;

  @override
  void initState() {
    super.initState();
    _controller = CameraController(widget.camera, ResolutionPreset.max);
    _controller.initialize();
  }

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

  @override
  Widget build(BuildContext context) {
    if (!_controller.value.isInitialized) {
      return Container();
    }
    return Scaffold(
        body: CameraPreview(_controller),
        // NEXT: ボタンの処理を追加
    );
  }
}

ボタンにタップと長押しを検知させる

今回は、FloatingActionButtonをタップ時には写真撮影、長押し時には動画を撮影するため、タップ時・長押し開始時・長押し終了時を3つのイベントを検知する必要があります。

そこで今回は、GestureDetectorを使用します。
GestureDetectorはタップや長押しなどの様々なユーザーの動作に対する処理を記載することができます。
今回は、タップされたことを検知するonTap、長押しされたことを検知するonLongPress、長押しの終了を検知するonLongPressUpの3つを使用します。

  @override
  Widget build(BuildContext context) {
    if (!_controller.value.isInitialized) {
      return Container();
    }
    return Scaffold(
        body: CameraPreview(_controller),
        floatingActionButton: FloatingActionButton(
          child: GestureDetector(
            // タップした時
            onTap: () async {
              // NEXT: 写真を撮影する
            },
            // 長押しを開始した時
            onLongPress: () async {
              // NEXT: 動画の撮影を開始する
            },
            // 長押しを終了した時
            onLongPressUp: () async {
              // NEXT:  動画の撮影を終了する
            },
            child: const Icon(Icons.add_a_photo),
          ),
          onPressed: () {},
        ));
  }
}

写真・動画を撮影する

CameraControllerのtakePictureメソッドで写真を撮影、startVideoRecordingメソッドで動画の撮影開始、stopVideoRecordingメソッドで動画の撮影を終了することが出来ます。

前項にて紹介したGestureDetectorのonTapに写真撮影処理、onLongPressに動画撮影開始処理、onLongPressUpに動画撮影終了処理を記載することで、ユーザー操作によって写真撮影か動画撮影が可能になります。

  @override
  Widget build(BuildContext context) {
    if (!_controller.value.isInitialized) {
      return Container();
    }
    return Scaffold(
        body: CameraPreview(_controller),
        floatingActionButton: FloatingActionButton(
          child: GestureDetector(
            // タップした時
            onTap: () async {
              // 写真撮影
              final image = await _controller.takePicture();
              // NEXT: 表示用の画面に遷移
            },
            // 長押しを開始した時
            onLongPress: () async {
              // 動画撮影開始
              await _controller.startVideoRecording();
            },
            // 長押しを終了した時
            onLongPressUp: () async {
              // 動画撮影終了
              final video = await _controller.stopVideoRecording();
              // NEXT: 表示用の画面に遷移
            },
            child: const Icon(Icons.add_a_photo),
          ),
          onPressed: () {},
        ));
  }
}

写真プレビューの表示

撮影した写真を表示する画面を作成します。

// 撮影した写真を表示する画面
class ImagePreview extends StatelessWidget {
  const ImagePreview({Key? key, required this.imagePath}) : super(key: key);

  final String imagePath;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Preview')),
      body: Center(child: Image.file(File(imagePath))),
    );
  }
}

写真撮影後に遷移する処理を追加します。

// タップした時
onTap: () async {
  // 写真撮影
  final image = await _controller.takePicture();
  // 表示用の画面に遷移
  await Navigator.of(context).push(
    MaterialPageRoute(
      builder: (context) => ImagePreview(imagePath: image.path),
      fullscreenDialog: true,
    ),
  );
},

これで、写真撮影と表示が出来ました!

動画プレビューの表示

撮影した動画を表示する画面を作成します。

撮影画面を表示した時と同様に、
動画を表示するには、VideoPlayerControllerクラスの初期化が必要になります。

// 撮影した動画を表示する画面
class VideoPreview extends StatefulWidget {
  const VideoPreview({
    Key? key,
    required this.videoPath,
  }) : super(key: key);

  final String videoPath;

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

class _VideoPreviewState extends State<VideoPreview> {
  late VideoPlayerController _controller;

  @override
  void initState() {
    super.initState();
    _controller = VideoPlayerController.file(File(widget.videoPath));
    _controller.initialize().then((_) {
      setState(() {});
      _controller.play();
    });
  }

  @override
  Widget build(BuildContext context) {
    if (!_controller.value.isInitialized) {
      return Container();
    }
    return Scaffold(
      appBar: AppBar(title: const Text('Preview')),
      body: Center(
        child: AspectRatio(
          aspectRatio: _controller.value.aspectRatio,
          child: VideoPlayer(_controller),
        ),
      ),
    );
  }

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

動画撮影後に遷移する処理を追加します。

// 長押しを終了した時
onLongPressUp: () async {
  // 動画撮影終了
  final video = await _controller.stopVideoRecording();
  // 表示用の画面に遷移
  await Navigator.of(context).push(
    MaterialPageRoute(
      builder: (context) => VideoPreview(videoPath: video.path),
      fullscreenDialog: true,
    ),
  );
},

これで動画撮影と撮影した動画の表示・再生についても出来ました!

最後に

今回は、Flutterを使って写真撮影・動画撮影可能なカメラ機能を実装してみました。
コードは少し長くなってしまいましたが、FlutterでiOS・Androidを共通のプログラミング言語で書けるのはかなりありがたいなと感じました。

明日は、nakada.rさんの〇〇です。是非こちらもご覧ください。


link to CloudFlag

最後に今回のコード全文を記載します。

import 'dart:io';

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

Future<void> main() async {
  // main 関数内で非同期処理を呼び出すための設定
  WidgetsFlutterBinding.ensureInitialized();
  // デバイスで使用可能なカメラを全て取得
  final cameras = await availableCameras();
  runApp(SampleApp(camera: cameras.first));
}

class SampleApp extends StatelessWidget {
  const SampleApp({
    Key? key,
    required this.camera,
  }) : super(key: key);

  final CameraDescription camera;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Camera(camera: camera),
    );
  }
}

// カメラ画面
class Camera extends StatefulWidget {
  const Camera({
    Key? key,
    required this.camera,
  }) : super(key: key);

  final CameraDescription camera;

  @override
  State<Camera> createState() => _CameraState();
}

class _CameraState extends State<Camera> {
  late CameraController _controller;

  @override
  void initState() {
    super.initState();
    _controller = CameraController(widget.camera, ResolutionPreset.max);
    _controller.initialize();
  }

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

  @override
  Widget build(BuildContext context) {
    if (!_controller.value.isInitialized) {
      return Container();
    }
    return Scaffold(
        body: CameraPreview(_controller),
        floatingActionButton: FloatingActionButton(
          child: GestureDetector(
            // タップした時
            onTap: () async {
              // 写真撮影
              final image = await _controller.takePicture();
              // 表示用の画面に遷移
              await Navigator.of(context).push(
                MaterialPageRoute(
                  builder: (context) => ImagePreview(imagePath: image.path),
                  fullscreenDialog: true,
                ),
              );
            },
            // 長押しを開始した時
            onLongPress: () async {
              // 動画撮影開始
              await _controller.startVideoRecording();
            },
            // 長押しを終了した時
            onLongPressUp: () async {
              // 動画撮影終了
              final video = await _controller.stopVideoRecording();
              // 表示用の画面に遷移
              await Navigator.of(context).push(
                MaterialPageRoute(
                  builder: (context) => VideoPreview(videoPath: video.path),
                  fullscreenDialog: true,
                ),
              );
            },
            child: const Icon(Icons.add_a_photo),
          ),
          onPressed: () {},
        ));
  }
}

// 撮影した写真を表示する画面
class ImagePreview extends StatelessWidget {
  const ImagePreview({Key? key, required this.imagePath}) : super(key: key);

  final String imagePath;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Preview')),
      body: Center(child: Image.file(File(imagePath))),
    );
  }
}

// 撮影した動画を表示する画面
class VideoPreview extends StatefulWidget {
  const VideoPreview({
    Key? key,
    required this.videoPath,
  }) : super(key: key);

  final String videoPath;

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

class _VideoPreviewState extends State<VideoPreview> {
  late VideoPlayerController _controller;

  @override
  void initState() {
    super.initState();
    _controller = VideoPlayerController.file(File(widget.videoPath));
    _controller.initialize().then((_) {
      setState(() {});
      _controller.play();
    });
  }

  @override
  Widget build(BuildContext context) {
    if (!_controller.value.isInitialized) {
      return Container();
    }
    return Scaffold(
      appBar: AppBar(title: const Text('Preview')),
      body: Center(
        child: AspectRatio(
          aspectRatio: _controller.value.aspectRatio,
          child: VideoPlayer(_controller),
        ),
      ),
    );
  }

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

 

のブログ