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.

wizard

Declare Camera Permission

  1. For Android, add the following to AndroidManifest.xml:

    <uses-permission android:name="android.permission.CAMERA" />
    
  2. 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.

  1. 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" }
    
  2. 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.

  1. Add the Kotlin CocoaPods Gradle plugin.

    1. Add the following to libs.versions.toml:

      [versions]
      kotlinCocoapods = { id = "org.jetbrains.kotlin.native.cocoapods", version.ref = "kotlin" }
      
    2. Add the following alias to your root folder’s build.gradle.kts file:

      plugins {
          alias(libs.plugins.kotlinCocoapods) apply false
      }
      
    3. Also add the following to the app’s build.gradle.kts file:

      plugins {
          alias(libs.plugins.kotlinCocoapods)
      }
      
  2. 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

  1. Add CameraPermissionState.kt for camera permission states.

    interface CameraPermissionState {
        val status: CameraPermissionStatus
        fun requestCameraPermission()
        fun goToSettings()
    }
    
    @Composable
    expect fun rememberCameraPermissionState(): CameraPermissionState
    
    enum class CameraPermissionStatus {
        Denied, Granted
    }
    
  2. Define the scanner component which has a callback to return the scanned QR code.

     @Composable
     expect fun Scanner(
         modifier: Modifier = Modifier,
         onScanned: (String) -> Unit,
     )
    
  3. Define the ScannerWithPermissions component 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

  1. 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()
            }
        }
    }
    
  2. Add a BarcodeAnalyzer.kt file 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()
        }
    }
    
  3. Add a ScannerView.kt file 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
            }
        )
    }
    
  4. Add a Scanner.android.kt file 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

  1. Create a OrientationListener.kt file 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
            )
        }
    }
    
  2. Create a new Scanner.kt file to hold several classes for the scanner.

    1. A ScannerPreviewView class 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()
          }
      }
      
    2. A ScannerCameraCoordinator class 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)
          }
      }
      
    3. A UiScannerView class 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,
              )
          )
      }
      
  3. Create a Scanner.ios.kt file 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