今回の記事はモバイルのカメラ&動画撮影アプリをまず動かしてみて、何を実装しているのか解説・理解しながら、作成を進めるコンセプトで進めていきます。
なぜなら、この記事の著者はとあるきっかけからモバイル(iOS、Android)でカメラアプリを作りたいなと思って調べたところ・・・、他のサイトでは「コードは書いている。でも何で動いているかはわからない。各コードの説明もない。」(何が分からないかわからない…ツライ)状態でした。
1行ごとに解説が欲しい…と思いましたね。そのため、丁寧に解説したいと思います。
記事の前提
記事を書くにあたり、対象とする・しない内容を明示しておきます。
- Android Studioを使い、その環境設定は実施済みです。
- 開発はiOSとAndroid向けのみ行います。
- Flutterの特定のバージョンのみを対象に開発します。
各種バージョンは以下の通り(現行最新)です。バージョンが変動するとコーディングルールが変化する場合がありますのでご注意ください。
> flutter doctor -v [√] Flutter (Channel stable, 2.10.2, on Microsoft Windows [Version 10.0.22000.556], locale ja-JP) • Flutter version 2.10.2 at C:\Users\saito\flutter • Upstream repository https://github.com/flutter/flutter.git • Framework revision 097d3313d8 (5 weeks ago), 2022-02-18 19:33:08 -0600 • Engine revision a83ed0e5e3 • Dart version 2.16.1 • DevTools version 2.9.2
Flutterの開発言語はDart、DartのベースはJavaScriptです。もし、コーディングルールがよくわからない場合は、JavaScriptを少し勉強してから取り組むと効率的だと思われます。
また、アプリの完成版が欲しい方は、一番下のまとめにあるGithubページを確認ください。
参考サイト
Flutter camera plugin: A deep dive with examples
上記サイトのアプリをベースにカスタマイズしています。実際にアプリのコードを利用する(記事の作成、アプリリリース等)場合は、上記サイトの利用規約をご確認ください。
やることメニュー
カメラプレビュー、カメラを撮影できるようにします。その後、カメラの状態を変更(カメラ画素、フラッシュ、通常とセルフィーの切り替え)できるようにし、最後に動画を撮影できるようにします。
新規プロジェクト作成と準備
プロジェクト作成
下記をコマンドラインで実行するか、新規Flutterプロジェクト作成を行ってください。
flutter create camera_demo_rfarms
インストール
ルートディレクトリのpubspec.yamlにインストールするパッケージを記載しておきます。
dependenciesの後に記載してください。
dependencies: camera: ^0.9.4+17 video_player: ^2.3.0 path_provider: ^2.0.9 permission_handler: ^9.2.0
各バージョンは最新版をググって入力しておきます。バージョンが低いと競合することが多いです。
インストールするには、Android Studioの左下にあるターミナル(下記画像参照)で下記のコマンドを実行するか、右上にあるPub Get(下記画像参照)ボタンを押してください。
(画像)
(インストールコマンド)
flutter pub get
ユーザー許可の取得
cameraはユーザー許可を取得する必要があります。詳細はcamera | Flutter Packageをご覧ください。
現行では、ios/Runner/Info.plistとandroid/app/build.gradleに変更が必要です。
以降でコーディングを行います。
コーディングの各ステップの流れとしては、まずはコードの内容を変更し、ビルドして画面の変化を確認します。その後、何をどう変更したのか確認しましょう。
カメラプレビューの作成
カメラプレビューの表示
lib/screens/camera_screen.dartを作成し、下記の通りに変更します。
import 'package:camera/camera.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../main.dart'; class CameraScreen extends StatefulWidget { @override _CameraScreenState createState() => _CameraScreenState(); } class _CameraScreenState extends State<CameraScreen> with WidgetsBindingObserver { CameraController? controller; bool _isCameraInitialized = false; void onNewCameraSelected(CameraDescription cameraDescription) async { final previousCameraController = controller; // Instantiating the camera controller final CameraController cameraController = CameraController( cameraDescription, ResolutionPreset.high, imageFormatGroup: ImageFormatGroup.jpeg, ); // Dispose the previous controller await previousCameraController?.dispose(); // Replace with the new controller if (mounted) { setState(() { controller = cameraController; }); } // Update UI if controller updated cameraController.addListener(() { if (mounted) setState(() {}); }); // Initialize controller try { await cameraController.initialize(); } on CameraException catch (e) { throw ('Error initializing camera: $e'); } // Update the Boolean if (mounted) { setState(() { _isCameraInitialized = controller!.value.isInitialized; }); } } @override void initState() { // Hide the status bar // SystemChrome.setEnabledSystemUIOverlays([]); onNewCameraSelected(cameras[0]); super.initState(); } @override void dispose() { controller?.dispose(); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { final CameraController? cameraController = controller; // App state changed before we got the chance to initialize. if (cameraController == null || !cameraController.value.isInitialized) { return; } if (state == AppLifecycleState.inactive) { // Free up memory when camera not active cameraController.dispose(); } else if (state == AppLifecycleState.resumed) { // Reinitialize the camera with same properties onNewCameraSelected(cameraController.description); } } @override Widget build(BuildContext context) { return Scaffold( body: _isCameraInitialized ? AspectRatio( aspectRatio: 1 / controller!.value.aspectRatio, child: controller!.buildPreview(), ) : Container(), ); } }
lib/main.dartを下記の通りに変更します。
import 'package:flutter/material.dart'; import 'screens/camera_screen.dart'; import 'package:camera/camera.dart'; List<CameraDescription> cameras = []; Future<void> main() async { try { WidgetsFlutterBinding.ensureInitialized(); cameras = await availableCameras(); } on CameraException catch (e) { throw ('Error in fetching the cameras: $e'); } runApp(MyApp()); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', theme: ThemeData( primarySwatch: Colors.blue, ), debugShowCheckedModeBanner: false, home: CameraScreen(), ); } }
ビルドを実行してみてください。カメラのプレビュー画面が表示されると思います。
カメラ視点の変更は、ALTボタンを押しながら、マウスやWASDキーをクリックしてみてください。
カメラプレビューの表示(解説)
大まかな流れとしては、元々あったlib/main.dartから、lib/screens/camera_screen.dartを呼び出してカメラのプレビューを表示しています。
lib/main.dart
まずは、lib/main.dartを確認します。
必要なパッケージをインポートします。
import 'package:flutter/material.dart'; import 'screens/camera_screen.dart'; import 'package:camera/camera.dart';
続いて、cameraのデータ形式(リスト)を定義します。
List<CameraDescription> cameras = [];
続いて、メイン関数を定義します。
Future<void> main() async { try { WidgetsFlutterBinding.ensureInitialized(); cameras = await availableCameras(); } on CameraException catch (e) { throw ('Error in fetching the cameras: $e'); } runApp(MyApp()); }
async-awaitを利用し、同期している(非同期関数)ように見せます。
try-catchを利用し、カメラの初期化にエラーが出た場合は「Error in fetching the cameras: $e」を表示します。カメラの初期化では、WidgetsBindingを定義し、Flutter Engineと通信ができるようにします([Flutter] WidgetsBindingとは何か?)。カメラ(cameras)リストは利用可能な複数(0:通常画面、1:セルフィー画面)を取得してきます。
その後、下記のMyAppクラスを生成します。
MyAppクラスは状態を持たないStatelessWidgetを継承して定義します。
class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', theme: ThemeData( primarySwatch: Colors.blue, ), debugShowCheckedModeBanner: false, home: CameraScreen(), ); } }
Widget build(BuildContext context) {}には、BuildContext は Element であり、 これからつくる(buildする) Widget が実際にどんな場所でどんな風に使われるのかを知るためのものという解釈があります。現実問題として、これを外すと、もしWidgetの内容が変更された場合に前後でデータが一致せずエラーが出ます。
MaterialAppを設定する際、debugShowCheckedModeBannerでデバッグモード表示をfalseに設定します。
lib/screens/camera_screen.dart
続いて、lib/screens/camera_screen.dartを確認します。
まずは、モジュールをインポートします。
import 'package:camera/camera.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../main.dart';
先ほど設定したmain.dartもインポートする必要があります。その理由は、camerasなどのデータを取得する必要があるためです。
CameraScreenクラスをStatefulWidgetを用いて定義します。
class CameraScreen extends StatefulWidget { @override _CameraScreenState createState() => _CameraScreenState(); }
続いて定義する_CameraScreenStateを利用し、createStateで状態を設定します。
_CameraScreenState
_CameraScreenStateはかなり情報量が多いため、いくつかに分割して解説します。
_CameraScreenStateはStateを継承して定義します。
class _CameraScreenState extends State<CameraScreen> with WidgetsBindingObserver { CameraController? controller; bool _isCameraInitialized = false;
WidgetsBindingObserverを使い、アプリの状態(復帰や待機、中止状態)を検知できるようにします(WidgetsBindingObserver class)。
CameraController型でcontrollerを定義します。型の後ろに?を記載することで、Null許容になります。
bool型で_isCameraInitializedを定義します。Cameraが初期化されたかどうかを判断します。_変数名の形式は、StatefulWidget内で状態をプライベート化するために利用します(Flutterの変数名の前のUnderscore “_”は何を意味しますか?)。
onNewCameraSelected
続いて、カメラの選択をするためにonNewCameraSelectedを定義します。こちらも長いため分けて解説します。
void onNewCameraSelected(CameraDescription cameraDescription) async { final previousCameraController = controller; // Instantiating the camera controller final CameraController cameraController = CameraController( cameraDescription, ResolutionPreset.high, imageFormatGroup: ImageFormatGroup.jpeg, ); // Dispose the previous controller await previousCameraController?.dispose();
こちらもasync-awaitを追加します。以前のカメラを停止させるときのdisposeに利用します。
finalでpreviousCameraControllerを定義します。finalとconstは定数という観点では似たようなものですが、定義のタイミングが違います(Dart finalとconstの違いについて)。finalはプログラムが実行され、変数が途中で定義(可変)されれば、そのあとは定数として扱われます。
cameraControllerは、cameraパッケージのCameraControllerを使って定義します。定義内容は、cameraDescription、ResolutionPreset.high、imageFormatGroupを利用します。実運用上変更する可能性があるのは、ResolutionPreset(カメラの画素数)でしょうか。それは後程変更できるようにします。
onNewCameraSelectedの最後です。各種状態を設定します。
// Replace with the new controller if (mounted) { setState(() { controller = cameraController; }); } // Update UI if controller updated cameraController.addListener(() { if (mounted) setState(() {}); }); // Initialize controller try { await cameraController.initialize(); } on CameraException catch (e) { throw ('Error initializing camera: $e'); } // Update the Boolean if (mounted) { setState(() { _isCameraInitialized = controller!.value.isInitialized; }); } }
状態を変更する際に使うsetState()を多用しています。controller!(クラスの後に感嘆符)は、Nullにならないとコンパイラに教えるようです(TypeScriptの変数の末尾の”!”(エクスクラメーション/感嘆符)の意味)。Flutterではどうなんでしょうか。それ以外は説明済みなので割愛します。
続いて、initState(状態設定)とdispose(停止)を定義します。
@override void initState() { // Hide the status bar // SystemChrome.setEnabledSystemUIOverlays([]); onNewCameraSelected(cameras[0]); super.initState(); } @override void dispose() { controller?.dispose(); super.dispose(); }
SystemChrome.setEnabledSystemUIOverlays([])を使うと、ステータスバーを表示させません。
camerasはリスト形式(0:通常画面、1:セルフィー画面)で得られているので、0を設定します。
disposeは説明できる内容はありません。Nullを許容しているクラスや定数(今回はcontroller)には、?を後ろにつける必要があります。
SystemChrome.setEnabledSystemUIOverlaysは非推奨に変更されたようです。下記に入れ替えてください(Flutter 2.5リリース周りのAPIを研究してみる)。
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky,);
続いて、カメラの状態(ライフサイクル)が変わった時のdidChangeAppLifecycleStateを定義します。
@override void didChangeAppLifecycleState(AppLifecycleState state) { final CameraController? cameraController = controller; // App state changed before we got the chance to initialize. if (cameraController == null || !cameraController.value.isInitialized) { return; } if (state == AppLifecycleState.inactive) { // Free up memory when camera not active cameraController.dispose(); } else if (state == AppLifecycleState.resumed) { // Reinitialize the camera with same properties onNewCameraSelected(cameraController.description); } }
こちらは、cameraControllerが定義できていなかったり、initializeがうまくいかない場合に再定義するために利用されます。
長かった_CameraScreenStateの最後です。ここは表示に利用されます。
@override Widget build(BuildContext context) { return Scaffold( body: _isCameraInitialized ? AspectRatio( aspectRatio: 1 / controller!.value.aspectRatio, child: controller!.buildPreview(), ) : Container(), ); } }
表示内容をScaffoldのbody内に定義します。以降の表示の変更は、ここで追加されていきます。
これでカメラプレビューの表示ができるようになりました。お疲れさまでした。
一端休憩しましょう。
続いて、カメラを撮影できるようにします。
カメラ撮影を可能にする
カメラを撮影できるようにする
撮影ボタンがなく、かつカメラを撮る関数がないので定義します。
まずは、カメラを撮る関数takePicture()を_CameraScreenState内に定義します。
Future<XFile?> takePicture() async { final CameraController? cameraController = controller; if (cameraController!.value.isTakingPicture) { // A capture is already pending, do nothing. return null; } try { XFile file = await cameraController.takePicture(); return file; } on CameraException catch (e) { print('Error occured while taking picture: $e'); return null; } }
続いて、撮影ボタンを作成します。コードの最後に定義しているWidget build(BuildContext context) {}内を変更します。
return SafeArea( child: Scaffold( body: _isCameraInitialized ? Column(children: [ AspectRatio( aspectRatio: 1 / controller!.value.aspectRatio, child: Stack(children: [ CameraPreview( controller!, ), Padding( padding: const EdgeInsets.fromLTRB( 16.0, 8.0, 16.0, 8.0, ), child: Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ InkWell( onTap: () async { XFile? rawImage = await takePicture(); File imageFile = File(rawImage!.path); int currentUnix = DateTime.now().millisecondsSinceEpoch; final directory = await getApplicationDocumentsDirectory(); String fileFormat = imageFile.path.split('.').last; await imageFile.copy( '${directory.path}/$currentUnix.$fileFormat', ); }, child: Stack( alignment: Alignment.center, children: [ Icon(Icons.circle, color: Colors.white38, size: 80), Icon(Icons.circle, color: Colors.white, size: 65), ], ), ), ]), ), ]), ), ]) : const Center( child: Text( 'LOADING', style: TextStyle(color: Colors.white), ), ), ));
SafeAreaクラスはどのOSでも適切な場所に設定する機能を持っています(SafeArea class)。
bodyは、特殊な構文で定義されているので確認します。bool値 (trueなら実行) : (falseなら実行)となります。つまり、カメラが設定されていなければ、LOADINGが表示されるが、設定されていればカメラプレビューとボタンが表示されます。
実際に撮影ボタンがタップされた場合は下記のコードが実行されます。
XFile? rawImage = await takePicture(); File imageFile = File(rawImage!.path); int currentUnix = DateTime.now().millisecondsSinceEpoch; final directory = await getApplicationDocumentsDirectory(); String fileFormat = imageFile.path.split('.').last; await imageFile.copy( '${directory.path}/$currentUnix.$fileFormat', );
takePicture()関数が実行され、rawImageが生成される。そのパスをimageFileが受け取る。保存の際、パスはファイルのベースパス/ミリセカンド.ファイルフォーマットと設定される。
最後に、パッケージが不足しているので最初に追加しておきましょう。
import 'dart:io'; import 'package:path_provider/path_provider.dart';
ビルドをして表示を確認します。
左上に撮影の白いボタンがあり、位置に違和感あり・・・ですが機能はします。
白いボタンをクリックするとAndroid Studio上のコンソールで下記のような表示がされます。一応動いているみたいですね。
撮影したファイルが確認できないと困るので、次は撮影した写真のプレビューを表示します。
撮影したファイルのプレビューを作成する
まずは、撮影したファイルを格納しておく変数を定義します。
CameraController? controller;直下に下記を定義しておきます。
File? _imageFile; List<File> allFileList = [];
続いて、_CameraScreenState内のtakePicture()のすぐ下に、撮影した画像を取得する関数を定義します。
refreshAlreadyCapturedImages() async { final directory = await getApplicationDocumentsDirectory(); List<FileSystemEntity> fileList = await directory.list().toList(); allFileList.clear(); List<Map<int, dynamic>> fileNames = []; fileList.forEach((file) { if (file.path.contains('.jpg') || file.path.contains('.mp4')) { allFileList.add(File(file.path)); String name = file.path.split('/').last.split('.').first; fileNames.add({0: int.parse(name), 1: file.path.split('/').last}); } }); if (fileNames.isNotEmpty) { final recentFile = fileNames.reduce((curr, next) => curr[0] > next[0] ? curr : next); String recentFileName = recentFile[1]; if (recentFileName.contains('.mp4')) { _imageFile = null; } else { _imageFile = File('${directory.path}/$recentFileName'); } setState(() {}); } }
getApplicationDocumentsDirectory()でルートディレクトリを取得し、撮影した画像データを取得します。
そのファイルのうち、指定された拡張子(jpg:画像、mp4:動画)のみをallFileListに追加します。
allFileListのうち、最新のファイルを_imageFileとして取得します。動画の場合、nullになるように定義していますが、今は使いません。
関数が定義できたので、更新時と初期設定の際に画像を読み込みます。
まずは、更新時のために、Widget build(BuildContext context) {} SafeArea内のawait imageFile.copy()の後に下記を定義します。
refreshAlreadyCapturedImages();
続いて、関数initState()のonNewCameraSelected(cameras[0]);とsuper.initState();の間に定義します。
refreshAlreadyCapturedImages();
最後に、表示を追加します。
カメラボタンが定義されているInkWellと同列(Children内)に、下記のように定義します。
InkWell( onTap: _imageFile != null ? () { Navigator.of(context).push( MaterialPageRoute( builder: (context) => PreviewScreen( imageFile: _imageFile!, fileList: allFileList, ), ), ); } : null, child: Container( width: 60, height: 60, decoration: BoxDecoration( color: Colors.black, borderRadius: BorderRadius.circular(10.0), border: Border.all( color: Colors.white, width: 2, ), image: _imageFile != null ? DecorationImage( image: FileImage(_imageFile!), fit: BoxFit.cover, ) : null, ), child: Container(), ), ),
プレビュー画像を表示するために、さまざまな調整をしています。
そこに、他ファイルで定義したPreviewScreenクラスを利用します。PreviewScreenはほかのファイルを2つ使い定義します。
コードの内容は表示を定義するだけですので、読み飛ばしていただいてもかまいません。
PreviewScreenクラス
lib/screens/preview_screen.dartを作成し、下記の通りに変更します。
こちらは、個別の画像(動画)を一つずつ確認する際に使用します。
import 'dart:io'; import 'package:flutter/material.dart'; import './captures_screen.dart'; class PreviewScreen extends StatelessWidget { final File imageFile; final List<File> fileList; const PreviewScreen({ required this.imageFile, required this.fileList, }); @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.black, body: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.all(8.0), child: TextButton( onPressed: () { Navigator.of(context).pushReplacement( MaterialPageRoute( builder: (context) => CapturesScreen( imageFileList: fileList, ), ), ); }, child: Text('Go to all captures'), style: TextButton.styleFrom( primary: Colors.black, backgroundColor: Colors.white, ), ), ), Expanded( child: Image.file(imageFile), ), ], ), ); } }
処理の流れとしては、クラス内変数を定義し、const PreviewScreen({});でこのクラスが他で使用されたときの入力を定義します。
「Go to all captures」ボタンがクリックされた場合、lib/screens/captures_screen.dartに移動します。
lib/screens/captures_screen.dartを作成し、下記の通りに変更します。
こちらは、撮影したすべての画像(動画)を表示するために使用します。
import 'dart:io'; import 'package:flutter/material.dart'; import './preview_screen.dart'; class CapturesScreen extends StatelessWidget { final List<File> imageFileList; const CapturesScreen({required this.imageFileList}); @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.black, body: SingleChildScrollView( physics: BouncingScrollPhysics(), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.all(16.0), child: Text( 'Captures', style: TextStyle( fontSize: 32.0, color: Colors.white, ), ), ), GridView.count( shrinkWrap: true, physics: NeverScrollableScrollPhysics(), crossAxisCount: 2, children: [ for (File imageFile in imageFileList) Container( decoration: BoxDecoration( border: Border.all( color: Colors.black, width: 2, ), ), child: InkWell( onTap: () { Navigator.of(context).pushReplacement( MaterialPageRoute( builder: (context) => PreviewScreen( fileList: imageFileList, imageFile: imageFile, ), ), ); }, child: Image.file( imageFile, fit: BoxFit.cover, ), ), ), ], ), ], ), ), ); } }
こちらは、特定の画像をクリックすることでlib/screens/preview_screen.dartに移動できるよう作成しています。
パッケージが不足しているので、lib/screens/camera_screen.dartの最初に追加しておきましょう。
import './preview_screen.dart';
ビルドして表示を確認してみましょう。
撮影ボタンの下にプレビューが追加されています。位置は後で補正するので気にしないでください。
撮影するか、またはアプリを立ち上げなおすことで、プレビューが正常に表示されることを確認ください。
撮影したすべての画像の表示も確認しておきます。プレビューをクリックし、Go to all cupturesをクリックしてください。
(少しカスタマイズしていますが、)撮影したファイルがすべて記録されています。
お疲れさまでした。一端休憩しましょう。
この後、カメラの状態の変更(カメラ画素、フラッシュ、通常⇔セルフィーなど)を設定し、最後に動画を撮影できるようにします。
カメラ状態の変更と調整
カメラの状態の変更
画素調整
画素を調整できるようにしていきます。
まずは、変数を定義します。_CameraScreenStateのallFileList直下に下記を追記します。
final resolutionPresets = ResolutionPreset.values; ResolutionPreset currentResolutionPreset = ResolutionPreset.high;
続いて、_CameraScreenStateのonNewCameraSelected関数内のcameraControllerの定義を下記に変更します。
final CameraController cameraController = CameraController( cameraDescription, currentResolutionPreset, imageFormatGroup: ImageFormatGroup.jpeg, );
以前はResolutionPreset.highの固定値で定義していましたが、その値を最初に定義したcurrentResolutionPresetに変え、可変になるように変更しました。
最後に、表示を追加します。
カメラボタンが定義されているInkWellと同列(Children内)のうち最初に、下記のように定義します。
Align( alignment: Alignment.topRight, child: Container( decoration: BoxDecoration( color: Colors.black87, borderRadius: BorderRadius.circular(10.0), ), child: Padding( padding: const EdgeInsets.only( left: 8.0, right: 8.0, ), child: DropdownButton<ResolutionPreset>( dropdownColor: Colors.black87, underline: Container(), value: currentResolutionPreset, items: [ for (ResolutionPreset preset in resolutionPresets) DropdownMenuItem( child: Text( preset .toString() .split('.')[1] .toUpperCase(), style: TextStyle( color: Colors.white), ), value: preset, ) ], onChanged: (value) { setState(() { currentResolutionPreset = value!; _isCameraInitialized = false; }); onNewCameraSelected( controller!.description); }, hint: Text("Select item"), ), ), ), ),
resolutionPresetsのリストにforを使うことで、ドロップダウンメニューを作っています。
では、表示を確認しましょう。
簡単にドロップダウンリストが右上に表示できましたね。
ズーム調整
まずは、変数を定義します。_CameraScreenStateのallFileList直下に下記を追記します。
double _minAvailableZoom = 1.0; double _maxAvailableZoom = 1.0; double _currentZoomLevel = 1.0;
設定変更のため、onNewCameraSelectedのawait cameraController.initialize();の直下に下記を定義します。
await Future.wait([ cameraController .getMaxZoomLevel() .then((value) => _maxAvailableZoom = value), cameraController .getMinZoomLevel() .then((value) => _minAvailableZoom = value), ]);
最後に、表示を追加します。
カメラボタンが定義されているInkWellと同列(Children内)に、下記のように定義します。上記で定義したDropdownButton
Row( children: [ Expanded( child: Slider( value: _currentZoomLevel, min: _minAvailableZoom, max: _maxAvailableZoom, activeColor: Colors.white, inactiveColor: Colors.white30, onChanged: (value) async { setState(() { _currentZoomLevel = value; }); await controller! .setZoomLevel(value); }, ), ), Padding( padding: const EdgeInsets.only(right: 8.0), child: Container( decoration: BoxDecoration( color: Colors.black87, borderRadius: BorderRadius.circular(10.0), ), child: Padding( padding: const EdgeInsets.all(8.0), child: Text( _currentZoomLevel .toStringAsFixed(1) + 'x', style: TextStyle( color: Colors.white), ), ), ), ), ], ),
露光調整
まずは、変数を定義します。_CameraScreenStateのallFileList直下に下記を追記します。
double _minAvailableExposureOffset = 0.0; double _maxAvailableExposureOffset = 0.0; double _currentExposureOffset = 0.0;
Future.wait内に下記を定義します。
cameraController .getMinExposureOffset() .then((value) => _minAvailableExposureOffset = value), cameraController .getMaxExposureOffset() .then((value) => _maxAvailableExposureOffset = value),
最後に、表示を追加します。
カメラボタンが定義されているInkWellと同列(Children内)に、下記のように定義します。上記で定義したRow()の後ろに設定します。
Expanded( child: RotatedBox( quarterTurns: 3, child: Container( height: 30, child: Slider( value: _currentExposureOffset, min: _minAvailableExposureOffset, max: _maxAvailableExposureOffset, activeColor: Colors.white, inactiveColor: Colors.white30, onChanged: (value) async { setState(() { _currentExposureOffset = value; }); await controller! .setExposureOffset(value); }, ), ), ), ),
追加で、上記コードの上部(ROW()との間)に、下記コードを追記します。
Padding( padding: const EdgeInsets.only( right: 8.0, top: 16.0), child: Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(10.0), ), child: Padding( padding: const EdgeInsets.all(8.0), child: Text( _currentExposureOffset .toStringAsFixed(1) + 'x', style: TextStyle(color: Colors.black), ), ), ), ),
それでは、表示を確認します。
ズーム調整(横)と露光調整(縦)が表示されました。スライダーを触ると、倍率を調整できることが分かると思います!
表示がおかしい場合、コーディングの順序を確認ください。Align, Padding, Expanded, Row, InkWell(撮影ボタン), InkWell(プレビュー)になっていると思います。
フラッシュ設定
まずは、変数を定義します。_CameraScreenStateのallFileList直下に下記を追記します。
FlashMode? _currentFlashMode;
onNewCameraSelectedのFuture.waitの後に下記を定義します。
_currentFlashMode = controller!.value.flashMode;
最後に、表示を追加します。
カメラボタンが定義されているInkWellと同列(Children内)に、下記のように定義します。
Expanded( child: SingleChildScrollView( physics: BouncingScrollPhysics(), child: Column( children: [ Padding( padding: const EdgeInsets.only(top: 8.0), child: Row( children: [ Expanded( child: Padding( padding: const EdgeInsets.only( left: 8.0, right: 4.0, ), child: TextButton( onPressed: _isRecordingInProgress ? null : () { if (_isVideoCameraSelected) { setState(() { _isVideoCameraSelected = false; }); } }, style: TextButton.styleFrom( primary: _isVideoCameraSelected ? Colors.black54 : Colors.black, backgroundColor: _isVideoCameraSelected ? Colors.white30 : Colors.white, ), child: Text('IMAGE'), ), ), ), Expanded( child: Padding( padding: const EdgeInsets.only( left: 4.0, right: 8.0), child: TextButton( onPressed: () { if (!_isVideoCameraSelected) { setState(() { _isVideoCameraSelected = true; }); } }, style: TextButton.styleFrom( primary: _isVideoCameraSelected ? Colors.black : Colors.black54, backgroundColor: _isVideoCameraSelected ? Colors.white : Colors.white30, ), child: Text('VIDEO'), ), ), ), ], ), ), Padding( padding: const EdgeInsets.fromLTRB( 16.0, 8.0, 16.0, 8.0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ InkWell( onTap: () async { setState(() { _currentFlashMode = FlashMode.off; }); await controller!.setFlashMode( FlashMode.off, ); }, child: Icon( Icons.flash_off, color: _currentFlashMode == FlashMode.off ? Colors.amber : Colors.white, ), ), InkWell( onTap: () async { setState(() { _currentFlashMode = FlashMode.auto; }); await controller!.setFlashMode( FlashMode.auto, ); }, child: Icon( Icons.flash_auto, color: _currentFlashMode == FlashMode.auto ? Colors.amber : Colors.white, ), ), InkWell( onTap: () async { setState(() { _currentFlashMode = FlashMode.always; }); await controller!.setFlashMode( FlashMode.always, ); }, child: Icon( Icons.flash_on, color: _currentFlashMode == FlashMode.always ? Colors.amber : Colors.white, ), ), InkWell( onTap: () async { setState(() { _currentFlashMode = FlashMode.torch; }); await controller!.setFlashMode( FlashMode.torch, ); }, child: Icon( Icons.highlight, color: _currentFlashMode == FlashMode.torch ? Colors.amber : Colors.white, ), ), ], ), ) ], ), ), ),
カメラ/動画の切り替えボタンと、フラッシュ設定を切り替えるアイコンを設定しました。Flutterの条件文の構文、isA ? True : Falseが随所に出てきます。それさえ理解できれば、それ以上に複雑なことはないでしょう。
続いて、動画撮影で使うものの、定義できていない変数を定義します。
bool _isVideoCameraSelected = false; bool _isRecordingInProgress = false;
さて、最後に表示を確認します。
表示はできているものの、何か違和感があります。これは後で対処します。
カメラを切り替える
通常のカメラと、セルフィーのカメラを切り替えます。
まずは、変数を定義します。_CameraScreenStateのallFileList直下に下記を追記します。
bool _isRearCameraSelected = true;
最後に、表示を追加します。
カメラボタンが定義されているInkWellと同列(Children内)に、InkWellのうちでは一番上部で下記のように定義します。
InkWell( onTap: _isRecordingInProgress ? () async { if (controller! .value.isRecordingPaused) { //await resumeVideoRecording(); } else { //await pauseVideoRecording(); } } : () { setState(() { _isCameraInitialized = false; }); onNewCameraSelected(cameras[ _isRearCameraSelected ? 1 : 0]); setState(() { _isRearCameraSelected = !_isRearCameraSelected; }); }, child: Stack( alignment: Alignment.center, children: [ Icon( Icons.circle, color: Colors.black38, size: 60, ), _isRecordingInProgress ? controller! .value.isRecordingPaused ? Icon( Icons.play_arrow, color: Colors.white, size: 30, ) : Icon( Icons.pause, color: Colors.white, size: 30, ) : Icon( _isRearCameraSelected ? Icons.camera_front : Icons.camera_rear, color: Colors.white, size: 30, ), ], ), ),
ただ、現在は表示に違和感があり、カメラ切り替え・カメラ撮影・プレビューが縦に並んでいます。表示が一致しない場合は、R2002/flutter_camera_demo_1をご覧ください。
次は、表示の不具合を修正します。
表示を修正する
縦並びから横並びに変更
下記の画像のように、カメラ切り替え・カメラ撮影・プレビューが縦に並んでいます。
現状、大ざっぱに考えると下記のように定義されています。
children: [ ズームなどの表示, InkWell(カメラ切り替え), InkWell(カメラ撮影ボタン), InkWell(プレビュー表示), フラッシュなどの調整, ]
それらを横並びにするために、ROW()を使用します。
children: [ ズームなどの表示, Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ InkWell(カメラ切り替え), InkWell(カメラ撮影ボタン), InkWell(プレビュー表示), ] ), フラッシュなどの調整, ]
mainAxisAlignment:MainAxisAlignment.spaceBetween,を定義することにより、オブジェクトごとにスペース(横軸)を開けています。
表示を確認してみましょう。
意図した通りに横並びになってくれました。
ただ、まだ表示に違和感があります。画面下に白いスペースが空いています・・・。そのため、次は縦の長さを調整します。
縦の長さを調整する
initState()の上に定義します。
こちらは、スクリーンの大きさによって設定が変動するようにします。
void onViewFinderTap(TapDownDetails details, BoxConstraints constraints) { if (controller == null) { return; } final offset = Offset( details.localPosition.dx / constraints.maxWidth, details.localPosition.dy / constraints.maxHeight, ); controller!.setExposurePoint(offset); controller!.setFocusPoint(offset); }
CameraPreview(controller!,)を変更します。
CameraPreview( controller!, child: LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) { return GestureDetector( behavior: HitTestBehavior.opaque, onTapDown: (details) => onViewFinderTap(details, constraints), ); }), ),
ただし、表示はまだ変わりません。
最後に、AspectRatio内のStackにchildrenとして定義されているExpanded(child: SingleChildScrollView(~))(カメラ/ビデオ切り替えとフラッシュ設定を変更する大きなボックス)を、AspectRatioと同列に定義しなおします。
下記から、
AspectRatio( child: Stack(children: [ 表示がいろいろと定義されている, Expanded(child: SingleChildScrollView(~)), ]) ),
下記のように変更します。
AspectRatio( child: Stack(children: [ 表示がいろいろと定義されている, ]) ), Expanded(child: SingleChildScrollView(~)),
AspectRatioが、Columnのchildrenとして定義されているため、このように同列に並べる変更が可能です。
最後に、SafeAreaのchildに指定されているScaffoldに背景色を追加します。
Widget build(BuildContext context) { return SafeArea( child: Scaffold( backgroundColor: Colors.black, body: _isCameraInitialized...() ) ) });
それでは表示を確認します。スライダーやカメラ切り替えをチェックしてみましょう。
触っていて切り替え時のバグを発見しました。下の露光スライダーをx10にして、カメラを切り替えるとエラーが出ます。対応方法を考えます。
どうやら、ExposureOffsetの最大値が通常とセルフィー画面で違うようです。そのため、カメラ切り替え時にリセットをかけます。
関数を作成します。
void resetCameraValues() async { _currentZoomLevel = 1.0; _currentExposureOffset = 0.0; }
onNewCameraSelectedのawait previousCameraController?.dispose();の後に下記を追記します。
resetCameraValues();
これで表示の作成は完了しました!お疲れさまでした。
一端休憩しましょう。ここまでの過程をR2002/flutter_camera_demo_2に保存しておきました。自分のコードと比較したい場合はどうぞ。
動画撮影を可能にする
動画を撮影できるようにする
では最後のステップです。
流れとしては、撮影開始、撮影終了、ポーズ、ポーズからの再開、動画プレイヤーの追加、撮影ボタンの編集を行います。
撮影開始
Future<void> startVideoRecording() async { final CameraController? cameraController = controller; if (controller!.value.isRecordingVideo) { // A recording has already started, do nothing. return; } try { await cameraController!.startVideoRecording(); setState(() { _isRecordingInProgress = true; print(_isRecordingInProgress); }); } on CameraException catch (e) { print('Error starting to record video: $e'); } }
cameraアプリのstartVideoRecording()を利用するために、いくつかの確認をしています。
撮影終了
Future<XFile?> stopVideoRecording() async { if (!controller!.value.isRecordingVideo) { // Recording is already is stopped state return null; } try { XFile file = await controller!.stopVideoRecording(); setState(() { _isRecordingInProgress = false; }); return file; } on CameraException catch (e) { print('Error stopping video recording: $e'); return null; } }
終了時に、ファイルをreturnしていることに注意してください。
ポーズ
Future<void> pauseVideoRecording() async { if (!controller!.value.isRecordingVideo) { // Video recording is not in progress return; } try { await controller!.pauseVideoRecording(); } on CameraException catch (e) { print('Error pausing video recording: $e'); } }
ポーズを実施するためにいくつか確認をしています。
ポーズからの再開
Future<void> resumeVideoRecording() async { if (!controller!.value.isRecordingVideo) { // No video recording was in progress return; } try { await controller!.resumeVideoRecording(); } on CameraException catch (e) { print('Error resuming video recording: $e'); } }
ポーズからの再開を実施するためにいくつか確認をしています。
こちらで、関数の定義は終了しました。
動画プレイヤーの追加
動画を撮影しても動画プレイヤーがないと確認ができません。そのため実装を行っていきます。
まずは、変数を定義します。_CameraScreenStateのallFileList直下に下記を追記します。
VideoPlayerController? videoController; File? _videoFile;
ファイルの先頭で必要なパッケージをインポートします。
import 'package:video_player/video_player.dart';
関数を定義します。
Future<void> _startVideoPlayer() async { if (_videoFile != null) { videoController = VideoPlayerController.file(_videoFile!); await videoController!.initialize().then((_) { // Ensure the first frame is shown after the video is initialized, // even before the play button has been pressed. setState(() {}); }); await videoController!.setLooping(true); await videoController!.play(); } }
撮影ボタンの編集
コード上で一番上にあるInkWellのawait resumeVideoRecording();とawait pauseVideoRecording();のコメントアウトを外します。
続いて、上から2番目のInkWell(カメラ撮影ボタン)を下記に変更します。
InkWell( onTap: _isVideoCameraSelected ? () async { if (_isRecordingInProgress) { XFile? rawVideo = await stopVideoRecording(); File videoFile = File(rawVideo!.path); int currentUnix = DateTime .now() .millisecondsSinceEpoch; final directory = await getApplicationDocumentsDirectory(); String fileFormat = videoFile .path .split('.') .last; _videoFile = await videoFile.copy( '${directory.path}/$currentUnix.$fileFormat', ); _startVideoPlayer(); } else { await startVideoRecording(); } } : () async { XFile? rawImage = await takePicture(); File imageFile = File(rawImage!.path); int currentUnix = DateTime.now() .millisecondsSinceEpoch; final directory = await getApplicationDocumentsDirectory(); String fileFormat = imageFile .path .split('.') .last; print(fileFormat); await imageFile.copy( '${directory.path}/$currentUnix.$fileFormat', ); refreshAlreadyCapturedImages(); }, child: Stack( alignment: Alignment.center, children: [ Icon( Icons.circle, color: _isVideoCameraSelected ? Colors.white : Colors.white38, size: 80, ), Icon( Icons.circle, color: _isVideoCameraSelected ? Colors.red : Colors.white, size: 65, ), _isVideoCameraSelected && _isRecordingInProgress ? Icon( Icons.stop_rounded, color: Colors.white, size: 32, ) : Container(), ], ), ),
当初はカメラ撮影のみしか機能が存在しませんでしたが、動画撮影も加わったため、表示の調整をしています。
上から3番目のInkWell(プレビュー表示)も下記の通りに変更します。
InkWell( onTap: _imageFile != null || _videoFile != null ? () { Navigator.of(context).push( MaterialPageRoute( builder: (context) => CapturesScreen( imageFileList: allFileList, ), ), ); } : null, child: Container( width: 60, height: 60, decoration: BoxDecoration( color: Colors.black, borderRadius: BorderRadius.circular(10.0), border: Border.all( color: Colors.white, width: 2, ), image: _imageFile != null ? DecorationImage( image: FileImage(_imageFile!), fit: BoxFit.cover, ) : null, ), child: videoController != null && videoController!.value.isInitialized ? ClipRRect( borderRadius: BorderRadius.circular(8.0), child: AspectRatio( aspectRatio: videoController! .value.aspectRatio, child: VideoPlayer(videoController!), ), ) : Container(), ), ),
こちらも動画撮影に対応するために変更しました。
最後に、プレビュー表示変更用のrefreshAlreadyCapturedImages()を変更します。
if (fileNames.isNotEmpty) {}内にあるif(recentFileName.contains(‘.mp4’)){}内に下記を追記します。
_videoFile = File('${directory.path}/$recentFileName'); _startVideoPlayer();
else側に下記を追記します。
_videoFile = null;
機能停止のためのdispose関数内に下記を追記します。
videoController?.dispose();
ユーザー許可を取得する画面を作成します。設定についてはpermission_handlerをご覧ください。
まずは、変数を定義します。_CameraScreenStateのallFileList直下に下記を追記します。
bool _isCameraPermissionGranted = false;
続いて、許可の状態を確認する関数として下記を定義します。その他の関数と同列に定義してください。
getPermissionStatus() async { await Permission.camera.request(); var status = await Permission.camera.status; if (status.isGranted) { print('Camera Permission: GRANTED'); setState(() { _isCameraPermissionGranted = true; }); // Set and initialize the new camera onNewCameraSelected(cameras[0]); refreshAlreadyCapturedImages(); } else { print('Camera Permission: DENIED'); } }
initState()の下記をgetPermissionStatus();に変更します。
onNewCameraSelected(cameras[0]); refreshAlreadyCapturedImages();
SafeArea(child: Scaffold())内にある下記を、、
body: _isCameraInitialized ?
下記の通りに変更します。ユーザー許可がされていない場合は、続いて定義する内容を表示させます。
body: _isCameraPermissionGranted ? _isCameraInitialized ?
構文としては、上記はif ~ if ~ else と同じなので、body: isA ? isB ? Column() : Column() : Column()と記載する必要があります。
上記で追加した? _isCameraPermissionGrantedに対応するように、最後にelseとして許可が取得できなかった内容を表示します。
: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Row(), const Text( 'Permission denied', style: TextStyle( color: Colors.white, fontSize: 24, ), ), SizedBox(height: 24), ElevatedButton( onPressed: () { getPermissionStatus(); }, child: Padding( padding: const EdgeInsets.all(8.0), child: Text( 'Give permission', style: TextStyle( color: Colors.white, fontSize: 24, ), ), ), ), ], ),
ここまででカメラ&動画撮影アプリの作成はほぼ終了です。
以降は、開発時によくあるのですが思わぬ不具合(バグ)があるため、そのバグを対処していきたいと思います。
最終課題
バグは2点存在します。それぞれ対処していきましょう。
- 動画が表示できない
- 動画を撮影しプレビューに反映すると、プレビューの挙動がおかしくなる
動画が表示できない
まずは何が問題か確認するため、動画が保存されているか確認します。
なぜなら、現状で考えられる問題のパターンは、1. 動画は正常に保存されているが表示に不具合があるのか、2. 動画は保存されているが正常な動画ファイルではないのか、3. 動画が保存されていないのかなどがあるでしょう。そのため、まずは動画が保存されているかどうかを確認します。
Android Studioのデバイスマネージャをクリックします。
そうすると、作成したデバイスが表示されます。そこで赤枠のディレクトリアイコンをクリックすると、デバイスのデータを確認できます。
データはアプリごとに分けて保存されているため、data/data/アプリ名/app_flutterを探してみてください。
探した後は、ファイルを右クリックして、開くコマンドを選択します。関連付けられたファイルで表示すると、どうやらデータの保存はうまくいっていることが分かりました。つまり、アプリの表示部分に課題があるということです。
今回の対応としては、ImageとVideoで条件を分けをして表示を切り分けました。パッケージとしては、ImageはImageで、VideoはVideoPlayer(screen/movie_screen.dartを新たに作成)を利用しました。完成した内容についてはまとめをご覧ください。
動画を撮影しプレビューに反映すると、プレビューの挙動がおかしくなる
画像のプレビューが無視されるようになります。アプリを再起動、リロードしたときだけは問題は出ません。
該当する表示部位を確認します。
child: videoController != null && videoController! .value.isInitialized ? ClipRRect( borderRadius: BorderRadius.circular( 8.0), child: AspectRatio( aspectRatio: videoController! .value.aspectRatio, child: VideoPlayer( videoController!), ), ) : Container(),
どうやらこのコードだと、画像を撮影した後にも、videoControllerが存在したままになっているため動画が表示されている可能性があります。
プレビューを変更する関数を下記から、
if (recentFileName.contains('.mp4')) { _imageFile = null; _videoFile = File('${directory.path}/$recentFileName'); _startVideoPlayer(); } else { _imageFile = File('${directory.path}/$recentFileName'); _videoFile = null; }
下記のように変更します。
if (recentFileName.contains('.mp4')) { _imageFile = null; _videoFile = File('${directory.path}/$recentFileName'); _startVideoPlayer(); } else { _imageFile = File('${directory.path}/$recentFileName'); _videoFile = null; videoController!.dispose(); # videoController = null; }
そうすることで、_videoFileが定義されていない場合は、videoControllerが初期化されます。
また、表示部分の下記を、
videoController != null && videoController!.value.isInitialized
下記の通り変更します。
_videoFile != null
そうすると、_videoFileがnullであれば動画を表示させないことが可能になります。
まとめ
長い記事になりましたが、お疲れさまでした。読んでいただきありがとうございました。
カメラと動画を撮影できるアプリを、1から作成することができましたね!Flutterはいろいろな機能を組み合わせて、機能をサクサクと作ることが可能です。
今回の実装はR2002/flutter_camera_demo_lastに最終版が保存されています。ご確認ください。
また、コードの不具合や疑問等があればコメントいただければと思います。それではありがとうございました!