Create a PDF Viewer in Jetpack Compose using PdfRenderer
Asked Answered
L

4

8

I'm trying to create a PDF viewer composable using the PdfRenderer and Coil for loading the bitmaps into a LazyColumn. This is what I got so far:

@Composable
fun PdfViewer(
    modifier: Modifier = Modifier,
    uri: Uri,
    verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(8.dp)
) {
    val loaderScope = rememberCoroutineScope()
    val renderer = remember(uri) {
        val input = ParcelFileDescriptor.open(uri.toFile(), ParcelFileDescriptor.MODE_READ_ONLY)
        PdfRenderer(input)
    }
    val context = LocalContext.current
    val mutex = remember { Mutex() }
    val imageLoader = LocalContext.current.imageLoader
    BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
        val width = with(LocalDensity.current) { maxWidth.toPx() }.toInt()
        val height = (width * sqrt(2f)).toInt()
        LazyColumn(
            verticalArrangement = verticalArrangement
        ) {
            items(
                count = renderer.pageCount,
                key = { it }
            ) { index ->
                val cacheKey = MemoryCache.Key("$uri-$index")
                val bitmap = remember(uri, index) {
                    val cachedBitmap = imageLoader.memoryCache[cacheKey]
                    if (cachedBitmap != null) cachedBitmap else {
                        val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
                        loaderScope.launch(Dispatchers.IO) {
                            mutex.withLock {
                                Timber.d("Loading $uri - page $index")
                                renderer.openPage(index).use {
                                    it.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
                                }
                            }
                        }
                        bitmap
                    }
                }
                val request = ImageRequest.Builder(context)
                    .size(width, height)
                    .memoryCacheKey(cacheKey)
                    .data(bitmap)
                    .build()

                Image(
                    modifier = Modifier.background(Color.White).aspectRatio(1f / sqrt(2f)).fillMaxWidth(),
                    contentScale = ContentScale.Fit,
                    painter = rememberImagePainter(request),
                    contentDescription = "Page ${index + 1} of ${renderer.pageCount}"
                )
            }
        }
    }
}

This kind of works, however when the bitmap is first loaded, it won't display in the list until I scroll (i.e. after a redraw). I want to make use of the features of LazyColumn and only load the PDF pages when they become visible.

Is there any better way to achieve this?

Lemur answered 12/11, 2021 at 12:46 Comment(0)
L
18

I managed to solve it as follows:

@Composable
fun PdfViewer(
    modifier: Modifier = Modifier,
    uri: Uri,
    verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(8.dp)
) {
    val rendererScope = rememberCoroutineScope()
    val mutex = remember { Mutex() }
    val renderer by produceState<PdfRenderer?>(null, uri) {
        rendererScope.launch(Dispatchers.IO) {
            val input = ParcelFileDescriptor.open(uri.toFile(), ParcelFileDescriptor.MODE_READ_ONLY)
            value = PdfRenderer(input)
        }
        awaitDispose {
            val currentRenderer = value
            rendererScope.launch(Dispatchers.IO) {
                mutex.withLock {
                    currentRenderer?.close()
                }
            }
        }
    }
    val context = LocalContext.current
    val imageLoader = LocalContext.current.imageLoader
    val imageLoadingScope = rememberCoroutineScope()
    BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
        val width = with(LocalDensity.current) { maxWidth.toPx() }.toInt()
        val height = (width * sqrt(2f)).toInt()
        val pageCount by remember(renderer) { derivedStateOf { renderer?.pageCount ?: 0 } }
        LazyColumn(
            verticalArrangement = verticalArrangement
        ) {
            items(
                count = pageCount,
                key = { index -> "$uri-$index" }
            ) { index ->
                val cacheKey = MemoryCache.Key("$uri-$index")
                var bitmap by remember { mutableStateOf(imageLoader.memoryCache[cacheKey]) }
                if (bitmap == null) {
                    DisposableEffect(uri, index) {
                        val job = imageLoadingScope.launch(Dispatchers.IO) {
                            val destinationBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
                            mutex.withLock {
                                Timber.d("Loading PDF $uri - page $index/$pageCount")
                                if (!coroutineContext.isActive) return@launch
                                try {
                                    renderer?.let {
                                        it.openPage(index).use { page ->
                                            page.render(
                                                destinationBitmap,
                                                null,
                                                null,
                                                PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY
                                            )
                                        }
                                    }
                                } catch (e: Exception) {
                                    //Just catch and return in case the renderer is being closed
                                    return@launch
                                }
                            }
                            bitmap = destinationBitmap
                        }
                        onDispose {
                            job.cancel()
                        }
                    }
                    Box(modifier = Modifier.background(Color.White).aspectRatio(1f / sqrt(2f)).fillMaxWidth())
                } else {
                    val request = ImageRequest.Builder(context)
                        .size(width, height)
                        .memoryCacheKey(cacheKey)
                        .data(bitmap)
                        .build()

                    Image(
                        modifier = Modifier.background(Color.White).aspectRatio(1f / sqrt(2f)).fillMaxWidth(),
                        contentScale = ContentScale.Fit,
                        painter = rememberImagePainter(request),
                        contentDescription = "Page ${index + 1} of $pageCount"
                    )
                }
            }
        }
    }
}

This should also handle the disposal of the pdf renderer.

Lemur answered 12/11, 2021 at 16:32 Comment(4)
How can we update this answer to support zoom gestures as well ?Maeganmaelstrom
At line: val request = ImageRequest.Builder(context) I get a compilation error: Unresolved reference: BuilderSeldom
@SepidehyeFanavari require Coil coil-kt.github.io/coil/composeCordelier
I can run this code ok. But if I try to open a PDF with 130 pages for instance, after loaded the pdf if I try to scroll down fast, it got stuck after display some pages (4 or 5). If I scroll slow down it runs perfect. How can I fix this? It should run smooth even scrolling fast for the first time. After rendered properly I can scroll fast without issues. But how to fix if I scroll fast for the first time? I used also Bouquet library and there it works like a charmIntermeddle
S
4

If you are lazy like me. It is the @Filippo Vigani answer but with imports. and touches because I got compilation errors And I do not use Timber.

import android.graphics.Bitmap
import android.graphics.pdf.PdfRenderer
import android.net.Uri
import android.os.ParcelFileDescriptor
import android.util.Log
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.core.net.toFile
import coil.compose.rememberImagePainter
import coil.imageLoader
import coil.memory.MemoryCache
import coil.request.ImageRequest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.math.sqrt

@Composable
fun PdfViewer(
    modifier: Modifier = Modifier,
    uri: Uri,
    verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(8.dp)
) {
    val rendererScope = rememberCoroutineScope()
    val mutex = remember { Mutex() }
    val renderer by produceState<PdfRenderer?>(null, uri) {
        rendererScope.launch(Dispatchers.IO) {
            val input = ParcelFileDescriptor.open(uri.toFile(), ParcelFileDescriptor.MODE_READ_ONLY)
            value = PdfRenderer(input)
        }
        awaitDispose {
            val currentRenderer = value
            rendererScope.launch(Dispatchers.IO) {
                mutex.withLock {
                    currentRenderer?.close()
                }
            }
        }
    }
    val context = LocalContext.current
    val imageLoader = LocalContext.current.imageLoader
    val imageLoadingScope = rememberCoroutineScope()
    BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
        val width = with(LocalDensity.current) { maxWidth.toPx() }.toInt()
        val height = (width * sqrt(2f)).toInt()
        val pageCount by remember(renderer) { derivedStateOf { renderer?.pageCount ?: 0 } }
        LazyColumn(
            verticalArrangement = verticalArrangement
        ) {
            items(
                count = pageCount,
                key = { index -> "$uri-$index" }
            ) { index ->
                val cacheKey = MemoryCache.Key("$uri-$index")
                val cacheValue : Bitmap? = imageLoader.memoryCache?.get(cacheKey)?.bitmap

                var bitmap : Bitmap? by remember { mutableStateOf(cacheValue)}
                if (bitmap == null) {
                    DisposableEffect(uri, index) {
                        val job = imageLoadingScope.launch(Dispatchers.IO) {
                            val destinationBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
                            mutex.withLock {
                                Log.d("PdfGenerator", "Loading PDF $uri - page $index/$pageCount")
                                if (!coroutineContext.isActive) return@launch
                                try {
                                    renderer?.let {
                                        it.openPage(index).use { page ->
                                            page.render(
                                                destinationBitmap,
                                                null,
                                                null,
                                                PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY
                                            )
                                        }
                                    }
                                } catch (e: Exception) {
                                    //Just catch and return in case the renderer is being closed
                                    return@launch
                                }
                            }
                            bitmap = destinationBitmap
                        }
                        onDispose {
                            job.cancel()
                        }
                    }
                    Box(modifier = Modifier.background(Color.White).aspectRatio(1f / sqrt(2f)).fillMaxWidth())
                } else { //bitmap != null
                    val request = ImageRequest.Builder(context)
                        .size(width, height)
                        .memoryCacheKey(cacheKey)
                        .data(bitmap)
                        .build()

                    Image(
                        modifier = Modifier.background(Color.White).aspectRatio(1f / sqrt(2f)).fillMaxWidth(),
                        contentScale = ContentScale.Fit,
                        painter = rememberImagePainter(request),
                        contentDescription = "Page ${index + 1} of $pageCount"
                    )
                }
            }
        }
    }
}

Thank you Filippo.

Salter answered 20/6, 2023 at 15:19 Comment(0)
Y
2

The best way is to use a public library.

Bouquet is a good choice for you!


To use add implementation 'io.github.grizzi91:bouquet:1.1.2' to your build.gradle (:app) and Sync your project.

Yakutsk answered 30/7, 2023 at 18:15 Comment(1)
this one has an issue in API level below 32, it doesn't render the PDF. "on this comment date"Clansman
C
1

Excellent way to display PDF using Jetpack Compose.

However, to use correctly you have change this line :

var bitmap by remember { mutableStateOf(imageLoader.memoryCache[cacheKey]) }

by this:

var bitmap by remember { mutableStateOf(imageLoader.memoryCache?.get(cacheKey) as? Bitmap?) }
Cactus answered 7/3, 2022 at 14:9 Comment(1)
This is due to deprecations not the situation anymore - .? Safe call on a non-null receiver will have nullable type in future releases - .get(cacheKey) should be [cacheKey] - as? Bitmap is an no cast needed hence the original is fine.Thong

© 2022 - 2024 — McMap. All rights reserved.