📸

flutter camera

に公開

概要

これは、デバイスのカメラへのアクセスを可能にする、iOS、Android、およびWeb用のFlutterプラグインです。

  • ウィジェット内にライブカメラプレビューを表示します。
  • スナップショットをキャプチャしてファイルに保存できます。
  • ビデオを録画します。
  • Dartからイメージストリームへのアクセスを追加します。

📸カメラパッケージを使ってみた

cameraというパッケージを使ってみた。使ってみたと言っても公式のコードをそのまま使ってみただけ。写真撮影はできなかったです。もう少し改良が必要。

add package:

flutter pub add camera

YouTubeの動画を参考に、iOS, Androidの設定をしていきましょう。
https://www.youtube.com/watch?v=g0JoDZmzlEk

iOS
Add two rows to the ios/Runner/Info.plist:

one with the key Privacy - Camera Usage Description and a usage description.
and one with the key Privacy - Microphone Usage Description and a usage description.
If editing Info.plist as text, add:

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

Android
Change the minimum Android sdk version to 21 (or higher) in your android/app/build.gradle file.

minSdkVersion 21

CameraControllerの設定ができれば、カメラは使えます。実機でないと試せないかとやってみましたが、撮影するボタンがない罠があった😅
ちょっと機能追加が必要みたいすね。image_pickerだと、カメラ機能ついていたような???

example

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

late List<CameraDescription> _cameras;

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  _cameras = await availableCameras();
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {

  late CameraController controller;

  @override
  void initState() {
    super.initState();
    controller = CameraController(_cameras[0], ResolutionPreset.max);
    controller.initialize().then((_) {
      if (!mounted) {
        return;
      }
      setState(() {});
    }).catchError((Object e) {
      if (e is CameraException) {
        switch (e.code) {
          case 'CameraAccessDenied':
            // Handle access errors here.
           
          default:
            // Handle other errors here.
            
        }
      }
    });
  }

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

  @override
  Widget build(BuildContext context) {
    if (!controller.value.isInitialized) {
      return Container();
    }
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: CameraPreview(
        controller
        ),
    );
  }
}

感想

カメラのプレビューを試すぐらいのサンプルのようですね。もし何かカメラアプリを作りたい人がいたらこちらの記事を参考にした方が良いと思われます。

https://flutter.salon/plugin/camera/

2025年12月追加

camera packageのズームインとズームアウトの機能をジェスチャーを使用して再現したソースコードを作ってみました。写真を撮るとスマートフォンの写真アプリに保存されます。Androidのみで検証しております。

https://pub.dev/packages/gal
https://pub.dev/packages/path_provider

改良版
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'package:gal/gal.dart';

late List<CameraDescription> _cameras;

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  _cameras = await availableCameras();
  runApp(const CameraApp());
}

/// CameraApp is the Main Application.
class CameraApp extends StatefulWidget {
  /// Default Constructor
  const CameraApp({super.key});

  @override
  State<CameraApp> createState() => _CameraAppState();
}

class _CameraAppState extends State<CameraApp> {
  late CameraController controller;
  double _minZoomLevel = 1.0;
  double _maxZoomLevel = 1.0;
  double _currentZoomLevel = 1.0;
  double _baseZoomLevel = 1.0;
  FlashMode _flashMode = FlashMode.off;
  bool _isTimerEnabled = false;
  int _countdown = 0;

  @override
  void initState() {
    super.initState();
    controller = CameraController(_cameras[0], ResolutionPreset.max);
    controller.initialize().then((_) {
      if (!mounted) {
        return;
      }
      controller.getMaxZoomLevel().then((value) => _maxZoomLevel = value);
      controller.getMinZoomLevel().then((value) => _minZoomLevel = value);
      controller.setFlashMode(_flashMode);
      setState(() {});
    }).catchError((Object e) {
      if (e is CameraException) {
        switch (e.code) {
          case 'CameraAccessDenied':
            // Handle access errors here.
            break;
          default:
            // Handle other errors here.
            break;
        }
      }
    });
  }

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

  Future<void> _takePicture() async {
    if (!controller.value.isInitialized) {
      return;
    }
    if (controller.value.isTakingPicture) {
      return;
    }

    if (_isTimerEnabled) {
      setState(() {
        _countdown = 3;
      });
      for (int i = 3; i > 0; i--) {
        await Future.delayed(const Duration(seconds: 1));
        if (!mounted) return;
        setState(() {
          _countdown = i - 1;
        });
      }
    }

    try {
      final XFile file = await controller.takePicture();
      await Gal.putImage(file.path);
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('Saved to gallery!')),
        );
      }
    } catch (e) {
      debugPrint(e.toString());
    } finally {
      if (mounted) {
        setState(() {
          _countdown = 0;
        });
      }
    }
  }

  Future<void> _setZoom(double zoom) async {
    if (!controller.value.isInitialized) {
      return;
    }
    // Clamp the zoom level
    final double newZoom = zoom.clamp(_minZoomLevel, _maxZoomLevel);
    await controller.setZoomLevel(newZoom);
    setState(() {
      _currentZoomLevel = newZoom;
    });
  }

  void _handleScaleStart(ScaleStartDetails details) {
    _baseZoomLevel = _currentZoomLevel;
  }

  Future<void> _handleScaleUpdate(ScaleUpdateDetails details) async {
    // details.scale is the scale factor since the start of the gesture
    final double newZoom = _baseZoomLevel * details.scale;
    await _setZoom(newZoom);
  }

  void _toggleFlash() {
    FlashMode newMode;
    if (_flashMode == FlashMode.off) {
      newMode = FlashMode.auto;
    } else if (_flashMode == FlashMode.auto) {
      newMode = FlashMode.always;
    } else {
      newMode = FlashMode.off;
    }

    controller.setFlashMode(newMode).then((_) {
      setState(() {
        _flashMode = newMode;
      });
    });
  }

  void _toggleTimer() {
    setState(() {
      _isTimerEnabled = !_isTimerEnabled;
    });
  }

  @override
  Widget build(BuildContext context) {
    if (!controller.value.isInitialized) {
      return Container();
    }
    return MaterialApp(
      home: Scaffold(
        body: Stack(
          children: [
            GestureDetector(
              onScaleStart: _handleScaleStart,
              onScaleUpdate: _handleScaleUpdate,
              child: Center(child: CameraPreview(controller)),
            ),
            // Controls
            Positioned(
              top: 50,
              right: 20,
              child: Column(
                children: [
                  IconButton(
                    icon: Icon(
                      _flashMode == FlashMode.off
                          ? Icons.flash_off
                          : (_flashMode == FlashMode.auto
                              ? Icons.flash_auto
                              : Icons.flash_on),
                      color: Colors.white,
                      size: 30,
                    ),
                    onPressed: _toggleFlash,
                  ),
                  const SizedBox(height: 20),
                  IconButton(
                    icon: Icon(
                      _isTimerEnabled ? Icons.timer_3 : Icons.timer_off,
                      color: Colors.white,
                      size: 30,
                    ),
                    onPressed: _toggleTimer,
                  ),
                ],
              ),
            ),
            Positioned(
              bottom: 30,
              left: 0,
              right: 0,
              child: Center(
                child: FloatingActionButton(
                  onPressed: _takePicture,
                  child: const Icon(Icons.camera_alt),
                ),
              ),
            ),
            if (_countdown > 0)
              Center(
                child: Text(
                  '$_countdown',
                  style: const TextStyle(
                    color: Colors.white,
                    fontSize: 80,
                    fontWeight: FontWeight.bold,
                    shadows: [
                      Shadow(
                        blurRadius: 10.0,
                        color: Colors.black,
                        offset: Offset(2.0, 2.0),
                      ),
                    ],
                  ),
                ),
              ),
          ],
        ),
      ),
    );
  }
}

flutter_hooks対応もしてみた。

StatefulWidgetだとsetStateを使うと、全てのWidgetを構築するのでパフォーマンスが良くなかったりする。Riverpodを使うべきか?
UIの場合は、flutter_hooksを使う方が適切。なぜなら、カメラを1ページでしか使わないので全ての場所で状態管理できるRiverpodを使う必要はない。

https://pub.dev/packages/flutter_hooks/install

flutter_hooks対応
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; // 追加
import 'package:gal/gal.dart';

late List<CameraDescription> _cameras;

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  _cameras = await availableCameras();
  runApp(const MaterialApp(home: CameraScreen()));
}

class CameraScreen extends HookWidget {
  const CameraScreen({super.key});

  @override
  Widget build(BuildContext context) {
    // 1. コントローラーの作成(一度だけ実行される)
    final controller = useMemoized(
        () => CameraController(_cameras[0], ResolutionPreset.max));
    
    // 2. 初期化状態とライフサイクル管理
    final isInitialized = useState(false);
    
    useEffect(() {
      controller.initialize().then((_) {
        isInitialized.value = true;
      }).catchError((Object e) {
        // エラーハンドリング
      });
      // ウィジェット破棄時にコントローラーも破棄
      return controller.dispose;
    }, [controller]);

    // 3. 各種状態管理
    final currentZoomLevel = useState(1.0);
    final flashMode = useState(FlashMode.off);
    final isTimerEnabled = useState(false);
    final countdown = useState(0);
    
    // ズーム用のベース値を保持
    final baseZoomLevel = useRef(1.0);

    // 初期化前はローディングなどを表示
    if (!isInitialized.value) {
      return const Scaffold(body: Center(child: CircularProgressIndicator()));
    }

    // 関数定義(useCallbackを使うと再描画時の再生成を防げますが、この規模なら直接関数でも可)
    Future<void> takePicture() async {
      if (controller.value.isTakingPicture) return;

      if (isTimerEnabled.value) {
        countdown.value = 3;
        for (int i = 3; i > 0; i--) {
          await Future.delayed(const Duration(seconds: 1));
          if (!context.mounted) return;
          countdown.value = i - 1;
        }
      }

      try {
        final XFile file = await controller.takePicture();
        await Gal.putImage(file.path);
        if (context.mounted) {
          ScaffoldMessenger.of(context).showSnackBar(
            const SnackBar(content: Text('Saved to gallery!')),
          );
        }
      } catch (e) {
        debugPrint(e.toString());
      } finally {
        countdown.value = 0;
      }
    }

    void toggleFlash() {
      FlashMode newMode;
      if (flashMode.value == FlashMode.off) {
        newMode = FlashMode.auto;
      } else if (flashMode.value == FlashMode.auto) {
        newMode = FlashMode.always;
      } else {
        newMode = FlashMode.off;
      }
      controller.setFlashMode(newMode).then((_) {
        flashMode.value = newMode;
      });
    }

    return Scaffold(
      body: Stack(
        children: [
          GestureDetector(
            onScaleStart: (details) {
              baseZoomLevel.value = currentZoomLevel.value;
            },
            onScaleUpdate: (details) async {
              final double newZoom = (baseZoomLevel.value * details.scale)
                  .clamp(1.0, await controller.getMaxZoomLevel());
              await controller.setZoomLevel(newZoom);
              currentZoomLevel.value = newZoom;
            },
            child: Center(child: CameraPreview(controller)),
          ),
          // ... UI部分はほぼ同じ ...
          Positioned(
            top: 50,
            right: 20,
            child: Column(
              children: [
                IconButton(
                  icon: Icon(
                    flashMode.value == FlashMode.off
                        ? Icons.flash_off
                        : (flashMode.value == FlashMode.auto
                            ? Icons.flash_auto
                            : Icons.flash_on),
                    color: Colors.white,
                    size: 30,
                  ),
                  onPressed: toggleFlash,
                ),
                const SizedBox(height: 20),
                IconButton(
                  icon: Icon(
                    isTimerEnabled.value ? Icons.timer_3 : Icons.timer_off,
                    color: Colors.white,
                    size: 30,
                  ),
                  onPressed: () => isTimerEnabled.value = !isTimerEnabled.value,
                ),
              ],
            ),
          ),
          Positioned(
            bottom: 30,
            left: 0,
            right: 0,
            child: Center(
              child: FloatingActionButton(
                onPressed: takePicture,
                child: const Icon(Icons.camera_alt),
              ),
            ),
          ),
          if (countdown.value > 0)
            Center(
              child: Text(
                '${countdown.value}',
                style: const TextStyle(
                  color: Colors.white,
                  fontSize: 80,
                  fontWeight: FontWeight.bold,
                  shadows: [
                    Shadow(
                      blurRadius: 10.0,
                      color: Colors.black,
                      offset: Offset(2.0, 2.0),
                    ),
                  ],
                ),
              ),
            ),
        ],
      ),
    );
  }
}

Discussion