Multiple Documents (Compose Pager)
Swipe between multiple PDFs with a Compose HorizontalPager.
/* * Copyright © 2020-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 android.widget.Toastimport androidx.activity.compose.BackHandlerimport androidx.activity.compose.setContentimport androidx.activity.enableEdgeToEdgeimport androidx.appcompat.app.AppCompatActivityimport androidx.compose.animation.AnimatedVisibilityimport androidx.compose.animation.expandVerticallyimport androidx.compose.animation.fadeInimport androidx.compose.animation.fadeOutimport androidx.compose.animation.shrinkVerticallyimport androidx.compose.animation.slideInVerticallyimport androidx.compose.animation.slideOutVerticallyimport androidx.compose.foundation.backgroundimport androidx.compose.foundation.layout.Arrangementimport androidx.compose.foundation.layout.Boximport androidx.compose.foundation.layout.Columnimport androidx.compose.foundation.layout.PaddingValuesimport androidx.compose.foundation.layout.WindowInsetsimport androidx.compose.foundation.layout.captionBarimport androidx.compose.foundation.layout.fillMaxSizeimport androidx.compose.foundation.layout.fillMaxWidthimport androidx.compose.foundation.layout.imeimport androidx.compose.foundation.layout.imePaddingimport androidx.compose.foundation.layout.paddingimport androidx.compose.foundation.layout.statusBarsPaddingimport androidx.compose.foundation.pager.HorizontalPagerimport androidx.compose.foundation.pager.rememberPagerStateimport androidx.compose.material3.ExperimentalMaterial3Apiimport androidx.compose.material3.MaterialThemeimport androidx.compose.material3.PrimaryTabRowimport androidx.compose.material3.Scaffoldimport androidx.compose.material3.Tabimport androidx.compose.material3.Textimport androidx.compose.runtime.Composableimport androidx.compose.runtime.LaunchedEffectimport androidx.compose.runtime.SideEffectimport androidx.compose.runtime.collectAsStateimport androidx.compose.runtime.getValueimport androidx.compose.runtime.keyimport androidx.compose.runtime.mutableStateOfimport androidx.compose.runtime.rememberimport androidx.compose.runtime.rememberCoroutineScopeimport androidx.compose.runtime.setValueimport androidx.compose.ui.Alignmentimport androidx.compose.ui.Modifierimport androidx.compose.ui.platform.LocalContextimport androidx.compose.ui.platform.LocalDensityimport androidx.compose.ui.unit.Densityimport androidx.compose.ui.unit.dpimport androidx.core.net.toUriimport com.pspdfkit.catalog.Rimport com.pspdfkit.catalog.SdkExampleimport com.pspdfkit.catalog.SdkExample.Companion.WELCOME_DOCimport com.pspdfkit.catalog.ui.theming.CatalogThemeimport com.pspdfkit.compose.theme.MainToolbarColorsimport com.pspdfkit.compose.theme.ToolbarPopupColorsimport com.pspdfkit.compose.theme.getUiColorsimport com.pspdfkit.configuration.activity.PdfActivityConfigurationimport com.pspdfkit.configuration.page.PageFitModeimport com.pspdfkit.configuration.page.PageLayoutModeimport com.pspdfkit.configuration.page.PageScrollDirectionimport com.pspdfkit.configuration.page.PageScrollModeimport com.pspdfkit.jetpack.compose.components.MainToolbarimport 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.ui.toolbar.ContextualToolbarimport com.pspdfkit.ui.toolbar.ToolbarCoordinatorLayoutimport kotlinx.coroutines.launchimport java.io.Fileimport java.lang.ref.WeakReference
/** * List of PDF files to be displayed in the example */private val FILES = listOf( WELCOME_DOC.removeSuffix(".pdf"), "Scientific-paper", "The-Cosmic-Context-for-Life", )
/** * Example entry point showing how to display multiple PDF documents with individual configurations */class DocumentPagerExample(context: Context) : SdkExample(context, R.string.documentPagerExampleTitle, R.string.documentPagerExampleDescription) { override fun launchExample(context: Context, configuration: PdfActivityConfiguration.Builder) { // Extract the documents from assets before launching the activity FileUtils.copyFilesFromAssetsToLocalStorage(context, FILES) { val intent = Intent(context, DocumentPagerExampleActivity::class.java) context.startActivity(intent) } }}
/** * Activity hosting the multiple PDF viewer Composable */class DocumentPagerExampleActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState)
setContent { CatalogTheme { PdfViewerPager( files = FILES, getFileUri = { filename -> File(filesDir, "$filename.pdf").toUri() }, ) } } }}
/** * Manages DocumentState instances for multiple PDFs using WeakReferences to prevent memory leaks */class DocumentStateManager(private val files: List<String>, private val getFileUri: (String) -> Uri) { private val documentStates = mutableMapOf<Int, WeakReference<DocumentState>>()
/** * Returns an existing DocumentState or creates a new one if needed * * @param index The index of the PDF in the files list * @return A DocumentState for the PDF at the given index */ @Composable fun getOrCreateDocumentState(index: Int): DocumentState { val weakRef = documentStates[index] val existingState = weakRef?.get()
// Return existing state if available if (existingState != null) { return existingState }
// Create a new state with different configuration based on document index val context = LocalContext.current val commonConfig = PdfActivityConfiguration .Builder(context) .defaultToolbarEnabled(false) .fitMode(PageFitMode.FIT_TO_WIDTH) val uri = getFileUri(files[index]) val configuration = createConfigurationForIndex(index, commonConfig) val newState = rememberDocumentState(uri, configuration)
// Store weak reference and return the new state documentStates[index] = WeakReference(newState) return newState }
/** * Creates a specific configuration based on the document index */ private fun createConfigurationForIndex(index: Int, commonConfig: PdfActivityConfiguration.Builder) = when (index) { 0 -> commonConfig.scrollMode(PageScrollMode.PER_PAGE).build() 1 -> commonConfig.scrollDirection(PageScrollDirection.VERTICAL).build() else -> commonConfig.layoutMode(PageLayoutMode.DOUBLE).build() }}
/** * Main Composable that displays multiple PDFs with tabs for navigation * * @param files List of PDF filenames to display * @param getFileUri Function to convert a filename to a URI */@Composablefun PdfViewerPager(files: List<String>, getFileUri: (String) -> Uri) { val localDensity = LocalDensity.current val documentStateManager = remember(files) { DocumentStateManager(files, getFileUri) }
val pagerState = rememberPagerState(pageCount = { files.size }) val coroutineScope = rememberCoroutineScope() var hideTopBar by remember { mutableStateOf(true) }
Column(modifier = Modifier.fillMaxSize()) { Scaffold( topBar = { AnimatedVisibility( visible = hideTopBar, enter = slideInVertically { with(localDensity) { -40.dp.roundToPx() } } + expandVertically(expandFrom = Alignment.Top) + fadeIn(initialAlpha = 0.3f), exit = slideOutVertically() + shrinkVertically() + fadeOut(), ) { // Tab row for document selection PdfTabRow( files = files, currentPage = pagerState.currentPage, onTabSelected = { index -> coroutineScope.launch { pagerState.animateScrollToPage(index) } }, ) } }, ) { paddingValues -> // Horizontal pager for PDF documents HorizontalPager( userScrollEnabled = false, state = pagerState, modifier = Modifier.weight(1f), ) { page -> key(page) { PdfDocumentPage( page = page, documentStateManager = documentStateManager, paddingValues = paddingValues, localDensity = localDensity, ) { hideTopBar = it } } } } }}
/** * Composable for the tab row to switch between documents */@OptIn(ExperimentalMaterial3Api::class)@Composableprivate fun PdfTabRow(files: List<String>, currentPage: Int, onTabSelected: (Int) -> Unit) { PrimaryTabRow( selectedTabIndex = currentPage, modifier = Modifier .fillMaxWidth() .background(MaterialTheme.colorScheme.background) .statusBarsPadding(), ) { files.forEachIndexed { index, title -> Tab( selected = currentPage == index, onClick = { onTabSelected(index) }, text = { Column( Modifier .padding(4.dp) .fillMaxWidth(), verticalArrangement = Arrangement.SpaceBetween, ) { Text( text = title, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.align(Alignment.CenterHorizontally), ) } }, ) } }}
/** * Composable for an individual PDF document page */@Composableprivate fun PdfDocumentPage( page: Int, documentStateManager: DocumentStateManager, paddingValues: PaddingValues, localDensity: Density, updateTopBarVisibility: (Boolean) -> Unit,) { val context = LocalContext.current val documentState = documentStateManager.getOrCreateDocumentState(page)
var immersiveModeEnabled by remember { mutableStateOf(false) } var contextualToolbarShown by remember { mutableStateOf(false) } val searchViewShown by documentState.searchViewShown.collectAsState() val toolbarVisibility = !immersiveModeEnabled && !contextualToolbarShown && !searchViewShown val tabRowVisibility = !immersiveModeEnabled && !searchViewShown
LaunchedEffect(documentState) { documentState.setOnContextualToolbarLifecycleListener(object : ToolbarCoordinatorLayout.OnContextualToolbarLifecycleListener { override fun onPrepareContextualToolbar(toolbar: ContextualToolbar<*>) = Unit override fun onDisplayContextualToolbar(toolbar: ContextualToolbar<*>) { contextualToolbarShown = true } override fun onRemoveContextualToolbar(toolbar: ContextualToolbar<*>) { contextualToolbarShown = false immersiveModeEnabled = false } }) }
LaunchedEffect(searchViewShown) { if (!searchViewShown) immersiveModeEnabled = false }
LaunchedEffect(tabRowVisibility) { updateTopBarVisibility(tabRowVisibility) }
val enableViewSpacer by documentState.viewWithOverlappingToolbarShown.collectAsState()
// Manage the content view's top padding (which offsets the thumbnail grid below the visible // toolbar) entirely from Compose via SideEffect. This runs synchronously after every // recomposition and calls into the SDK's setContentViewTopPadding, which suppresses the // automatic Java-side adjustments in ToolbarCoordinatorLayout so the two don't race. // // Crucially, enableViewSpacer is true both during normal grid mode AND editing mode, so the // padding value stays constant across that transition — no grid jump, no thumbnail recycling. // // We use the SDK's own contextual toolbar height (toolbarSizePx) rather than deriving it from // the Compose MainToolbar's measured height. The measured height can be stale by one frame // when showTitleBar changes (e.g. as the grid opens), which would cause a 1-frame spike that // pushes the grid too low. The SDK value is always correct and matches adjustContentViewTopPadding. val contextualToolbarSizePx = documentState.getContextualToolbarSizePx() SideEffect { documentState.setContentViewTopPadding(if (enableViewSpacer) contextualToolbarSizePx else 0) }
val imeVisible = WindowInsets.ime.getBottom(LocalDensity.current) > 0 BackHandler(enabled = (contextualToolbarShown || enableViewSpacer) && !imeVisible) { documentState.handleBackPress() }
Box( contentAlignment = Alignment.TopCenter, ) { Column(modifier = Modifier.padding(top = paddingValues.calculateTopPadding())) { // PDF Document View DocumentView( documentState = documentState, modifier = Modifier.weight(1f).imePadding(), documentManager = createDocumentManager(context) { immersiveModeEnabled = it }, ) }
// Animated toolbar. When the contextual (editing) toolbar is taking over, skip the slide // and shrink — a plain fade avoids visual noise while the editing toolbar slides in. AnimatedVisibility( visible = toolbarVisibility, enter = slideInVertically { with(localDensity) { -40.dp.roundToPx() } } + expandVertically(expandFrom = Alignment.Top) + fadeIn(initialAlpha = 0.3f), exit = if (contextualToolbarShown) fadeOut() else slideOutVertically() + shrinkVertically() + fadeOut(), ) { // Common toolbar for all pages MainToolbar( modifier = Modifier.padding(top = paddingValues.calculateTopPadding()), documentState = documentState, windowInsets = WindowInsets.captionBar, colorScheme = createCustomUiColors(), showTitleBar = !enableViewSpacer, ) } }}
/** * Creates a document manager with listeners for document events */@Composableprivate fun createDocumentManager(context: Context, onImmersiveModeChanged: (Boolean) -> Unit) = getDefaultDocumentManager( documentListener = DefaultListeners.documentListeners( onDocumentLoaded = { Toast.makeText(context, "Document loaded", Toast.LENGTH_SHORT).show() }, ), annotationListener = DefaultListeners.annotationListeners( onAnnotationSelected = { annotation, _ -> Toast .makeText( context, "${annotation.type} selected", Toast.LENGTH_SHORT, ).show() }, onAnnotationDeselected = { annotation, _ -> Toast .makeText( context, "${annotation.type} deselected", Toast.LENGTH_SHORT, ).show() }, ), uiListener = DefaultListeners.uiListeners( onImmersiveModeEnabled = onImmersiveModeChanged, ),)
/** * Creates custom UI colors for the toolbar */@Composableprivate fun createCustomUiColors() = getUiColors().copy( mainToolbar = MainToolbarColors( backgroundColor = MaterialTheme.colorScheme.primary, textColor = MaterialTheme.colorScheme.onPrimary, popup = ToolbarPopupColors(backgroundColor = MaterialTheme.colorScheme.primary), titleTextColor = MaterialTheme.colorScheme.onPrimary, ),)
/** * Utility object for file operations */object FileUtils { /** * Copies files from assets to local storage * * @param context Android context * @param fileNames List of filenames to copy * @param onComplete Callback to run after copying completes */ fun copyFilesFromAssetsToLocalStorage(context: Context, fileNames: List<String>, onComplete: () -> Unit = {}) { fileNames.forEach { fileName -> val destinationFile = File(context.filesDir, "$fileName.pdf")
if (!destinationFile.exists()) { try { context.assets.open("$fileName.pdf").use { inputStream -> destinationFile.outputStream().use { outputStream -> inputStream.copyTo(outputStream, BUFFER_SIZE) } } } catch (e: Exception) { e.printStackTrace() } } }
onComplete() }
private const val BUFFER_SIZE = 8192}This code sample is an example that illustrates how to use our SDK. Please adapt it to your specific use case.