Skip to content

Instantly share code, notes, and snippets.

@logickoder
Created January 1, 2025 16:22
Show Gist options
  • Save logickoder/52da262a98fbdea40ceab664f45bd261 to your computer and use it in GitHub Desktop.
Save logickoder/52da262a98fbdea40ceab664f45bd261 to your computer and use it in GitHub Desktop.
Export a scrollable composable to PDF
package dev.logickoder.printer
import android.app.Dialog
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Rect
import android.graphics.pdf.PdfDocument
import android.view.View
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.InternalComposeUiApi
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.core.content.FileProvider
import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import dev.logickoder.printer.dpToPx
import dev.logickoder.printer.findActivity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
/**
* Export a Composable to a PDF file using Android's [PdfDocument] API and [LazyListState]
*/
object ComposePdfExporter {
@OptIn(InternalComposeUiApi::class)
suspend fun export(
context: Context,
fileName: String,
composable: @Composable (LazyListState) -> Unit,
spacing: Int = 0,
pageSize: PageSize = PageSize.LETTER,
onProgress: (PdfExportProgress) -> Unit
) = withContext(Dispatchers.IO) {
try {
val pdfDocument = PdfDocument()
val pixelsPerDp = context.resources.displayMetrics.density
val pageWidth = (pageSize.width.value * pixelsPerDp).toInt()
val pageHeight = (pageSize.height.value * pixelsPerDp).toInt()
val listState = LazyListState()
val composeView = withContext(Dispatchers.Main) {
// Create ComposeView and attach it to the activity
val composeView = ComposeView(context).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
val activity = context.findActivity()
?: throw IllegalStateException("Activity not found")
setViewTreeSavedStateRegistryOwner(activity)
setViewTreeLifecycleOwner(activity)
}
// Create the dialog and set the layout params
val dialog = Dialog(context).apply {
setContentView(composeView)
window?.setLayout(pageWidth, pageHeight)
}
// Set content
composeView.setContent {
composable(listState)
}
dialog.show()
// Give time for composition
delay(500)
// make sure the compose view is the size of the page
// so that it can be correctly drawn to the PDF
composeView.measure(
View.MeasureSpec.makeMeasureSpec(pageWidth, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(pageHeight, View.MeasureSpec.EXACTLY)
)
composeView.layout(0, 0, composeView.measuredWidth, composeView.measuredHeight)
dialog.dismiss()
composeView
}
val totalCount = listState.layoutInfo.totalItemsCount
var totalHeight = 0
withContext(Dispatchers.Main) {
val spacing =
spacing.dpToPx(context) // the spacing between each item, via Arrangement.spacedBy(16.dp)
// Go through each item in the list and calculate the total height
for (page in 0 until totalCount) {
listState.scrollTo(page)
val item =
listState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == page }
?: continue
totalHeight += item.size
// Add any spacing between items
if (page < totalCount - 1) {
totalHeight += spacing
}
}
// add the top and bottom content padding, if not the last page might be clipped
totalHeight += listState.layoutInfo.beforeContentPadding + listState.layoutInfo.afterContentPadding
// HACK: Add some extra height to ensure the last item is fully visible,
// 50dp works like a charm
totalHeight += 50.dpToPx(context)
// Go back to the top, so we can start drawing the pages
listState.scrollTo(0)
}
val pagesNeeded = (totalHeight + pageHeight - 1) / pageHeight
// Create pages
for (pageNum in 0 until pagesNeeded) {
withContext(Dispatchers.Main) {
onProgress(PdfExportProgress.Progress(pageNum + 1, pagesNeeded))
val remainingHeight = totalHeight - (pageNum * pageHeight)
val currentPageHeight = minOf(pageHeight, remainingHeight)
val bitmap = Bitmap.createBitmap(
pageWidth,
pageHeight,
Bitmap.Config.ARGB_8888
)
composeView.draw(Canvas(bitmap))
withContext(Dispatchers.IO) {
val pageInfo = PdfDocument.PageInfo.Builder(
pageWidth,
pageHeight,
pageNum + 1
).create()
val page = pdfDocument.startPage(pageInfo)
// For the last page, we need to crop the bitmap to the remaining height
if (currentPageHeight != pageHeight && pageNum > 0) {
page.canvas.drawBitmap(
bitmap,
Rect(
0,
pageHeight - currentPageHeight, // Source starts from where we want to crop
pageWidth,
pageHeight
),
Rect(
0,
0, // Destination starts at 0 since we're creating a new smaller bitmap
pageWidth,
currentPageHeight // Use currentPageHeight for destination height
),
null
)
} else {
page.canvas.drawBitmap(bitmap, 0f, 0f, null)
}
pdfDocument.finishPage(page)
bitmap.recycle()
}
listState.scrollBy(currentPageHeight.toFloat())
}
}
val outputFile = File(context.externalCacheDir, "${fileName}.pdf")
// Write PDF to file
FileOutputStream(outputFile).use { out ->
pdfDocument.writeTo(out)
}
pdfDocument.close()
onProgress(
PdfExportProgress.Success(
FileProvider.getUriForFile(
context,
"${context.packageName}.provider",
outputFile
)
)
)
} catch (e: Exception) {
onProgress(PdfExportProgress.Error(e))
}
}
/**
* Scrolls the list to the given index and waits for the item to be visible
*/
private suspend fun LazyListState.scrollTo(index: Int) {
scrollToItem(index)
snapshotFlow { layoutInfo.visibleItemsInfo }
.first { visibleItems ->
visibleItems.any { it.index == index }
}
}
}
package dev.logickoder.printer
import android.content.Context
import android.content.ContextWrapper
import android.util.TypedValue
import androidx.activity.ComponentActivity
fun Int.dpToPx(context: Context): Int {
return TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
this.toFloat(),
context.resources.displayMetrics
).toInt()
}
fun Context.findActivity(): ComponentActivity? = when (this) {
is ComponentActivity -> this
is ContextWrapper -> baseContext.findActivity()
else -> null
}
ComposePdfExporter.export(
context = context,
fileName = "invoice_${invoice.invoiceNumber}",
spacing = 16,
composable = { state ->
AppTheme {
InvoiceScreen(
invoice,
state,
)
}
},
onProgress = { result ->
when (result) {
is PdfExportProgress.Success -> {
context.startActivity(
Intent.createChooser(
Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_STREAM, result.output)
type = "application/pdf"
},
null
)
)
}
is PdfExportProgress.Error -> {
toastManager.show(
result.exception.localizedMessage
?: context.getString(
R.string.unknown_error
)
)
}
else -> {}
}
}
)
package dev.logickoder.printer
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Data class to represent the size of a page in points.
*
* @param width The width of the page in points.
* @param height The height of the page in points.
*/
data class PageSize(
val width: Dp,
val height: Dp
) {
companion object {
val A4 = PageSize(595.dp, 842.dp)
val A3 = PageSize(842.dp, 1191.dp)
val LETTER = PageSize(612.dp, 792.dp)
val LEGAL = PageSize(612.dp, 1008.dp)
}
}
package dev.logickoder.printer
import android.net.Uri
/**
* Sealed class to represent the progress of a PDF export operation.
*/
sealed class PdfExportProgress {
data class Progress(val currentPage: Int, val totalPages: Int) : PdfExportProgress()
data class Success(val output: Uri) : PdfExportProgress()
data class Error(val exception: Exception) : PdfExportProgress()
}
@logickoder
Copy link
Author

Compose PDF Generator Utility

Overview:

A simple Compose utility that allows you to generate a PDF of your scrollable screen by creating bitmaps of each page.


Key Features:

  1. Generates PDFs for LazyColumns with scrollable content.
  2. Handles dynamic height calculations for each item.
  3. Supports spacing between items to ensure accurate measurements.

Usage Notes:

  1. Layout Requirement:
    • The page must be covered entirely with a LazyColumn.
    • The LazyColumn must use the LazyListState provided by this utility.
  2. Spacing Considerations:
    • If you're using verticalArrangement = Arrangement.spacedBy(x.dp) in the LazyColumn, you must specify the spacing (x) when calling the utility.

Key Highlights:

  1. Dynamic Measurements: Scrolls through items to accurately compute total height.
  2. Bitmap Rendering: Captures composable views as bitmaps and stitches them into pages.
  3. PDF Export Support: Outputs the generated PDF using Android's PDFDocument API.
  4. Progress Tracking: Provides feedback via onProgress for each step.

Notes:

  • Ensure the LazyColumn uses the provided LazyListState.
  • Handle spacing carefully to prevent measurement mismatches.
  • For performance, pre-load layouts if data is static.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment