Replace and Refresh Documents
Swap the displayed document or reload the current one on a hoisted DocumentState with setCustomPdfSource — without recreating the DocumentView.
/* * Copyright © 2026 PSPDFKit GmbH. All rights reserved. * * The PSPDFKit Sample applications are licensed with a modified BSD license. * Please see License for details. This notice may not be removed from this file. */
package com.pspdfkit.catalog.examples.kotlin
import android.content.Contextimport android.content.Intentimport android.net.Uriimport android.os.Bundleimport androidx.activity.compose.BackHandlerimport androidx.activity.compose.setContentimport androidx.activity.enableEdgeToEdgeimport androidx.appcompat.app.AppCompatActivityimport androidx.compose.animation.AnimatedVisibilityimport androidx.compose.animation.fadeInimport androidx.compose.animation.fadeOutimport androidx.compose.animation.slideInVerticallyimport androidx.compose.animation.slideOutVerticallyimport androidx.compose.foundation.layout.Arrangementimport androidx.compose.foundation.layout.Boximport androidx.compose.foundation.layout.Rowimport androidx.compose.foundation.layout.WindowInsetsimport androidx.compose.foundation.layout.fillMaxSizeimport androidx.compose.foundation.layout.paddingimport androidx.compose.foundation.layout.statusBarsimport androidx.compose.foundation.layout.windowInsetsPaddingimport androidx.compose.material3.Buttonimport androidx.compose.material3.CircularProgressIndicatorimport androidx.compose.material3.OutlinedButtonimport androidx.compose.material3.Textimport androidx.compose.runtime.Composableimport androidx.compose.runtime.collectAsStateimport androidx.compose.runtime.getValueimport androidx.compose.runtime.mutableStateOfimport androidx.compose.runtime.rememberimport androidx.compose.runtime.setValueimport androidx.compose.ui.Alignmentimport androidx.compose.ui.Modifierimport androidx.compose.ui.unit.dpimport com.pspdfkit.catalog.Rimport com.pspdfkit.catalog.SdkExampleimport com.pspdfkit.catalog.tasks.ExtractAssetTaskimport com.pspdfkit.catalog.ui.theming.CatalogThemeimport com.pspdfkit.configuration.activity.PdfActivityConfigurationimport com.pspdfkit.document.DocumentSourceimport com.pspdfkit.jetpack.compose.interactors.DefaultListenersimport com.pspdfkit.jetpack.compose.interactors.DocumentStateimport com.pspdfkit.jetpack.compose.interactors.getDefaultDocumentManagerimport com.pspdfkit.jetpack.compose.interactors.rememberDocumentStateimport com.pspdfkit.jetpack.compose.views.DocumentViewimport com.pspdfkit.utils.getSupportParcelableExtra
/** * Replacing and refreshing the displayed document **without recreating the composable**, through * [DocumentState.setCustomPdfSource] on a hoisted [DocumentState]. The contract: * * - **When it is safe to call:** any time after the [DocumentView] has composed and attached. * Calling it before that is a silent no-op (no exception, no log). * - **The call is asynchronous.** Completion is observed through the `DocumentManager`'s * `onDocumentLoaded` callback — it fires again for the new document once loading completes, * the same way it does for the initial document. * - **Preserved:** the underlying fragment, the [PdfActivityConfiguration], and every listener * registered through the `DocumentManager`. * - **Reset:** the zoom resets to 1.0 on every reload. The current page index carries over into * the new document as-is; with `restoreLastViewedPage` enabled on the configuration (the * default), the incoming document's own last-viewed page can then take over once loading * completes. * - **Guard the page index before replacing:** because the index carries over unguarded, * swapping to a document with fewer pages than the current index can crash. Set a valid page * first — `documentState.documentConnection.setPageIndex(0)` — before calling * [DocumentState.setCustomPdfSource] with a different, potentially shorter document. * - **Replace vs. refresh:** pass a [DocumentSource] to swap the displayed document; pass * `null` to reload the current source (e.g. after its backing file changed on disk). * - **Keeping the reader's place:** capture [DocumentState.getViewState] before the call and * apply [DocumentState.setViewState] from `onDocumentLoaded`. This example keeps one bundle * per document, so switching back restores that document's page, zoom, and viewport — a * bundle is only ever restored onto the same document it was captured from. */class CustomPdfSourceExample(context: Context) : SdkExample(context, R.string.customPdfSourceExampleTitle, R.string.customPdfSourceExampleDescription) { override fun launchExample(context: Context, configuration: PdfActivityConfiguration.Builder) { // Extract both documents up front so the swap button has a target ready. ExtractAssetTask.extract(WELCOME_DOC, title, context) { firstDocument -> ExtractAssetTask.extract(ANNOTATIONS_EXAMPLE, title, context) { secondDocument -> val intent = Intent(context, CustomPdfSourceActivity::class.java) .putExtra(CustomPdfSourceActivity.EXTRA_FIRST, Uri.fromFile(firstDocument)) .putExtra(CustomPdfSourceActivity.EXTRA_SECOND, Uri.fromFile(secondDocument)) // The catalog-prepared configuration carries the catalog theme and the // user's preference toggles — pass it through instead of rebuilding. .putExtra(CustomPdfSourceActivity.EXTRA_CONFIG, configuration.build()) context.startActivity(intent) } } }}
class CustomPdfSourceActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) val first = intent.getSupportParcelableExtra(EXTRA_FIRST, Uri::class.java)!! val second = intent.getSupportParcelableExtra(EXTRA_SECOND, Uri::class.java)!! val configuration = intent.getSupportParcelableExtra(EXTRA_CONFIG, PdfActivityConfiguration::class.java)
setContent { CatalogTheme { CustomPdfSourceDocument(first, second, configuration) } } }
companion object { const val EXTRA_FIRST = "CustomPdfSourceActivity.FirstDocument" const val EXTRA_SECOND = "CustomPdfSourceActivity.SecondDocument" const val EXTRA_CONFIG = "CustomPdfSourceActivity.Configuration" }}
@Composableprivate fun CustomPdfSourceDocument(first: Uri, second: Uri, baseConfiguration: PdfActivityConfiguration?) { val context = androidx.compose.ui.platform.LocalContext.current val configuration = remember(baseConfiguration) { // Layer the example's needs on top of the catalog-prepared configuration (theme, // preference toggles) instead of discarding it. (baseConfiguration?.let { PdfActivityConfiguration.Builder(it) } ?: PdfActivityConfiguration.Builder(context)) // The buttons below occupy the strip where the document title overlay would render. .documentTitleOverlayEnabled(false) .build() } // The state is hoisted once with the FIRST document; swapping never recreates it — the // fragment, configuration, and DocumentManager listeners all survive the source change. // File-backed documents open through their Uri (native, memory-mapped); a stream-backed // DataProvider would buffer the whole document onto the Java heap. val documentState = rememberDocumentState(first, configuration)
// Which source is currently requested, and whether a swap/refresh is still loading. var showingSecond by remember { mutableStateOf(false) } var isLoading by remember { mutableStateOf(false) } var loadedDocumentTitle by remember { mutableStateOf<String?>(null) }
// Continuity across the swap: each document's view state (page, zoom, viewport) is captured // when leaving it and re-applied from onDocumentLoaded when returning to it — a bundle is // only ever restored onto the SAME document it was captured from. A refresh restores the // state it captured right before the call, so the reader keeps their place. val savedViewStates = remember { mutableMapOf<Boolean, Bundle?>() } var pendingViewStateRestore by remember { mutableStateOf<Bundle?>(null) }
// The buttons follow the SDK chrome: hidden while the UI is hidden (immersive mode) and // while any special mode is active (annotation / document / content editing — signaled by a // non-null contextual toolbar), so they never sit under the top-docked contextual bar or // distract from an active tool. var uiVisible by remember { mutableStateOf(true) } val coordinator by documentState.toolbarCoordinator.collectAsState() val modeActive = coordinator?.contextualToolbar?.collectAsState()?.value != null
Box(Modifier.fillMaxSize()) { DocumentView( documentState = documentState, modifier = Modifier.fillMaxSize(), documentManager = getDefaultDocumentManager( documentListener = DefaultListeners.documentListeners( // The completion signal for setCustomPdfSource: fires again for the new // document once the asynchronous replacement/refresh finishes. onDocumentLoaded = { document -> isLoading = false loadedDocumentTitle = document.title // Restore the page, zoom, and viewport captured for THIS document — // applied after load so it also wins over restoreLastViewedPage. pendingViewStateRestore?.let { documentState.setViewState(it) } pendingViewStateRestore = null }, ), uiListener = DefaultListeners.uiListeners( onImmersiveModeEnabled = { immersive -> uiVisible = !immersive }, ), ), )
// Anchored below the SDK main toolbar (status-bar strip + the standard 56dp toolbar // row) — the bottom edge belongs to the thumbnail bar. Fixed clearance on purpose: // measuring through the fragment would read 0 before it attaches. AnimatedVisibility( visible = uiVisible && !modeActive, enter = slideInVertically { -it } + fadeIn(), exit = slideOutVertically { -it } + fadeOut(), modifier = Modifier .align(Alignment.TopCenter) .windowInsetsPadding(WindowInsets.statusBars) .padding(top = 56.dp + 12.dp), ) { SourceButtons( showingSecond = showingSecond, isLoading = isLoading, onSwap = { // Capture the leaving document's place FIRST (before the page-index guard // below clobbers it), so switching back restores page, zoom, and viewport. savedViewStates[showingSecond] = documentState.getViewState() // Guard the page index BEFORE replacing: it carries over into the new // document unguarded, and a shorter document can crash on an out-of-range // index. Page 0 is always valid. documentState.documentConnection.setPageIndex(0) isLoading = true showingSecond = !showingSecond // The incoming document's own saved place, if it was visited before. pendingViewStateRestore = savedViewStates[showingSecond] val target = if (showingSecond) second else first // Replace: asynchronous — completion arrives in onDocumentLoaded above. documentState.setCustomPdfSource(DocumentSource(target)) }, onRefresh = { // Same-document reload: capture now, restore from onDocumentLoaded — the // reload alone would reset the zoom to 1.0. pendingViewStateRestore = documentState.getViewState() isLoading = true // Refresh: null reloads the CURRENT source (same completion signal). documentState.setCustomPdfSource(null) }, ) }
if (isLoading) { CircularProgressIndicator(Modifier.align(Alignment.Center)) } } // Back unwinds like PdfActivity: while a mode or an overlay view is active, let the SDK // close it the layered way (contextual toolbar first, then the overlay); only the default // viewer state lets the system back finish the example. Both gate signals are observable // flows, so the handler's enabled state never goes stale. val overlayShown by documentState.viewWithOverlappingToolbarShown.collectAsState() BackHandler(enabled = modeActive || overlayShown) { documentState.handleBackPress() }}
@Composableprivate fun SourceButtons(showingSecond: Boolean, isLoading: Boolean, onSwap: () -> Unit, onRefresh: () -> Unit) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Button(enabled = !isLoading, onClick = onSwap) { Text(if (showingSecond) "Show first document" else "Show second document") } OutlinedButton(enabled = !isLoading, onClick = onRefresh) { Text("Refresh") } }}This code sample is an example that illustrates how to use our SDK. Please adapt it to your specific use case.