📸

【Flutter】無音カメラを実装してみた

2025/01/26に公開

この記事は

シャッター音のならない、カメラアプリを作りたーい

結論

カメラを起動後に撮影ボタンをタップすると、カメラを一時停止してスクリーンショットする。
これにより、無音でカメラ撮影ができました!

1. パッケージの追加

デバイス上のカメラ操作ができる
https://pub.dev/packages/camera/versions/0.11.1
スクリーンショットを撮ってくれる
https://pub.dev/packages/screenshot
画像を端末に保存してくれる
https://pub.dev/packages/image_gallery_saver

2. プレビューを実装

デザインは、BeRealっぽく🎵
2本指でズームできるようにします。

camera_preview.dart
class CameraPreviewWidget extends StatefulWidget {
  const CameraPreviewWidget({
    super.key,
    required this.controller,
    required this.minAvailableZoom,
    required this.maxAvailableZoom,
    this.borderRadius = 60,
  });

  final CameraController controller;
  final double minAvailableZoom;
  final double maxAvailableZoom;

  final double borderRadius;

  
  State<CameraPreviewWidget> createState() => _CameraPreviewWidgetState();
}

class _CameraPreviewWidgetState extends State<CameraPreviewWidget> {
  double _currentScale = 1;
  double _baseScale = 1;

  int _pointers = 0;

  
  Widget build(BuildContext context) {
    return Listener(
      onPointerDown: (_) => _pointers++,
      onPointerUp: (_) => _pointers--,
      child: ClipRRect(
        borderRadius: BorderRadius.circular(widget.borderRadius),
        child: CameraPreview(
          widget.controller,
          child: LayoutBuilder(
            builder: (BuildContext context, BoxConstraints constraints) {
              return GestureDetector(
                behavior: HitTestBehavior.opaque,
                onScaleStart: _handleScaleStart,
                onScaleUpdate: _handleScaleUpdate,
                onTapDown: (TapDownDetails details) =>
                    onViewFinderTap(details, constraints),
              );
            },
          ),
        ),
      ),
    );
  }

  void _handleScaleStart(ScaleStartDetails details) {
    _baseScale = _currentScale;
  }

  Future<void> _handleScaleUpdate(ScaleUpdateDetails details) async {
    if (_pointers != 2) {
      return;
    }

    _currentScale = (_baseScale * details.scale)
        .clamp(widget.minAvailableZoom, widget.maxAvailableZoom);

    await widget.controller.setZoomLevel(_currentScale);
  }

  void onViewFinderTap(TapDownDetails details, BoxConstraints constraints) {
    final Offset offset = Offset(
      details.localPosition.dx / constraints.maxWidth,
      details.localPosition.dy / constraints.maxHeight,
    );
    widget.controller.setExposurePoint(offset);
    widget.controller.setFocusPoint(offset);
  }
}

3. カメラを実装

コントローラの初期化をします。

camera_screen_v3.dart
final cameras = useState<List<CameraDescription>>([]);
final controller = useState<CameraController?>(null);

final initializeCameraController = useCallback(
  ({
    required CameraDescription camera,
    required ValueNotifier<CameraController?> controller,
    required ValueNotifier<bool> cameraLoaded,
  }) async {
    try {
      final cameraController = CameraController(
        camera,
     // 画質の設定
        ResolutionPreset.high,
      );

      controller.value = cameraController;
      await cameraController.initialize();
      cameraLoaded.value = true;
    } on CameraException catch (error) {
      if (error.code == 'CameraAccessDenied') {}
      rethrow;
    }
  },
  [],
);

useEffect(
  () {
    availableCameras().then((availableCameras) {
      cameras.value = availableCameras;
      if (availableCameras.isEmpty) return;

      initializeCameraController(
        camera: availableCameras.first,
        controller: controller,
        cameraLoaded: cameraLoaded,
      );
    });

    return () {
      controller.value?.dispose();
    };
  },
  [],
);

4. 撮影を実装

スクリーンショットパッケージのcaptureFromWidgetメソッド内で、Widgetを定義します。撮影時にWidget(ここではプレビュー)が計算して、画像データにしてくれます。
また、撮影時に計算してくれるので、プレビューでは角丸だったのが、保存された写真は長方形になってくれます。

camera_screen_v3.dart
final takePicture = useCallback(
  () async {
    try {
      if (isTakingPicture.value) return;

      final cameraController = controller.value;

      if (cameraController == null ||
          !cameraController.value.isInitialized) {
        return;
      }
      isTakingPicture.value = true;
      // カメラを一時停止
      await cameraController.pausePreview();
      // スクリーンショットの撮影
      final capturedImage = await screenshotController.captureFromWidget(
        CameraPreviewWidget(
          controller: cameraController,
          minAvailableZoom: minAvailableZoom.value,
          maxAvailableZoom: maxAvailableZoom.value,
          borderRadius: 0,
        ),
      );
      // 画像保存
      await ImageGallerySaver.saveImage(capturedImage);
      // カメラの再開
      await cameraController.resumePreview();
    } catch (e) {
      debugPrint('写真撮影エラー: $e');
    } finally {
      isTakingPicture.value = false;
    }
  },
  [
    controller,
    isTakingPicture,
    minAvailableZoom,
    maxAvailableZoom,
  ],
);

5. カメラの向き切り替えを実装

camera_screen_v3.dart
final switchCamera = useCallback(
  ({
    required List<CameraDescription> cameras,
    required CameraController currentController,
    required ValueNotifier<CameraController?> controller,
    required ValueNotifier<bool> cameraLoaded,
    required ValueNotifier<bool> isChangingCamera,
  }) async {
    final description = controller.value!.description;
    final cameraDescription = cameras.firstWhere((element) {
      final direction =
          description.lensDirection == CameraLensDirection.front
              ? CameraLensDirection.back
              : CameraLensDirection.front;
      return element.lensDirection == direction;
    });

    if (controller.value != null) {
      return controller.value!.setDescription(cameraDescription);
    } else {
      return initializeCameraController(
        camera: cameraDescription,
        controller: controller,
        cameraLoaded: cameraLoaded,
      );
    }
  },
  [controller.value],
);

5. 撮影後にアニメーション追加

https://pub.dev/packages/circular_seek_bar

seekbar.dart
class SeekBar extends StatefulWidget {
  const SeekBar({super.key, required this.progress});

  final double progress;

  
  State<SeekBar> createState() => _SeekBarState();
}

class _SeekBarState extends State<SeekBar> {
  
  Widget build(BuildContext context) {
    return CircularSeekBar(
      width: double.infinity,
      height: 250,
      progress: widget.progress,
      barWidth: 8,
      startAngle: 45,
      sweepAngle: 270,
      strokeCap: StrokeCap.butt,
      progressGradientColors: const [
        Colors.red,
        Colors.orange,
        Colors.yellow,
        Colors.green,
        Colors.blue,
        Colors.indigo,
        Colors.purple,
      ],
      dashWidth: 1,
      dashGap: 2,
    );
  }
}

おわり

すべてのコード

https://github.com/mzunohkaru/Flutter-App-Silent-Camera

アイコン作成

AIに生成してもらいました。
作品名:カメラのレンズ越しの美女

Discussion