Blog

Lens & Layouts: Camera Integration in Compose

On Friday, Jan 17, 2025
post image

As Seen In - jetc.dev Newsletter Issue #250

Introduction

Social media and visual content increase has made camera functionality essential in modern Android applications. From photo-sharing platforms to augmented reality experiences, camera integration has evolved beyond simple photo capture. With Jetpack Compose’s declarative UI paradigm, Android developers now have a more intuitive way to implement camera features, simplifying what was historically a complex integration. This guide explores leveraging CameraX and Compose to build robust camera experiences in your Android applications.

Let’s tackle a practical scenario that encompasses the core camera functionalities many apps require. We’ll build an example application that demonstrates essential camera features: runtime permission handling, and photo capture implementation. This example serves as a foundation that you can extend to your specific use cases, whether you’re building a social media app, a document scanner, or any application requiring camera integration.

Project Setup

Required Dependencies

For this example, we’ll focus on the CameraX API, a Jetpack library that simplifies camera integration while leveraging the power of the Camera2 framework. CameraX provides balance between ease of use and advanced functionality, allowing developers to implement complex and highly customized use cases with significantly less boilerplate code compared to Camera2. It abstracts much of the low-level complexity while still supporting advanced features like focus, exposure, and image capture.

In addition to handling camera functionality, managing runtime permissions is essential for camera integration. Permissions are a key part of the user experience and must be implemented properly to avoid crashes or denied functionality. To streamline permission handling in a Jetpack Compose app, we recommend using the Accompanist Permissions library.

Let’s add the libraries to the libs.versions.toml file:


[versions]

# Accompanist Permissions
accompanistPermissions = "0.37.0"

# Camera Core
cameraCore = "1.4.1"

[libraries]
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" }

androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "cameraCore" }
androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "cameraCore" }
androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "cameraCore" }
androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = "cameraCore" }

[bundles]
androidx-camera = ["androidx-camera-core", "androidx-camera-camera2", "androidx-camera-lifecycle", "androidx-camera-view"]

The androidx-camera bundle was created to group all the camera libraries for a single, streamlined import into the build.gradle file

Now, let’s add the dependencies to the app-level build.gradle file.


dependencies {
    //...
    implementation(libs.accompanist.permissions)
    implementation(libs.bundles.androidx.camera)
}

ℹ️ For most use cases, the CameraX library is the recommended choice, as it simplifies camera development with an intuitive API and backward compatibility with Android 5.0 (API level 21) and higher. CameraX is built on top of Camera2, providing the flexibility of modern camera features while abstracting away much of the complexity. Key features of CameraX include lifecycle-aware components, easy access to camera functionality, and support for use cases like preview, image capture, and analysis.

AndroidManifest configuration

Before we dive into the implementation, it’s essential to configure the AndroidManifest.xml to ensure the app has the required permissions and features for camera functionality.

Add the following entries to the AndroidManifest.xml file:


<!--    Permissions-->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />

<uses-feature
    android:name="android.hardware.camera.any"
    android:required="false" />

The <uses-feature> tag specifies that the app uses the device’s camera hardware. By setting android:required="false", the app can still be installed on devices without a camera, ensuring broader compatibility.

Permission Handling

Managing permissions in Jetpack Compose involves both declarative UI and reactive permission state management. In this example, we’ll leverage rememberPermissionState to observe permission status and rememberLauncherForActivityResult to handle permission requests. This approach allows seamless integration of permissions with navigation and error handling in your app.

Runtime Permission Request

To use the camera, the app must request the CAMERA permission at runtime. In Compose, the Accompanist Permissions library provides a simple, declarative API to handle permission requests. Here’s an example of how to request the camera permission:


import android.Manifest
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState

@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun UserPhotosScreen(
    onNavigate: (NavigationEvent) -> Unit,
    viewModel: UserPhotosScreenViewModel,
) {
    val uiState by viewModel.uiState.collectAsState()
    val permissionState = rememberPermissionState(
        permission = Manifest.permission.CAMERA
    )

    val requestPermissionLauncher = rememberLauncherForActivityResult(
        ActivityResultContracts.RequestPermission()
    ) { isGranted ->
        if (isGranted) {
            // Navigate to the next screen if permission is granted
            onNavigate(NavigationEvent.OnNavigateToAddPhoto)
        } else {
            // Handle permission denial with an error event
            viewModel.handleCameraPermissionError(
                "Camera permission is required to use this feature.")
            )
        }
    }

    // Monitor permission status and update UI state
    LaunchedEffect(Unit) {
        viewModel.setCameraPermission(permissionState.status.isGranted)
    }

    AppLayout(
        floatingActionButton = {
            FloatingActionButton(onClick = {
                if (uiState.showCamera && uiState.hasCameraPermission) {
                        onNavigate(NavigationEvent.OnNavigateToAddPhoto)
                } else {
                    // Trigger permission request if needed
                    requestPermissionLauncher.launch(Manifest.permission.CAMERA)
                }
            })
        }
    ) {
        // Composable content
    }
}

Explanation

  1. Permission State Management. rememberPermissionState tracks the status of the camera permission. It simplifies observing and reacting to changes in permission state directly within the Compose lifecycle.

    • permissionState.status.isGranted checks if the permission is granted.
  2. Permission Request Launcher. rememberLauncherForActivityResult is used to request the camera permission.

    • On success (isGranted == true), it navigates to the photo capture screen using onNavigate(NavigationEvent.OnNavigateToAddPhoto).
    • On failure, it triggers an error event with a localized message.
  3. Effect Handling with LaunchedEffect. A LaunchedEffect monitors and initializes the camera permission state by dispatching an event. This ensures your app’s state is always in sync with the permission status.

  4. Handling Permission Denial. When the user denies permission, the code sends a clear error message using UserPhotosEvents.HandleCameraPermissionError, allowing the app to display feedback or take further action.

Building the Camera Interface

Creating an intuitive and functional camera interface is a crucial part of the user experience in any camera-based application. With Jetpack Compose, we can create highly customizable and interactive UI components while integrating seamlessly with the underlying camera functionalities.

The CameraView Composable

The CameraView composable is a reusable UI component that provides a camera preview interface, along with controls for flash, back navigation, and an optional action button. It ensures the camera view is displayed correctly and incorporates design elements for usability.

Key Features:

  1. Camera Preview: Utilizes the AndroidView composable to embed the PreviewView for camera output.
  2. Grid Overlay: Includes a grid layer to assist users with alignment.
  3. Action Buttons:
    • A close button for navigation.
    • A flash toggle button for enabling/disabling the camera flash.
    • A customizable action button for additional functionality, such as capturing a photo.

Here’s the implementation of the CameraView:


import androidx.camera.view.PreviewView
import androidx.compose.ui.viewinterop.AndroidView
// more imports

@Composable
fun CameraView(
    previewView: PreviewView,
    flashEnabled: Boolean,
    onFlashIconClick: () -> Unit = {},
    goBack: () -> Unit = {},
    applyActionButton: (@Composable BoxScope.() -> Unit)? = null,
) {
    Box(
        modifier = Modifier
            .fillMaxSize()
            .systemBarsPadding()
            .background(Color.Black)
    ) {
        AndroidView(
            factory = { previewView },
            modifier = Modifier.fillMaxSize(),
            onRelease = {}
        )
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .height(56.dp)
                .align(Alignment.TopCenter)
                .background(Color.Black.copy(alpha = 0.7f))
        )

        // Grid Layer - positioned between top and bottom bars
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .align(Alignment.TopCenter)
                .padding(top = 56.dp)
                .height(with(LocalDensity.current) {
                    LocalConfiguration.current.screenHeightDp.dp - 56.dp - 100.dp
                })
        ) {
            CameraGrid() //custom grid layer to assist users with alignment
        }

        Box(
            modifier = Modifier
                .fillMaxWidth()
                .height(100.dp)
                .align(Alignment.BottomCenter)
                .background(Color.Black.copy(alpha = 0.7f))
                .padding(16.dp),
        ) {
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceEvenly,
                verticalAlignment = Alignment.CenterVertically
            ) {
                FloatingActionButton(
                    modifier = Modifier
                        .padding(start = 16.dp)
                        .size(40.dp),
                    contentColor = colorResource(R.color.pale_sky_blue),
                    containerColor = colorResource(R.color.charcoal_gray),
                    shape = RoundedCornerShape(10.dp),
                    onClick = goBack,
                ) {
                    Icon(
                        painter = painterResource(id = R.drawable.close),
                        contentDescription = null,
                    )
                }

                Box(
                    modifier = Modifier.weight(1f),
                    contentAlignment = Alignment.Center
                ) {
                    applyActionButton?.invoke(this@Box)
                }

                FloatingActionButton(
                    modifier = Modifier.size(40.dp),
                    contentColor = colorResource(R.color.pale_sky_blue),
                    containerColor = colorResource(R.color.charcoal_gray),
                    shape = RoundedCornerShape(10.dp),
                    onClick = onFlashIconClick,
                ) {
                    Icon(
                        painter = painterResource(
                            id = if (flashEnabled) R.drawable.flashlight_on
                            else R.drawable.flashlight_off
                        ),
                        contentDescription = null,
                    )
                }
            }
        }
    }
}

This composable serves as the base UI for your camera interface. The parameters allow flexibility to adapt it for various use cases, like capturing photos or videos.

The UserCameraView Composable

The UserCameraView composable integrates the CameraView UI with CameraX APIs to handle camera lifecycle, flash mode, and photo capture. It demonstrates how to set up the camera pipeline and bind the camera preview to the lifecycle owner.

Key Features:

  1. CameraX Integration:
    • Configures the camera with CameraSelector, Preview, and ImageCapture.
    • Uses ProcessCameraProvider to bind the camera lifecycle to the current composable’s lifecycle owner.
  2. Flash Mode Handling:
    • Toggles between flash modes using the setFlashMode callback.
  3. Photo Capture:
    • Captures images with the takePhoto function, which interacts with the ImageCapture use case.

Here’s the implementation of the UserCameraView:


import android.net.Uri
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCapture.FLASH_MODE_ON
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
// more imports
import java.util.concurrent.Executor

@Composable
fun UserCameraView(
    executor: Executor,
    onGoBack: () -> Unit,
    flashMode: Int,
    setFlashMode: () -> Unit,
    imageCapture: ImageCapture,
    takePhoto: (File, Executor, ImageCapture) -> Unit
) {
    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 cameraSelector = CameraSelector.Builder().requireLensFacing(lensFacing).build()

    val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) }
    val cameraProvider = cameraProviderFuture.get()

    val photoDirectory: ()-> File = {
        val mediaDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
        File(mediaDir, context.getString(R.string.app_name)).apply { mkdirs() }
        if (mediaDir != null && mediaDir.exists()) mediaDir else context.filesDir
    }

    LaunchedEffect(lensFacing, flashMode) {
        lifecycleOwner.withStateAtLeast(Lifecycle.State.RESUMED) {
            cameraProviderFuture.addListener({
                cameraProvider.unbindAll()
                cameraProvider.bindToLifecycle(
                    lifecycleOwner, cameraSelector, preview, imageCapture
                )
            }, context.mainExecutor)
            preview.surfaceProvider = previewView.surfaceProvider
        }
    }

    CameraView(
        previewView = previewView,
        flashEnabled = flashMode == FLASH_MODE_ON,
        onFlashIconClick = setFlashMode,
        goBack = onGoBack,
        applyActionButton = {
            IconButton(
                modifier = Modifier.align(Alignment.Center),
                onClick = {
                    takePhoto(photoDirectory, executor, imageCapture)
                }
            ) {
                Icon(
                    painter = painterResource(id = R.drawable.take_photo_button),
                    contentDescription = null,
                    tint = Color.White
                )
            }
        }
    )
}

Lifecycle and State Management

  • Camera Lifecycle: LaunchedEffect ensures that the camera provider is properly initialized and bound to the lifecycle owner whenever the composable is active.
  • State Handling: Reacts dynamically to changes in flashMode and lensFacing to update the camera configuration.

The AddPhotoScreen Composable

The AddPhotoScreen composable acts as the entry point for the photo-taking feature. It combines permissions, navigation, and camera UI to create a cohesive flow.

Key Features

  1. Permission Handling The cameraState manages the camera permission status. If the permission is granted and the uiState.showCamera flag is true, the UserCameraView is displayed. Otherwise, the app remains in a different state (e.g., awaiting user action).

  2. Camera View Integration The UserCameraView composable is conditionally rendered when:

    • The cameraState.status.isGranted is true.
    • The uiState.showCamera flag is set, indicating the user wants to access the camera.
  3. Preview Navigation When state.showPreview is true, navigation is triggered to the preview screen with the captured photo’s URI.


import android.Manifest
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors

@OptIn(ExperimentalPermissionsApi::class)
@SuppressLint("UnrememberedMutableState")
@Composable
fun AddPhotoScreen(
    onNavigate: (NavigationEvent) -> Unit,
    viewModel: AddPhotoScreenViewModel,
) {
    val uiState by viewModel.uiState.collectAsState()
    val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor()
    val cameraState = rememberPermissionState(permission = Manifest.permission.CAMERA)

    if (cameraState.status.isGranted && uiState.showCamera) {
        UserCameraView(
            executor = cameraExecutor,
            onImageCaptured = { viewModel.onImageCaptured(it) },
            onGoBack = { onNavigate(NavigationEvent.OnNavigateUp) },
            flashMode = uiState.flashMode,
            setFlashMode = { viewModel.onFlashModeChanged },
            imageCapture = uiState.imageCapture,
            takePhoto = { photoDirectory, executor, imageCapture ->
                viewModel.takePhoto(photoDirectory, executor, imageCapture)
            }
        )
    }

    if (uiState.showPreview) {
        onNavigate(NavigationEvent.OnNavigateToPhotoPreview(uiState.photoUri.toString()))
    }
}

Explanation of Components

  1. Permission State The rememberPermissionState composable handles runtime camera permissions.

    • If permission is denied, the camera interface is not shown, and you can guide users to enable permissions.
  2. Executor Management A SingleThreadExecutor is used to manage background tasks like photo capturing, ensuring smooth UI performance.

  3. State Management

    • uiState.showCamera: Determines whether the camera view is active.
    • uiState.showPreview: Indicates when to navigate to the preview screen.
    • uiState.flashMode: Tracks the current flash state and allows toggling.
    • uiState.imageCapture: Passes the ImageCapture instance for capturing photos.

Photo Management

Handling Photo Capture

Capturing a photo is a central feature of any camera-enabled application. In this section, we’ll delve into how to manage the photo capture process using a ViewModel. We’ll explore the AddPhotoScreenState data class, the takePhoto function, and how these components interact to handle image capture, manage state, and navigate the user through the photo-taking workflow.

State Management

Managing UI state effectively ensures a responsive and robust user experience. The AddPhotoScreenState data class encapsulates all the necessary state information for the photo capture process.

AddPhotoScreenState Data Class


import android.net.Uri
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCapture.FLASH_MODE_OFF

data class AddPhotoScreenState(
    val isLoading: Boolean = false,
    val showCamera: Boolean = true,
    val showPreview: Boolean = false,
    val photoUri: Uri? = null,
    val flashMode: Int = FLASH_MODE_OFF,
    val imageCapture: ImageCapture = ImageCapture.Builder().setFlashMode(FLASH_MODE_OFF).build(),
    val error: String? = null,
)

This structured state management allows the UI to react seamlessly to changes, such as displaying a loading indicator during photo capture or navigating to a preview screen once a photo is taken.

Implementing functionality to take the photo

The take photo functionality orchestrates capturing an image, saving it to storage, and updating the state accordingly. Here’s how it’s implemented:


import android.net.Uri
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException
import java.io.File
import java.util.concurrent.Executor

class AddPhotoViewModel() : ViewModel() {
    private val viewModelState = MutableStateFlow(AddPhotoScreenState())
    val uiState = viewModelState.asStateFlow()

    fun takePhoto(
        photoDirectory: File,
        executor: Executor,
        imageCapture: ImageCapture,
    ) {
        val photoFile = File(
            photoDirectory,
            "${System.currentTimeMillis()}.jpg"
        )

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

        imageCapture.takePicture(
            outputOptions,
            executor,
            object : ImageCapture.OnImageSavedCallback {
                override fun onError(exception: ImageCaptureException) {
                    viewModelState.update {
                        it.copy(exception = TakingPictureException())
                    }
                }

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

    private fun handleImageCapture(uri: Uri){
        viewModelState.update {
            it.copy(
                shouldShowCamera = false,
                shouldShowPhoto = true,
                photoUri = uri
            )
        }
    }
}

Explanation

  1. File Creation: A File object is created to store the captured photo. The filename incorporates the timestamp to ensure uniqueness.

  2. Output Options: ImageCapture.OutputFileOptions defines where and how the image will be saved.

  3. Photo Capture: The takePicture method of ImageCapture initiates the photo capture process. It requires the output options, an executor for background processing, and a callback to handle success or failure.

  4. Error Handling: If an error occurs during photo capture, it is logged, and the ViewModel’s state is updated with an error message to inform the user.

  5. Success Handling: Upon successful photo capture, the saved file is converted to a URI, and the onImageCaptured callback is invoked with this URI.

Preview Section

Domain models

First of all, to support different image sources such as remote, local, or temporary, let’s add a couple of sealed classes to represent the expected behavior and inherit it to the different domain models:


// Base Photo model
sealed class BasePhoto(
    open val id: Long,
    open val title: String,
    open val imageSource: ImageSource
)

sealed class ImageSource {
    data class Remote(val thumbnailUrl: String, val url: String) : ImageSource()
    data class Local(val path: String) : ImageSource()
    data class Temporary(val uri: Uri) : ImageSource()
}

// Domain Models
@Immutable
data class LocalPhoto(
    override val id: Long,
    override val title: String,
    val path: String,
    val size: Long,
    val lastModified: Long,
) : BasePhoto(
    id = id,
    title = title,
    imageSource = ImageSource.Local(path)
)

@Immutable
data class RemotePhoto(
    override val id: Long,
    override val title: String,
    val thumbnailUrl: String,
    val url: String,
    val album: String,
) : BasePhoto(
    id = id,
    title = title,
    imageSource = ImageSource.Remote(thumbnailUrl, url)
)

@Immutable
data class TemporaryPhoto(
    override val id: Long = System.currentTimeMillis(),
    override val title: String = "",
    val uri: Uri
) : BasePhoto(
    id = id,
    title = title,
    imageSource = ImageSource.Temporary(uri)
)

The ImagePreview Composable

With the ImagePreview composable, we can display an image from various sources. It also manages user interaction through action buttons and gracefully handles loading states or potential errors. Here’s how it’s implemented:


// more imports
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import coil3.compose.rememberAsyncImagePainter
import coil3.request.ImageRequest
import coil3.request.crossfade

@Composable
fun ImagePreview(
    photo: BasePhoto,
    isLoading: Boolean = false,
    error: String? = null,
    clearError: () -> Unit = {},
    onCloseAction: () -> Unit = {},
    leftSideButton: Pair<String, () -> Unit>? = null,
    rightSideButton: Pair<String, () -> Unit>? = null,
) {
    val screenHeight = LocalConfiguration.current.screenHeightDp
    val context = LocalContext.current
    Box(
        modifier = Modifier
            .systemBarsPadding()
            .background(Color.Black)
    ) {
        if (isLoading) {
            ResourceLoading()
        }
        if (error != null) {
            InfoDialog(
                onClick = clearError,
                onDismissClick = clearError,
                message = error,
            )
        }
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .padding(start = 15.dp)
                .height((screenHeight * 0.2).dp)
                .background(Color.Black),
        ) {
            FloatingActionButton(
                modifier = Modifier
                    .align(Alignment.CenterStart)
                    .height(40.dp)
                    .width(40.dp),
                contentColor = colorResource(R.color.pale_sky_blue),
                containerColor = colorResource(R.color.charcoal_gray),
                shape = RoundedCornerShape(10.dp),
                onClick = onCloseAction
            ) {
                Icon(
                    painter = painterResource(id = R.drawable.close),
                    contentDescription = null,
                )
            }
        }
        when (val source = photo.imageSource) {
            is ImageSource.Remote -> {
                val painter = rememberAsyncImagePainter(source.url)
                val painterState by painter.state.collectAsState()
                when (painterState) {
                    is AsyncImagePainter.State.Empty -> {}
                    is AsyncImagePainter.State.Loading -> {
                        CircularProgressIndicator(
                            modifier = Modifier.width(42.dp).align(Alignment.Center),
                            color = MaterialTheme.colorScheme.secondary,
                            trackColor = MaterialTheme.colorScheme.surfaceVariant,
                        )
                    }

                    is AsyncImagePainter.State.Error -> {
                        Text(
                            text = stringResource(id = R.string.details_album_screen_unable_load_photo),
                            modifier = Modifier.padding(16.dp),
                            textAlign = TextAlign.Center,
                            fontWeight = FontWeight.SemiBold
                        )
                    }

                    is AsyncImagePainter.State.Success -> {
                        Image(
                            painter = painter,
                            contentDescription = photo.title,
                            modifier = Modifier
                                .matchParentSize()
                        )
                    }
                }
            }

            is ImageSource.Local -> {
                AsyncImage(
                    modifier = Modifier
                        .fillMaxWidth()
                        .height((screenHeight * 0.5).dp)
                        .align(Alignment.Center),
                    model = ImageRequest.Builder(LocalContext.current).data(source.path)
                        .crossfade(true)
                        .build(),
                    contentDescription = null,
                    contentScale = ContentScale.Inside,
                )
            }

            is ImageSource.Temporary -> {
                AsyncImage(
                    modifier = Modifier
                        .fillMaxWidth()
                        .height((screenHeight * 0.5).dp)
                        .align(Alignment.Center),
                    model = ImageRequest.Builder(context)
                        .data(source.uri)
                        .crossfade(true)
                        .build(),
                    contentDescription = null,
                    contentScale = ContentScale.Inside,
                )
            }
        }

        // Actions buttons
        Box(
            modifier = Modifier
                .fillMaxSize()
                .systemBarsPadding(),
            contentAlignment = Alignment.BottomCenter
        ) {
            Row(
                modifier = Modifier
                    .fillMaxWidth()
                    .height((screenHeight * 0.12).dp)
                    .background(colorResource(id = R.color.charcoal_gray)),
                horizontalArrangement = Arrangement.SpaceBetween,
            ) {
                leftSideButton?.let {
                    TextButton(
                        modifier = Modifier.padding(
                            top = (screenHeight * 0.02).dp, start = 18.dp
                        ), colors = ButtonDefaults.buttonColors(
                            contentColor = colorResource(id = R.color.soft_lavender_blue),
                            containerColor = colorResource(
                                id = R.color.charcoal_gray
                            )
                        ), onClick = it.second
                    ) {
                        Text(
                            text = it.first,
                            style = MaterialTheme.typography.labelLarge
                        )
                    }
                }

                rightSideButton?.let {
                    TextButton(
                        modifier = Modifier.padding(top = (screenHeight * 0.02).dp, end = 18.dp),
                        colors = ButtonDefaults.buttonColors(
                            contentColor = colorResource(id = R.color.soft_lavender_blue),
                            containerColor = colorResource(
                                id = R.color.charcoal_gray
                            )
                        ),
                        onClick = it.second
                    ) {
                        Text(
                            text = it.first,
                            style = MaterialTheme.typography.labelLarge
                        )
                    }
                }
            }
        }
    }
}

The coil3 dependency is used to handle the image preview feat.

The PhotoPreviewScreen Composable

The PhotoPreviewScreen allows users to review the photo they’ve just captured and decide their next action—whether to confirm, retake, or discard the image. Leveraging the previously implemented ImagePreview composable, this screen integrates tightly with the app’s navigation and state management, ensuring a seamless user experience.


@Composable
fun PhotoPreviewScreen(
    uriString: String,
    onNavigate: (NavigationEvent) -> Unit,
    viewModel: PhotoPreviewScreenViewModel,
) {
    val uiState by viewModel.uiState.collectAsState()
    val photoUri = uriString.takeIf { it.isNotEmpty() }?.let { Uri.parse(it) }
    LaunchedEffect(key1 = uriString) {
        viewModel.loadPhotoUri(uriString)
    }

    if (uiState.isPhotoSaved) {
        LaunchedEffect(Unit) {
            onNavigate(NavigationEvent.OnNavigateToUserPhotos)
        }
    }

    ImagePreview(
        photo = TemporaryPhoto(uri = photoUri),
        isLoading = uiState.isLoading,
        error = uiState.error,
        clearError = { viewModel.clearError() },
        onCloseAction = {
            photoUri.path?.let { path ->
                viewModel.removePreviewAndRedirect(File(path))
            }
        },
        leftSideButton = Pair(
            stringResource(id = R.string.user_photos_screen_retake_photo)) {
            photoUri.path?.let { path ->
                viewModel.removePreviewAndRedirect(File(path))
            }
        },
        rightSideButton = Pair(stringResource(id = R.string.user_photos_screen_confirm)) {
            viewModel.persistUserPhoto()
        }
    )
}

To support actions for persisting or removing data, various approaches can be taken based on the chosen persistence method (e.g., Room database, SQLite) and the specific requirements of the app.

Wrapping Up

Integrating camera functionality into modern Android applications goes beyond simply adding a feature—it’s about delivering an intuitive and cohesive user experience that aligns with the app’s objectives. This guide has illustrated how to leverage Jetpack Compose and CameraX to streamline what has traditionally been a complex process. By adopting a declarative approach, we’ve explored managing runtime permissions, building user-centric interfaces, and implementing dynamic features for photo capture and preview.

The combination of CameraX’s advanced capabilities and Compose’s UI flexibility allows developers to create highly interactive camera experiences with minimal boilerplate code. Whether it’s a social media app requiring photo sharing, a document scanner with precise capture needs, or an augmented reality tool leveraging advanced camera features, these techniques provide a solid foundation.

As we conclude, it’s important to consider how these building blocks can be tailored to meet specific app requirements, including storage solutions, error handling, and customization. By extending these principles, you can create camera experiences that are both technically robust and delightful for users.

References

Share this post: