Repository navigation
Preview and capture
Task: own the camera controller correctly (open, switch, dispose), show the preview, and capture the picture the user actually sees.
Conditions: the packages and platform setup from
Getting started, and an initialized CameraController. The
recipes below are widgets that take that controller, so you can put them in place of the YuvCameraView of the Getting
started page.
| Object | Created by | Disposed by |
|---|---|---|
CameraController (from the camera plugin) |
You: CameraController(description, ResolutionPreset...) then await initialize()
|
You. YuvCameraView never disposes it. |
YuvCameraView |
You, in your widget tree | The framework, when it leaves the tree. Its frame stream stops then. |
YuvCameraViewController |
You, and you pass it to the view | You: call dispose() when your widget is disposed. |
The YuvImage returned by capture()
|
yuv_camera, as a new image |
It is yours: nothing else holds it. |
When several screens share one camera, the owner of the controller is a YuvCameraOrchestrator instead of you; see
Sharing the camera.
YuvCameraView requires an initialized controller. With an uninitialized one its frame source reports
CameraController should be initialized, and the view shows Camera error: followed by that error. Opening the
camera can fail: availableCameras() may return an empty list, and initialize() throws when the user denies the
permission. Catch both, and check mounted after every await, as the Getting started page does.
The camera plugin's own documentation describes how to react to the app moving to the background and back
(camera on pub.dev). With your own controller, your app manages that lifecycle;
YuvCameraLeaseBuilder releases its lease while hidden and acquires one again when shown. For controller replacement:
if you pass a different CameraController to an existing YuvCameraView, it stops the old stream without calling
onStreamStopped, drops any frame and capture in flight, and starts the new one.
Do not switch a controller in place with setDescription: a changed camera id or description is a terminal source
error. Remove the old view, create and initialize a new CameraController, then build the view with it.
On Android, YuvCameraFrame.orientation is calculated from the current device orientation and the camera sensor
orientation. CameraController.lockCaptureOrientation() does not lock this YUV preview orientation; do not assume it
has the same policy as CameraPreview.
| Parameter | Meaning |
|---|---|
cameraController |
Required. The initialized controller whose frames are shown. |
viewController |
A YuvCameraViewController for capture and the current geometry; geometry is null before the first drawn frame, after detach, or while switching controllers. |
fit, alignment
|
How the frame fills the space. fit defaults to YuvFrameFit.cover, which crops the picture; use contain to see all of it. |
onFrame, onFrameInterval
|
Your frame callback and its minimum interval (200 ms by default). See Frame processing. |
overlayBuilder |
Builds a widget over the preview from the geometry of the displayed frame. See Geometry and overlays. |
onStreamStopped |
Called only after a stream error stops the stream; not on removal or controller replacement. |
onFramePresented |
Called for every frame that was confirmed as drawn; its argument reports shader availability (YuvFramePresenter.hasShader), not whether that frame used shader paint. BGRA and unsupported/gapped layouts can use fallback while the flag is true. |
On native platforms the YUV source owns the controller's image stream. Do not call startImageStream, start video
recording with an onAvailable callback, or subscribe directly to platform frames while it is active. If the controller
is owned by YuvCameraOrchestrator, a terminal stream/controller failure releases the active leases and disposes the
controller after cancellation completes; its YuvCameraLeaseBuilder displays Retry and obtains a fresh controller.
viewController.capture() returns the next frame that is actually drawn on screen, cropped to what is visible, as a new
YuvImage (or null). This recipe adds a capture button to a preview, shows the result and encodes it as PNG.
There is no built-in timeout. If the view remains attached but has not drawn a frame, the future stays pending; removing
the view completes pending capture with null.
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'package:yuv_camera/yuv_camera.dart';
import 'package:yuv_ffi/yuv_ffi.dart';
/// Encodes [image] as PNG bytes.
Future<Uint8List> toPng(YuvImage image) async {
final decoded = await image.toImage();
try {
final data = await decoded.toByteData(format: ui.ImageByteFormat.png);
if (data == null) throw StateError('PNG encoding returned no data.');
return data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
} finally {
decoded.dispose();
}
}
/// A preview with a capture button; [controller] must be initialized.
class CapturePanel extends StatefulWidget {
const CapturePanel({super.key, required this.controller});
final CameraController controller;
@override
State<CapturePanel> createState() => _CapturePanelState();
}
class _CapturePanelState extends State<CapturePanel> {
final YuvCameraViewController _view = YuvCameraViewController();
YuvImage? _captured;
String _status = 'Press the button to capture.';
bool _busy = false;
Future<void> _capture() async {
setState(() => _busy = true);
try {
final image = await _view.capture();
if (!mounted) return;
if (image == null) {
setState(() => _status = 'Nothing was captured: the view is not attached or stopped first.');
return;
}
final png = await toPng(image);
if (!mounted) return;
setState(() {
_captured = image;
_status = 'Captured ${image.width}x${image.height}, PNG ${png.length} bytes.';
});
} catch (error) {
if (mounted) setState(() => _status = 'Capture failed: $error');
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
void dispose() {
_view.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
Expanded(
child: YuvCameraView(cameraController: widget.controller, viewController: _view),
),
Padding(padding: const EdgeInsets.all(8), child: Text(_status)),
if (_captured case final image?)
SizedBox(
height: 120,
width: double.infinity,
child: YuvImageWidget(image: image, boxFit: BoxFit.contain),
),
],
),
floatingActionButton: FloatingActionButton(onPressed: _busy ? null : _capture, child: const Icon(Icons.camera)),
);
}
}Expected result: pressing the button shows a line such as Captured 480x640, PNG 312345 bytes. (the size and byte count
depend on your camera and the picture) and a thumbnail of the captured picture under the preview.
What capture() does:
- It waits for the next frame that is really drawn, then returns it. It is not a snapshot of an older frame, and it is not a screenshot of your overlays: it contains camera pixels only.
- The image is cropped to the visible part of the frame and has the frame's rotation and mirror applied, so it matches
what the preview shows. With the default
coverfit that can be less than the whole camera frame. - A second call while one is waiting returns the same pending result.
- It returns
nullwhen the view is not attached to the controller (for example before the view was built), or when the view stops before a frame is drawn: the widget is disposed, the camera controller is replaced, or the stream fails. - An unexpected end of the camera stream is a stream error. The view stops and calls
onStreamStopped; EOF is not a successful end of a live camera delivery. - The returned image is a new image that you own, and you may mutate it. Its planes can keep the camera's row stride
rather than being tightly packed; call
pack()(see the yuv_ffi wiki) if you need tight planes. To show or process it, useYuvImageWidgetor the operations ofyuv_ffi.
viewController.geometry is a ValueListenable<YuvFrameGeometry?> with the geometry of the displayed frame, null
until the first frame is drawn. It is the same object the overlayBuilder receives.
Replace the controller only after the view that uses it has left the tree. This recipe removes the view, waits for the frame that disposes it, disposes the old controller, and then opens the next camera.
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'package:yuv_camera/yuv_camera.dart';
/// A preview with a button that switches to the next available camera.
class SwitchableCamera extends StatefulWidget {
const SwitchableCamera({super.key});
@override
State<SwitchableCamera> createState() => _SwitchableCameraState();
}
class _SwitchableCameraState extends State<SwitchableCamera> {
List<CameraDescription> _cameras = const [];
int _index = 0;
CameraController? _controller;
Object? _problem;
bool _switching = false;
@override
void initState() {
super.initState();
_open(0);
}
Future<void> _open(int index) async {
CameraController? created;
try {
if (_cameras.isEmpty) _cameras = await availableCameras();
if (!mounted) return;
if (_cameras.isEmpty) {
setState(() => _problem = 'No camera is available.');
return;
}
created = CameraController(_cameras[index], ResolutionPreset.medium, enableAudio: false);
await created.initialize();
if (!mounted) {
await created.dispose();
return;
}
setState(() {
_index = index;
_controller = created;
_problem = null;
});
} catch (error) {
await created?.dispose();
if (mounted) setState(() => _problem = error);
}
}
Future<void> _switch() async {
if (_switching || _cameras.length < 2) return;
_switching = true;
try {
final old = _controller;
setState(() => _controller = null); // removes the YuvCameraView, which stops its stream
await WidgetsBinding.instance.endOfFrame; // the view is gone after this frame
await old?.dispose();
if (mounted) await _open((_index + 1) % _cameras.length);
} finally {
_switching = false;
}
}
@override
void dispose() {
_controller?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final controller = _controller;
if (_problem != null) return Scaffold(body: Center(child: Text('Camera unavailable: $_problem')));
if (controller == null) return const Scaffold(body: Center(child: CircularProgressIndicator()));
return Scaffold(
body: YuvCameraView(cameraController: controller),
floatingActionButton: FloatingActionButton(onPressed: _switch, child: const Icon(Icons.cameraswitch)),
);
}
}Expected result: on a device with two cameras, pressing the button shows a spinner for a moment and then the other camera; on a device with one camera nothing happens. This recipe was compiled and analyzed for Windows and Web; the project has not run camera switching on a physical camera, so test it on your target devices.
| Symptom | Check and action |
|---|---|
capture() returns null
|
The view was not attached yet, or it stopped before drawing a frame. Capture after the preview is visible, and do not dispose the controller or the page while waiting. |
capture() returns an image smaller than the camera frame |
Expected with fit: YuvFrameFit.cover: only the visible part is returned. Use fit: YuvFrameFit.contain to keep the whole frame. |
The preview shows Camera error: ...
|
The frame stream failed or the controller was not initialized. onStreamStopped fires too. Open the camera again with a new controller; see Platforms and troubleshooting. |
An exception from initialize()
|
The permission was denied, no camera is available or it is in use. Catch it and show a message, as in Getting started. |
| The captured image does not look like the preview | Compare fit: capture follows the visible part and orientation, but not your overlays. |
- Frame processing: handle frames before they are captured.
- Geometry and overlays: the geometry behind the visible area.
-
yuv_ffi: Displaying images: show or keep a captured
YuvImage.
onFramePresented and Device check JSON shader retain their existing signatures and schema. A true value means
the shader is loaded and available; it is not renderer-path telemetry for that frame.