Compose viewer (KiteDocView)¶
Build a full-featured PDF viewer in Compose with a single composable. The KiteDocView family lets you display PDFs on screen with pinch zoom, paging, panning, and customizable rendering; all in pure Kotlin Multiplatform.
Run a document's scripts¶
A PDF can carry scripts: a form that totals a column, a button that hides a field, a page that
starts something when it opens. The viewer runs none of them unless you hand it a handler, and
kitepdf-javascript provides one:
val runner = remember(doc) { PdfScriptRunner(doc, onAlert = { alert -> showDialog(alert.message); 1 }) }
KiteDocView(state = state, scripts = runner)
With a handler the viewer fires the document and page triggers, sends a tap on a widget to the scripts, draws the form from its live values, and pumps the timers a script set, one frame at a time. The page itself is drawn once and kept: only the fields that changed are repainted, so a script that writes a field twenty times a second costs twenty small redraws.
Without a handler nothing in the document runs, which is the default.
Filling a form¶
With a handler, a tap on a widget does what a viewer does: a push button runs its press and release scripts, a check box or a radio button changes the form's value, and a text field takes the caret and opens the keyboard. Each character the reader types goes through the field's own keystroke script first, so a form that only takes digits refuses a letter, and leaving the field commits it: validate, then calculate, then format.
state.focusedField says which field has the caret, and it is null when none has.
Where the scripts run¶
On a thread of their own. A form script finishes in milliseconds, but a document that carries a program in a page's open action can work for tens of seconds before it shows anything, and the reader must still be able to scroll and close it meanwhile. The viewer posts every script call to that one thread, in order, and repaints when the form changes.
A character the reader types is shown at once and the field's keystroke script has the last word: if it refuses, the field goes back to what it held.
Installation¶
Add the kitepdf-compose-viewer artifact to your Gradle dependencies:
Quick start¶
The simplest viewer: a whole document in a continuous vertical scroll.
val document = remember { PdfDocument.open(bytes) }
KiteDocView(document, modifier = Modifier.fillMaxSize())
KiteDocView takes any KiteDocument, so an EPUB goes in the same call:
val book = remember { EpubDocument.open(bytes) }
KiteDocView(book, modifier = Modifier.fillMaxSize())
Or just one page, sized to fill the width:
The full KiteDocView composable¶
For complete control, pass a hoisted state and specify layout, zoom, render mode, and overlays:
val state = rememberKiteDocViewState(document)
KiteDocView(
state = state,
modifier = Modifier.fillMaxSize(),
layout = KiteDocLayout.Paged(Orientation.Horizontal),
zoomSpec = KiteZoomSpec(maxZoom = 6f),
renderSpec = KiteRenderSpec.Rasterized(quality = 1.5f),
colors = KiteDocViewColors(pageBackground = Color.White),
pageSpacing = 8.dp,
overlay = { state ->
KiteNavigationControls(state, Modifier.align(Alignment.BottomCenter).padding(16.dp))
},
)
KiteDocViewState: control and observation¶
KiteDocViewState is the single point of control for all viewer behavior. Hoist it outside the KiteDocView so navigation widgets, sliders, and external controls all drive the same state.
Navigation¶
All navigation methods are suspending; call them from a coroutine scope:
scope.launch {
// Jump to a page (immediately)
state.scrollToPage(2)
// Animate to a page (smooth scroll)
state.animateScrollToPage(2)
// One page at a time
state.nextPage()
state.previousPage()
}
Call these in a LaunchedEffect or from a coroutine scope (e.g. a button's onClick via rememberCoroutineScope()):
val scope = rememberCoroutineScope()
Button(onClick = { scope.launch { state.nextPage() } }) {
Text("Next")
}
Zoom and pan¶
// Set zoom immediately (clamped to spec.minZoom..maxZoom)
state.setZoom(2.5f)
// Animate to a zoom level (e.g. double-tap at a position)
scope.launch { state.animateZoomTo(3f, focal = tapPosition) }
// Reset to minimum zoom and center
state.resetZoom()
// Pan by a delta (clamped to content bounds)
state.panBy(Offset(100f, 50f))
// Query current state
println("Zoom: ${state.zoom}") // 1.0 = fit
println("Pan: ${state.panOffset}")
println("Current page: ${state.currentPage}")
println("Is zoomed in? ${state.isZoomed}")
Layout modes¶
Control how pages are arranged and navigated:
KiteDocLayout.Continuous (default)¶
All pages in one lazy-loaded strip, scrollable in a single axis. Zoom is magnifier-style: the whole strip scales around the viewport center while scrolling stays native along the scroll axis.
KiteDocView(
state,
layout = KiteDocLayout.Continuous(orientation = Orientation.Vertical),
// vertical = scroll down through all pages; horizontal = scroll right
)
Best for: reading documents end-to-end (papers, reports), where the page count matters less than the scroll position.
KiteDocLayout.Paged (snap paging)¶
One page per screen, snapped. Swipe or drive programmatically to flip pages. Each page fits letterbox-style within the viewport.
KiteDocView(
state,
layout = KiteDocLayout.Paged(
orientation = Orientation.Horizontal,
offscreenPages = 1, // pages pre-rasterized on each side
),
)
Best for: books, slide decks, comics; anything where users think in "pages" not "scroll position".
offscreenPages: pages kept composed and rasterized on each side of the visible page (default 1). Raise to cover faster flinging; set 0 to minimise memory. While idle, the immediate neighbours are pre-rendered so a swipe never stalls.
KiteDocLayout.SinglePage¶
Exactly one fixed page, letterboxed to fill the viewport:
Zoom & gesture configuration¶
Customise pinch, double-tap, pan, and zoom bounds:
val spec = KiteZoomSpec(
pinchEnabled = true,
doubleTapEnabled = true,
panEnabled = true,
minZoom = 1f,
maxZoom = 8f,
doubleTapZoom = 2.5f, // what double-tap toggles to
resetZoomOnPageChange = true, // snap to minZoom when paging
)
KiteDocView(state, zoomSpec = spec)
These bounds are honoured by both gestures and programmatic calls (setZoom, animateZoomTo), so an app driving zoom from a slider is governed by the same range.
To disable zoom entirely:
Rendering: rasterized vs. vectorized¶
The renderSpec parameter controls how pages become pixels. Choose the right trade-off for your use case.
KiteRenderSpec.Rasterized (default)¶
Vector-render each page once into a bitmap per (size, zoom, quality) bucket, then draw that bitmap and GPU-transform it during gestures. Scrolling and panning are cheap; the PDF engine never re-executes.
val spec = KiteRenderSpec.Rasterized(
quality = 1f, // supersampling multiplier over on-screen resolution
maxBitmapLongSide = 4096, // memory cap
rerasterizeOnZoom = true, // re-render at settled zoom for crispness
preserveHairlines = true, // compensate sub-pixel strokes
)
KiteDocView(state, renderSpec = spec)
When to use: - Dense pages with heavy content (graphs, photographs, detailed illustrations). - Lots of pinch-zooming and panning (fast gestures, content-independent cost). - Slow devices, where re-drawing the page every frame would stutter.
Parameters:
quality(default 1.0): supersampling multiplier over on-screen pixels.1.0= rasterize exactly at display resolution (fastest and sharpest).>1.0(e.g. 1.5) oversamples for screenshots or print-like export.<1.0undersamples for cheap thumbnails.maxBitmapLongSide(default 4096): hard memory cap. Large pages and deep zoom won't exceed this on the longest side.rerasterizeOnZoom(default true): after a zoom settles, re-render the visible page at the zoomed resolution so deep zoom stays crisp. Costs one extra rasterization per zoom settle.preserveHairlines(default true): scale the engine's stroke floors by the ratio of the raster to the screen. A zero-width stroke then stays one screen pixel wide, and other sub-pixel strokes (ECG traces, fine table rules) keep their weight when the bitmap is downscaled.
KiteRenderSpec.Vectorized¶
Re-execute each page's content stream into a live Canvas every composition, transformed by zoom/pan via a GPU layer. No bitmap; lower memory footprint, resolution-independent quality.
val spec = KiteRenderSpec.Vectorized(
hairlineWidthPx = 1f, // width of a zero-width stroke, in device pixels
)
KiteDocView(state, renderSpec = spec)
When to use: - Simple pages with minimal content (forms, text-only documents). - Deep zoom crispness matters more than gesture smoothness. - Memory is scarce (no bitmap overhead). - Every composition must stay crisp (e.g. animation).
Parameters:
hairlineWidthPx(default 1.0): the width in device pixels of a stroke whose line width is 0.1.0is the one device pixel of ISO 32000-1, 8.4.3.2. Other thin strokes (ECG traces, fine borders) widen to a fifth of this width, as MuPDF draws them, so they stay visible without turning into solid pixels.
Rasterized vs. Vectorized trade-off
Rasterized wins on gesture smoothness: scroll and pan never re-execute the PDF engine. It trades memory (one bitmap) and rasterization latency for instant playback.
Vectorized wins on memory and true resolution independence but re-draws on every composition. On Android the vector display list replays under the live transform so zoom stays crisp mid-pinch; on Skia targets (iOS, desktop, web) the layer is texture-cached so deep in-gesture zoom softens until the draw re-runs.
For most apps, Rasterized with rerasterizeOnZoom=true is the sweet spot: responsive gestures and crisp zoom, with a small memory footprint per page.
Custom canvas decorators¶
Pass canvasDecorator to either render spec to filter ink, inspect glyphs or
insert drawing calls inside the page's own paint pass. This hook is also
available on KitePageRasterizer.rasterize and rasterizeOffMain.
val decorator: KiteCanvasDecorator = remember {
{ inner ->
object : KiteCanvas by inner {
override fun fillPath(
path: KitePath, ctm: KiteMatrix, color: RgbColor,
evenOdd: Boolean, alpha: Double, blendMode: KiteBlendMode,
) {
inner.fillPath(path, ctm, color, evenOdd, alpha * 0.8, blendMode)
}
}
}
}
KiteDocView(
state = state,
renderSpec = KiteRenderSpec.Rasterized(canvasDecorator = decorator),
)
The supplied canvas includes the reader theme, so custom colours pass through
its colour mapping. Paper, viewer overlays and interactive form controls are
outside this hook. The wrapper receives the same coordinates and matrices as
KiteCanvas and should delegate operations it does not customize.
Some KiteCanvas operations have two overloads. drawImage has one with a
blend mode, which the renderer calls for an image that does not paint with
the Normal blend mode. applySoftMask has one with a transfer function, which
the renderer calls for a soft mask that has one. To customize such an
operation, override both overloads.
With by inner, Kotlin sends an overload that the wrapper does not override
straight to inner.
Rasterization can run on a background thread and repeat on Main when a page needs system-font text. Create a fresh wrapper in the function, keep it repeatable, and never retain the supplied canvas. Cache hits do not invoke it. Remember the function for cache reuse; replace it when captured rendering settings change so the viewer redraws with a new cache key.
Colors¶
Control the paper and viewport background:
val colors = KiteDocViewColors(
pageBackground = Color.White, // behind page content
viewportBackground = Color.Black, // letterbox / gutter
)
KiteDocView(state, colors = colors)
Most PDFs assume white paper and paint nothing behind their content, so pageBackground typically stays white.
KiteDocViewColors also carries searchHighlight (the fill for state.searchHighlights) and selectionHighlight (the fill for the active text selection).
Highlights¶
KiteDocViewState has two highlight channels, and both paint over the page in the same pass.
searchHighlights is the plain one. Every hit paints in KiteDocViewColors.searchHighlight, which is what search results want:
highlights is the app-owned one. Each entry is a KiteHighlight, which wraps a hit with its own colour and its own optional margin marker:
state.highlights = notes.map { note ->
KiteHighlight(
hit = KiteSearchHit(note.pageIndex, note.quads, note.text),
color = note.category.tint, // null keeps KiteDocViewColors.searchHighlight
edgeMarker = true, // a pill in the page margin
edgeMarkerColor = Color(0xFFEF6C00),
)
}
The marker sits in the page's right margin, level with the highlighted text, so a reader can tell a note lives on the page without hunting for the words. It scales with the rendered page, so it keeps its proportions in a thumbnail and at deep zoom alike, and its inner edge is clamped past the highlighted quads so it never paints over the words.
Clear either channel by assigning an empty list.
Text selection¶
A long press anchors a selection, dragging extends it, and the result lands in state.selection (with state.onSelectionChange for a callback). The viewer never touches the clipboard: read selection.text and copy it in your app.
While a selection is live, state.isSelectionActive is true, and the viewer suppresses one-finger panning and the list or pager's own scrolling so the page cannot move out from under the selection. Two-finger pinch zoom keeps working. The flag turns on the moment the long press fires and stays on until state.clearSelection(), which any tap on the page also calls.
val state = rememberKiteDocViewState(document)
state.onSelectionChange = { sel -> selectedText = sel?.text }
// Elsewhere, e.g. in a selection action bar:
if (state.isSelectionActive) {
Button(onClick = { clipboard.setText(state.selection?.text.orEmpty()) }) { Text("Copy") }
}
state.selectionInProgress is true only while the finger is still down on the drag that is building the selection, and drops the moment it lifts. Gate a context menu on it: a popup shown mid-drag covers the words being chosen. isSelectionActive cannot tell those apart, because it deliberately stays on after the finger lifts to keep the page from drifting.
Turning selection off¶
Some documents are pictures, not prose: a chart, a scan, an ECG trace, a generated report you only ever look at. There, a long press that paints a blue wash over a label is noise, and the gesture competes with panning. Pass selectionEnabled = false and the whole thing goes away:
No long press selects, no wash is painted, no thumbs appear, and a selection already on screen is dropped (which also hands back the pan and scroll locks it was holding). The gesture is not attached at all, so it cannot compete with panning. Everything else, zoom, pan, tap, links, page navigation, is untouched. The default stays true.
Selection menu¶
KiteSelectionMenu is a ready-made context menu for the overlay slot. It appears when a selection exists and the drag has ended, lists your actions, and can offer a wrapping row of highlight-colour swatches:
KiteDocView(state, overlay = {
KiteSelectionMenu(
state = state,
items = listOf(
KiteSelectionMenuItem("Copy") { clipboard.setText(AnnotatedString(it.text)) },
KiteSelectionMenuItem("Add note", clearsSelection = false) { openNoteEditor(it) },
),
highlightColors = listOf(Color(0xFFFFF176), Color(0xFFA5D6A7), Color(0xFF90CAF9)),
onHighlightColorPicked = { sel, color -> addHighlight(sel, color) },
)
})
Every visual layer is replaceable: container swaps the card, itemContent swaps how one action renders, colorSwatch swaps how one colour renders, and alignment moves the whole menu. For a completely different menu, skip the composable and build your own against state.selection and state.selectionInProgress; the built-in one is a default, not a contract.
Selection handles¶
The two boundary markers ("thumbs") are canvas vector drawing inside the page's draw pass, not composables, so they scale and pan in lockstep with the words they bound.
They drag. Press on a thumb and that end of the selection follows your finger while the other end stays put; haul one past the other and the two ends swap, the same as a platform text field. The rest of the gesture layer is untouched: a press that misses both thumbs is still an ordinary press, so long-press selection, tap and pan behave exactly as before. Nothing to enable, and the selection stays on one page as it always did.
Recolour the markers with KiteDocViewColors.selectionHandle, or replace the drawing entirely with KiteDocViewColors.selectionHandlePainter:
KiteDocView(state, colors = KiteDocViewColors(
selectionHandlePainter = KiteSelectionHandlePainter { edge, x, top, bottom, color ->
drawCircle(color, radius = (bottom - top) * 0.35f, center = Offset(x, bottom))
},
))
The default is KiteSelectionHandleDefaults.CaretAndDot: a caret spanning the boundary line with a grab dot beneath it.
One catch with a custom painter: the grab area is the boundary line, not the shape you paint. It is a fixed 24.dp radius in screen pixels, so the touch target stays the same size at every zoom level while the marker scales with the text. Draw your marker near its boundary and the two agree; draw it far away and readers will be grabbing empty space.
Opening at a saved position¶
A PDF is ready the moment it opens. A reflowable EPUB is not: it has to be laid
out before it has pages, and a whole book takes seconds. So KiteDocView reads
and lays out one chapter at a time, starting with the one the reader is on.
val state = rememberKiteDocViewState(book, savedBookmark)
KiteDocView(state, Modifier.fillMaxSize())
val savedBookmark = state.currentBookmark() // save on pause
The sample app in sample/ runs this loop against a generated 24-chapter book.
The rest of the book loads in the background, nearest chapter first. A chapter that lands above the reader does not move their page: the strip is keyed by reading position and each publication corrects the pager before the frame draws, so the viewer holds the page, the zoom, and any active selection while the book fills in. A saved Flow bookmark shows its chapter's placeholder from the very first frame.
Chapters that have not been laid out yet hold one page-shaped slot each. A
reader can scroll onto one and wait there; when the chapter arrives they land
on its first page. Replace what that slot shows with chapterPlaceholder:
Reading the position¶
| Member | Use it for |
|---|---|
state.currentLocation |
where the reader is, always exact |
state.currentBookmark() |
a content anchor that survives EPUB reflow |
state.currentScrollPosition |
the leading visible page and continuous offset in pixels |
state.currentPage |
the slot on screen, for an indicator |
state.knownPageCount |
pages laid out so far |
state.isComplete |
true once the total is final |
state.scrollTo(location) / scrollTo(bookmark) |
move, laying out one chapter |
For an exact continuous PDF scroll position, save currentScrollPosition:
val saved: KiteScrollPosition = state.currentScrollPosition
// Persist saved.location.chapter, saved.location.page and saved.offsetPx.
val reopened = rememberKiteDocViewState(document, saved)
// Or restore an existing viewer from a coroutine:
state.scrollTo(saved)
This records the leading visible page, which can differ from the page nearest
its viewport's centre (currentLocation), plus the unzoomed pixel offset into
that page. Both continuous axes and right-to-left layout direction are
supported. An exact visual match requires the same page layout, viewport and
density; zoom and cross-axis pan are separate. For reflowable EPUBs whose font
or page size changes, use currentBookmark() to retain the content anchor.
Paged and single-page layouts report an offset of zero and restore the page.
KitePageIndicator prefixes the total with ~ until isComplete.
state.pageCount is still there and still exact, but reading it lays out every
chapter. Prefer knownPageCount with isComplete.
Link taps¶
A tap on a link inside the document is handled for you: internal jumps (PDF destinations, EPUB hrefs into another chapter) scroll to the target page and never reach your code.
Everything else goes to onLinkTap as a KiteLinkAction. Return true once
you have handled it; false lets the tap fall through to onTap.
KiteDocView(
state = state,
onLinkTap = { link ->
link.uri?.let { openInBrowser(it); true } ?: false
},
)
link.uri answers for both formats, so opening web links needs no when. When
you do need the format-native payload:
| Case | Comes from | Carries |
|---|---|---|
KiteLinkAction.Uri |
an EPUB href with a scheme | the URL |
KiteLinkAction.Pdf |
any PDF /A action the viewer does not perform itself |
the parsed PdfAction (a URI, a remote GoTo, a Launch, JavaScript, a form submit) |
onLinkTap = { link ->
when (link) {
is KiteLinkAction.Uri -> { openInBrowser(link.uri); true }
is KiteLinkAction.Pdf -> when (val action = link.action) {
is PdfAction.Uri -> { openInBrowser(action.uri); true }
is PdfAction.Launch -> { warnAboutLaunch(action.filename); true }
else -> false
}
}
}
Notes in place¶
An EPUB link can mark itself as a reference to a note, a glossary entry or a
bibliography entry (see EpubLink.kind in EPUB). The viewer offers
such a link to onEpubReferenceTap before it scrolls. Return true to keep the
reader on the page, for example after you show the note in a popup. Return
false and the viewer scrolls to the target as usual.
KiteDocView(
state = state,
onEpubReferenceTap = { link ->
book.linkTarget(link.href)?.let { note -> showNote(note.text); true } ?: false
},
)
An ordinary internal link never reaches this callback.
Navigation widgets¶
Ready-made UI components for common patterns. They all take a KiteDocViewState, so they work from anywhere in your tree; inside the viewport (via overlay), in your top bar, in a side panel.
Page indicator¶
Display "current / total" page count:
KitePageIndicator(
state,
modifier = Modifier.padding(8.dp),
format = { current, total -> "Page ${current + 1} / $total" },
)
Navigation controls¶
Previous / page number / next pill. Made for floating over the viewport:
overlay = { state ->
KiteNavigationControls(
state,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(16.dp),
contentColor = Color.White,
containerColor = Color(0xB3222222), // semi-transparent dark
)
}
Buttons auto-disable at the ends (no previous on page 0, no next on the last page).
Thumbnail strip¶
Horizontal carousel of tappable page thumbnails. Current page is outlined; tap any thumbnail to animate there:
KiteThumbnailStrip(
state,
modifier = Modifier.fillMaxWidth(),
thumbnailHeight = 72.dp,
spacing = 8.dp,
selectedBorderColor = Color.Blue,
pageBackground = Color.White,
)
Thumbnails rasterize independently at strip resolution (cheap), so they don't block the main viewer.
The overlay slot¶
Float HUD components over the viewport. The overlay lambda receives the state and a BoxScope for alignment:
KiteDocView(
state,
overlay = { state ->
// Everything here floats over the pages
KiteNavigationControls(state, Modifier.align(Alignment.BottomCenter))
// Add your own widgets
Text(
"${state.currentPage + 1}",
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp),
)
},
)
Export rendered pages¶
Capture a page bitmap and save it as PNG:
KiteDocView(
state,
onPageRendered = { pageIndex, bitmap ->
// bitmap is an ImageBitmap ready for export
val pngBytes = bitmap.encodeToPng()
if (pngBytes != null) {
// Write to file, share, or upload
File("page_$pageIndex.png").writeBytes(pngBytes)
}
},
)
This callback fires every time a page finishes rasterizing (i.e. the bitmap is ready). In rasterized mode it fires once per bucket; in vectorized mode it never fires (no bitmap to hand back).
Custom viewer: KitePageRasterizer¶
If you need a viewer that doesn't fit the built-in layouts (e.g. a thumbnail grid, an image-gallery-style pager, or a PNG batch export), use KitePageRasterizer directly:
@Composable
fun MyCustomPdfViewer(document: PdfDocument) {
val rasterizer = rememberKitePageRasterizer()
for (pageIndex in 0 until document.pageCount) {
val page = document.pages[pageIndex]
val bitmap = rasterizer.rasterize(
page,
widthPx = 1080,
heightPx = 1440,
background = Color.White,
hairlineWidthPx = 1f,
)
// Use bitmap for your own layout
}
}
rememberKitePageRasterizer() wires the rasterizer to the composition's density, layout direction, and text measurement engine. For off-composition rasterization (e.g. a background job), construct KitePageRasterizer directly if you already have a TextMeasurer.
Placeholder while rasterizing¶
Show a custom placeholder while a page bitmap is being rendered:
KiteDocView(
state,
pagePlaceholder = { pageIndex ->
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.LightGray),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
},
)
By default, pages show a solid pageBackground color until their raster lands.
Crossfade on page transition¶
Freshly rasterized pages fade in smoothly rather than popping (160 ms by default). The previous frame remains visible during re-rasterization, so placeholder → page and crisp-zoom refreshes read as a gentle dissolve, never a flash.
Performance notes¶
- Lazy composition: Continuous mode composes only visible pages and their immediate offscreen neighbours (paged mode pre-renders
offscreenPageson each side). Millions of pages are supported; only visible ones cost anything. - Rasterization is off the main thread:
KiteDocViewrenders page bitmaps throughKitePageRasterizer.rasterizeOffMain()on a background pool after composition settles, so scrolling and input stay responsive; results land through a page-bitmap LRU cache. The jitter on a page turn is avoided by pre-fetching neighbours while idle. - Synchronous escape hatch:
KitePageRasterizer.rasterize()still runs on the calling thread for callers that need a bitmap right now; text measurement inside it is serialized internally, so either entry point is safe to use. - Zoom settle debounce: By default,
rerasterizeOnZoom=truewaits approximately 220 ms after zoom stops before re-rendering, so quick pinch-and-release doesn't thrash the rasterizer.