Tutorial · 20 min · intermediate
A camera screen
AVFoundation and CameraX through one controller — live native preview, tap-to-focus, flash, flip, and photo + video capture straight to the gallery.
What you'll build — running on device.
The camera is the hardest surface to fake and the easiest to get wrong:
focus, exposure, rotation, and preview latency are all platform-pipeline
concerns. dartnative_camera adapts Flutter’s official camera plugin to
run natively — AVFoundation on iOS, CameraX on Android — with the
controller API you know. You’ll build a complete camera screen: live
preview, flash, flip, an aspect-ratio selector, photo capture with crop,
and video recording with a REC timer — everything saved straight to the
system gallery.
What you need
- A project from Your first DartNative app
- Four plugins in your pubspec:
dartnative_camera(capture),dartnative_image_crop(aspect crop),dartnative_compressor(video thumbnails),dartnative_audio(shutter sounds) — all^1.0.0 - iOS: four
Info.plistkeys —NSCameraUsageDescription,NSMicrophoneUsageDescription, and the Photos pair (NSPhotoLibraryUsageDescription/NSPhotoLibraryAddUsageDescription) for the demo’s gallery save + browse. The tutorial repo’sInfo.plistships all four; a missing camera key crashes on first open. (Android’s manifest declarations ship with the plugin) - Android:
minSdk = 26inandroid/app/build.gradle.kts—dartnative_compressor’s floor; new projects default lower and the manifest merge fails until it’s raised. And a runtime permission request inMainActivity.onCreate— manifest declarations alone never grantCAMERA/RECORD_AUDIOon Android 6+; copy the request block from the tutorial repo’sMainActivity.kt(it mirrors the plugin example’s) - A physical device — simulators have no camera
Step 1 — Enumerate and open
A CameraController is a Dart object that owns one camera session: you
construct it around a lens, initialize it, and every capture call goes
through it. The screen enumerates the lenses once at startup and opens
the first one:
final cams = await DartNativeCamera.availableCameras();
if (!mounted) return;
if (cams.isEmpty) {
setState(() => _error = 'No cameras on this device.');
return;
}
_controller?.dispose();
final c = CameraController(_cameras[index], ResolutionPreset.high);
await c.initialize();
// Re-apply current flash mode on the new controller.
c.setFlashMode(_flash);
Flipping front/back is the same code path — dispose the old controller,
open the controller at the other lens’s index. The OS shows its
permission prompt on first access, so _openCamera wraps initialize()
in a try/catch and checks the error text for permission wording to show
an “enable it in Settings” hint instead of a raw exception.
Step 2 — Put the native preview on screen
CameraPreview is a widget that hosts the platform’s own preview view
directly inside your layout — the pixels never cross into Dart. That’s
also why tap-to-focus and pinch-to-zoom already work: they’re
implemented inside the native view, where the focus and zoom logic lives.
return Positioned(
bottom: previewBottom,
left: 0,
right: 0,
height: previewHeight,
// Pinch-to-zoom + tap-to-focus are handled inside the native preview
// view (DNCameraPreviewView on iOS, DNCameraContainerView on Android).
// No Dart GestureDetector — same pattern as dartnative_google_maps.
child: CameraPreview(controller: _controller!),
);
One layout rule: give the preview explicit Positioned bounds rather
than AspectRatio/Center. The native surface needs concrete
dimensions at first attach — a widget that measures to 0×0 on the first
frame keeps the camera’s surface from ever appearing. The demo computes
previewBottom/previewHeight from the selected aspect ratio in
build(), so 16:9 fills the screen and 4:3 letterboxes between the top
bar and the shutter.
Step 3 — Take a photo
The photo path is four calls: lock the orientation, capture, crop to the
selected aspect ratio, save. takePicture() resolves to a JPEG path in
the app cache; saveToGallery adds it to the system photo library
(MediaStore on Android, PHPhotoLibrary on iOS — the screen requests the
Photos “add” permission up front in initState).
// Snapshot orientation at shutter time so EXIF / AVCaptureConnection
// reflects where the device is pointing right now, not at init time.
c.setCaptureOrientation(_toCaptureOrientation(_captureOrientation));
final rawPath = await c.takePicture();
…
final pathToSave = await _cropToSelectedAspect(rawPath);
setState(() => _lastPhotoPath = pathToSave);
final saved = await c.saveToGallery(pathToSave);
_cropToSelectedAspect uses dartnative_image_crop to re-encode the
JPEG to the 16:9 / 4:3 / 1:1 the user picked in the top bar — a cheap
single-frame crop, skipped entirely when the source already matches. The
last shot shows as a round thumbnail via Image.file(File(path)), and
tapping it opens the system gallery with controller.openGallery().
Step 4 — Record video
In video mode the same shutter button toggles recording. Start and stop are symmetric with the photo path — orientation snapshot, capture call, gallery save:
c.setCaptureOrientation(_toCaptureOrientation(_captureOrientation));
await c.startVideoRecording();
final path = await c.stopVideoRecording();
final saved = await c.saveToGallery(path, isVideo: true);
…
final thumb = await DartNativeCompressor.getVideoThumbnail(File(path));
getVideoThumbnail (from dartnative_compressor) extracts a frame-0
JPEG so the thumbnail updates just like it does after a still. Two
details worth stealing from the demo:
- The start-recording sound cue is awaited before recording starts — otherwise its tail leaks into the video’s audio track, because the cue plays through the speaker while the mic is already open.
- The REC timer stores its elapsed time in a
ValueNotifier— a small observable box that only rebuilds the widgets listening to it — so the per-second tick repaints the little red label, not the whole screen.
Step 5 — Orientation, handled eagerly
DeviceOrientationListener is DartNative’s live feed of the physical
device orientation (independent of UI rotation). The screen pushes every
change into the capture pipeline the moment it happens:
void _onOrientation() {
_captureOrientation = DeviceOrientationListener.current;
…
_controller?.setCaptureOrientation(
_toCaptureOrientation(_captureOrientation),
);
}
Why eagerly and not at shutter time? The first orientation change on an AVFoundation session costs ~400 ms while it reconfigures — pausing preview frames. Paying that during rotation (where the user expects a transition) feels fine; paying it on the shutter tap (where they expect an instant response) feels broken. By capture time the call is a no-op.
The flip side: the UI itself must not rotate — Apple’s Camera pins its
screen and lets only the media orientation change. The pin freezes just
the screen: rotating the device still produces landscape photos and
videos, courtesy of the eager capture-orientation updates above. The
tutorial screen pins in initState / dispose:
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); // enter
SystemChrome.setPreferredOrientations(DeviceOrientation.values); // leave
Skip the pin in a rotation-enabled app and the camera chrome spins while the preview letterboxes — the classic mistake, invisible in portrait-only apps.
Step 6 — One reconciler rule worth knowing
A camera screen rebuilds constantly (busy states, thumbnails, mode
switches). DartNative bridges each GestureDetector.onTap to native code
through a NativeCallable — the FFI object that lets a native view call
back into your Dart closure — and re-registers it whenever the closure’s
identity changes across setState. Method tear-offs create a new
identity every rebuild, which is racy if a tap is mid-flight.
late final VoidCallback _onShutterStable = _onShutter;
…
GestureDetector(onTap: _onShutterStable, …)
Caching the tear-off in a late final field keeps the identity stable
for the widget’s whole life. It’s a good habit on any screen with
rapid-fire gestures.
Why this is native
Preview frames never leave the platform: the pixels go from the sensor to a native preview view that happens to sit in your Dart layout. Focus, zoom, and exposure are AVFoundation’s and CameraX’s own logic — not gesture math re-implemented over a texture — which is why the preview feels exactly like the system camera.
The finished code
The finished code lives in the public repo’s tutorials/camera folder —
dn create ., dn run on a real device. The screen
(lib/screens/media/camera_demo.dart) is a byte-identical copy of the
playground’s CameraDemo, launched by a thin lib/main.dart — so
improvements to the playground screen flow straight into this tutorial as
literal file copies.