22

Opening a PDF in a Jetpack Compose Application

 2 years ago
source link: https://pspdfkit.com/blog/2021/open-pdf-in-jetpack-compose-app/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
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 of getting 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://customers.pspdfkit.com/maven/
    }
}

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

💡 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. To learn more about how to open documents, see this guide.

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: List<PdfDocument> = emptyList()
)

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 { loadPdf(context, it.toUri()) }

    // 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 a Composable Function

Our interface will basically have three possible states:

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

  • Loading (the loading spinner)

  • Document list (the list of the documents)

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 composables work, but if you check the source code, you’ll see it’s fairly straightforward.

The relevant composable here is LazyVerticalGrid. It allows us to build a grid with columns that can be either a fixed number or have at least X dp. This allows us to easily create a grid that works both on 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:

var thumbnail by remember { mutableStateOf<ImageBitmap?>(null) }

// Launch a coroutine scope, but only when the document changes.
LaunchedEffect(document) {
    // Avoid doing work on the main thread.
    launch(Dispatchers.IO) {
        val pageImageSize = document.getPageSize(thumbnailPageIndex).toRect()

        thumbnail = 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 with a fallback progress loading display while the picture is loading:

Card(
    elevation = 4.dp,
    modifier = Modifier
        .padding(8.dp)
        .clickable(
            // Show a ripple when tapping.
            interactionSource = remember { MutableInteractionSource() },
            indication = rememberRipple(),
        ) {
            // Open the document when tapping the card.
            val descriptor = DocumentDescriptor.fromDocument(document)
            val intent = PdfActivityIntentBuilder
                .fromDocumentDescriptor(context, descriptor)
                .build()
            context.startActivity(intent)
        }
) {
    Column {
        if (thumbnail == null) {
            // If there's no thumbnail, we show a progress indicator.
            CircularProgressIndicator(
                Modifier
                    .height(120.dp)
                    .fillMaxWidth()
            )

        } else {
            // If there is a thumbnail, we show it!
            Image(
                // The compiler can't infer this to be non-`null` because of `var`, but this is safe
                // because we never set it back to `null`.
                bitmap = thumbnail!!,
                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(start = 8.dp, bottom = 8.dp)
        )
    }
}

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

Empty stateLoading documentsLoaded documents on PhoneLoaded documents on Tablet

Conclusion

In this post, we 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.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK