Take Pictures with CameraX in Jetpack Compose

Take a picture using the device camera can be a difficult process to figure out when using XML. It is even more confusing when attempting to do so using Jetpack Compose.

This article will cover how to use CameraX in a Jetpack Compose project and display the image using Coil.

Requesting Permission

Start by adding the following camera permissions to the AndroidManifest.xml file.

<uses-feature android:name="android.hardware.camera.any"/>
<uses-permission android:name="android.permission.CAMERA"/>

Create a requestPermissionLauncher property in the MainActivity.kt file.

private val requestPermissionLauncher = registerForActivityResult(
    ActivityResultContracts.RequestPermission()
) { isGranted ->
    if (isGranted) {
        Log.i("kilo", "Permission granted")
    } else {
        Log.i("kilo", "Permission denied")
    }
}

The block of this function will pass a Boolean that specifies whether the request permission was granted.

Create a function that will request the camera permission.

private fun requestCameraPermission() {
    when {
        ContextCompat.checkSelfPermission(
            this,
            Manifest.permission.CAMERA
        ) == PackageManager.PERMISSION_GRANTED -> {
            Log.i("kilo", "Permission previously granted")
        }

        ActivityCompat.shouldShowRequestPermissionRationale(
            this,
            Manifest.permission.CAMERA
        ) -> Log.i("kilo", "Show camera permissions dialog")

        else -> requestPermissionLauncher.launch(Manifest.permission.CAMERA)
    }
}

The when statement will determine which step the user should be directed through by checking the camera permission from the manifest.

Make sure to call the function.

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
        Text(text = "Like and subscribe")
    }

    requestCameraPermission()
}

Build and run to see the OS prompt to allow camera permissions. Depending on the clicked choice, you will see the different statements logged in the console.

Showing the Camera

Add the following dependencies in the app build.gradle file.

// CameraX
def camerax_version = "1.0.1"
implementation "androidx.camera:camera-camera2:$camerax_version"
implementation "androidx.camera:camera-lifecycle:$camerax_version"
implementation "androidx.camera:camera-view:1.0.0-alpha27"

// Icons
implementation "androidx.compose.material:material-icons-extended:$compose_version"

The CameraX dependencies are self explanatory. The Icons dependency is optional, but it will be used to create the image capture button.

In a new file named CameraView.kt, create a function that is responsible for capture the photo.

private fun takePhoto(
    filenameFormat: String,
    imageCapture: ImageCapture,
    outputDirectory: File,
    executor: Executor,
    onImageCaptured: (Uri) -> Unit,
    onError: (ImageCaptureException) -> Unit
) {

    val photoFile = File(
        outputDirectory,
        SimpleDateFormat(filenameFormat, Locale.US).format(System.currentTimeMillis()) + ".jpg"
    )

    val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build()

    imageCapture.takePicture(outputOptions, executor, object: ImageCapture.OnImageSavedCallback {
        override fun onError(exception: ImageCaptureException) {
            Log.e("kilo", "Take photo error:", exception)
            onError(exception)
        }

        override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
            val savedUri = Uri.fromFile(photoFile)
            onImageCaptured(savedUri)
        }
    })
}

The arguments passed to the takePhoto function will be used to create an output file, which is then used to build the outputOptions. The ImageCapture object is then used to call takePicture which then passes the ImageCaptureException or image Uri through the callback of the takePhoto function.

Before creating the composable for the CameraView, add the following method to Context:

private suspend fun Context.getCameraProvider(): ProcessCameraProvider = suspendCoroutine { continuation ->
    ProcessCameraProvider.getInstance(this).also { cameraProvider ->
        cameraProvider.addListener({
            continuation.resume(cameraProvider.get())
        }, ContextCompat.getMainExecutor(this))
    }
}

This makes it easier to get the ProcessCameraProvider which is an asynchronous process that needs to be suspended.

Now create the composable:

@Composable
fun CameraView(
    outputDirectory: File,
    executor: Executor,
    onImageCaptured: (Uri) -> Unit,
    onError: (ImageCaptureException) -> Unit
) {
    // 1
    val lensFacing = CameraSelector.LENS_FACING_BACK
    val context = LocalContext.current
    val lifecycleOwner = LocalLifecycleOwner.current

    val preview = Preview.Builder().build()
    val previewView = remember { PreviewView(context) }
    val imageCapture: ImageCapture = remember { ImageCapture.Builder().build() }
    val cameraSelector = CameraSelector.Builder()
        .requireLensFacing(lensFacing)
        .build()

    // 2
    LaunchedEffect(lensFacing) {
        val cameraProvider = context.getCameraProvider()
        cameraProvider.unbindAll()
        cameraProvider.bindToLifecycle(
            lifecycleOwner,
            cameraSelector,
            preview,
            imageCapture
        )

        preview.setSurfaceProvider(previewView.surfaceProvider)
    }

    // 3
    Box(contentAlignment = Alignment.BottomCenter, modifier = Modifier.fillMaxSize()) {
        AndroidView({ previewView }, modifier = Modifier.fillMaxSize())

        IconButton(
            modifier = Modifier.padding(bottom = 20.dp),
            onClick = {
                Log.i("kilo", "ON CLICK")
                takePhoto(
                    filenameFormat = "yyyy-MM-dd-HH-mm-ss-SSS",
                    imageCapture = imageCapture,
                    outputDirectory = outputDirectory,
                    executor = executor,
                    onImageCaptured = onImageCaptured,
                    onError = onError
                )
            },
            content = {
                Icon(
                    imageVector = Icons.Sharp.Lens,
                    contentDescription = "Take picture",
                    tint = Color.White,
                    modifier = Modifier
                        .size(100.dp)
                        .padding(1.dp)
                        .border(1.dp, Color.White, CircleShape)
                )
            }
        )
    }
}
  1. This is where all the setup is taking place; making sure to keep things like previewView and imageCapture in a remembered state, allowing them to recompose as needed.
  2. Launched allows the getCameraProvider function to be called and await a the result, allowing the ProcessCameraProvider to be bound to the lifecycle and the preview to use the previewView as a surface provider.
  3. Lastly is the UI of the camera view, making the preview take up the whole screen and a button that can be used to call takePhoto.

Navigate back to the MainActivity.kt file and add the following members:

private lateinit var outputDirectory: File
private lateinit var cameraExecutor: ExecutorService

private var shouldShowCamera: MutableState<Boolean> = mutableStateOf(false)

...

private fun handleImageCapture(uri: Uri) {
    Log.i("kilo", "Image captured: $uri")
    shouldShowCamera.value = false
}

private fun getOutputDirectory(): File {
    val mediaDir = externalMediaDirs.firstOrNull()?.let {
        File(it, resources.getString(R.string.app_name)).apply { mkdirs() }
    }

    return if (mediaDir != null && mediaDir.exists()) mediaDir else filesDir
}

override fun onDestroy() {
    super.onDestroy()
    cameraExecutor.shutdown()
}

The focus here is on keeping the output directory, camera executor, and should show camera boolean in memory. The functions help manage those properties.

Add the CameraView to the setContent method in onCreate along with setting values for outputDirectory and cameraExecutor:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
        if (shouldShowCamera.value) {
            CameraView(
                outputDirectory = outputDirectory,
                executor = cameraExecutor,
                onImageCaptured = ::handleImageCapture,
                onError = { Log.e("kilo", "View error:", it) }
            )
        }
    }

    requestCameraPermission()

    outputDirectory = getOutputDirectory()
    cameraExecutor = Executors.newSingleThreadExecutor()
}

The CameraView will only be shown if shouldShowCamera has a value of true. By default it is false and needs to be updated when the camera permission is granted.

private val requestPermissionLauncher = registerForActivityResult(
    ActivityResultContracts.RequestPermission()
) { isGranted ->
    if (isGranted) {
        Log.i("kilo", "Permission granted")
        shouldShowCamera.value = true // 👈🏽
        
 ...

private fun requestCameraPermission() {
    when {
        ContextCompat.checkSelfPermission(
            this,
            Manifest.permission.CAMERA
        ) == PackageManager.PERMISSION_GRANTED -> {
            Log.i("kilo", "Permission previously granted")
            shouldShowCamera.value = true // 👈🏽
        }
        ....

Whether the camera permission was granted on the initial OS prompt or was previously granted, the shouldShowCamera.value is updated so the camera preview will be shown on the screen. Handling denied permission should also be handled accordingly.

Build and run to see the camera preview show on screen and log the Uri of the photo.

Showing the Photo

Add the following line to the app build.gradle file to make Coil a dependency of the project.

// Coil
implementation "io.coil-kt:coil-compose:1.4.0"

Back in the MainActivity.kt file, add the following properties:

private lateinit var photoUri: Uri
private var shouldShowPhoto: MutableState<Boolean> = mutableStateOf(false)

The photoUri will be stored in memory so it can be used to display the image. shouldShowPhoto works like shouldShowCamera where it will be the Boolean responsible for displaying the photo once it is captured.

In the handleImageCapture method, add the following lines to the bottom of the function.

photoUri = uri
shouldShowPhoto.value = true

In the setContent, below the code that shows the CameraView add the following snippet.

if (shouldShowPhoto.value) {
    Image(
        painter = rememberImagePainter(photoUri),
        contentDescription = null,
        modifier = Modifier.fillMaxSize()
    )
}

The image will now be shown when shouldShowPhoto has been updated to true

Build and run to see your fully functioning camera app 🎉