Blog Infos
Author
Published
Topics
, , ,
Author
Published

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
Kotlin Multiplatform Shared Camera

 

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)
view raw Camera.kt hosted with ❤ by GitHub

/commonMain/common/Camera.kt

Explanation:
  • Platform-Agnostic Structure CameraView is defined as an expect composable, which allows you to implement platform-specific camera UIs separately for Android, iOS, etc., using Kotlin Multiplatform.
  • Event-Driven Camera Control CameraEvent is a sealed class that represents user actions (like capturing or switching camera). These events are dispatched through a coroutine-based Channel to the UI layer.
  • Reactive State Handling via Flow CameraCallback collects camera events using eventFlow, 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 when CameraView is 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()
})
}
view raw Camera.ios.kt hosted with ❤ by GitHub

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

,

When sharing isn’t caring: Using platform-specific code in Kotlin Multiplatform

Sharing code across platforms is a wonderful superpower. But sometimes, sharing 100% of your codebase isn’t the goal. Maybe you’re migrating existing apps to multiplatform, maybe you have platform-specific libraries or APIs you want to…
Watch Video

When sharing isn’t caring: Using platform-specific code in Kotlin Multiplatform

Russell Wolf
Kotlin Multiplatform Developer

When sharing isn’t caring: Using platform-specific code in Kotlin Multiplatform

Russell Wolf
Kotlin Multiplatform ...

When sharing isn’t caring: Using platform-specific code in Kotlin Multiplatform

Russell Wolf
Kotlin Multiplatform Deve ...

Jobs

Explanation

Camera Setup

  • Initializes AVCaptureSession with photo preset for high-resolution image capture.
  • Adds AVCaptureStillImageOutput with JPEG codec configuration to the session.

Camera Devices & Switching

  • Retrieves front and back cameras using AVCaptureDevice and determines which one to activate.
  • Switches between front/back cameras by removing and re-adding the appropriate AVCaptureDeviceInput.

Preview Rendering

  • Creates a UIView container and inserts an AVCaptureVideoPreviewLayer to show live camera feed.
  • Uses UIKitView from 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.CaptureImage captures a photo asynchronously and saves it to a temporary location.
  • CameraEvent.SwitchCamera reconfigures 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.

Menu