In this article, we’ll dive into how to build a custom camera module in a Compose Multiplatform (CMP) project using a shared design pattern. We’ll leverage expect/actual to abstract platform-specific camera logic, while keeping the UI and interaction layer common across Android and iOS.

Why a Custom Camera in CMP?
- Native camera APIs are platform-specific
- Compose Multiplatform enables shared UI but needs abstraction for platform access
- A common capture interface improves testability, reuse, and architectural cleanliness
- Does not depend on 3rd party libraries

Like above mentioned diagram we implement CameraView in separate platform and controls & control designs are in commonMain.
Lets get started
Add following in commonMain
| sealed class CameraEvent { | |
| object Init : CameraEvent() | |
| object CaptureImage : CameraEvent() | |
| object SwitchCamera : CameraEvent() | |
| } | |
| abstract class CameraCallback { | |
| private val _event = Channel<CameraEvent>() | |
| val eventFlow: Flow<CameraEvent> get() = _event.receiveAsFlow() | |
| suspend fun sendEvent(event: CameraEvent) { | |
| this._event.send(event) | |
| } | |
| abstract fun onCaptureImage(image: Path?) | |
| } | |
| @Composable | |
| expect fun CameraView(callback: CameraCallback) |
/commonMain/common/Camera.kt
Explanation:
- Platform-Agnostic Structure
CameraViewis defined as anexpectcomposable, which allows you to implement platform-specific camera UIs separately for Android, iOS, etc., using Kotlin Multiplatform. - Event-Driven Camera Control
CameraEventis a sealed class that represents user actions (like capturing or switching camera). These events are dispatched through a coroutine-basedChannelto the UI layer. - Reactive State Handling via Flow
CameraCallbackcollects camera events usingeventFlow, making the UI respond dynamically to user-triggered actions outside the composable. - Abstracted Capture Result Handling
onCaptureImage()is an abstract function that handles image results uniformly across platforms, providing a clean contract for success or error callbacks.
We used CameraCallback to observer user event on each platform. And received onCaptureImage in common UI to get the image path.
| package com.codingwitharul.bookmyslot.common | |
| import android.content.Context | |
| import android.net.Uri | |
| import android.util.Log | |
| import androidx.camera.core.CameraSelector | |
| import androidx.camera.core.ImageCapture | |
| import androidx.camera.core.ImageCapture.OutputFileOptions.Builder | |
| import androidx.camera.core.ImageCaptureException | |
| import androidx.camera.core.Preview | |
| import androidx.camera.lifecycle.ProcessCameraProvider | |
| import androidx.camera.view.PreviewView | |
| import androidx.compose.foundation.layout.fillMaxSize | |
| import androidx.compose.runtime.Composable | |
| import androidx.compose.runtime.DisposableEffect | |
| import androidx.compose.runtime.LaunchedEffect | |
| import androidx.compose.runtime.getValue | |
| import androidx.compose.runtime.mutableIntStateOf | |
| import androidx.compose.runtime.remember | |
| import androidx.compose.runtime.rememberCoroutineScope | |
| import androidx.compose.runtime.setValue | |
| import androidx.compose.ui.Modifier | |
| import androidx.compose.ui.platform.LocalContext | |
| import androidx.compose.ui.viewinterop.AndroidView | |
| import androidx.core.content.ContextCompat | |
| import androidx.lifecycle.compose.LocalLifecycleOwner | |
| import kotlinx.coroutines.launch | |
| import kotlinx.io.files.Path | |
| import java.io.File | |
| import java.util.concurrent.Executor | |
| @Composable | |
| actual fun CameraView(callback: CameraCallback) { | |
| val context = LocalContext.current | |
| val lifeCycleOwner = LocalLifecycleOwner.current | |
| val scope = rememberCoroutineScope() | |
| val previewView = remember { PreviewView(context) } | |
| val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) } | |
| val imageCapture: ImageCapture = remember { ImageCapture.Builder().build() } | |
| var cameraLens by remember { mutableIntStateOf(CameraSelector.LENS_FACING_BACK) } | |
| fun takePicture() { | |
| val photoFile = File( | |
| getOutputDirectory(context), | |
| "${System.currentTimeMillis()}.jpg" | |
| ) | |
| val outputOptions = Builder(photoFile).build() | |
| val mainExecutor: Executor = ContextCompat.getMainExecutor(context) | |
| imageCapture.takePicture( | |
| outputOptions, | |
| mainExecutor, | |
| object : ImageCapture.OnImageSavedCallback { | |
| override fun onError(exc: ImageCaptureException) { | |
| Log.e("TAG", "Photo capture failed: ${exc.message}", exc) | |
| scope.launch { | |
| callback.onCaptureImage(error= exc.message, image=null) | |
| } | |
| } | |
| override fun onImageSaved(output: ImageCapture.OutputFileResults) { | |
| val savedUri = output.savedUri ?: Uri.fromFile(photoFile) | |
| Log.d("TAG", "Photo capture succeeded: $savedUri") | |
| scope.launch { | |
| callback.onCaptureImage(savedUri.path?.let { path -> Path(path) }) | |
| } | |
| } | |
| } | |
| ) | |
| } | |
| fun switchLens() { | |
| cameraLens = if (cameraLens == CameraSelector.LENS_FACING_BACK) | |
| CameraSelector.LENS_FACING_FRONT else CameraSelector.LENS_FACING_BACK | |
| } | |
| LaunchedEffect(Unit) { | |
| callback.eventFlow.collect { | |
| when (it) { | |
| CameraEvent.CaptureImage -> takePicture() | |
| CameraEvent.SwitchCamera -> switchLens() | |
| } | |
| } | |
| } | |
| LaunchedEffect(lifeCycleOwner, context, cameraLens) { | |
| val cameraProviderFuture = ProcessCameraProvider.getInstance(context) | |
| cameraProviderFuture.addListener({ | |
| try { | |
| val cameraProvider = cameraProviderFuture.get() | |
| val preview = Preview.Builder().build().also { | |
| it.surfaceProvider = previewView.surfaceProvider | |
| } | |
| val cameraSelector = CameraSelector.Builder() | |
| .requireLensFacing(cameraLens) | |
| .build() | |
| cameraProvider.unbindAll() | |
| cameraProvider.bindToLifecycle( | |
| lifeCycleOwner, | |
| cameraSelector, | |
| preview, | |
| imageCapture | |
| ) | |
| Log.d("CameraView", "Camera use cases bound successfully.") | |
| } catch (exc: Exception) { | |
| Log.e("CameraView", "Use case binding failed", exc) | |
| scope.launch { | |
| callback.onCaptureImage(error= exc.message, image=null) | |
| } | |
| } | |
| }, ContextCompat.getMainExecutor(context)) | |
| } | |
| AndroidView( | |
| factory = { previewView }, | |
| modifier = Modifier.fillMaxSize(), | |
| ) | |
| DisposableEffect(Unit) { | |
| onDispose { | |
| Log.d("CameraView", "Disposing CameraView, unbinding camera use cases.") | |
| try { | |
| val cameraProvider = cameraProviderFuture.get() | |
| cameraProvider.unbindAll() | |
| // previewView.surfaceProvider = null | |
| } catch (e: Exception) { | |
| Log.e("CameraView", "Error during camera unbinding/cleanup", e) | |
| } | |
| } | |
| } | |
| } | |
| /** | |
| * | |
| * Store photo in private folder. | |
| * | |
| * App's internal files directory. No special permissions needed. | |
| */ | |
| private fun getOutputDirectory(context: Context): File { | |
| val mediaDir = context.externalMediaDirs.firstOrNull()?.let { | |
| File(it, "bms").apply { mkdirs() } | |
| } | |
| return if (mediaDir != null && mediaDir.exists()) { | |
| mediaDir | |
| } else { | |
| context.filesDir | |
| } | |
| } |
Explanation
Preview Setup
PreviewView: A view that displays the camera preview.Preview.Builder(): Creates a preview use case.- Binds the preview to the lifecycle of the activity using
cameraProvider.bindToLifecycle.
Capture Photo (takePicture)
- Creates a file in the app’s internal or external storage.
- Uses
ImageCapture.takePicture()to capture the photo. - Handles success and error via
OnImageSavedCallback.
Switch Camera Lens (switchLens)
- Toggles between front (
CameraSelector.LENS_FACING_FRONT) and back (LENS_FACING_BACK) cameras. - Rebinds the camera with the updated lens configuration.
Camera Binding (LaunchedEffect)
- Waits for the camera to be ready (
cameraProviderFuture). - Builds and binds the camera use cases (preview and image capture).
- Reacts to lens changes to rebind camera.
Composable Lifecycle Hooks
DisposableEffect: Unbinds camera use cases whenCameraViewis disposed, preventing resource leaks.LaunchedEffect: Observes camera events (CameraEvent) and triggers actions accordingly.
File Storage
getOutputDirectory(): Ensures the image is saved in either a custom external media directory (bms) or fallback to internal storage.- No special permissions are required for internal storage.
Event Observer
callback.eventFlow.collect {
when (it) {
CameraEvent.CaptureImage -> takePicture()
CameraEvent.SwitchCamera -> switchLens()
}
}
- Listens for camera-related events.
- Makes the composable reactive to user actions outside the view (like a button click elsewhere that triggers
CameraEvent).
And Finally iOS Implementation,
| @OptIn(ExperimentalForeignApi::class) | |
| @Composable | |
| actual fun CameraView(callback: CameraCallback) { | |
| val session = AVCaptureSession() | |
| session.sessionPreset = AVCaptureSessionPresetPhoto | |
| val output = AVCaptureStillImageOutput().apply { | |
| outputSettings = mapOf(AVVideoCodecKey to platform.AVFoundation.AVVideoCodecJPEG) | |
| } | |
| session.addOutput(output) | |
| val cameraPreviewLayer = AVCaptureVideoPreviewLayer(session = session) | |
| LaunchedEffect(Unit) { | |
| val backCamera = | |
| AVCaptureDevice.devicesWithMediaType(AVMediaTypeVideo).firstOrNull { device -> | |
| (device as AVCaptureDevice).position == AVCaptureDevicePositionBack | |
| } as? AVCaptureDevice ?: return@LaunchedEffect | |
| val frontCamera = | |
| AVCaptureDevice.devicesWithMediaType(AVMediaTypeVideo).firstOrNull { device -> | |
| (device as AVCaptureDevice).position == AVCaptureDevicePositionFront | |
| } as? AVCaptureDevice ?: return@LaunchedEffect | |
| var currentCamera = backCamera | |
| var currentInput = | |
| AVCaptureDeviceInput.deviceInputWithDevice(currentCamera, null) as AVCaptureDeviceInput | |
| session.addInput(currentInput) | |
| session.addOutput(output) | |
| session.startRunning() | |
| callback.eventFlow.collect { | |
| when (it) { | |
| CameraEvent.CaptureImage -> { | |
| val connection = output.connectionWithMediaType(AVMediaTypeVideo) | |
| if (connection != null) { | |
| output.captureStillImageAsynchronouslyFromConnection(connection) { sampleBuffer, error -> | |
| if (sampleBuffer != null && error == null) { | |
| val imageData = | |
| AVCaptureStillImageOutput.jpegStillImageNSDataRepresentation( | |
| sampleBuffer | |
| ) | |
| if (imageData != null) { | |
| val filePath = | |
| platform.Foundation.NSTemporaryDirectory() + UIDevice.currentDevice.identifierForVendor?.UUIDString + ".jpg" | |
| imageData.writeToFile(filePath, true) | |
| callback.onCaptureImage(Path(filePath)) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| CameraEvent.SwitchCamera -> { | |
| session.stopRunning() | |
| session.removeInput(currentInput) | |
| currentCamera = if (currentCamera == backCamera) frontCamera else backCamera | |
| currentInput = AVCaptureDeviceInput.deviceInputWithDevice( | |
| currentCamera, | |
| null | |
| ) as AVCaptureDeviceInput | |
| session.addInput(currentInput) | |
| session.startRunning() | |
| } | |
| } | |
| } | |
| } | |
| UIKitView( | |
| modifier = Modifier.fillMaxSize(), | |
| background = Color.Black, | |
| factory = { | |
| val container = UIView() | |
| container.layer.addSublayer(cameraPreviewLayer) | |
| cameraPreviewLayer.videoGravity = AVLayerVideoGravityResizeAspectFill | |
| container | |
| }, | |
| onResize = { container: UIView, rect: CValue<CGRect> -> | |
| CATransaction.begin() | |
| CATransaction.setValue(true, kCATransactionDisableActions) | |
| container.layer.setFrame(rect) | |
| cameraPreviewLayer.setFrame(rect) | |
| CATransaction.commit() | |
| }) | |
| } |
Job Offers
Explanation
Camera Setup
- Initializes
AVCaptureSessionwith photo preset for high-resolution image capture. - Adds
AVCaptureStillImageOutputwith JPEG codec configuration to the session.
Camera Devices & Switching
- Retrieves front and back cameras using
AVCaptureDeviceand determines which one to activate. - Switches between front/back cameras by removing and re-adding the appropriate
AVCaptureDeviceInput.
Preview Rendering
- Creates a
UIViewcontainer and inserts anAVCaptureVideoPreviewLayerto show live camera feed. - Uses
UIKitViewfrom Compose to embed the native preview into the Compose UI with full size and dynamic resizing.
Event Handling via Flow
- Collects camera-related events from
CameraCallback.eventFlow: CameraEvent.CaptureImagecaptures a photo asynchronously and saves it to a temporary location.CameraEvent.SwitchCamerareconfigures session inputs to switch between cameras.- The result is passed back via
callback.onCaptureImage(imagePath).
Sobasically same implementation like android with iosComponent in it. Dont forget the callback!!!
How to Consume?
Lets see how we consume inside commonMain
Create ImageCaptureView Composable Component, below approach will be useful when we reuse the ImageCaptureView on different screens.
| @Preview | |
| @Composable | |
| fun ImageCaptureView(onImageCaptured: (Path?) -> Unit, onClose: () -> Unit = {}) { | |
| val scope = rememberCoroutineScope() | |
| val callback = remember { | |
| object : CameraCallback() { | |
| override fun onCaptureImage(image: Path?, error: String?) { | |
| showToast("Image Captured $image") | |
| onImageCaptured(image) | |
| } | |
| } | |
| } | |
| fun takePicture() = scope.launch { | |
| callback.sendEvent(CameraEvent.CaptureImage) | |
| } | |
| fun switchCamera() = scope.launch { | |
| callback.sendEvent(CameraEvent.SwitchCamera) | |
| } | |
| Box(modifier = Modifier.fillMaxSize()) { | |
| // CameraView from each platform using expect/actual functionality | |
| CameraView(callback) | |
| // Custom Capture View Design | |
| Row( | |
| modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth() | |
| .height(120.dp) | |
| .background(color = Color.Black.copy(alpha = 0.5f)) | |
| .padding(16.dp), | |
| horizontalArrangement = Arrangement.SpaceBetween, | |
| verticalAlignment = Alignment.CenterVertically | |
| ) { | |
| IconButton( | |
| modifier = Modifier | |
| .size(60.dp), | |
| onClick = onClose, colors = IconButtonDefaults.iconButtonColors( | |
| containerColor = Color.White.copy(alpha = 0.2f), | |
| contentColor = MaterialTheme.colorScheme.onPrimary | |
| ) | |
| ) { | |
| Icon( | |
| imageVector = vectorResource(Res.drawable.close), | |
| contentDescription = "Settings", | |
| modifier = Modifier.size(48.dp), | |
| tint = Color.Red | |
| ) | |
| } | |
| IconButton( | |
| onClick = ::takePicture, | |
| modifier = Modifier | |
| .size(80.dp), | |
| colors = IconButtonDefaults.iconButtonColors( | |
| containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f), | |
| contentColor = MaterialTheme.colorScheme.onPrimary | |
| ) | |
| ) { | |
| Icon( | |
| imageVector = vectorResource(Res.drawable.ic_camera_capture), | |
| contentDescription = "Take photo", | |
| modifier = Modifier.size(48.dp) | |
| ) | |
| } | |
| IconButton( | |
| modifier = Modifier | |
| .size(60.dp), | |
| onClick = ::switchCamera, colors = IconButtonDefaults.iconButtonColors( | |
| containerColor = Color.White.copy(alpha = 0.2f), | |
| contentColor = Color.Black | |
| ) | |
| ) { | |
| Icon( | |
| imageVector = vectorResource(Res.drawable.ic_camera_rotate), | |
| contentDescription = "Settings", | |
| modifier = Modifier.size(48.dp), | |
| ) | |
| } | |
| } | |
| } | |
| } |
And wherever we want to implement in application we can do like following.
var showCamera by remember { mutableStateOf(false) }
var imagePath by remember { mutableStateOf<Path?>(null) }
if (showCamera) {
ImageCaptureView(onImageCaptured = {
scope.launch {
showCamera = false
imagePath = it
}
}, onClose = { showCamera = false })
}
Conclusion
Our implementation successfully combines shared business logic with powerful native features, delivering a consistent UI across platforms. It’s scalable for additional capabilities like video recording, zoom, and filters. You’ve achieved a modern, maintainable solution that aligns with best practices for MultiPlatform development.
There are libraries that make this easier, but if we truly want a custom camera solution, this thread will be helpful. 🙂
Let me know if you’d like to extend this with extra functionality or refactor pieces for even better performance or testing. 😉
This article was previously published on proandroiddev.com.



