Blog Post

Opening a PDF in a Jetpack Compose Application

Illustration: Opening a PDF in a Jetpack Compose Application

Jetpack Compose is Google’s newest declarative UI framework. It’s built from the ground up to be expressive and elegant and to empower engineers to develop UIs effortlessly without sacrificing quality of code. Earlier this year, Google announced that Jetpack Compose was mature enough to be considered in beta, and some companies are already starting to rewrite parts of their codebase to include it. It’s only a matter of time before Compose starts to become the default way of building new applications on Android (and, quite possibly, beyond).

In this blog post, you’ll learn how to integrate PSPDFKit for Android with your Jetpack Compose application. We’ll look at how to create a @Composable function that previews a document’s thumbnail and opens the document when tapped. In case you want to dive into the code, you can check it out in this repo. Even though it’s by no means what can be considered production-ready code, there are some good practices baked into it that can potentially be useful for real-world scenarios.

Getting Started

Before we can start building our app, we need to have Jetpack Compose and PSPDFKit for Android configured. If you already have these two in place, you can skip to the next section.

The easiest way to get started with a Compose project is to use Android Studio’s dedicated template. It’ll add the necessary dependencies and configure the Gradle files for us. So, if you’re creating a project from scratch, this is the first step you should take.

Android Studio template selection modal

Once you have your project with Compose set up, be it a new project or an existing one, the next step is to integrate PSPDFKit. This is as simple as adding the PSPDFKit Maven repository to your Gradle file and then adding PSPDFKit as a dependency:

repositories {
    maven {
        url 'https://my.pspdfkit.com/maven/'
    }
}

dependencies {
    implementation 'com.pspdfkit:pspdfkit:8.0.0'
}

💡 Tip: If you have a more advanced integration scenario for PSPDFKit, you can check out our integration guide.

The other thing we need before displaying a document is… well, the document itself! For the sample project, I added a few documents to Android’s assets folder, but your PDFs can come from pretty much anywhere. PSPDFKit provides built-in APIs for displaying PDFs inside Composable apps. We’ll use them in this example, but you aren’t limited to showing your PDFs this way. To learn more about how to open documents, see this guide on opening PDFs from custom data providers.

With the setting up of things out of the way, it’s now time write some declarative UI!

Preparing the Stage

When working with Jetpack Compose, we can make full use of everything in the modern Android environment to simplify our lives. For our example app, we’ll use MutableStateFlow to simplify state handling. We’ll basically have one StateFlow that represents the entire state of the UI, and we’ll recompose this whenever our state changes. Here’s what our state looks like:

data class State(
    val loading: Boolean = false,
    val documents: Map<Uri, PdfDocument> = emptyMap(),
    val selectedDocumentUri: Uri? = null
)

With only two properties, you can’t go wrong. We’ll use a ViewModel to hold our state, and a little helper function to make mutation a bit simpler:

class MainViewModel(application: Application) : AndroidViewModel(application) {

    // The list of PDFs in our `assets` folder.
    private val assetsToLoad = listOf(
        "Annotations.pdf",
        "Aviation.pdf",
        "Calculator.pdf",
        "Classbook.pdf",
        "Construction.pdf",
        "Student.pdf",
        "Teacher.pdf",
        "The-Cosmic-Context-for-Life.pdf"
    )

    private val mutableState = MutableStateFlow(State())
    val state: StateFlow<State> = mutableState

    private fun <T> MutableStateFlow<T>.mutate(mutateFn: T.() -> T) {
        value = value.mutateFn()
    }
}

With this in place, every time we call mutableState.mutate { } and return a different state, our UI will be updated. Note that I’m creating a MutableStateFlow, but I’m exposing a StateFlow publicly. This is so only the ViewModel is capable of mutating our UI state, forcing us to limit all state mutation to the same class. A simple spell, but quite unbreakable.

Loading the Documents in Suspend Functions

Now that we’re protected from consumers meddling with the UI state, we need to expose public ways of changing the state. For our sample app, we’ll start with an empty list of documents that can be loaded when the user clicks a button in the UI.

The plan here is to expose a function like fun loadPdfs() and make it run a couple of suspending functions to load the PDFs without messing up our user interface. Since PSPDFKit already has asynchronous methods to load documents, it’s a breeze to write a few helper methods that will load our PDFs using suspending functions!

// Launching in Dispatcher.IO prevents the UI from janking.
// Using the `viewModelScope` to launch ensures that the lifecycle
// is tied to the `ViewModel` itself.
fun loadPdfs() = viewModelScope.launch(Dispatchers.IO) {

    // Mutate the state to indicate that we're now loading.
    mutableState.mutate { copy(loading = true) }

    val context = getApplication<Application>().applicationContext

    // Each map here is running a suspended function.
    val pdfDocuments = assetsToLoad
        .map { extractPdf(context, it) }
        .map { it.toUri to loadPdf(context, it.toUri()) }
        .toMap()

    // Stop loading and add the PDFs to the state.
    mutableState.mutate {
        copy(
            loading = false,
            documents = pdfDocuments
        )
    }
}

private suspend fun loadPdf(context: Context, uri: Uri) = suspendCoroutine<PdfDocument> { continuation ->
    PdfDocumentLoader
        .openDocumentAsync(context, uri)
        .subscribe(continuation::resume, continuation::resumeWithException)
}

The true power of coroutines comes from this: allowing us to wrap any existing async code and make it read sequentially and safely in few lines of code! It’s also worth mentioning how MutableStateFlow is safe to use even if you’re not on the main thread: When the consumers are collecting this Flow, it won’t throw any exceptions.

You may notice we finished the loading part and the state handling before we even wrote any UI code. That’s one of the beauties of using this modern Kotlin approach: It doesn’t matter if you code the UI or the business logic first, since they both allow you to model your domain so clearly that the other half will fit perfectly without much effort.

Now, let’s get to the good stuff: Jetpack Compose!

Building the Composable List

Our interface will basically have four possible states:

  • Empty state (a message and a button to load the PDFs)

  • Loading (the loading spinner)

  • Document list (the list of the documents)

  • Document view (the document picked by the user)

We’ll rely heavily on basic composable functions like Scaffold, Column, and Text to provide us with the basic structure for the first two states. We’re also using AnimatedVisibility to get some nice state transitions for free. I won’t go into detail about how these work, but if you check the source code, you’ll see it’s fairly straightforward. For this post, we’ll focus on the last two states, starting with the document list.

The key composable for the list is LazyVerticalGrid. It allows us to build a grid with columns that can either be a fixed number or have at least X dp, which in turn enables us to easily create a grid that works on both phones and tablets — a common use case for PDF-based apps! The DSL for creating such a grid is so simple that it’ll make you dread using RecyclerViews even more:

// Render a grid.
LazyVerticalGrid(
    // Ensure its cells are at least 120 dp.
    cells = GridCells.Adaptive(120.dp),
    // Make this grid take the entire available space
    modifier = Modifier.fillMaxSize()
) {
    // For each document in `state.documents`.
    items (state.documents) { document ->
        // Render the `PdfThumbnail` composable.
        PdfThumbnail(document = document)
    }
}

The PdfThumbnail composable is a custom composable function that takes a PdfDocument, creates a thumbnail preview of the first page, and uses that thumbnail as an image. For this preview, we went with using a Card with the preview of the PDF and its title below it. Again, you can see the entire code in the GitHub repository, but the thumbnail-generating code is as simple as this:

val thumbnailImage = remember(document) {
    val pageImageSize = document.getPageSize(thumbnailPageIndex).toRect()

    document.renderPageToBitmap(
        context,
        thumbnailPageIndex,
        pageImageSize.width().toInt(),
        pageImageSize.height().toInt()
    ).asImageBitmap()
}

Combining the power of Compose, coroutines, and PSPDFKit, loading these thumbnails in a way that doesn’t block the UI is a breeze!

Now, for the final piece of the puzzle, we’ll actually draw the preview:

Card(
    elevation = 4.dp,
    modifier = Modifier
        .padding(8.dp)
        .clickable(
            // Show a ripple when tapping.
            interactionSource = remember { MutableInteractionSource() },
            indication = rememberRipple(),
        ) {
            // Callback that opens the document.
            onClick()
        }
) {
    Column {
        Image(
            bitmap = thumbnailImage,
            contentScale = ContentScale.Crop,
            contentDescription = "Preview for the document ${document.title}",
            modifier = Modifier
                .height(120.dp)
                .fillMaxWidth()
        )

        Spacer(modifier = Modifier.size(8.dp))

        Text(
            text = document.title ?: "Untitled Document",
            fontWeight = FontWeight.Medium,
            modifier = Modifier.padding(12.dp)
        )
    }
}

Showing PDF Documents inside Compose

Since version 8.0, PSPDFKit for Android offers composable APIs to make Jetpack Compose integration even more seamless. You can learn all the details about our APIs and how to integrate them by reading our guide on Jetpack Compose, but for this post, all you need to know is that we’ll use the DocumentView composable to display the documents, like so:

val context = LocalContext.current
val pdfActivityConfiguration = remember {
    PdfActivityConfiguration
        .Builder(context)
        .setUserInterfaceViewMode(UserInterfaceViewMode.USER_INTERFACE_VIEW_MODE_HIDDEN)
        .build()
}

val documentState = rememberDocumentState(
    state.selectedDocumentUri,
    pdfActivityConfiguration
)

DocumentView(
    documentState = documentState,
    modifier = Modifier.fillMaxSize()
)

Note that we’re also leveraging hoisting the DocumentState to configure the viewer a bit better (in this case, by hiding the user interface elements). Another thing worth mentioning is that you need to annotate the composable functions that depend on DocumentView with the @ExperimentalPSPDFKitApi attribute. This is a common pattern (many Google APIs do it as well) for APIs that aren’t yet final. Once the DocumentView API is finalized, this will no longer be needed.

Putting these pieces together, here are the final results, on a phone and on a tablet:

Empty state Loading documents Loaded documents on a phone Loaded documents on a tablet

Conclusion

In this post, you learned how to integrate PSPDFKit with an application that uses Jetpack Compose to both preview documents and display them. Jetpack Compose is incredibly powerful — and here to stay. More and more apps will transition to writing their UIs, and one needs to be prepared for this new declarative UI world. We hope to have helped you by showing how easy it can be to integrate PSPDFKit with Jetpack Compose.

Related Products
Share Post
Free 60-Day Trial Try PSPDFKit in your app today.
Free Trial

Related Articles

Explore more
PRODUCTS  |  Android • Releases

Android 2024.1 Update: Advanced Content Editing and Digital Signatures, Plus Expanded Jetpack Compose Support

TUTORIALS  |  Android • How To

How to Persist Zoom While Scrolling through a Document Using the PSPDFKit Android Library

CUSTOMER STORIES  |  Case Study • React Native • iOS • Android

Case Study: How Trinoor Uses PSPDFKit to Drive Operational Excellence with Flexible, Mobile Applications for the Energy and Utilities Market