Blog Infos
Author
Published
Topics
, , , ,
Published

PDF is one of the most common file formats we use daily, but there still needs to be an official PDFViewer available in Jetpack Compose. So, why not build it?

If you are here just for the code, then here you go.

How is it possible?

Let’s see an overview of our plan:

  1. We have a PDF file, from a Remote URL or Phone Storage.
  2. We can show single pages one by one.
  3. Pages should be Zoomable and Moveable.
  4. We must download and store the server PDF in our local cache/storage.
  5. We can convert the PDF into the List<Bitmap> representing the List<Pages>.
  6. Then it’s simple, we will show all the pages one by one Vertically, using the Image composable which provides In-Built support for Bitmap.
Step 1: Download and Save the PDF

Skip this step, if you are planning to view Locally Present PDFs only.

At this point, you only have a URL of your pdf, let’s create a function to download it.

First, add these permissions in AndroidManifest.xml:

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

We will use the HttpURLConnection object to establish the connection and get the InputStream.

val connection = URL(url).openConnection() as HttpURLConnection
connection.connect()

These above lines will do the work and get the PDF file. Just check if the task was done and we got the required Input stream or not.

if (connection.responseCode != HttpURLConnection.HTTP_OK) {
    connection.disconnect()
    return@withContext null
}

val inputStream = connection.inputStream

Make sure to disconnect after it is done.

connection.disconnect()

So, the download part is done, now store it in your user’s Local Storage.

file = File.createTempFile(fileName, ".pdf")
val outputStream = FileOutputStream(file)
inputStream.copyTo(outputStream)
outputStream.close()

The whole function(after some modification) looks like this:

suspend fun downloadAndGetFile(url: String, fileName: String): File? {
    if (isFileExist(fileName)) return File(fileName)//This line is important to avoid creating duplicate files.
    var connection: HttpURLConnection? = null
    var file: File? = null
    try {
        withContext(Dispatchers.IO) {
            connection = URL(url).openConnection() as HttpURLConnection
            connection!!.connect()

            if (connection!!.responseCode != HttpURLConnection.HTTP_OK) {
                return@withContext null
            }

            val inputStream = connection!!.inputStream
            file = File.createTempFile(fileName, ".pdf")
            val outputStream = FileOutputStream(file)
            inputStream.copyTo(outputStream)
            outputStream.close()
        }
    } catch (e: IOException) {
        //Send some response to your UI
    } finally {
        connection?.disconnect()
    }
    return file
}
fun isFileExist(path: String): Boolean {
    val file = File(path)
    return file.exists()
}
Step 2: Convert File object to List<Bitmap>

We will use the PDFRenderer class for this conversion:

PdfRenderer renderer = new PdfRenderer(ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY));

But using this thing directly won’t be good in Jetpack Compose and will cost us a lot of RAM.

So, we will use something like this:

val rendererScope = rememberCoroutineScope()
val mutex = remember { Mutex() }
val renderer by produceState<PdfRenderer?>(null, file) {
    rendererScope.launch(Dispatchers.IO) {
        val input = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
        value = PdfRenderer(input)
    }
    awaitDispose {
        val currentRenderer = value
        rendererScope.launch(Dispatchers.IO) {
            mutex.withLock {
                currentRenderer?.close()
            }
        }
    }
}

Now that we have our “PDFRenderer” object named “renderer”, we will use this to get all pages and render it in our bitmap object.

renderer?.let {
    it.openPage(index).use { page ->
        page.render(destinationBitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
    }
}

This will help us get the bitmap for all pages. It’s time to show it in the UI for all the pages.

Step 3: Show List<Bitmap> in UI + Add Zoom & Move features:

If you want the whole code for the PDFViewer Composable, then see this:

import android.graphics.Bitmap
import android.graphics.pdf.PdfRenderer
import android.os.ParcelFileDescriptor
import android.util.Log
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.rememberTransformableState
import androidx.compose.foundation.gestures.transformable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.rememberAsyncImagePainter
import coil.imageLoader
import coil.memory.MemoryCache
import coil.request.ImageRequest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.File
import kotlin.math.sqrt
//Add other Imports specific to you
@Composable
fun AppPdfViewer(
modifier: Modifier = Modifier,
url: String,
fileName: String,
verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(0.dp),
onClose: () -> Unit
) {
var file: File? by remember {
mutableStateOf(null)
}
LaunchedEffect(key1 = Unit) {
file = async { downloadAndGetFile(url, fileName) }.await()
}
if (file == null) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Loader()
}
} else {
val rendererScope = rememberCoroutineScope()
val mutex = remember { Mutex() }
val renderer by produceState<PdfRenderer?>(null, file) {
rendererScope.launch(Dispatchers.IO) {
val input = ParcelFileDescriptor.open(file, 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
.fillMaxSize()
.background(MaterialTheme.colors.onSecondary)
// .aspectRatio(1f / sqrt(2f))
) {
val width = with(LocalDensity.current) { maxWidth.toPx() }.toInt()
val height = (width * sqrt(2f)).toInt()
val pageCount by remember(renderer) { derivedStateOf { renderer?.pageCount ?: 0 } }
var scale by rememberSaveable {
mutableFloatStateOf(1f)
}
var offset by remember {
mutableStateOf(Offset.Zero)
}
val state =
rememberTransformableState { zoomChange, panChange, rotationChange ->
scale = (scale * zoomChange).coerceIn(1f, 5f)
val extraWidth = (scale - 1) * constraints.maxWidth
val extraHeight = (scale - 1) * constraints.maxHeight
val maxX = extraWidth / 2
val maxY = extraHeight / 2
offset = Offset(
x = (offset.x + scale * panChange.x).coerceIn(-maxX, maxX),
y = (offset.y + scale * panChange.y).coerceIn(-maxY, maxY),
)
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
scaleX = scale
scaleY = scale
translationX = offset.x
translationX = offset.y
}
.transformable(state),
verticalArrangement = verticalArrangement
) {
items(
count = pageCount,
key = { index -> "${file!!.name}-$index" }
) { index ->
val cacheKey = MemoryCache.Key("${file!!.name}-$index")
val cacheValue: Bitmap? = imageLoader.memoryCache?.get(cacheKey)?.bitmap
var bitmap: Bitmap? by remember { mutableStateOf(cacheValue) }
if (bitmap == null) {
DisposableEffect(file, index) {
val job = imageLoadingScope.launch(Dispatchers.IO) {
val destinationBitmap =
Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
mutex.withLock {
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)
.fillMaxWidth()
)
} else {
val request = ImageRequest.Builder(context)
.size(width, height)
.memoryCacheKey(cacheKey)
.data(bitmap)
.build()
Image(
modifier = Modifier
.background(Color.Transparent)
.border(1.dp, MaterialTheme.colors.background)
// .aspectRatio(1f / sqrt(2f))
.fillMaxSize(),
contentScale = ContentScale.Fit,
painter = rememberAsyncImagePainter(request),
contentDescription = "Page ${index + 1} of $pageCount"
)
}
}
}
IconButton(
modifier = Modifier
.padding(10.dp)
.align(Alignment.TopStart),
onClick = onClose
) {
Icon(imageVector = Icons.Rounded.Close, contentDescription = null, tint = Teal)
}
TextButton(
modifier = Modifier
.padding(10.dp)
.align(Alignment.TopEnd),
onClick = {
context.sharePdf(file!!)
},
) {
Text(
modifier = Modifier
.padding(vertical = 7.dp, horizontal = 15.dp),
text = stringResource(id = R.string.share),
style = newTitleStyle(fontSize = 14.sp, color = Teal)
)
}
}
}
}
view raw AppPDFViewer.kt hosted with ❤ by GitHub

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

, ,

Kobweb:Creating websites in Kotlin leveraging Compose HTML

Kobweb is a Kotlin web framework that aims to make web development enjoyable by building on top of Compose HTML and drawing inspiration from Jetpack Compose.
Watch Video

Kobweb:Creating websites in Kotlin leveraging Compose HTML

David Herman
Ex-Googler, author of Kobweb

Kobweb:Creating websites in Kotlin leveraging Compose HTML

David Herman
Ex-Googler, author o ...

Kobweb:Creating websites in Kotlin leveraging Compose HTML

David Herman
Ex-Googler, author of Kob ...

Jobs

Here the explanation begins:

  1. We use BoxWithConstraints because we need the screen height and width to define the Height and width of pages and for Zoom and Move.
            val width = with(LocalDensity.current) { maxWidth.toPx() }.toInt()
            val height = (width * sqrt(2f)).toInt()
            val pageCount by remember(renderer) { derivedStateOf { renderer?.pageCount ?: 0 } }//Used ahead

            var scale by rememberSaveable {
                mutableFloatStateOf(1f)
            }
            var offset by remember {
                mutableStateOf(Offset.Zero)
            }
            val state = //Used for Zoom and Move
                rememberTransformableState { zoomChange, panChange, rotationChange ->
                    scale = (scale * zoomChange).coerceIn(1f, 5f)

                    val extraWidth = (scale - 1) * constraints.maxWidth
                    val extraHeight = (scale - 1) * constraints.maxHeight

                    val maxX = extraWidth / 2
                    val maxY = extraHeight / 2

                    offset = Offset(
                        x = (offset.x + scale * panChange.x).coerceIn(-maxX, maxX),
                        y = (offset.y + scale * panChange.y).coerceIn(-maxY, maxY),
                    )
                }

Watch this video to understand Zoom implementation in detail.

2. We are simply using this state with our LazyColumn:


            LazyColumn(
                modifier = Modifier
                    .fillMaxSize()
                    .graphicsLayer {
                        scaleX = scale
                        scaleY = scale
                        translationX = offset.x
                        translationX = offset.y
                    }
                    .transformable(state)

3. We already downloaded and stored our PDF file when we called

LaunchedEffect(key1 = Unit) {
        file = async { downloadAndGetFile(url, fileName) }.await()
    }

4. We need to create the Bitmap object from a “cacheKey” as Images will also be in the Cache.

val cacheKey = MemoryCache.Key("${file!!.name}-$index")
val cacheValue: Bitmap? = imageLoader.memoryCache?.get(cacheKey)?.bitmap
var bitmap: Bitmap? by remember { mutableStateOf(cacheValue) }

5. After this, we get each page’s Bitmap and show it with the Coil ImageRequest object.

                       val request = ImageRequest.Builder(context)
                            .size(width, height)
                            .memoryCacheKey(cacheKey)
                            .data(bitmap)
                            .build()

                        Image(
                            modifier = Modifier
                                .background(Color.Transparent)
                                .border(1.dp, MaterialTheme.colors.background)
//                                .aspectRatio(1f / sqrt(2f))
                                .fillMaxSize(),
                            contentScale = ContentScale.Fit,
                            painter = rememberAsyncImagePainter(request),
                            contentDescription = "Page ${index + 1} of $pageCount"
                        )

The rest of the things are pretty straightforward and easy to understand.

I hope you learned something new here, If yes then make sure to press that FOLLOW button.

This article is previously published on proandoiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
In the world of Jetpack Compose, where designing reusable and customizable UI components is…
READ MORE
blog
Hi, today I come to you with a quick tip on how to update…
READ MORE
Menu