Fotocamera in tempo reale in Flutter con il pacchetto camera: anteprima, scatto e salvataggio

Foto di Josh Power su Unsplash

GuideIntermedio45 min Flutter 3.x

Fotocamera in tempo reale in Flutter con il pacchetto camera: anteprima, scatto e salvataggio

Il pacchetto camera è la soluzione ufficiale del team Flutter per accedere al sensore fotografico del dispositivo e mostrare un'anteprima live direttamente nell'albero dei widget. A differenza di image_picker, che delega lo scatto all'app fotocamera di sistema, con camera costruisci la tua interfaccia: overlay personalizzati, controlli di flash e zoom, scatto con un tocco, switch tra camera frontale e posteriore.

In questo tutorial costruiremo passo dopo passo una schermata fotocamera completa e robusta. Vedremo in particolare:

  • la configurazione dei permessi su Android e iOS;
  • l'inizializzazione asincrona del CameraController e la gestione degli errori;
  • la corretta gestione del ciclo di vita (fondamentale: se non rilasci la camera quando l'app va in background rischi crash e blocchi del sensore);
  • scatto, salvataggio del file in una directory persistente e anteprima del risultato;
  • controlli avanzati: flash, zoom con pinch, messa a fuoco a tocco.

Prerequisiti: conoscenza di StatefulWidget, Future/async e del pacchetto path_provider. Serve un dispositivo fisico: gli emulatori offrono una camera simulata poco affidabile, e il simulatore iOS non ha fotocamera.

  1. 1

    Installare il pacchetto e configurare i permessi nativi

    Aggiungi al pubspec.yaml il pacchetto camera e path_provider (per salvare gli scatti in una directory persistente dell'app):

    flutter pub add camera path_provider
    

    Poi configura le piattaforme native, passaggio obbligatorio senza il quale l'app crasha o la camera non si apre:

    Android — in android/app/build.gradle assicurati che minSdkVersion sia almeno 21:

    defaultConfig {
        minSdkVersion 21
    }
    

    Il plugin dichiara già il permesso CAMERA nel proprio manifest, quindi di norma non devi aggiungerlo. Se vuoi registrare anche audio, aggiungi RECORD_AUDIO in android/app/src/main/AndroidManifest.xml.

    iOS — in ios/Runner/Info.plist servono le descrizioni d'uso, altrimenti Apple rifiuta l'app e il sistema termina il processo:

    <key>NSCameraUsageDescription</key>
    <string>L'app usa la fotocamera per scattare foto dei tuoi documenti.</string>
    <key>NSMicrophoneUsageDescription</key>
    <string>Il microfono serve solo per registrare video con audio.</string>
    

    Il pacchetto camera richiede il permesso runtime automaticamente alla prima inizializzazione del controller: non serve permission_handler, ma devi comunque gestire il caso in cui l'utente neghi l'accesso (lo vedremo nel passo 3).

    dependencies:
      flutter:
        sdk: flutter
      camera: ^0.11.0
      path_provider: ^2.1.4

    Risultato atteso

    Le dipendenze sono installate e i progetti nativi sono configurati: `flutter pub get` termina senza errori e l'app compila su device fisico.

  2. 2

    Recuperare le camere disponibili all'avvio dell'app

    La funzione availableCameras() interroga il sistema e restituisce la lista delle fotocamere (CameraDescription), ciascuna con il proprio lensDirection (back, front, external) e l'orientamento del sensore.

    Va chiamata dopo WidgetsFlutterBinding.ensureInitialized(), perché usa i platform channel. È buona pratica farlo una sola volta nel main() e passare la lista alla schermata, invece di ricalcolarla ad ogni rebuild.

    Attenzione a due casi reali:

    • la lista può essere vuota (dispositivi senza camera, emulatori mal configurati);
    • availableCameras() può lanciare una CameraException se i servizi camera non sono disponibili.

    Gestiamo entrambi con un try/catch e una lista di fallback vuota.

    import 'package:camera/camera.dart';
    import 'package:flutter/material.dart';
    
    List<CameraDescription> cameras = <CameraDescription>[];
    
    Future<void> main() async {
      WidgetsFlutterBinding.ensureInitialized();
      try {
        cameras = await availableCameras();
      } on CameraException catch (e) {
        debugPrint('Errore nel recupero delle camere: ${e.code} ${e.description}');
      }
      runApp(const MyApp());
    }
    
    class MyApp extends StatelessWidget {
      const MyApp({super.key});
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          title: 'Demo Fotocamera',
          theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true),
          home: cameras.isEmpty
              ? const Scaffold(
                  body: Center(child: Text('Nessuna fotocamera disponibile')),
                )
              : CameraScreen(cameras: cameras),
        );
      }
    }

    Risultato atteso

    All'avvio l'app conosce l'elenco delle fotocamere del dispositivo; se non ce ne sono mostra un messaggio invece di crashare.

  3. 3

    Inizializzare il CameraController e mostrare l'anteprima

    Il CameraController è l'oggetto che apre il sensore e alimenta il widget CameraPreview. L'inizializzazione è asincrona: finché initialize() non è completata, CameraPreview non può essere costruito.

    Parametri chiave del costruttore:

    • ResolutionPreset: low, medium, high, veryHigh, ultraHigh, max. Più alta la risoluzione, più memoria e più lag in anteprima: high è un ottimo compromesso per gli scatti.
    • enableAudio: mettilo a false se scatti solo foto, così su iOS non viene richiesto il permesso microfono.
    • imageFormatGroup: ImageFormatGroup.jpeg evita conversioni costose su iOS.

    Gestiamo gli errori con i codici tipici di CameraException: CameraAccessDenied (permesso negato), CameraAccessRestricted (restrizioni parentali), AudioAccessDenied.

    Per l'anteprima usiamo un AspectRatio basato su controller.value.aspectRatio in modo che l'immagine non risulti deformata.

    class CameraScreen extends StatefulWidget {
      const CameraScreen({super.key, required this.cameras});
    
      final List<CameraDescription> cameras;
    
      @override
      State<CameraScreen> createState() => _CameraScreenState();
    }
    
    class _CameraScreenState extends State<CameraScreen> {
      CameraController? _controller;
      int _cameraIndex = 0;
      String? _errorMessage;
    
      @override
      void initState() {
        super.initState();
        _setupCamera(widget.cameras[_cameraIndex]);
      }
    
      Future<void> _setupCamera(CameraDescription description) async {
        // Rilascia l'eventuale controller precedente prima di crearne uno nuovo.
        await _controller?.dispose();
    
        final controller = CameraController(
          description,
          ResolutionPreset.high,
          enableAudio: false,
          imageFormatGroup: ImageFormatGroup.jpeg,
        );
        _controller = controller;
    
        try {
          await controller.initialize();
          if (!mounted) return;
          setState(() => _errorMessage = null);
        } on CameraException catch (e) {
          if (!mounted) return;
          setState(() {
            _errorMessage = switch (e.code) {
              'CameraAccessDenied' => 'Permesso fotocamera negato.',
              'CameraAccessRestricted' => 'Accesso alla fotocamera limitato.',
              _ => 'Errore fotocamera: ${e.description}',
            };
          });
        }
      }
    
      @override
      void dispose() {
        _controller?.dispose();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        final controller = _controller;
    
        if (_errorMessage != null) {
          return Scaffold(body: Center(child: Text(_errorMessage!)));
        }
        if (controller == null || !controller.value.isInitialized) {
          return const Scaffold(
            backgroundColor: Colors.black,
            body: Center(child: CircularProgressIndicator()),
          );
        }
    
        return Scaffold(
          backgroundColor: Colors.black,
          body: Center(
            child: AspectRatio(
              aspectRatio: controller.value.aspectRatio,
              child: CameraPreview(controller),
            ),
          ),
        );
      }
    }

    Risultato atteso

    Aprendo la schermata il sistema chiede il permesso fotocamera e, una volta concesso, appare l'anteprima live a schermo con le proporzioni corrette.

  4. 4

    Gestire il ciclo di vita: rilasciare e riattivare la camera

    Questo è il passo che la maggior parte dei tutorial dimentica e che causa i bug più frequenti: se l'utente mette l'app in background mentre la camera è aperta, il sistema operativo può revocare l'accesso al sensore. Al ritorno in foreground l'anteprima resta nera o l'app crasha.

    La soluzione è usare il WidgetsBindingObserver e reagire a didChangeAppLifecycleState:

    • su inactive / paused / hidden → dispose() del controller;
    • su resumed → ricrea il controller con la stessa CameraDescription.

    Aggiungiamo anche onNewCameraSelected per lo switch tra camera posteriore e frontale, riutilizzando _setupCamera.

    class _CameraScreenState extends State<CameraScreen>
        with WidgetsBindingObserver {
      // ... campi del passo precedente
    
      @override
      void initState() {
        super.initState();
        WidgetsBinding.instance.addObserver(this);
        _setupCamera(widget.cameras[_cameraIndex]);
      }
    
      @override
      void dispose() {
        WidgetsBinding.instance.removeObserver(this);
        _controller?.dispose();
        super.dispose();
      }
    
      @override
      void didChangeAppLifecycleState(AppLifecycleState state) {
        final controller = _controller;
        if (controller == null || !controller.value.isInitialized) return;
    
        switch (state) {
          case AppLifecycleState.inactive:
          case AppLifecycleState.paused:
          case AppLifecycleState.hidden:
          case AppLifecycleState.detached:
            controller.dispose();
            _controller = null;
            if (mounted) setState(() {});
          case AppLifecycleState.resumed:
            _setupCamera(widget.cameras[_cameraIndex]);
        }
      }
    
      Future<void> _switchCamera() async {
        if (widget.cameras.length < 2) return;
        setState(() {
          _cameraIndex = (_cameraIndex + 1) % widget.cameras.length;
          _controller = null; // mostra lo spinner durante la transizione
        });
        await _setupCamera(widget.cameras[_cameraIndex]);
      }
    }

    Risultato atteso

    Mettendo l'app in background e riaprendola l'anteprima riparte correttamente; il pulsante di switch alterna camera posteriore e frontale senza errori.

  5. 5

    Scattare una foto e salvarla in una directory persistente

    controller.takePicture() restituisce un XFile che punta a un file in una directory temporanea: il sistema può cancellarlo in qualsiasi momento. Se vuoi conservare lo scatto, copialo nella directory documenti dell'app ottenuta con path_provider.

    Alcune accortezze fondamentali:

    • evita scatti concorrenti controllando controller.value.isTakingPicture (o una flag _isBusy), altrimenti ottieni l'eccezione captureInProgress;
    • takePicture() è async: dopo l'await verifica sempre mounted prima di chiamare setState o usare il context;
    • genera un nome file univoco, ad esempio con il timestamp.
    import 'dart:io';
    import 'package:path_provider/path_provider.dart';
    
    bool _isBusy = false;
    File? _lastPicture;
    
    Future<void> _takePicture() async {
      final controller = _controller;
      if (controller == null ||
          !controller.value.isInitialized ||
          controller.value.isTakingPicture ||
          _isBusy) {
        return;
      }
    
      setState(() => _isBusy = true);
      try {
        final XFile shot = await controller.takePicture();
    
        final dir = await getApplicationDocumentsDirectory();
        final fileName = 'IMG_${DateTime.now().millisecondsSinceEpoch}.jpg';
        final saved = await File(shot.path).copy('${dir.path}/$fileName');
    
        if (!mounted) return;
        setState(() => _lastPicture = saved);
      } on CameraException catch (e) {
        if (!mounted) return;
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('Scatto fallito: ${e.description}')),
        );
      } finally {
        if (mounted) setState(() => _isBusy = false);
      }
    }

    Risultato atteso

    Premendo il pulsante di scatto viene creato un file JPEG nella cartella documenti dell'app e il percorso viene salvato nello stato.

  6. 6

    Costruire i controlli: scatto, miniatura, flash e zoom con pinch

    Ora componiamo l'interfaccia sopra l'anteprima usando uno Stack:

    • in basso una barra con pulsante di scatto, switch camera e miniatura dell'ultimo scatto;
    • in alto un pulsante per ciclare le modalità flash (off, auto, always, torch);
    • un GestureDetector che gestisce il pinch-to-zoom tramite onScaleStart/onScaleUpdate, rispettando i limiti restituiti da getMinZoomLevel() e getMaxZoomLevel();
    • il tap-to-focus con setFocusPoint, che accetta un Offset con coordinate normalizzate tra 0 e 1.

    Nota: su alcuni dispositivi la camera frontale non supporta il flash né lo zoom. Avvolgi sempre queste chiamate in un try/catch o verifica controller.value.description.lensDirection.

    double _minZoom = 1.0, _maxZoom = 1.0, _zoom = 1.0, _baseZoom = 1.0;
    FlashMode _flashMode = FlashMode.off;
    
    Future<void> _initZoomLimits(CameraController c) async {
      _minZoom = await c.getMinZoomLevel();
      _maxZoom = await c.getMaxZoomLevel();
      _zoom = _minZoom;
    }
    
    Future<void> _cycleFlash() async {
      final controller = _controller;
      if (controller == null) return;
      const modes = [FlashMode.off, FlashMode.auto, FlashMode.always, FlashMode.torch];
      final next = modes[(modes.indexOf(_flashMode) + 1) % modes.length];
      try {
        await controller.setFlashMode(next);
        if (mounted) setState(() => _flashMode = next);
      } on CameraException catch (_) {
        // flash non supportato su questa camera
      }
    }
    
    Widget _buildPreviewWithGestures(CameraController controller) {
      return GestureDetector(
        onScaleStart: (_) => _baseZoom = _zoom,
        onScaleUpdate: (details) async {
          final newZoom = (_baseZoom * details.scale).clamp(_minZoom, _maxZoom);
          if ((newZoom - _zoom).abs() < 0.01) return;
          _zoom = newZoom;
          await controller.setZoomLevel(_zoom);
        },
        onTapUp: (details) async {
          final box = context.findRenderObject() as RenderBox;
          final local = box.globalToLocal(details.globalPosition);
          final point = Offset(local.dx / box.size.width, local.dy / box.size.height);
          try {
            await controller.setFocusPoint(point);
            await controller.setExposurePoint(point);
          } on CameraException catch (_) {}
        },
        child: CameraPreview(controller),
      );
    }
    
    Widget _buildControls() {
      return Positioned(
        left: 0,
        right: 0,
        bottom: 24,
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceEvenly,
          children: [
            SizedBox(
              width: 56,
              height: 56,
              child: _lastPicture == null
                  ? const SizedBox.shrink()
                  : ClipRRect(
                      borderRadius: BorderRadius.circular(8),
                      child: Image.file(_lastPicture!, fit: BoxFit.cover),
                    ),
            ),
            GestureDetector(
              onTap: _isBusy ? null : _takePicture,
              child: Container(
                width: 76,
                height: 76,
                decoration: BoxDecoration(
                  shape: BoxShape.circle,
                  color: Colors.white.withOpacity(_isBusy ? 0.4 : 1),
                  border: Border.all(color: Colors.white54, width: 4),
                ),
              ),
            ),
            IconButton(
              iconSize: 36,
              color: Colors.white,
              onPressed: _switchCamera,
              icon: const Icon(Icons.cameraswitch),
            ),
          ],
        ),
      );
    }

    Risultato atteso

    L'anteprima risponde al pinch per lo zoom e al tap per la messa a fuoco; la barra inferiore mostra il pulsante di scatto, lo switch camera e la miniatura dell'ultima foto.

  7. 7

    Aprire l'anteprima a schermo intero e rifinire la UX

    Chiudiamo il flusso aggiungendo una schermata di anteprima dello scatto, dove l'utente può confermare o ripetere la foto. Il risultato viene restituito con Navigator.pop al chiamante.

    Ultime rifiniture consigliate prima di andare in produzione:

    • Bloccare l'orientamento durante lo scatto con controller.lockCaptureOrientation(DeviceOrientation.portraitUp) se la tua UI è solo verticale: evita foto ruotate in modo imprevisto.
    • Risoluzione adattiva: usa ResolutionPreset.medium sui dispositivi di fascia bassa per ridurre il consumo di memoria.
    • Liberare lo spazio: cancella periodicamente i file temporanei generati da takePicture() nella cache dir.
    • Ricorda che CameraPreview non va mai inserito in un ListView che lo ricostruisce di continuo: mantieni un solo controller attivo per volta.
    • Per test automatici, astrai le chiamate dietro un servizio (es. CameraService) così da poterlo mockare: il plugin non funziona negli unit test.
    class PicturePreviewPage extends StatelessWidget {
      const PicturePreviewPage({super.key, required this.file});
    
      final File file;
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          backgroundColor: Colors.black,
          body: SafeArea(
            child: Column(
              children: [
                Expanded(child: Center(child: Image.file(file))),
                Padding(
                  padding: const EdgeInsets.all(16),
                  child: Row(
                    mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                    children: [
                      TextButton.icon(
                        onPressed: () => Navigator.pop(context, false),
                        icon: const Icon(Icons.refresh, color: Colors.white),
                        label: const Text('Ripeti',
                            style: TextStyle(color: Colors.white)),
                      ),
                      FilledButton.icon(
                        onPressed: () => Navigator.pop(context, true),
                        icon: const Icon(Icons.check),
                        label: const Text('Usa questa foto'),
                      ),
                    ],
                  ),
                ),
              ],
            ),
          ),
        );
      }
    }
    
    // Nel metodo di scatto, dopo aver salvato il file:
    Future<void> _confirmShot(File saved) async {
      final accepted = await Navigator.push<bool>(
        context,
        MaterialPageRoute(builder: (_) => PicturePreviewPage(file: saved)),
      );
      if (accepted != true) {
        await saved.delete();
        if (mounted) setState(() => _lastPicture = null);
      }
    }

    Risultato atteso

    Dopo lo scatto si apre l'anteprima a schermo intero: confermando il file resta salvato, scegliendo "Ripeti" viene cancellato e si torna alla fotocamera pronta per un nuovo scatto.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!