Skip to content
Go back

Through the Lens: Barcode Scanning in Compose Multiplatform

by KMP Bits

KMP Bits Cover

In GT racing, every car on the grid carries a timing transponder: a small device that fires a signal each time the car crosses a detection loop buried in the asphalt. The system on the pit wall reads that signal and shows the same lap time on the same screen, regardless of which manufacturer built the transponder inside the car. The loop is the contract. The transponder is the implementation.

A few years ago, when I was starting to build apps with Compose Multiplatform, I needed a barcode scanner. One screen, shared UI, both platforms. In practice, the camera wasn’t reachable through CMP at the time. There was no clean way to render a live camera preview in a shared Composable and interact with the device’s scanner API. I ended up splitting the UI: Jetpack Compose on Android, SwiftUI on iOS, KMP handling the shared logic in the middle. It worked. But it felt like running two separate pit wall monitors when I’d been sold on having one.

CMP’s interop surface has grown enough now that you can drive the camera from a shared Composable, and the tool that makes it clean is one you already know: expect/actual. Not in a ViewModel, not in a repository. At the Composable level itself. The shared screen declares the contract; each platform provides its own camera.


expect/actual beyond business logic

Most KMP examples show expect/actual at the business logic layer: a date formatter, a local database driver, a permissions utility. That makes sense. These are stateless functions or classes, easy to reason about in isolation. The pattern is approachable there.

But a @Composable function is still just a function. And a function can be expect. That’s the only real insight this article is built on. The timing loop, the shared Composable in commonMain, defines what data comes out (a scanned barcode string). The transponder, the actual on each platform, decides how to read it. commonMain never sees MLKit. It never sees AVFoundation. It calls BarcodeScanner and gets a callback.


Project setup

Android needs CameraX to render the preview and MLKit to process frames. iOS uses AVFoundation, which ships with the OS. No external dependency required.

# libs.versions.toml
[versions]
camerax = "1.4.1"
mlkit-barcode = "17.3.0"

[libraries]
camerax-core = { group = "androidx.camera", name = "camera-core",      version.ref = "camerax" }
camerax-camera2 = { group = "androidx.camera", name = "camera-camera2",   version.ref = "camerax" }
camerax-lifecycle = { group = "androidx.camera", name = "camera-lifecycle",  version.ref = "camerax" }
camerax-view = { group = "androidx.camera", name = "camera-view",      version.ref = "camerax" }
mlkit-barcode = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "mlkit-barcode" }
// build.gradle.kts — androidMain sourceSet
androidMain {
    dependencies {
        implementation(libs.camerax.core)
        implementation(libs.camerax.camera2)
        implementation(libs.camerax.lifecycle)
        implementation(libs.camerax.view)
        implementation(libs.mlkit.barcode)
    }
}

Both platforms need camera permission declared before anything runs.

<!-- AndroidManifest.xml -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<!-- iOS Info.plist -->
<key>NSCameraUsageDescription</key>
<string>Used to scan barcodes.</string>

The uses-feature line on Android marks the camera as optional, which keeps the app installable on devices without one. If barcode scanning is your app’s core feature, set required="true" instead.


The expect Composable

// commonMain/scanner/BarcodeScanner.kt

@Composable
expect fun BarcodeScanner(
    onBarcodeDetected: (String) -> Unit,
    modifier: Modifier = Modifier
)

That’s the whole contract. A Composable that fires onBarcodeDetected when it reads a code. Whatever runs underneath, which ML library, which camera API, which system framework, is invisible to commonMain. The screen using this Composable doesn’t need to know.


Android: MLKit + CameraX

The Android actual wraps a PreviewView in AndroidView, binds it to the lifecycle, and feeds frames to MLKit’s barcode scanner via ImageAnalysis.

// androidMain/scanner/BarcodeScanner.android.kt

@Composable
actual fun BarcodeScanner(
    onBarcodeDetected: (String) -> Unit,
    modifier: Modifier = Modifier
) {
    val context = LocalContext.current
    val lifecycleOwner = LocalLifecycleOwner.current

    // Tracks the last detected value to avoid firing the callback on every frame
    val lastCode = remember { mutableStateOf<String?>(null) }

    val scanner = remember {
        BarcodeScanning.getClient(
            BarcodeScannerOptions.Builder()
                .setBarcodeFormats(Barcode.FORMAT_ALL_FORMATS)
                .build()
        )
    }

    AndroidView(
        modifier = modifier,
        factory = { ctx ->
            val previewView = PreviewView(ctx)
            val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)

            cameraProviderFuture.addListener({
                val cameraProvider = cameraProviderFuture.get()

                val preview = Preview.Builder().build().also {
                    it.setSurfaceProvider(previewView.surfaceProvider)
                }

                val analyzer = ImageAnalysis.Builder()
                    .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
                    .build()
                    .also { analysis ->
                        analysis.setAnalyzer(ContextCompat.getMainExecutor(ctx)) { imageProxy ->
                            processFrame(imageProxy, scanner) { code ->
                                if (code != lastCode.value) {
                                    lastCode.value = code
                                    onBarcodeDetected(code)
                                }
                            }
                        }
                    }

                try {
                    cameraProvider.unbindAll()
                    cameraProvider.bindToLifecycle(
                        lifecycleOwner,
                        CameraSelector.DEFAULT_BACK_CAMERA,
                        preview,
                        analyzer
                    )
                } catch (e: Exception) {
                    // Log this in production — common cause is the camera being used by another app
                }
            }, ContextCompat.getMainExecutor(ctx))

            previewView
        }
    )
}

@androidx.camera.core.ExperimentalGetImage
private fun processFrame(
    imageProxy: ImageProxy,
    scanner: com.google.mlkit.vision.barcode.BarcodeScanner,
    onDetected: (String) -> Unit
) {
    val mediaImage = imageProxy.image ?: run {
        imageProxy.close()
        return
    }

    val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)

    scanner.process(image)
        .addOnSuccessListener { barcodes ->
            barcodes.firstOrNull()?.rawValue?.let(onDetected)
        }
        .addOnCompleteListener {
            // Always close the proxy — CameraX blocks the next frame until you do
            imageProxy.close()
        }
}

STRATEGY_KEEP_ONLY_LATEST drops frames when the analyzer is busy. For a barcode scanner that’s the right call: you’d rather skip a frame than queue them up and fall behind.


iOS: AVFoundation

The iOS side uses UIKitView to embed a UIView subclass that owns the AVFoundation capture session. The Kotlin code here talks directly to iOS frameworks through KMP’s interop.

// iosMain/scanner/BarcodeScanner.ios.kt

@Composable
actual fun BarcodeScanner(
    onBarcodeDetected: (String) -> Unit,
    modifier: Modifier = Modifier
) {
    UIKitView(
        modifier = modifier,
        factory = { BarcodeScannerView(onBarcodeDetected) }
    )
}
// iosMain/scanner/BarcodeScannerView.kt

class BarcodeScannerView(
    private val onBarcodeDetected: (String) -> Unit
) : UIView(frame = CGRectZero.readValue()),
    AVCaptureMetadataOutputObjectsDelegateProtocol {

    private val captureSession = AVCaptureSession()
    private var previewLayer: AVCaptureVideoPreviewLayer? = null

    init {
        setupSession()
    }

    private fun setupSession() {
        val device = AVCaptureDevice.defaultDeviceWithMediaType(AVMediaTypeVideo) ?: return
        val input = try {
            AVCaptureDeviceInput(device = device, error = null)
        } catch (e: Exception) { return }

        if (captureSession.canAddInput(input)) captureSession.addInput(input)

        val output = AVCaptureMetadataOutput()
        if (captureSession.canAddOutput(output)) {
            captureSession.addOutput(output)
            output.setMetadataObjectsDelegate(this, queue = dispatch_get_main_queue())
            // metadataObjectTypes must be set after adding the output to the session
            output.metadataObjectTypes = listOf(
                AVMetadataObjectTypeQRCode,
                AVMetadataObjectTypeEAN13Code,
                AVMetadataObjectTypeEAN8Code,
                AVMetadataObjectTypeCode128Code
            )
        }

        val layer = AVCaptureVideoPreviewLayer(session = captureSession)
        layer.videoGravity = AVLayerVideoGravityResizeAspectFill
        self.layer.addSublayer(layer)
        previewLayer = layer

        captureSession.startRunning()
    }

    override fun layoutSubviews() {
        super.layoutSubviews()
        // Keep the preview in sync as the view's bounds change
        previewLayer?.frame = bounds
    }

    override fun removeFromSuperview() {
        // Stop the session when the Composable leaves the tree
        captureSession.stopRunning()
        super.removeFromSuperview()
    }

    override fun captureOutput(
        output: AVCaptureOutput,
        didOutputMetadataObjects: List<*>,
        fromConnection: AVCaptureConnection
    ) {
        val obj = didOutputMetadataObjects.firstOrNull() as? AVMetadataMachineReadableCodeObject
        obj?.stringValue?.let(onBarcodeDetected)
    }
}

removeFromSuperview is where you stop the session. When the shared Composable leaves the composition, UIKitView removes the view from the hierarchy. That’s the right moment to release the camera.

If you were building a KMP + Native UI project instead of CMP, you’d wrap the same BarcodeScannerView in a UIViewRepresentable and expose it through SwiftUI — the AVFoundation logic is identical, only the wrapper changes. For a scanner living inside a shared screen, the actual Composable approach is the cleaner path. For a scanner that needs native sheet dismissals or deeply integrated camera overlays, the native route gives you more control.


Using the scanner in commonMain

A note on architecture: The implementations above bundle camera setup, frame processing, and UI rendering into a single Composable. That’s intentional here. It keeps the pattern visible without extra indirection. In a production app, you’d want to separate those concerns. A CameraProvider expect/actual could own the session lifecycle on each platform, and a BarcodeAnalyzer could handle the frame processing independently. The actual Composable would then just wire them together. The expect Composable contract in commonMain stays exactly the same either way.

The payoff. The screen that uses BarcodeScanner lives in commonMain:

// commonMain/scanner/ScannerScreen.kt

@Composable
fun ScannerScreen(
    onBarcodeScanned: (String) -> Unit
) {
    var hasPermission by remember { mutableStateOf(false) }

    RequestCameraPermission(
        onGranted = { hasPermission = true },
        onDenied = { /* show rationale */ }
    )

    if (hasPermission) {
        BarcodeScanner(
            onBarcodeDetected = onBarcodeScanned,
            modifier = Modifier.fillMaxSize()
        )
    } else {
        Box(
            modifier = Modifier.fillMaxSize(),
            contentAlignment = Alignment.Center
        ) {
            Text("Camera permission required")
        }
    }
}

A quick note: RequestCameraPermission follows the same expect/actual pattern. The Android actual uses the activity result API; the iOS actual calls AVCaptureDevice.requestAccessForMediaType. The structure is identical to BarcodeScanner, just shorter.

commonMain calls BarcodeScanner, gets a barcode string back, and hands it to whatever comes next. It doesn’t import AndroidView. It doesn’t import UIKitView. The pit wall reads the same data regardless of which transponder is in the car.


Gotchas

1. Never skip imageProxy.close() on Android. CameraX blocks the next frame in the ImageAnalysis pipeline until the current ImageProxy is closed. Miss it once and the scanner silently stops updating after the first frame. The addOnCompleteListener is the right place: it fires whether MLKit succeeds or fails.

2. Set metadataObjectTypes after addOutput on iOS. The list of supported types isn’t populated until the output is part of an active session. Setting it before captureSession.addOutput(output) does nothing and fails silently. This one takes a while to find the first time.

3. Debounce the callback. Both platforms fire on every frame that contains a barcode. If you’re navigating away on the first scan, the lastCode state check in the Android example handles it. On iOS the delegate fires from the main queue, so a simple var lastDetected: String? property on BarcodeScannerView works fine.

4. Verify preview orientation if you support landscape on Android. PreviewView doesn’t automatically rotate the preview in all configurations. If your app supports landscape, test it specifically and set the target rotation on Preview.Builder if needed.

5. iOS requires a physical device. AVFoundation’s capture pipeline doesn’t run on the simulator. You’ll see FigCaptureSourceSimulator errors in the console and a black preview — that’s the simulator failing to deliver frames, not a bug in your code. Test the iOS target on a real device.


Wrapping up

expect/actual is usually introduced as a business logic tool because that’s where the examples are clean and the concept is easy to isolate. But @Composable is just a function, and a function can be expect. The loop is buried in commonMain. The transponder — whichever platform is reading the signal — lives in androidMain or iosMain. The pit wall never sees MLKit or AVFoundation. It sees a barcode string.

That’s the shape of the pattern. Once you see it at the Composable level, you start noticing every platform-specific UI component that was forcing a screen split it didn’t deserve: a signature pad, a map view, a camera preview, a document picker. Each one is a loop waiting for a transponder.

Keep it flat out. 🏁


The full demo for this article is be available on GitHub.

The KMP Bits app is available on App Store and Google Play — built entirely with KMP.


Share this post on:

Comments

0 / 250

Loading comments...


Next Post
Homologated: Publishing Your Kotlin Multiplatform Library to Maven Central