Announced at Google I/O 2025 and now stable. Build camera UIs with CameraXViewfinder and camera-compose: setup, coordinate transforms, tap-to-focus, pinch-to-zoom, photos and video, plus migration.

AI-generated image. Android robot and Jetpack Compose logo, used for illustrative/editorial purposes.
Introduction
Remember your first camera screen in Jetpack Compose? Pure declarative joy… until the preview. Then came that familiar AndroidView(PreviewView) detour. It worked, but it felt wrong: A View-shaped hole in the middle of your composable (like an iFrame…), plus tap-to-focus math that never quite felt trustworthy.
After I/O ’25, that compromise is over.
- No more AndroidView(PreviewView) for camera previews.
- New CameraXViewfinder composable renders CameraX
SurfaceRequestdirectly in Compose. - Correct Built-in coordinate transforms (tap‑to‑focus, overlays) and a cleaner, declarative mental model.
Note:
“At I/O ’25 the Compose support was announced in alpha/beta, with stable artifacts shipping on September — so now its the time to take a look.”
Companion Project
You will find a companion project on GitHub for this article that demonstrates the new Jetpack Compose inclusive features of CameraX.
Permission UX (quick note)
We keep this article focused on the Compose + CameraX bits. The companion project implements the full runtime flow:
- Request
CAMERA at the preview entry point. - Request
RECORD_AUDIO only when the user starts recording (mic on demand). - A tiny
PermissionGatecomposable handles grant/deny/re-request inside the Compose tree. - To satisfy Lint around
@RequiresPermission, the call sites also do an explicitcheckSelfPermission(...)before invoking mic-dependent APIs.
See the repo for the exact PermissionGate and how we wire it into the Capture screen.
What Actually Changed?
The CameraX team dropped androidx.camera:camera-compose with a deceptively simple API: CameraXViewfinder. But this isn’t just “PreviewView wrapped in a composable”. It’s a complete rewrite for Compose and a fundamental rethink of how camera surfaces integrate with Compose.
Here’s what changed at the architectural level:
First-class Compose target
The viewfinder rendering pipeline now treats Compose as a primary platform. Surface lifecycle, rotation handling, and scaling all happen in Compose-idiomatic ways.
Correct coordinate transforms out of the box
Remember calculating where a tap on your preview actually maps to the camera sensor, accounting for rotation, aspect ratio crops, and scaling modes? A MutableCoordinateTransformer handles that. Tap-to-focus just… works now.
True composable semantics
Want to clip() your preview to a custom shape? Apply a graphicsLayer transform? Animate it with AnimatedContent? You can now do all of this without fighting the renderer. It’s a composable like any other.
CameraX 1.5.x maturity
The entire stack got polished: ProcessCameraProvider.awaitInstance() for Kotlin coroutines, stable artifacts across the board, better docs. This isn’t a beta experiment… It’s production-ready.
Why This Actually Matters
If you’ve been building camera features, you know the pain points:
- The mental model split: “Think in Compose for UI, think in View for camera, translate between them constantly.”
- The gesture-coordination nightmare: Touch events in Compose, focus metering in View coordinates, and pray your math is correct.
- The z-order headaches:
PreviewViewoften used aSurfaceViewwhich renders in a separate layer. Compose overlays didn’t reliably sit on top, so reticles, guides, and buttons could disappear behind the preview. - The lifecycle dance: Synchronizing Compose recomposition with View lifecycle with CameraX use case binding
All of that friction? Gone.
“You now write camera UIs the same way you write the rest of your modern Android app. One paradigm. One mental model. Pure Compose.”
Show Me the Code
Let’s start with the absolute minimum — a working camera preview (fixed state pattern: separate the writer MutableStateFlow from the reader collectAsState).
@Composable
fun CameraPreview(modifier: Modifier = Modifier) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
// Writer: MutableStateFlow we can update from CameraX callbacks
val surfaceRequests = remember { MutableStateFlow<SurfaceRequest?>(null) }
// Reader: Compose state derived from the flow
val surfaceRequest by surfaceRequests.collectAsState(initial = null)
// Bind CameraX use cases once
LaunchedEffect(Unit) {
val provider = ProcessCameraProvider.awaitInstance(context)
val preview = Preview.Builder().build().apply {
// When CameraX needs a surface, publish it to Compose
setSurfaceProvider { request ->
surfaceRequests.value = request
}
}
provider.unbindAll()
provider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview
)
}
// The actual Compose viewfinder
surfaceRequest?.let { request ->
CameraXViewfinder(
surfaceRequest = request,
modifier = modifier.fillMaxSize()
)
}
}
That’s it. No AndroidView. No PreviewView. Just a composable that receives a SurfaceRequest and renders it.
The pattern is clean: “CameraX publishes surface requests, Compose consumes them. One direction. No callbacks bouncing between worlds.”
Optional: Preview with a lens-switch FAB (front/back)
@Composable
fun PreviewWithLensSwitch(modifier: Modifier = Modifier) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val surfaceRequests = remember { MutableStateFlow<SurfaceRequest?>(null) }
val surfaceRequest by surfaceRequests.collectAsState(initial = null)
// remember current lens
var useFront by rememberSaveable { mutableStateOf(false) }
val selector = if (useFront) CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA
// bind when camera selector changes (front/back camera)
LaunchedEffect(selector) {
val provider = ProcessCameraProvider.awaitInstance(context)
val preview = Preview.Builder().build().apply {
setSurfaceProvider { req -> surfaceRequests.value = req }
}
provider.unbindAll()
provider.bindToLifecycle(lifecycleOwner, selector, preview)
}
Box(Modifier.fillMaxSize()) {
surfaceRequest?.let { req ->
CameraXViewfinder(surfaceRequest = req, modifier = Modifier.fillMaxSize())
}
FloatingActionButton(
onClick = { useFront = !useFront },
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp)
) { Icon(Icons.Rounded.Cameraswitch, contentDescription = "Switch camera") }
}
}
The Real Test: Interactive Camera Controls
Here’s where the old approach fell apart. Let’s implement tap-to-focus and pinch-to-zoom… The features that used to require View coordinate hacks (also using the fixed writer/reader pattern):
@Composable
fun InteractiveCameraPreview(
modifier: Modifier = Modifier,
onFocusTap: (success: Boolean) -> Unit = {}
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
var camera by remember { mutableStateOf<Camera?>(null) }
val surfaceRequests = remember { MutableStateFlow<SurfaceRequest?>(null) }
val surfaceRequest by surfaceRequests.collectAsState(initial = null)
// Bind camera once
LaunchedEffect(Unit) {
val provider = ProcessCameraProvider.awaitInstance(context)
val preview = Preview.Builder().build().apply {
setSurfaceProvider { req -> surfaceRequests.value = req }
}
camera = provider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview
)
}
// Coordinate transformer: Compose UI → Camera surface
val coordinateTransformer = remember { MutableCoordinateTransformer() }
surfaceRequest?.let { request ->
CameraXViewfinder(
surfaceRequest = request,
coordinateTransformer = coordinateTransformer,
modifier = modifier
.fillMaxSize()
.pointerInput(camera) {
// Tap-to-focus
detectTapGestures { offset ->
val cam = camera ?: return@detectTapGestures
// Transform Compose coordinates to camera surface
val surfacePoint = with(coordinateTransformer) {
offset.transform()
}
val meteringFactory = SurfaceOrientedMeteringPointFactory(
request.resolution.width.toFloat(),
request.resolution.height.toFloat()
)
val focusPoint = meteringFactory.createPoint(
surfacePoint.x,
surfacePoint.y
)
val action = FocusMeteringAction.Builder(
focusPoint,
FocusMeteringAction.FLAG_AF or FocusMeteringAction.FLAG_AE
).setAutoCancelDuration(3, TimeUnit.SECONDS).build()
cam.cameraControl
.startFocusAndMetering(action)
.addListener(
{ onFocusTap(true) },
ContextCompat.getMainExecutor(context)
)
}
}
.pointerInput(camera) {
// Pinch-to-zoom
detectTransformGestures { _, _, zoom, _ ->
val cam = camera ?: return@detectTransformGestures
val zoomState = cam.cameraInfo.zoomState.value ?: return@detectTransformGestures
val newRatio = (zoomState.zoomRatio * zoom).coerceIn(
zoomState.minZoomRatio,
zoomState.maxZoomRatio
)
cam.cameraControl.setZoomRatio(newRatio)
}
}
)
}
}
Look at that tap-to-focus implementation. Notice what you’re not doing:
- No manual rotation compensation
- No aspect ratio math for coordinate mapping
- No View → Surface → Sensor coordinate chain calculations
- No “pray it works on landscape” testing marathons
The MutableCoordinateTransformer handles all of it. You tap in Compose coordinates, it transforms to camera coordinates, done.
This is the difference between “technically possible” and “actually pleasant to implement.”
Capturing Photos and Video
Adding capture capabilities follows the same clean pattern — bind additional use cases, trigger them from Compose UI.
We’ll also request microphone only when attempting to record, using a simple PermissionGate pattern (matches our project’s approach of asking for audio exclusively when needed).
@Composable
fun CameraScreen() {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
var camera by remember { mutableStateOf<Camera?>(null) }
var imageCapture by remember { mutableStateOf<ImageCapture?>(null) }
var videoCapture by remember { mutableStateOf<VideoCapture<Recorder>?>(null) }
var activeRecording by remember { mutableStateOf<Recording?>(null) }
val surfaceRequests = remember { MutableStateFlow<SurfaceRequest?>(null) }
val surfaceRequest by surfaceRequests.collectAsState(initial = null)
// Bind all use cases
LaunchedEffect(Unit) {
val provider = ProcessCameraProvider.awaitInstance(context)
val preview = Preview.Builder().build().apply {
setSurfaceProvider { req -> surfaceRequests.value = req }
}
imageCapture = ImageCapture.Builder()
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
.build()
val recorder = Recorder.Builder()
.setQualitySelector(QualitySelector.from(Quality.FHD))
.build()
videoCapture = VideoCapture.withOutput(recorder)
camera = provider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
imageCapture!!,
videoCapture!!
)
}
Box(modifier = Modifier.fillMaxSize()) {
// Camera preview
surfaceRequest?.let { request ->
CameraXViewfinder(
surfaceRequest = request,
modifier = Modifier.fillMaxSize()
)
}
// Compose UI controls
Row(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 32.dp)
) {
// Capture photo button
IconButton(
onClick = { capturePhoto(context, imageCapture) }
) {
Icon(Icons.Default.PhotoCamera, "Take Photo")
}
Spacer(modifier = Modifier.width(32.dp))
// Video record toggle (mic requested only when needed)
PermissionGate(
permission = Permission.RECORD_AUDIO,
// Optional: custom UI if permission is not yet granted
contentNonGranted = { missing, humanReadable, requestPermissions ->
// Minimal, inline UX: re-request directly
Button(onClick = { requestPermissions(missing) }) {
Text("Grant $humanReadable")
}
}
) {
IconButton(
onClick = {
activeRecording = toggleRecording(
context,
videoCapture,
activeRecording
)
}
) {
Icon(
if (activeRecording == null) Icons.Default.RadioButtonUnchecked
else Icons.Default.Stop,
"Record Video"
)
}
}
}
}
}
private fun capturePhoto(context: Context, imageCapture: ImageCapture?) {
val capture = imageCapture ?: return
val name = "IMG_${System.currentTimeMillis()}.jpg"
val contentValues = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, name)
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
// On Android 10+ you could also set RELATIVE_PATH = "DCIM/CameraX"
}
val outputOptions = ImageCapture.OutputFileOptions.Builder(
context.contentResolver,
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
contentValues
).build()
capture.takePicture(
outputOptions,
ContextCompat.getMainExecutor(context),
object : ImageCapture.OnImageSavedCallback {
override fun onImageSaved(output: ImageCapture.OutputFileResults) {
// Success: output.savedUri
}
override fun onError(exception: ImageCaptureException) {
// Handle error
}
}
)
}
private fun toggleRecording(
context: Context,
videoCapture: VideoCapture<Recorder>?,
currentRecording: Recording?
): Recording? {
val capture = videoCapture ?: return null
// Stop if already recording
if (currentRecording != null) {
currentRecording.stop()
return null
}
// Start new recording
val name = "VID_${System.currentTimeMillis()}.mp4"
val contentValues = ContentValues().apply {
put(MediaStore.Video.Media.DISPLAY_NAME, name)
// On Android 10+ you could also set RELATIVE_PATH = "DCIM/CameraX"
}
val outputOptions = MediaStoreOutputOptions.Builder(
context.contentResolver,
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
).setContentValues(contentValues).build()
return capture.output
.prepareRecording(context, outputOptions)
.withAudioEnabled() // mic permission is ensured by PermissionGate above
.start(ContextCompat.getMainExecutor(context)) { event ->
// Handle recording events (e.g., finalize, error)
}
}
This is pure Compose UI construction. Your camera buttons live in the same composable tree as your preview. No bridging logic. No separate View hierarchy to manage.
Migration Strategy: PreviewView → CameraXViewfinder
If you have existing camera code using PreviewView, here’s your migration path:
Before (the old way):
@Composable
fun OldCameraPreview() {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val previewView = remember { PreviewView(context) }
LaunchedEffect(previewView) {
val provider = ProcessCameraProvider.getInstance(context).get()
val preview = Preview.Builder().build()
preview.setSurfaceProvider(previewView.surfaceProvider)
provider.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview)
}
AndroidView(
factory = { previewView },
modifier = Modifier.fillMaxSize()
)
}
After (the Compose-native way):
@Composable
fun NewCameraPreview() {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val selector = CameraSelector.DEFAULT_BACK_CAMERA
val surfaceRequests = remember { MutableStateFlow<SurfaceRequest?>(null) }
val surfaceRequest by surfaceRequests.collectAsState(initial = null)
LaunchedEffect(Unit) {
val provider = ProcessCameraProvider.awaitInstance(context)
val preview = Preview.Builder().build().apply {
setSurfaceProvider { req -> surfaceRequests.value = req }
}
provider.unbindAll()
provider.bindToLifecycle(lifecycleOwner, selector, preview)
}
surfaceRequest?.let {
CameraXViewfinder(
surfaceRequest = it,
modifier = Modifier.fillMaxSize()
)
}
}
The key mindset shift: instead of giving CameraX a View’s SurfaceProvider, you publish SurfaceRequest objects to Compose state and render them with CameraXViewfinder.
Dependencies You Need
Add to your build.gradle.kts:
val cameraxVersion = "1.5.1"
dependencies {
implementation("androidx.camera:camera-core:$cameraxVersion")
implementation("androidx.camera:camera-camera2:$cameraxVersion")
implementation("androidx.camera:camera-lifecycle:$cameraxVersion")
implementation("androidx.camera:camera-video:$cameraxVersion")
// The new Compose-native viewfinder
implementation("androidx.camera:camera-compose:$cameraxVersion")
}
Manifest permissions:
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
Implementation modes (performance vs. composition)
CameraXViewfinder can render the preview in two ways:
EXTERNAL (SurfaceView-backed)
Camera frames render on their own Surface composed by the system outside your Compose draw pass. Think “a live video layer behind your UI.”. Often enables hardware overlays → best performance/latency. Great for full-screen rectangular previews behind standard UI chrome. Per-pixel effects on the camera pixels (complex clipping/blur) won’t apply because it’s a separate layer.
- Pros: lower latency, less GPU work, great for full-screen preview/recording.
- Cons: not affected by per-pixel UI effects (rounded masks/blur), won’t show up in Compose screenshots.
EMBEDDED (TextureView-backed)
Camera frames as a GPU texture drawn inside your Compose render pass — like a redrawable panel, behaving like any other composable. You get deep clipping/masking/animations/blur/z-ordering, at the cost of more GPU work and slightly higher latency.
- Pros: behaves like normal UI; clips, alpha, blur, fancy shapes, complex z-order all work.
- Cons: more GPU work → slightly higher latency/jank risk under heavy UI or on mid-range devices.
Rule of thumb
- Full-screen/performant → EXTERNAL
- fancy composition/effects → EMBEDDED.
If you don’t specify a mode, the library picks a sensible default. To force one:
import androidx.camera.viewfinder.core.ImplementationMode CameraXViewfinder( surfaceRequest = request, implementationMode = ImplementationMode.EXTERNAL // or ImplementationMode.EMBEDDED )
Real-World Gotchas
Coordinate transforms are not optional
Don’t pass raw Compose offsets to metering factories. Always use the coordinate transformer. The math looks simple until you test on landscape, or on a foldable, or with non-standard aspect ratios.
Front camera is mirrored
If you’re drawing overlays or processing captured images, remember the front camera preview is mirrored by default but captured images are not. Account for this in your UI/processing logic.
Test on real devices
Camera behavior varies across OEMs. What works perfectly on a Pixel might have quirks on Samsung or Xiaomi. Test your critical flows on representative hardware.
Permission UX
Request CAMERA at the entry point; request RECORD_AUDIO only when starting a recording (as a good practice). The inline PermissionGate pattern above keeps that logic inside your Compose tree.
Advanced: Foldables and Adaptive UIs
Since CameraXViewfinder is just another composable, foldable support is straightforward. A simple two-pane vs full-screen layout is typically enough; animate between states using AnimatedContent if you like.
@Composable
fun AdaptiveCameraScreen(surfaceRequest: SurfaceRequest?) {
val expanded = remember { mutableStateOf(false) } // pretend this reflects window size/hinge state
AnimatedContent(targetState = expanded.value, label = "layout") { isExpanded ->
if (isExpanded) {
Row(Modifier.fillMaxSize()) {
surfaceRequest?.let {
CameraXViewfinder(
surfaceRequest = it,
modifier = Modifier
.weight(1f)
.aspectRatio(9f / 16f)
)
}
Box(Modifier.weight(1f)) { /* CameraControls(Modifier.align(Alignment.Center)) */ }
}
} else {
Box(Modifier.fillMaxSize()) {
surfaceRequest?.let {
CameraXViewfinder(
surfaceRequest = it,
modifier = Modifier.fillMaxSize()
)
}
/* CameraControls(Modifier.align(Alignment.BottomCenter)) */
}
}
}
}
Testing Checklist (pragmatic)
- Verify tap-to-focus accuracy in portrait/landscape and with
ContentScale.Crop/Fit. - Exercise zoom limits; ensure smooth pinch and programmatic zoom transitions.
- Switch cameras (front/back) and re-verify transforms + mirror behavior.
- Navigate away/back, rotate, and process configuration changes; preview should recover without flicker.
- Record video while focusing/zooming; ensure no dropped surfaces.
Job Offers
The Bigger Picture
This release is significant not just for what it ships, but for what it signals.
For years, camera development in Android has felt like a second-class citizen. You could build modern UIs with Compose everywhere except the camera screen, where you’d grudgingly reach for View interop. It worked, but it always felt like you were coding with one hand tied behind your back.
camera-compose isn’t just a new artifact. It’s the CameraX team saying: “Compose is a first-class platform for camera development now.”
That means:
- Future camera features will be designed with Compose in mind, not retrofitted.
- The community will build Compose-first camera libraries and components.
- Best practices will evolve around composable camera UIs
- The documentation and samples will reflect modern Android development.
We’re seeing this pattern across the Android ecosystem — APIs that started as View-centric getting proper Compose-native counterparts. camera-compose is one of the most impactful examples yet.
What You Should Do Now
If you’re starting a new camera feature:
Use CameraXViewfinder from day one. Don’t even consider PreviewView. The code is cleaner, the mental model is simpler, and you’ll thank yourself later.
If you have existing camera code:
Add camera-compose to your dependencies and migrate one screen at a time. Start with your simplest camera UI (probably a basic preview-only screen) to get comfortable with the new APIs. Then tackle the complex stuff.
If you’re building a library:
Now is the time to add Compose-native camera components to your SDK. Developers are looking for composable camera solutions, and the ecosystem is ready for them.
Further Reading
- CameraX Release Notes — Official changelog and artifacts
- Camera Viewfinder Documentation — Implementation modes, scaling, alignment
- CameraX Samples on GitHub — Real code examples
The King is Dead / Long Live the King
The era of AndroidView camera previews is over. If you’re building camera features in 2025 and beyond, you’re building them in Compose. The tooling is finally here to support that properly.
Now go remove that AndroidView wrapper and write some beautiful camera UIs.
This article was previously published on proandroiddev.com


