Skip to content

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:

// commonMain
dependencies {
    implementation("io.github.yuroyami:kitepdf-compose-viewer:0.10.0")
}
dependencies {
    implementation("io.github.yuroyami:kitepdf-compose-viewer:0.10.0")
}

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:

KiteDocView(document, page = 2, modifier = Modifier.fillMaxWidth())

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.

val state = rememberKiteDocViewState(document)

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:

KiteDocView(state, layout = KiteDocLayout.SinglePage(pageIndex = 3))

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:

KiteDocView(state, zoomSpec = KiteZoomSpec.Disabled)

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.0 undersamples 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.0 is 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:

state.searchHighlights = document.search("invoice").toList()

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:

KiteDocView(state, selectionEnabled = false)

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:

KiteDocView(
    state = state,
    chapterPlaceholder = { chapter -> CircularProgressIndicator() },
)

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.

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.

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" },
)

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 offscreenPages on each side). Millions of pages are supported; only visible ones cost anything.
  • Rasterization is off the main thread: KiteDocView renders page bitmaps through KitePageRasterizer.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=true waits approximately 220 ms after zoom stops before re-rendering, so quick pinch-and-release doesn't thrash the rasterizer.

See also