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.plist keys — NSCameraUsageDescription, NSMicrophoneUsageDescription, and the Photos pair (NSPhotoLibraryUsageDescription / NSPhotoLibraryAddUsageDescription) for the demo’s gallery save + browse. The tutorial repo’s Info.plist ships all four; a missing camera key crashes on first open. (Android’s manifest declarations ship with the plugin)
  • Android: minSdk = 26 in android/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 in MainActivity.onCreate — manifest declarations alone never grant CAMERA/RECORD_AUDIO on Android 6+; copy the request block from the tutorial repo’s MainActivity.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.

Open the finished code →