How to Build a Compose Multiplatform QR Code Scanner
Compose Multiplatform is a declarative UI framework that allows you to develop shared UIs for Android, iOS, desktop, and web. You can reuse code across platforms while retaining the benefits of native programming (based on Kotlin Multiplatform, KMP).
In this article, we are going to build a mobile QR code scanner using Compose Multiplatform and Dynamsoft Barcode Reader.
Demo video:
Prerequisites
Get your trial key.
Create a New Compose Multiplatform Project
Goto Kotlin Multiplatform Wizard to create a new Compose Multiplatform project for Android and iOS.

Declare Camera Permission
-
For Android, add the following to
AndroidManifest.xml:<uses-permission android:name="android.permission.CAMERA" /> -
For iOS, add the following to
Info.plist<key>NSCameraUsageDescription</key> <string>For barcode scanning</string>
Add Dependencies
Next, let’s add dependencies related to QR code scanning.
Android
We need to add CameraX, Accompanist and Dynamsoft Barcode Reader.
-
Add the following to
libs.versions.toml:[versions] accompanist = "0.34.0" androidxCamera = "1.3.4" dynamsoft-barcode-reader = "10.4.3000" [libraries] accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "androidxCamera" } androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "androidxCamera" } androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = "androidxCamera" } androidx-material3-android = { group = "androidx.compose.material3", name = "material3-android", version.ref = "material3Android" } dynamsoft-barcode-reader = { module = "com.dynamsoft:dynamsoftbarcodereaderbundle", version.ref = "dynamsoft-barcode-reader" } -
Add the following to
composeApp/build.gradle.kts:kotlin { sourceSets { androidMain.dependencies { implementation(libs.accompanist.permissions) implementation(libs.androidx.camera.camera2) implementation(libs.androidx.camera.lifecycle) implementation(libs.androidx.camera.view) implementation(libs.dynamsoft.barcode.reader) } } }
iOS
We are going to integrate Dynamsoft Barcode Reader via Cocoapods.
-
Add the Kotlin CocoaPods Gradle plugin.
-
Add the following to
libs.versions.toml:[versions] kotlinCocoapods = { id = "org.jetbrains.kotlin.native.cocoapods", version.ref = "kotlin" } -
Add the following alias to your root folder’s
build.gradle.ktsfile:plugins { alias(libs.plugins.kotlinCocoapods) apply false } -
Also add the following to the app’s
build.gradle.ktsfile:plugins { alias(libs.plugins.kotlinCocoapods) }
-
-
Add the following to the app’s
build.gradle.kts:cocoapods { // Required fields version = "1.0" summary = "CocoaPods test library" homepage = "https://github.com/JetBrains/kotlin" ios.deploymentTarget = "15.0" // Specify path to Podfile podfile = project.file("../iosApp/Podfile") framework { baseName = "ComposeApp" isStatic = true } pod("DynamsoftBarcodeReader") { version = "9.6.40" } xcodeConfigurationToNativeBuildType["CUSTOM_DEBUG"] = NativeBuildType.DEBUG xcodeConfigurationToNativeBuildType["CUSTOM_RELEASE"] = NativeBuildType.RELEASE }
Sync your project to create the required composeApp.podspec file.
In addition, init the pod project under iosApp using pod init. Use the following as the content for the Podfile.
platform :ios, '15.0'
target 'iosApp' do
use_frameworks!
# Local podspec from path
pod 'composeApp', :path => '../composeApp/composeApp.podspec'
end
You may also meet the following error when compiling:
'embedAndSign' task can't be used in a project with dependencies to pods.
In this case, add the following to gradle.properties:
kotlin.apple.deprecated.allowUsingEmbedAndSignWithCocoaPodsDependencies=true
Create a Scanner Component
Let’s first define the component in the common code and then implement it in the native code.
Define the Component
-
Add
CameraPermissionState.ktfor camera permission states.interface CameraPermissionState { val status: CameraPermissionStatus fun requestCameraPermission() fun goToSettings() } @Composable expect fun rememberCameraPermissionState(): CameraPermissionState enum class CameraPermissionStatus { Denied, Granted } -
Define the scanner component which has a callback to return the scanned QR code.
@Composable expect fun Scanner( modifier: Modifier = Modifier, onScanned: (String) -> Unit, ) -
Define the
ScannerWithPermissionscomponent to deal with permission.@Composable fun ScannerWithPermissions( modifier: Modifier = Modifier, onScanned: (String) -> Unit, permissionText: String = "Camera is required for QR Code scanning", openSettingsLabel: String = "Open Settings", ) { ScannerWithPermissions( modifier = modifier.clipToBounds(), onScanned = onScanned, permissionDeniedContent = { permissionState -> Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) { Text( modifier = Modifier.padding(6.dp), text = permissionText ) Button(onClick = { permissionState.goToSettings() }) { Text(openSettingsLabel) } } } ) } @Composable fun ScannerWithPermissions( modifier: Modifier = Modifier, onScanned: (String) -> Unit, permissionDeniedContent: @Composable (CameraPermissionState) -> Unit, ) { val permissionState = rememberCameraPermissionState() LaunchedEffect(Unit) { if (permissionState.status == CameraPermissionStatus.Denied) { permissionState.requestCameraPermission() } } if (permissionState.status == CameraPermissionStatus.Granted) { Scanner(modifier, onScanned = onScanned) } else { permissionDeniedContent(permissionState) } }
Android Implementation
-
Initialize the license in
MainActivity.kt:class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (savedInstanceState == null) { LicenseManager.initLicense("LICENSE-KEY", this) { isSuccess: Boolean, error: Exception? -> Log.d("DBR",isSuccess.toString()) if (!isSuccess) { error?.printStackTrace() } } } setContent { App() } } } -
Add a
BarcodeAnalyzer.ktfile which implements CameraX’s image analyzer. It gets the camera frames and use Dynamsoft Barcode Reader to read barcodes from them.class BarcodeAnalyzer( private val onScanned: (String) -> Unit, private val context: Context, ) : ImageAnalysis.Analyzer { private val router = CaptureVisionRouter(context) //used to call Dynamsoft Barcode Reader @SuppressLint("UnsafeOptInUsageError") override fun analyze(imageProxy: ImageProxy) { imageProxy.image?.let { image -> val buffer = image.planes[0].buffer val nRowStride = image.planes[0].rowStride val nPixelStride = image.planes[0].pixelStride val length = buffer.remaining() val bytes = ByteArray(length) buffer[bytes] val imageData = ImageData() imageData.bytes = bytes imageData.width = image.width imageData.height = image.height imageData.stride = nRowStride * nPixelStride imageData.format = EnumImagePixelFormat.IPF_NV21 val capturedResult = router.capture(imageData,EnumPresetTemplate.PT_READ_SINGLE_BARCODE) if (capturedResult.decodedBarcodesResult != null) { if (capturedResult.decodedBarcodesResult!!.items.isNotEmpty()) { val result = capturedResult.decodedBarcodesResult!!.items[0] onScanned(result.text) } } } imageProxy.close() } } -
Add a
ScannerView.ktfile to hold the camera preview.@Composable fun CameraView( modifier: Modifier = Modifier, analyzer: BarcodeAnalyzer ) { val localContext = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(localContext) } AndroidView( modifier = modifier.fillMaxSize(), factory = { context -> val previewView = PreviewView(context) val preview = Preview.Builder().build() val selector = CameraSelector.Builder() .requireLensFacing(CameraSelector.LENS_FACING_BACK) .build() preview.setSurfaceProvider(previewView.surfaceProvider) val imageAnalysis = ImageAnalysis.Builder().build() imageAnalysis.setAnalyzer( ContextCompat.getMainExecutor(context), analyzer ) runCatching { cameraProviderFuture.get().unbindAll() cameraProviderFuture.get().bindToLifecycle( lifecycleOwner, selector, preview, imageAnalysis ) }.onFailure { Log.e("CAMERA", "Camera bind error ${it.localizedMessage}", it) } previewView } ) } -
Add a
Scanner.android.ktfile to contain the implementation of the scanner component.@Composable actual fun Scanner( modifier: Modifier, onScanned: (String) -> Unit, ) { val context = LocalContext.current val analyzer = remember() { BarcodeAnalyzer(onScanned, context) } CameraView(modifier,analyzer) } @OptIn(ExperimentalPermissionsApi::class) @Composable actual fun rememberCameraPermissionState(): CameraPermissionState { val accPermissionState = rememberPermissionState(android.Manifest.permission.CAMERA) val context = LocalContext.current val wrapper = remember(accPermissionState) { AccompanistPermissionWrapper(accPermissionState, context) } return wrapper } @OptIn(ExperimentalPermissionsApi::class) class AccompanistPermissionWrapper (val accPermissionState: PermissionState, private val context: Context): CameraPermissionState { override val status: CameraPermissionStatus get() = accPermissionState.status.toCameraPermissionStatus() override fun requestCameraPermission() { accPermissionState.launchPermissionRequest() } override fun goToSettings() { val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) intent.data = Uri.parse("package:" + context.packageName) ContextCompat.startActivity(context, intent, null) } } @OptIn(ExperimentalPermissionsApi::class) private fun PermissionStatus.toCameraPermissionStatus(): CameraPermissionStatus { return when (this) { is PermissionStatus.Denied -> CameraPermissionStatus.Denied PermissionStatus.Granted -> CameraPermissionStatus.Granted } }
iOS Implementation
-
Create a
OrientationListener.ktfile to listen to the orientation changes.@OptIn(ExperimentalForeignApi::class) class OrientationListener( val orientationChanged: (UIDeviceOrientation) -> Unit ) : NSObject() { val notificationName = platform.UIKit.UIDeviceOrientationDidChangeNotification @Suppress("UNUSED_PARAMETER") @ObjCAction fun orientationDidChange(arg: NSNotification) { orientationChanged(UIDevice.currentDevice.orientation) } fun register() { NSNotificationCenter.defaultCenter.addObserver( observer = this, selector = NSSelectorFromString( OrientationListener::orientationDidChange.name + ":" ), name = notificationName, `object` = null ) } fun unregister() { NSNotificationCenter.defaultCenter.removeObserver( observer = this, name = notificationName, `object` = null ) } } -
Create a new
Scanner.ktfile to hold several classes for the scanner.-
A
ScannerPreviewViewclass to as the container of the camera preview.@OptIn(ExperimentalForeignApi::class) class ScannerPreviewView(private val coordinator: ScannerCameraCoordinator): UIView(frame = cValue { CGRectZero }) { @OptIn(ExperimentalForeignApi::class) override fun layoutSubviews() { super.layoutSubviews() CATransaction.begin() CATransaction.setValue(true, kCATransactionDisableActions) layer.setFrame(frame) coordinator.setFrame(frame) CATransaction.commit() } } -
A
ScannerCameraCoordinatorclass to open the camera using AVFoudation and read barcodes using Dynamsoft Barcode Reader:@OptIn(ExperimentalForeignApi::class) class ScannerCameraCoordinator( val onScanned: (String) -> Unit ): AVCaptureVideoDataOutputSampleBufferDelegateProtocol, DBRLicenseVerificationListenerProtocol, NSObject() { private var previewLayer: AVCaptureVideoPreviewLayer? = null lateinit var captureSession: AVCaptureSession lateinit var barcodeReader: DynamsoftBarcodeReader var lastTime: Long = 0 @OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) fun prepare(layer: CALayer) { DynamsoftBarcodeReader.initLicense("LICENSE-KEY", this) barcodeReader = DynamsoftBarcodeReader() captureSession = AVCaptureSession() val device = AVCaptureDevice.defaultDeviceWithMediaType(AVMediaTypeVideo) if (device == null) { println("Device has no camera") return } println("Initializing video input") val videoInput = memScoped { val error: ObjCObjectVar<NSError?> = alloc<ObjCObjectVar<NSError?>>() val videoInput = AVCaptureDeviceInput(device = device, error = error.ptr) if (error.value != null) { println(error.value) null } else { videoInput } } println("Adding video input") if (videoInput != null && captureSession.canAddInput(videoInput)) { captureSession.addInput(videoInput) } else { println("Could not add input") return } val videoDataOutput = AVCaptureVideoDataOutput() println("Adding video output") if (captureSession.canAddOutput(videoDataOutput)) { captureSession.addOutput(videoDataOutput) val map = HashMap<Any?, Any>() map.put( platform.CoreVideo.kCVPixelBufferPixelFormatTypeKey, platform.CoreVideo.kCVPixelFormatType_32BGRA ) videoDataOutput.videoSettings = map videoDataOutput.setSampleBufferDelegate(this, queue = dispatch_get_main_queue()) } else { println("Could not add output") return } println("Adding preview layer") previewLayer = AVCaptureVideoPreviewLayer(session = captureSession).also { it.frame = layer.bounds it.videoGravity = AVLayerVideoGravityResizeAspectFill println("Set orientation") setCurrentOrientation(newOrientation = UIDevice.currentDevice.orientation) println("Adding sublayer") layer.bounds.useContents { println("Bounds: ${this.size.width}x${this.size.height}") } layer.frame.useContents { println("Frame: ${this.size.width}x${this.size.height}") } layer.addSublayer(it) } println("Launching capture session") GlobalScope.launch(Dispatchers.Default) { captureSession.startRunning() } } fun setCurrentOrientation(newOrientation: UIDeviceOrientation) { when (newOrientation) { UIDeviceOrientation.UIDeviceOrientationLandscapeLeft -> previewLayer?.connection?.videoOrientation = AVCaptureVideoOrientationLandscapeRight UIDeviceOrientation.UIDeviceOrientationLandscapeRight -> previewLayer?.connection?.videoOrientation = AVCaptureVideoOrientationLandscapeLeft UIDeviceOrientation.UIDeviceOrientationPortrait -> previewLayer?.connection?.videoOrientation = AVCaptureVideoOrientationPortrait UIDeviceOrientation.UIDeviceOrientationPortraitUpsideDown -> previewLayer?.connection?.videoOrientation = AVCaptureVideoOrientationPortraitUpsideDown else -> previewLayer?.connection?.videoOrientation = AVCaptureVideoOrientationPortrait } } override fun captureOutput( output: AVCaptureOutput, didOutputSampleBuffer: CMSampleBufferRef?, fromConnection: AVCaptureConnection ) { val interval = NSDate().timeIntervalSince1970*1000 - lastTime if (interval > 1000) { val imageBuffer: CVImageBufferRef? = CMSampleBufferGetImageBuffer(didOutputSampleBuffer) val ciImage = platform.CoreImage.CIImage(cVPixelBuffer = imageBuffer) val cgImage = CIContext().createCGImage(ciImage, ciImage.extent) var image = UIImage(cgImage) val result = barcodeReader.decodeImage(image, null) if (result != null) { if (result.isNotEmpty()) { val textResult: iTextResult = result[0] as iTextResult textResult.barcodeText?.let { onFound(it) } } } else { println("result is null") } lastTime = (NSDate().timeIntervalSince1970*1000).toLong() } } fun onFound(code: String) { onScanned(code) } fun setFrame(rect: CValue<CGRect>) { previewLayer?.setFrame(rect) } override fun DBRLicenseVerificationCallback(isSuccess: Boolean, error: NSError?) { println("LicenseVerificationCallback") println(isSuccess) } } -
A
UiScannerViewclass using the above classes together.@Composable fun UiScannerView( modifier: Modifier = Modifier, onScanned: (String) -> Unit ) { val coordinator = remember { ScannerCameraCoordinator( onScanned = onScanned ) } DisposableEffect(Unit) { val listener = OrientationListener { orientation -> coordinator.setCurrentOrientation(orientation) } listener.register() onDispose { listener.unregister() } } UIKitView<UIView>( modifier = modifier.fillMaxSize(), factory = { val previewContainer = ScannerPreviewView(coordinator) println("Calling prepare") coordinator.prepare(previewContainer.layer) previewContainer }, properties = UIKitInteropProperties( isInteractive = true, isNativeAccessibilityEnabled = true, ) ) }
-
-
Create a
Scanner.ios.ktfile to contain the implementation of the scanner component.@Composable actual fun Scanner( modifier: Modifier, onScanned: (String) -> Unit, ) { UiScannerView( modifier = modifier, onScanned = { onScanned(it) }, ) } @Composable actual fun rememberCameraPermissionState(): CameraPermissionState { return remember { IosMutableCameraPermissionState() } } abstract class MutableCameraPermissionState: CameraPermissionState { override var status: CameraPermissionStatus by mutableStateOf(getCameraPermissionStatus()) } class IosMutableCameraPermissionState: MutableCameraPermissionState() { override fun requestCameraPermission() { AVCaptureDevice.requestAccessForMediaType(AVMediaTypeVideo) { this.status = getCameraPermissionStatus() } } override fun goToSettings() { val appSettingsUrl = NSURL(string = UIApplicationOpenSettingsURLString) if (UIApplication.sharedApplication.canOpenURL(appSettingsUrl)) { UIApplication.sharedApplication.openURL(appSettingsUrl) } } } fun getCameraPermissionStatus(): CameraPermissionStatus { val authorizationStatus = AVCaptureDevice.authorizationStatusForMediaType(AVMediaTypeVideo) return if (authorizationStatus == AVAuthorizationStatusAuthorized) CameraPermissionStatus.Granted else CameraPermissionStatus.Denied }
Use the Scanner Component
In App.kt, use the component and display the barcode text.
@Composable
@Preview
fun App() {
MaterialTheme {
var showContent by remember { mutableStateOf(false) }
var barcodeResult by remember {mutableStateOf("")}
Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
Button(onClick = {
showContent = !showContent
if (showContent) {
barcodeResult = ""
}
}) {
Text("Toggle Scanner")
}
Text(barcodeResult)
if (showContent) {
val scope = rememberCoroutineScope()
ScannerWithPermissions(
modifier = Modifier.padding(16.dp),
onScanned = {
scope.launch {
println(it)
barcodeResult = it
}
},
)
}
}
}
}
All right, we’ve completed the demo.
Source Code
Check out the source code to have a try:
https://github.com/tony-xlh/Compose-Multiplatform-QR-Code-Scanner