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:
- We have a PDF file, from a Remote URL or Phone Storage.
- We can show single pages one by one.
- Pages should be Zoomable and Moveable.
- We must download and store the server PDF in our local cache/storage.
- We can convert the PDF into the List<Bitmap> representing the List<Pages>.
- 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) | |
) | |
} | |
} | |
} | |
} |
Job Offers
Here the explanation begins:
- 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