Nutrient 12 migration guide
This guide outlines the changes to Nutrient Android SDK 12.0. Several independent areas changed. The toolbars come first, followed by the annotation inspector’s color picker, the settings dialog, the Material 3 modal surfaces, and the document overview grid. The smaller items come last: the simple-compose example app’s move into the Catalog and the document tab bar’s move to Compose. They also include the Kotlin conversion of PdfFragment and PdfTabBar and a new Instant enum case.
Nutrient 12 replaces every toolbar of PdfActivity and PdfUiFragment with a Jetpack Compose implementation. That covers the main toolbar, the annotation creation toolbar, the document editing and content editing toolbars, and the inline search bar. The Compose toolbar item model is now the single source of truth for toolbar content. This is a clean break with no deprecation window. The Android options-menu contract (Menu, menu XML, onCreateOptionsMenu, …) no longer drives the main toolbar, and the View-based ContextualToolbar hierarchy and ToolbarCoordinatorLayout are removed. This guide covers the main toolbar first. Then it covers the contextual toolbars, the text selection toolbar, custom layouts, the new floating toolbars, and the behavior changes to check in existing integrations.
Requirements
- Compile against API 37.1. The SDK targets API 37, and its Android Archive (AAR) metadata requires compiling against API level 37 with minor version 1. An app with a lower
compileSdkfails its Gradle build until it setscompileSdk = 37andcompileSdkMinor = 1. YourtargetSdkcan stay where it is. - Kotlin 2.3 or newer for Kotlin projects. The SDK is built with Kotlin 2.4.10, and a Kotlin compiler reads metadata up to one version ahead of itself.
- Jetpack Compose 1.12.0. Gradle resolves an app’s Compose dependencies to at least 1.12.0 because the SDK depends on it.
Overview of changes
| Old | New |
|---|---|
onCreateOptionsMenu/onPrepareOptionsMenu overrides | setToolbarItemsProvider(ToolbarItemsProvider) |
onGenerateMenuItemIds (OnMenuItemsGenerateListener) | ToolbarItemsProvider (reorder/insert in the returned list) |
onOptionsItemSelected (toolbar items) | setOnToolbarItemClickListener(OnToolbarItemClickListener) |
invalidateOptionsMenu()/supportInvalidateOptionsMenu()/PdfUi.invalidateMenu() | invalidateToolbarItems() |
OnToolbarMenuChangedListener (PdfUiFragment hosts) | Same three APIs on PdfUiFragment/PdfUi |
| Menu XML resources for toolbar items | ToolbarItem.Button/Toggle/Submenu built-in code |
com.pspdfkit.ui.toolbar.MainToolbar (View) | Compose toolbar; custom hosts render NutrientMainToolbar |
com.pspdfkit.ui.search.PdfSearchViewInline | Compose inline search bar inside the toolbar; PdfUi.isInlineSearchActive() |
getActiveViewType() == VIEW_SEARCH (inline search) | PdfUi.isInlineSearchActive() |
| Standalone document title overlay strip | Rendered inside the main toolbar (themed with it, shown in portrait too) |
| XML theme attributes as the only toolbar theming | Still the default, plus setMainToolbarColorScheme(UiColorScheme)/NutrientTheme |
| (No inset control) | ToolbarInsets + ToolbarCoordinator.setMainToolbarInsets/NutrientMainToolbar(windowInsets = …) |
Toolbar.setNavigationIcon/setNavigationOnClickListener on pspdf__toolbar_main | setToolbarNavigationIcon/setOnToolbarNavigationClickListener on PdfUi and DocumentState |
MainToolbar.setForcedVisibility(GONE) | ToolbarCoordinator.setMainToolbarHostVisible(false) |
Menu setup → toolbar items API
This section lists what was removed from the options-menu contract and shows how to rebuild the same toolbar with the items API.
What was removed
PdfActivityno longer overridesonCreateOptionsMenu/onPrepareOptionsMenu/onOptionsItemSelectedto build the toolbar; overriding them in a subclass no longer affects toolbar content.invalidateOptionsMenu()no longer refreshes toolbar items.OnMenuItemsGenerateListener(withonGenerateMenuItemIds) — Deleted.OnToolbarMenuChangedListener— Deleted.PdfUi.invalidateMenu()— Deleted.- Menu XML isn’t inflated into the toolbar.
The PdfActivity.MENU_OPTION_* IDs (R.id.pspdf__menu_option_*) are unchanged. They’re now the ToolbarItem.id values your click listener receives, but they’re no longer View IDs. The Compose toolbar has no views to match, so onView(withId(R.id.pspdf__menu_option_search)) and UI Automator lookups by resource ID find nothing. Each item carries a Compose test tag with the ID’s resource entry name instead, so an Espresso test moves to the Compose test APIs:
// Before:onView(withId(R.id.pspdf__menu_option_search)).perform(click())
// After:composeTestRule.onNodeWithTag("pspdf__menu_option_search").performClick()A custom item gets its tag the same way, from its id’s resource entry name.
Old
Before 12.0, a PdfActivity subclass added custom items by overriding the options-menu callbacks:
class CustomActionsActivity : PdfActivity() {
override fun onGenerateMenuItemIds(menuItems: MutableList<Int>): List<Int> { val indexOfOutlineButton = menuItems.indexOf(PdfActivity.MENU_OPTION_OUTLINE) menuItems.addAll(indexOfOutlineButton + 1, listOf(R.id.custom_action1, R.id.custom_action2)) return menuItems }
override fun onCreateOptionsMenu(menu: Menu): Boolean { super.onCreateOptionsMenu(menu) menu.findItem(R.id.custom_action1).apply { title = "Menu Item 1" setIcon(R.drawable.ic_arrow_left) setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS) } menu.findItem(R.id.custom_action2).apply { title = "Menu Item 2" setIcon(R.drawable.ic_arrow_right) } return true }
override fun onOptionsItemSelected(item: MenuItem): Boolean { val handled = when (item.itemId) { R.id.custom_action1 -> { showToast("Action 1"); true } R.id.custom_action2 -> { showToast("Action 2"); true } else -> false } return handled || super.onOptionsItemSelected(item) }}New
No overrides — set a provider and a click listener (for example, in onCreate). The provider receives the SDK’s default items in order and returns the list to render; ordering, insertion, and removal all happen on that list. showAsAction is replaced by each item’s overflowBehavior:
class CustomActionsActivity : PdfActivity() {
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState)
setToolbarItemsProvider { defaultItems -> val customItems = listOf( ToolbarItem.Button( actionId = "my.app.action1", // Stable string, survives process death. id = R.id.custom_action1, // `R.id` used for clicks and test tags. contentDescription = "Menu Item 1", // Accessibility label + overflow text. icon = ToolbarIcon.Resource(R.drawable.ic_arrow_left), overflowBehavior = ToolbarItem.OverflowBehavior.AlwaysShow, ), ToolbarItem.Button( actionId = "my.app.action2", id = R.id.custom_action2, contentDescription = "Menu Item 2", icon = ToolbarIcon.Resource(R.drawable.ic_arrow_right), ), ) // Insert after the outline button, exactly like the old `onGenerateMenuItemIds`. val outlineIndex = defaultItems.indexOfFirst { it.id == PdfActivity.MENU_OPTION_OUTLINE } defaultItems.toMutableList().apply { addAll(outlineIndex + 1, customItems) } }
setOnToolbarItemClickListener { id -> when (id) { R.id.custom_action1 -> { showToast("Action 1"); true } R.id.custom_action2 -> { showToast("Action 2"); true } else -> false // `false` = let the SDK handle its default items. } } }}Key concepts of the item model (com.pspdfkit.compose.toolbar.state):
ToolbarItem.Button— A plain action.Togglehas a selected state (annotation tools).Submenugroups nestedchildren, which the contextual and floating toolbars open as a menu.The main toolbar renders a
Submenuas a single button that reports its ownidto the click listener. It never shows the children, so build main toolbar menus from separate items.actionId: String— A stable identity for dispatch and state save/restore. It survives process death even if theR.idinteger changes between SDK versions. Namespace custom ones ("my.app.sign").id: Int— The legacyR.id, used by the click listener and auto-derived test tags.state: ToolbarItemState—isEnabled/isSelected/isVisible. To disable or hide a default item, return acopywith a new state instead of mutating aMenuItem:setToolbarItemsProvider { defaultItems ->defaultItems.map { item ->if (item.id == PdfActivity.MENU_OPTION_EDIT_ANNOTATIONS) {item.copyWithState(item.state.copy(isVisible = false))} else item}}overflowBehavior—AlwaysShow(≈SHOW_AS_ACTION_ALWAYS),IfRoom(≈SHOW_AS_ACTION_IF_ROOM),Never(pinned to the overflow menu). No pregrouping needed.position: ItemEdge—Start/Endedge grouping.icon: ToolbarIcon—Resource(id, tint),Vector(image, tint),Composed { … }for dynamic icons (such as a color-preview circle), orNonefor text-only.
Dynamic updates
To refresh the toolbar when your state changes, call the new invalidation method instead of invalidateOptionsMenu():
// Old: state changed → `invalidateOptionsMenu()`.invalidateOptionsMenu()
// New: state changed → re-run the provider.invalidateToolbarItems()PdfUiFragment in a custom activity
A PdfUiFragment hosted in your own activity used the same options-menu model and now uses the same items API as PdfActivity.
Old
The hosting activity implemented OnToolbarMenuChangedListener (auto-discovered in onAttach) or set it explicitly, then mutated the Menu:
class MyActivity : AppCompatActivity(), OnToolbarMenuChangedListener { override fun onCreateToolbarMenu(menu: Menu) { menu.add(0, R.id.custom_action1, 0, "Share").setIcon(R.drawable.ic_share) } override fun onPrepareToolbarMenu(menu: Menu) { menu.findItem(R.id.custom_action1)?.isEnabled = documentLoaded } override fun onToolbarMenuItemClick(item: MenuItem): Boolean = if (item.itemId == R.id.custom_action1) { share(); true } else false}New
OnToolbarMenuChangedListener is gone (including the onAttach auto-discovery). Use the same items API — it lives on PdfUi, so it’s identical on PdfActivity and PdfUiFragment:
val pdfUiFragment = supportFragmentManager.findFragmentByTag(TAG) as PdfUiFragmentpdfUiFragment.setToolbarItemsProvider { defaultItems -> defaultItems + ToolbarItem.Button( actionId = "my.app.share", id = R.id.custom_action1, contentDescription = "Share", icon = ToolbarIcon.Resource(R.drawable.ic_share), )}pdfUiFragment.setOnToolbarItemClickListener { id -> if (id == R.id.custom_action1) { share(); true } else false}// When your own state changes (the old `onPrepareToolbarMenu` moment):pdfUiFragment.invalidateToolbarItems()Navigation icon
findViewById(R.id.pspdf__toolbar_main) returns null now: The SDK no longer inflates a Toolbar, so setNavigationIcon and setNavigationOnClickListener on it have no target. The navigation (up or back) icon is host state on PdfUi, identical on PdfActivity and PdfUiFragment:
pdfUiFragment.setToolbarNavigationIcon(ToolbarIcon.Resource(R.drawable.pspdf__ic_arrow_back), "Back")pdfUiFragment.setOnToolbarNavigationClickListener { finish() }// Remove it again:pdfUiFragment.setToolbarNavigationIcon(null, null)The icon is rendered at the leading edge as a standard toolbar item, with the 48dp target, the theme tint, and a tooltip. Passing null as the label falls back to AppCompat’s localized “Navigate up,” the same label the View Toolbar gave its navigation button. It survives configuration changes and the fragment’s own implementation swaps like the items provider does. The SDK has no default navigation behavior: Without a listener, the tap does nothing. Underneath, the icon lives on ToolbarFrame.navigationIcon, set through ToolbarCoordinator.setMainToolbarNavigationIcon. A tap dispatches ToolbarAction.NavigationClicked, which ToolbarCoordinator.addNavigationClickHandler wraps for Java callers.
Hiding the toolbar without the SDK undoing it
The SDK rewrites the ToolbarCoordinator.setMainToolbarVisible flag on every visibility recompute (immersive mode, inline search, a contextual bar docking on top). A host’s hide through it is therefore gone at the next toggle. setMainToolbarHostVisible(false) is the host’s veto and replaces MainToolbar.setForcedVisibility(GONE). What is published is the SDK’s request ANDed with it, and lifting the veto restores whatever the SDK last requested. Use it for a runtime hide; defaultToolbarEnabled(false) remains the configuration-time way to have no SDK toolbar at all.
val coordinator = pdfUiFragment.nutrientToolbarCoordinator()coordinator.setMainToolbarHostVisible(false)Compose DocumentView hosts
Previously, the Compose DocumentView had no way to customize the main toolbar (the old experimental com.pspdfkit.jetpack.compose.components.MainToolbar composable is deleted). Now DocumentState exposes the same API, safe to call before the DocumentView attaches:
val documentState = rememberDocumentState(uri, configuration)
LaunchedEffect(documentState) { documentState.setToolbarItemsProvider { defaultItems -> defaultItems + myCustomItem } documentState.setOnToolbarItemClickListener { id -> if (id == R.id.custom_action1) { doThing(); true } else false } // The SDK-rendered bar's navigation icon; a host composing its own `NutrientMainToolbar` // fills the `navigationIcon` slot instead. documentState.setToolbarNavigationIcon(ToolbarIcon.Resource(R.drawable.ic_back), "Back") documentState.setOnToolbarNavigationClickListener { onBack() }}// Later, when state feeding the provider changes.documentState.invalidateToolbarItems()If you used the Compose toolbar preview in 11.6 or 11.7
11.6 and 11.7 shipped an early public version of the Compose toolbar API. Its signatures changed in 12.0:
NutrientMainToolbar’scustomTitleandcustomActionsparameters are removed. Use thetitleandactionsslots described in the slots and chrome guide.ToolbarIcon.Composed.contentnow receives the tint the toolbar would apply in the item’s current state,@Composable (tint: Color) -> Unit, so a composed icon can match the normal, selected, and disabled colors.ToolbarItem.Buttonnow defaults toposition = ItemEdge.End, where 11.7 defaulted toStart. Passposition = ItemEdge.Startto keep a button on the leading edge.NutrientToolbarLayouttakes anImmutableList<ToolbarItem>; convert withtoImmutableList().NutrientToolbarItemButtongains theonLongClick,invertedSelection,toolbarPosition, andindicationparameters listed in the new API reference.
Rendering the toolbar yourself
With PdfActivityConfiguration.Builder.defaultToolbarEnabled(false), the host can place the toolbar anywhere. DocumentState.toolbarCoordinator publishes the fragment-scoped ToolbarCoordinator once the UI is ready (null until then):
val coordinator by documentState.toolbarCoordinator.collectAsState()
Box(Modifier.fillMaxSize()) { DocumentView(documentState = documentState, modifier = Modifier.fillMaxSize())
coordinator?.let { c -> NutrientTheme(colorScheme = myColors()) { // Themes items, title, and the overflow popup. NutrientMainToolbar( coordinator = c, modifier = Modifier.fillMaxWidth(), // The bar is as wide as its content unless given a width. windowInsets = WindowInsets(0, 0, 0, 0), // Host already handles the status bar. ) } }}The coordinator also exposes searchSession (inline-search activity as a flow) and frame (visibility that flips with immersive mode). Custom chrome can observe both to animate alongside the toolbar. See MainToolbarCustomizationExample in the Catalog app for a complete toolbar built this way, and ComposePdfViewerExample for a full Compose app hosting the bar over DocumentView.
Slots and chrome
NutrientMainToolbar has the shape of Material 3’s TopAppBar. It takes navigationIcon, title, actions, and searchBar slots, or one content lambda for a bar that isn’t “navigation, title, actions” at all. It also takes flat shape, shadowElevation, windowInsets, colors, itemIndication, enter, and exit parameters, all defaulting to NutrientMainToolbarDefaults. The SDK’s own pieces are public composables (NutrientMainToolbarTitle, NutrientToolbarItems, NutrientToolbarSearchBar, and NutrientMainToolbarContent), so composing your own content never means giving up the coordinator’s items:
// A back button in front of the SDK title and items, drawn as a floating pill.NutrientMainToolbar( coordinator = c, navigationIcon = { IconButton(onClick = onBack) { Icon(painterResource(R.drawable.ic_back), contentDescription = "Back", tint = UiTheme.colors.mainToolbar.iconColor) } }, shape = NutrientMainToolbarDefaults.FloatingShape, shadowElevation = NutrientMainToolbarDefaults.FloatingShadowElevation, windowInsets = NutrientMainToolbarDefaults.NoWindowInsets, itemIndication = ToolbarItemIndication(shape = CircleShape),)
// A bar that is not "navigation, title, actions": lay the SDK pieces out yourself.NutrientMainToolbar(coordinator = c, modifier = Modifier.align(Alignment.TopEnd)) { NutrientToolbarItems(c, Modifier.weight(1f)) NutrientMainToolbarTitle(c)}A few rules follow from that shape:
navigationIcondefaults toNutrientMainToolbarNavigationIcon(coordinator), which renders the icon set throughsetToolbarNavigationIconand dispatchesToolbarAction.NavigationClicked. A composed slot replaces it.title,actions,searchBar, andcontentrun in aNutrientToolbarScope, whoseweight()andalign()modifiers work likeRowScope’s. The defaultactionscarryModifier.weight(1f), so trailing items pin to the far edge of a full-bleed bar.searchBarreplaces the slots while inline search is open, whatever the host composed. Its default is the SDK search bar filling the width.- The toolbar is as wide as its content, the way
Rowis, and a width modifier wins.Modifier.fillMaxWidth()is the full-bleed bar the SDK’s own mount passes. Passing nothing gives a floating pill exactly as wide as its icons. The toolbar animates width changes itself; don’t addModifier.animateContentSize(), which clips its shadow. modifieris applied to the outermost node, the show/hide animation, soBoxScope.align, padding, andonSizeChangedwork from the call site. The subtree is removed once the toolbar has animated away. A modifier that must reserve space while the bar is hidden belongs on a container around it.- A Material
Surfacedrawsshape,shadowElevation, andwindowInsetsinside that animation, so a pill slides away with the bar. A tap that lands on the toolbar but misses every button stops there instead of reaching the document. colorsanditemIndicationare provided to everything inside the bar, so slot content reads the same scheme throughUiTheme.colors.mainToolbar. Every item, the SDK’s and yours, shows its pressed, hovered, and selected states with the sameToolbarItemIndication. The default is the 12dp round rect the View toolbar drew its selection with, now also bounding the ripple.
The toolbar is no longer an ActionBar/View
com.pspdfkit.ui.toolbar.MainToolbar(the View subclass) is deleted. Code that calledfindViewByIdon it, passed it tosetSupportActionBar, or themed it as anandroidx.appcompat.widget.Toolbarmust move to the items/theming APIs above.getSupportActionBar()in aPdfActivitysubclass no longer returns the main toolbar’sActionBar.ActionBar-based title/home-button tweaks don’t apply to the Compose toolbar. Set the home or up button withsetToolbarNavigationIcon+setOnToolbarNavigationClickListener(see navigation icon). For a runtime hide the SDK doesn’t undo, useToolbarCoordinator.setMainToolbarHostVisible(false), which replacesMainToolbar.setForcedVisibility(GONE).com.pspdfkit.ui.toolbar.ToolbarCoordinatorLayoutis deleted along with the main toolbar. Toolbar placement, docking, and drag chrome are owned by the Compose toolbar scaffold in every host. SeeToolbarCoordinatorLayoutis removed for custom layouts.
The contextual toolbars (annotation creation, document editing, content editing) moved to Compose as well and are covered in their own section. The popup toolbars (TextSelectionPopupToolbar for selected text and AnnotationPopupToolbar for a selected annotation, customized through OnPreparePopupToolbarListener) are unchanged.
Inline search
Inline search is now a Compose bar rendered inside the main toolbar’s own composition — it’s no longer a View in the activity layout:
com.pspdfkit.ui.search.PdfSearchViewInlineis deleted.- For
SearchType.INLINE,PSPDFKitViews.getSearchView()now returnsnull.SearchType.MODULARis unchanged (PdfSearchViewModular/PdfSearchViewLazystill exist). - Inline search no longer reports as
VIEW_SEARCH:getActiveViewType()returnsVIEW_NONEwhile the inline bar is open. - Inline search state (query, active result) now survives rotation.
Old
Code that observed the inline search View looked like this:
val searchView = pspdfKitViews.searchViewif (searchView is PdfSearchViewInline) { searchView.addOnVisibilityChangedListener(object : OnVisibilityChangedListener { override fun onShow(view: View) { onSearchOpened() } override fun onHide(view: View) { onSearchClosed() } })}// or:val searchOpen = activeViewType == PSPDFKitViews.Type.VIEW_SEARCHNew
Read the search state directly on PdfUi, or observe it through the coordinator in Compose hosts:
// `PdfActivity`/`PdfUiFragment` (`PdfUi`):val searchOpen = isInlineSearchActive()
// Compose hosts — observe it reactively via the coordinator:val searchSession by coordinator.searchSession.collectAsState()val searchActive = searchSession?.isActive?.collectAsState()?.value == trueFor a real search View with a rich API, switch to SearchType.MODULAR; for headless search, use com.pspdfkit.document.search.TextSearch.
Document title overlay
The title overlay (pspdf__activity_title_overlay) is no longer a standalone View strip below the toolbar — it renders inside the main toolbar’s composition.
Behavior changes:
- It follows the main toolbar’s color theme (
DocumentTitleOverlayColors, part ofMainToolbarColors— defaults to the toolbar background/title colors). - It’s now shown in portrait as well (previously landscape-only).
- No blank strip is reserved when the document has no title.
PSPDFKitViews.getDocumentTitleOverlayView() is removed, since the SDK no longer has a TextView to return. PdfActivityConfiguration.Builder.documentTitleOverlayEnabled(...) works as before. Custom Compose hosts replace the title area through the title slot, NutrientMainToolbar(title = { … }). They put a back button in front of it with navigationIcon.
Theming
XML theme attributes (pspdf__actionBarIconsStyle, toolbar background/title attributes, …) remain the default and keep working — the Compose toolbar reads them via getUiColors(). The sections below cover the programmatic and Compose theming APIs that sit on top.
Programmatic theming from PdfActivity
Set a color scheme on the activity to override the XML attributes at runtime:
// `null` clears the override and falls back to the XML attrs.setMainToolbarColorScheme(myUiColorScheme)The scheme colors the toolbar row, title, title overlay, and the overflow/submenu popup:
val scheme = defaults.copy( // e.g. `getUiColors()` captured in a composition, or a fully built `UiColorScheme`. mainToolbar = MainToolbarColors( backgroundColor = Color(0xFF1E2A44), textColor = Color.White, popup = ToolbarPopupColors(backgroundColor = Color(0xFF1E2A44)), titleTextColor = Color.White, // `iconColor` defaults to `textColor`; `iconSelectedColor`/`iconDisabledColor` derive from it. ),)Compose hosts
Wrap the toolbar in NutrientTheme (see the section on rendering the toolbar yourself above). getUiColors() gives the XML-derived defaults, and getComposeUiColors() gives the pure-Compose defaults; copy the mainToolbar block.
Inset management — New API
Previously, the toolbar assumed it was mounted edge-to-edge at the top of the window and reserved a status-bar strip unconditionally. In activities with custom layouts (a DrawerLayout wrapping the activity layout, or content positioned below the status bar), this desynced: Opening inline search clipped the search bar, and toolbars shifted. The toolbar now derives the reserved strip from its actual window position, so SDK-mounted hosts need no changes and get correct behavior. It also reserves the side system bars. In landscape with a three-button navigation bar, or beside a display cutout, the trailing overflow item no longer renders under the system buttons. Both insets are part of NutrientMainToolbarDefaults.windowInsets(coordinator), so a host that passes its own windowInsets owns the side insets too.
Controlling insets explicitly
ToolbarInsets (in com.pspdfkit.compose.toolbar.state) describes what the toolbar reserves:
ToolbarInsets.StatusBars— The default; correct for edge-to-edge top mounting.ToolbarInsets.None— The toolbar sits away from the top edge or the host already spaces it.ToolbarInsets.Custom(top, bottom, start, end)— FixedDpinsets;ToolbarInsets.fixedTop(topPx, density)is the Java-friendly factory.
Two ways to apply:
// 1. Host-agnostic — works for the SDK-mounted toolbar too (`PdfActivity`/`PdfUiFragment`,// e.g. an embedded `PdfFragment` sitting below other chrome):coordinator.setMainToolbarInsets(ToolbarInsets.None)
// 2. Compose-only — parameter on the composable; overrides (1) when set:NutrientMainToolbar(coordinator = c, windowInsets = WindowInsets(0, 0, 0, 0))Document viewport behavior change
The document no longer shifts when the inline search bar opens or content-editing mode starts. The viewport is only inset while UI actually overlaps it (content-editing styling bar, keyboard). If your integration compensated for the old jump (manual padding/scroll offsets around SearchType.INLINE or content editing), remove that workaround.
Contextual toolbars
One generic NutrientContextualToolbar, driven by a ToolbarCoordinator, renders the annotation creation, document editing, and content editing toolbars in Jetpack Compose. The coordinator is scoped to its host: ComponentActivity.nutrientToolbarCoordinator() returns the activity’s, Fragment.nutrientToolbarCoordinator() returns the fragment’s, and a PdfUiFragment uses its own, so ask the host that shows the bar. In a PdfActivity or PdfUiFragment, the bar mounts automatically and is never constructed by the integration. Everything that used to be done on a toolbar instance is done on the coordinator instead.
What was removed
com.pspdfkit.ui.toolbar.ContextualToolbar(the base class),AnnotationToolbar,DocumentEditingToolbar,ContentEditingToolbar,ContextualToolbarMenuBar,ContextualToolbarSubMenu, andToolbarCoordinatorLayoutController.ToolbarCoordinatorLayout.OnContextualToolbarLifecycleListener,OnContextualToolbarMovementListener, andOnContextualToolbarPositionListener, and their setters onPdfActivity,PdfUiFragment,PdfUi, andDocumentState.ToolbarCoordinatorLayout.displayContextualToolbar/removeContextualToolbar/getCurrentlyDisplayedContextualToolbar/attachContextualToolbar/detachContextualToolbar.AnnotationToolbar.ItemToAnnotationToolMapperandAnnotationToolbar.setShouldShowStylusButton().- The
PSPDFKitPreferences.getLastToolbarPosition/setLastToolbarPositionoverloads that took aContextualToolbar. The ID-based overloads remain, retyped toToolbarDockPosition(see preferences). ContextualToolbar.MIN_TOOLBAR_CAPACITYmoved toPresetMenuItemGroupingRule.MIN_TOOLBAR_CAPACITY.
ContextualToolbarMenuItem, MenuItemGroupingRule, PresetMenuItemGroupingRule, and DefaultMenuItemGroupingRule still exist but are deprecated. They only carry the grouping-rule bridge described below and are removed with it in the next major version.
Getting the coordinator
Every contextual toolbar customization starts from the host’s coordinator:
import com.pspdfkit.jetpack.compose.toolbar.nutrientToolbarCoordinator
// In any `ComponentActivity` or `Fragment` host:val coordinator = nutrientToolbarCoordinator()
// Compose-first hosts collect it from the document state instead (`null` until the UI is ready):val coordinator by documentState.toolbarCoordinator.collectAsState()Every add… method on the coordinator returns a ToolbarSubscription, which is AutoCloseable. Close it in the host’s teardown callback.
Reacting to a contextual toolbar showing or hiding
The lifecycle listener is replaced by a listener on the coordinator’s contextual state:
// Before — the lifecycle listener:setOnContextualToolbarLifecycleListener(object : ToolbarCoordinatorLayout.OnContextualToolbarLifecycleListener { override fun onDisplayContextualToolbar(toolbar: ContextualToolbar<*>) { toolbarShown = true } override fun onRemoveContextualToolbar(toolbar: ContextualToolbar<*>) { toolbarShown = false } override fun onPrepareContextualToolbar(toolbar: ContextualToolbar<*>) = Unit})
// After — observe the coordinator's contextual state; `null` means no bar is shown.val subscription = coordinator.addContextualToolbarChangeListener { state -> toolbarShown = state != null when (state?.kind) { ContextualToolbarKind.Annotation -> { /* Annotation creation bar. */ } ContextualToolbarKind.DocumentEditing -> { /* Document editing bar. */ } ContextualToolbarKind.ContentEditing -> { /* Content editing bar. */ } null -> { /* Hidden. */ } }}Coroutine hosts can collect coordinator.contextualToolbar (a StateFlow<ContextualToolbarState?>) instead. The state also carries the bar’s items, its position, and whether it isDraggable, so the movement and position listeners have the same replacement: state.position changes when the user docks the bar elsewhere. Drag start and end callbacks (OnContextualToolbarMovementListener) have no public replacement in SDK hosts; a host rendering its own bar receives them through NutrientContextualToolbarDragListener.
Customizing items and handling clicks
Items are immutable state on the coordinator rather than mutable ContextualToolbarMenuItems on a View. Transform them with updateContextualItems, and observe taps on the coordinator’s action bus:
// Before:override fun onPrepareContextualToolbar(toolbar: ContextualToolbar<*>) { if (toolbar is AnnotationToolbar) { toolbar.setMenuItems(toolbar.menuItems + myCustomItem) toolbar.setOnMenuItemClickListener { _, item -> if (item.id == R.id.my_item) { doThing(); true } else false } }}
// After — add the item whenever the annotation bar appears, and listen for the click:coordinator.addContextualToolbarChangeListener { state -> if (state?.kind == ContextualToolbarKind.Annotation && state.items.none { it.id == R.id.my_item }) { coordinator.updateContextualItems { items -> items.add(myCustomToolbarItem) } }}coordinator.addActionListener { action -> if (action is ToolbarAction.ItemClicked && action.id == R.id.my_item) doThing()}Click actions carry the bar they came from in action.kind (null for the main toolbar). That tells an annotation-tool tap apart from a main-toolbar item with the same ID.
addActionListener observes only; the SDK’s own handler always runs too. To consume a click, as returning true from the old OnMenuItemClickListener did, register an interceptor. It runs before every listener, and returning true stops the SDK from seeing the action:
coordinator.addActionInterceptor { action -> action is ToolbarAction.ItemClicked && action.kind == ContextualToolbarKind.Annotation && action.id in gatedToolIds // `true` = consumed, the tool is never activated.}Custom tool items activate their tool directly from the action listener with fragment.enterAnnotatingMode(tool, variant); ItemToAnnotationToolMapper has no replacement. Long-press events aren’t exposed on the action bus, because a long press opens a group’s submenu.
To remove an item, filter it out in updateContextualItems. This is also how you hide the stylus toggle now that setShouldShowStylusButton(false) is gone. It appears automatically whenever a stylus is connected, and filtering out R.id.pspdf__annotation_toolbar_item_stylus removes it.
Grouping
The preset-based grouping rules keep working through a deprecated bridge for one major version. Register the rule once, on the coordinator, instead of on each toolbar instance in onPrepareContextualToolbar:
// Before:override fun onPrepareContextualToolbar(toolbar: ContextualToolbar<*>) { if (toolbar is AnnotationToolbar) toolbar.setMenuItemGroupingRule(MyPresetGroupingRule(this))}
// After — deprecated bridge, scoped to one bar kind:coordinator.setMenuItemGroupingRule(this, ContextualToolbarKind.Annotation, MyPresetGroupingRule(this))
// Replacement — one pure-data provider for every bar, branching on kind:coordinator.setContextualToolbarGroupingProvider { kind, capacity, itemCount, defaultPreset -> if (kind == ContextualToolbarKind.Annotation) myPreset(capacity) else defaultPreset}Only the getGroupPreset() data of a PresetMenuItemGroupingRule is honored by the bridge. A custom groupMenuItems(...) override operates on View menu items and can’t be bridged; port it to a ContextualToolbarGroupingProvider returning ToolbarPresetEntry lists.
Docking and draggability
Docking and draggability moved from the toolbar instance to the coordinator:
// Before — layout params and setters on the toolbar instance:toolbar.setDraggable(false)toolbar.layoutParams = ToolbarCoordinatorLayout.LayoutParams(Position.LEFT, EnumSet.of(Position.LEFT))
// After — coordinator-level, applies to the bar that is currently shown:coordinator.setContextualToolbarPosition(ToolbarPosition.Start)coordinator.setContextualToolbarDraggable(false)ToolbarPosition is logical (Top, Start, End). It resolves against the layout direction, so a bar docked at Start sits on the right in a right-to-left layout. Both setters are silent no-ops while no contextual bar is shown, so call them from an addContextualToolbarChangeListener callback. The user-dragged position persists across sessions in SDK hosts, exactly as the View bar’s did.
To restrict which docks the user may drag a contextual bar to, what ToolbarCoordinatorLayout.LayoutParams.allowedPositions did, call coordinator.setContextualToolbarAllowedPositions(listOf(ToolbarPosition.Top)). It holds across every show and mode change, and applies to the shown bar and every later one. The host can only narrow the set. A bar’s own set is intersected with the host’s, and an empty intersection keeps the bar’s own docks so nothing becomes undockable. A bar sitting on a forbidden dock moves to the first allowed one. Passing null clears the restriction.
Preferences {#preferences}
The physical dock (TOP, LEFT, RIGHT) moved from ToolbarCoordinatorLayout.LayoutParams.Position to the top-level com.pspdfkit.ui.toolbar.ToolbarDockPosition, with identical constants and ordinals, so persisted preferences restore unchanged:
// Before:PSPDFKitPreferences.get(context) .setLastToolbarPosition(R.id.pspdf__document_editing_toolbar, ToolbarCoordinatorLayout.LayoutParams.Position.TOP)
// After:PSPDFKitPreferences.get(context) .setLastToolbarPosition(R.id.pspdf__document_editing_toolbar, ToolbarDockPosition.TOP)Self-hosted toolbars with a plain PdfFragment
Hosts that build their own activity around a PdfFragment used to construct an AnnotationToolbar and hand it to ToolbarCoordinatorLayout.displayContextualToolbar. Now they mount the SDK’s Compose toolbar stack and bind it to the fragment:
// Before:val toolbar = AnnotationToolbar(context)toolbarCoordinatorLayout.displayContextualToolbar(toolbar, true)
// After — mount the toolbar stack (drag handle, dock slots, dock persistence included)// and show the annotation bar whenever the fragment enters annotating mode:val coordinator = nutrientToolbarCoordinator()toolbarHostContainer.addView(nutrientToolbarsComposeView(this, coordinator))val annotationToolbar = AnnotationToolbarBinder(coordinator)fragment.addOnAnnotatingModeChangeListener(object : OnAnnotatingModeChangeListener { override fun onEnterAnnotatingMode(controller: AnnotatingController) = annotationToolbar.attach(controller) override fun onExitAnnotatingMode(controller: AnnotatingController) = annotationToolbar.detach() override fun onChangeAnnotatingMode(controller: AnnotatingController) = Unit})AnnotationToolbarBinder.position and isDraggable apply on every attach(). A host that also renders main toolbar items pairs this with PdfFragmentToolbarBinder. The binder keeps the coordinator’s undo, redo, and annotation tool items (pspdf.undo, pspdf.redo, pspdf.annotation.{tool} action IDs) in sync with the fragment. Call attach(fragment) once the fragment is attached and detach() when it goes away. Pass showMainToolbar = true to nutrientToolbarsComposeView to render the SDK’s main toolbar in the same stack.
Custom hosts own docking. The binders restore the last-used dock position, but only the host places the bar. nutrientToolbarsComposeView provides the same top, start, and end dock slots and drop-zone chrome PdfActivity has. A host that mounts nutrientContextualToolbarComposeView in its own container instead must either implement docking or pin the position (binder.position = ToolbarPosition.Top). Otherwise, a restored Start or End dock renders a vertical bar at the container’s origin with no way to move it. The drag handle only shows when the mount is passed a NutrientContextualToolbarDragListener.
Self-hosted setups also persist the dock themselves: Observe coordinator.contextualToolbar, store state.position, and set the binder’s position before the next attach().
Rendering the contextual bar yourself
A host that sets defaultContextualToolbarEnabled(false) and draws the contextual bar itself composes NutrientContextualToolbar(coordinator). It follows the same TopAppBar shape as the main toolbar. It has a closeButton slot, whose NutrientContextualToolbarScope exposes the bar’s state and a dispatch that stamps the action with the bar’s kind. It also has flat shape, border, sideDockedShape, sideDockedBorder, shadowElevation, windowInsets, colors, itemIndication, enter, and exit parameters defaulting to NutrientContextualToolbarDefaults. Two shape and border pairs cover the bar’s two looks: a strip docked at the top and a rounded rail docked at a side.
NutrientContextualToolbar( coordinator = c, modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp), shape = RoundedCornerShape(28.dp), sideDockedShape = RoundedCornerShape(28.dp), shadowElevation = 8.dp, colors = NutrientContextualToolbarDefaults.colors().copy(backgroundColor = MaterialTheme.colorScheme.primaryContainer),)A top-docked bar is as wide as its items unless given a width. Pass Modifier.fillMaxWidth() for the edge-to-edge strip PdfActivity shows, and nothing for a pill in a corner. A side-docked bar is always NutrientContextualToolbarDefaults.ToolbarSize thick. The default windowInsets are none, because NutrientToolbarScaffold and the SDK’s own mount already place the bar inside them. Pass a dragListener to show the drag handle; a bar that offers no docking shows none. CustomToolbarsDocumentViewExample and CustomToolbarsPdfActivityExample in the Catalog app render the bar this way.
Theming
The generic pspdf__contextualToolbarStyle (background, border, icon, and activated-icon colors, submenu colors) themes every Compose contextual toolbar, as it did the View bars. The per-bar styleables are no longer read: pspdf__annotationCreationToolbarIconsStyle/pspdf__AnnotationCreationToolbarIcons (per-tool icons, pspdf__iconsColor, pspdf__iconsColorActivated, pspdf__undoIcon, pspdf__redoIcon), pspdf__DocumentEditingToolbarIcons, and pspdf__ContentEditingToolbar. Per-item icon replacement goes through updateContextualItems, swapping the item for a copy with a different ToolbarIcon.
pspdf__toolbarCoordinatorLayoutStyle (drag-target color, contextual toolbar height) outlives the deleted View class. The Compose toolbar scaffold reads the same attribute and styleable, so existing themes keep working.
UI tests
The bars have no View IDs. onView(withId(R.id.pspdf__annotation_toolbar)) and the per-item ID matchers find nothing. Compose test tags carry the same resource entry names, and the bar’s surface is tagged pspdf__contextual_toolbar:
// Before:onView(withId(R.id.pspdf__document_editing_toolbar_item_done)).perform(click())
// After:composeTestRule.onNodeWithTag("pspdf__document_editing_toolbar_item_done").performClick()Text selection toolbar
The deprecated docked com.pspdfkit.ui.toolbar.TextSelectionToolbar is removed. The popup toolbar (com.pspdfkit.ui.toolbar.popup.TextSelectionPopupToolbar), the default since it was introduced, is the only text selection toolbar.
textSelectionPopupToolbarEnabled()onPdfConfiguration.BuilderandPdfActivityConfiguration.Builder, andPdfConfiguration.isTextSelectionPopupToolbarEnabled, are removed. They could only meantrueonce the fallback was gone. Delete the calls.- Customizing the popup toolbar is unchanged:
setOnPreparePopupToolbarListenerandonPrepareTextSelectionPopupToolbarkeep working. SeePopupToolbarCustomItemsExamplein the Catalog app. pspdf__textSelectionToolbarIconsStyleandPSPDFKit.TextSelectionToolbarIconsonly themed the removed View toolbar and are removed with it. Colors come frompspdf__popupToolbarStyle(pspdf__backgroundColor,pspdf__itemTint,pspdf__itemTintDisabled). Per-action icon overrides such aspspdf__copyIconhave no theme equivalent. In the prepare listener, replace thePopupToolbarMenuItemwith one carrying the drawable instead.- Espresso: The
pspdf__text_selection_toolbarID is unchanged, but the toolbar lives in a popup window, so match it withinRoot(isPlatformPopup()). - Behavior: The removed View toolbar forced the user interface visible while text was selected. The popup toolbar keeps immersive mode active, matching the annotation selection popup.
ToolbarCoordinatorLayout is removed
com.pspdfkit.ui.toolbar.ToolbarCoordinatorLayout and its LayoutParams are deleted. The Compose toolbar scaffold owns placement, docking, drag chrome, and the content inset in every host.
Custom activity layouts passed through PdfActivityConfiguration.Builder.layout() provide a ComposeView with the ID pspdf__toolbar_compose_host instead of the coordinator layout. It overlays the content view:
<!-- Before: --><com.pspdfkit.ui.toolbar.ToolbarCoordinatorLayout android:id="@+id/pspdf__toolbar_coordinator" android:layout_width="match_parent" android:layout_height="match_parent" />
<!-- After: --><androidx.compose.ui.platform.ComposeView android:id="@+id/pspdf__toolbar_compose_host" android:layout_width="match_parent" android:layout_height="match_parent" />Custom PdfFragment hosts that called toolbarCoordinatorLayout.bindNutrientContextualToolbar(coordinator) add nutrientToolbarsComposeView(context, coordinator) to their container instead; see self-hosted toolbars.
PdfActivity no longer pads the document for a contextual toolbar when defaultContextualToolbarEnabled(false) is set and the host renders the contextual toolbar itself.
Floating toolbars
New in 12.0, for hosts that draw their own chrome: NutrientFloatingToolbar renders the main toolbar’s items as a Material 3 Expressive floating pill, and NutrientFloatingContextualToolbar does the same for whichever contextual bar is shown. The ToolbarCoordinator drives both. You can drag and dock them to any window edge, and they lay out horizontally or vertically depending on the edge and animate with springs.
val coordinator by documentState.toolbarCoordinator.collectAsState()
Box(Modifier.fillMaxSize()) { DocumentView(documentState = documentState, modifier = Modifier.fillMaxSize()) coordinator?.let { c -> NutrientFloatingToolbar( coordinator = c, state = rememberFloatingToolbarState(initialEdge = FloatingToolbarEdge.Bottom), collapsible = true, // Shrinks to the active item, expands on tap. ) NutrientFloatingContextualToolbar(coordinator = c) }}FloatingToolbarState holds the current edge, and allowedEdges restricts where the pill can dock. The contextual pill also honors the bar’s own allowed docks and its close button placement. Items declared with OverflowBehavior.Never stay in the pill’s overflow menu. A selected item, such as the item of an open outline or an active tool, renders with a filled primary container. NutrientFloatingToolbarItemButton, NutrientFloatingToolbarMenuItem, and NutrientFloatingToolbarSearchTheme are the building blocks for a host laying out its own pills from coordinator items. Pair the composables with defaultToolbarEnabled(false) and defaultContextualToolbarEnabled(false) so the SDK doesn’t also draw its docked bars. See MainToolbarFloatingExample in the Catalog app. MainToolbarFabMenuExample shows the same coordinator items as a Material 3 Expressive floating action button (FAB) menu.
Status bar scrim
A Compose host that disables the SDK main toolbar and floats its own pill below the status bar leaves the strip above it uncovered. The document then scrolls under the clock and system icons. NutrientDocumentStatusBarScrim blurs (API 31 and later) and tints that strip. It samples the document through a backdrop that Modifier.nutrientBackdropSource records on the DocumentView. It decides for itself when to show. It stays off while the SDK toolbar is mounted, in immersive mode, or while the page sits below the strip. It fades in as the page scrolls up into it.
val backdrop = rememberNutrientBackdropState()
Box(Modifier.fillMaxSize()) { // Must be the first child: the scrim samples whatever drew before it. DocumentView(documentState = documentState, modifier = Modifier.fillMaxSize().nutrientBackdropSource(backdrop)) NutrientDocumentStatusBarScrim(documentState = documentState, state = backdrop, modifier = Modifier.align(Alignment.TopCenter)) coordinator?.let { NutrientFloatingToolbar(coordinator = it, modifier = Modifier.statusBarsPadding()) }}The default tint follows the window’s status bar icon appearance, translucent white under dark icons and translucent black under light ones. You can override color and blurRadius through NutrientStatusBarScrimDefaults. NutrientStatusBarScrim is the same scrim with an explicit visible flag, for chrome that isn’t driven by a DocumentState. In PdfActivity and nutrientToolbarsComposeView hosts, the document is a sibling View and can’t be sampled, so the scrim falls back to a flat tint there. MainToolbarCustomizationExample mounts it.
Toolbar behavior changes {#toolbar-behavior-changes}
None of these need code changes in an SDK-hosted integration, but they change what users see, and some of them make a workaround unnecessary.
- Long-pressing an icon-only button shows a tooltip naming the action. It appears on the docked bars, overflow and close buttons, submenu children, and floating pills, as the View toolbars did through
TooltipCompat. The tooltip opens on the bar’s document side, so it never covers the bar or lands offscreen. A host composing its own buttons passes their dock, as inNutrientToolbarItemButton(toolbarPosition = …)andNutrientFloatingToolbarItemButton(edge = …). Buttons that already spend their long press, such as a submenu group, show no tooltip. - A modifier passed to
NutrientToolbarItemButtonsizes the slot the button is centered in, not the button itself. An item given an explicit width keeps the standard 48dp touch target and selection background. - The system bars stay while a contextual toolbar is onscreen. Entering annotation creation mode with a side-docked bar, or in a host with
defaultToolbarEnabled(false), used to hide the user interface. The status and navigation bars went with it. Meanwhile, the bar the user was working in stayed onscreen. The system bars now follow the contextual toolbar’s presence. This was long-standing behavior ofhideUserInterfaceWhenCreatingAnnotationsmeetingimmersiveMode, bothtrueby default. It also changes existing integrations with side-docked annotation toolbars. - The document no longer moves when a contextual toolbar appears or changes dock. With the SDK main toolbar disabled, the document used to be reinset whenever a contextual bar showed or closed. It was also reinset when the user dragged the bar to another edge. Its pages were refit each time too. The content inset now depends only on the SDK’s own main toolbar. The SDK also no longer hides its main toolbar or insets the document for a contextual bar it doesn’t draw. A
defaultContextualToolbarEnabled(false)host therefore doesn’t get a blank strip, and its status bar and thumbnail bar stay where they are. - The toolbar keeps clear of the landscape navigation bar. With a three-button navigation bar in landscape, the trailing overflow item rendered under the system buttons and couldn’t be tapped. The main toolbar now reserves the side system bar and display cutout insets. It reads them live, so a rotation applies in the same frame. See inset management if you pass your own
windowInsets. - The main toolbar’s items change shape while the page grid is open. While the document overview is displayed, the row becomes the grid’s own. It holds the document editor item (
PdfActivity.MENU_OPTION_DOCUMENT_EDITOR), the grid item that closes it, and the overflow-only settings and document info items. The items that act on the document view behind the grid aren’t in it. AToolbarItemsProviderreceives this shorter list as itsdefaults. The SDK calls it again on every refresh, including when the grid opens and closes. A provider that indexes intodefaultsmust check for the item it sits beside and append when it’s absent.
Removed API reference
| Removed | Replacement |
|---|---|
com.pspdfkit.ui.toolbar.MainToolbar | Compose toolbar (NutrientMainToolbar for custom hosts) |
com.pspdfkit.ui.search.PdfSearchViewInline | Compose inline search; isInlineSearchActive()/SearchType.MODULAR/TextSearch |
com.pspdfkit.listeners.OnMenuItemsGenerateListener | ToolbarItemsProvider |
com.pspdfkit.listeners.OnToolbarMenuChangedListener | setToolbarItemsProvider + setOnToolbarItemClickListener on PdfUiFragment |
PdfUi.invalidateMenu() | invalidateToolbarItems() |
PdfActivity.onCreateOptionsMenu/onPrepareOptionsMenu overrides (toolbar path) | ToolbarItemsProvider |
PdfActivity.onGenerateMenuItemIds | ToolbarItemsProvider (list order) |
PdfActivity.onGetShowAsAction/PdfUiFragment.onGetShowAsAction | ToolbarItem.overflowBehavior |
com.pspdfkit.jetpack.compose.components.MainToolbar (experimental composable) | NutrientMainToolbar + DocumentState.toolbarCoordinator |
com.pspdfkit.ui.toolbar.ToolbarCoordinatorLayout and LayoutParams, R.id.pspdf__toolbar_coordinator | ComposeView with ID pspdf__toolbar_compose_host; nutrientToolbarsComposeView |
ToolbarCoordinatorLayout.LayoutParams.Position | com.pspdfkit.ui.toolbar.ToolbarDockPosition |
ContextualToolbar, AnnotationToolbar, DocumentEditingToolbar, ContentEditingToolbar, ContextualToolbarMenuBar, ContextualToolbarSubMenu, ToolbarCoordinatorLayoutController | ToolbarCoordinator (contextualToolbar, updateContextualItems, addActionListener, addActionInterceptor) |
OnContextualToolbarLifecycleListener, OnContextualToolbarMovementListener, OnContextualToolbarPositionListener, and their setters | ToolbarCoordinator.addContextualToolbarChangeListener/contextualToolbar |
ToolbarCoordinatorLayout.displayContextualToolbar/removeContextualToolbar/bindNutrientContextualToolbar | nutrientToolbarsComposeView + PdfFragmentToolbarBinder/AnnotationToolbarBinder |
AnnotationToolbar.ItemToAnnotationToolMapper, setShouldShowStylusButton | Action listener + enterAnnotatingMode; filter pspdf__annotation_toolbar_item_stylus |
PSPDFKitPreferences.get/setLastToolbarPosition(ContextualToolbar, …) | ID-based overloads with ToolbarDockPosition |
com.pspdfkit.ui.toolbar.TextSelectionToolbar, textSelectionPopupToolbarEnabled(), isTextSelectionPopupToolbarEnabled | TextSelectionPopupToolbar (always used) |
pspdf__textSelectionToolbarIconsStyle, PSPDFKit.TextSelectionToolbarIcons | pspdf__popupToolbarStyle |
pspdf__annotationCreationToolbarIconsStyle, pspdf__DocumentEditingToolbarIcons, pspdf__ContentEditingToolbar (no longer read) | pspdf__contextualToolbarStyle; per-item icons via updateContextualItems |
Toolbar.setNavigationIcon/setNavigationOnClickListener on R.id.pspdf__toolbar_main (now null) | setToolbarNavigationIcon/setOnToolbarNavigationClickListener on PdfUi, DocumentState; navigationIcon slot |
PSPDFKitViews.getDocumentTitleOverlayView() | None; the overlay is part of the main toolbar (title slot for custom hosts) |
MainToolbar.setForcedVisibility(GONE) | ToolbarCoordinator.setMainToolbarHostVisible(false) |
ToolbarCoordinatorLayout.LayoutParams.allowedPositions | ToolbarCoordinator.setContextualToolbarAllowedPositions(List<ToolbarPosition>?) |
RecyclerView under R.id.pspdf__tabs_bar_list (adapters, decorations, ItemTouchHelper); pspdf__tab_item.xml and its IDs | NutrientTabBar: TabBarColors, NutrientTabBarDefaults.dimensions(), the tabContent slot, NutrientTabBarDefaults.TabContent(closeButton = …) |
SettingsOptions.snapToPoint, snapToSelf, and showSmartGuides — along with their constructor parameters | PSPDFKitPreferences.setMeasurementSnappingEnabled/setSnapToSelfEnabled/setSmartGuidesEnabled |
pspdf__settings_preset_still_image_horizontal, pspdf__settings_preset_still_image_vertical | None; the settings presets are removed |
New API reference
| API | Where |
|---|---|
setToolbarItemsProvider/setOnToolbarItemClickListener/invalidateToolbarItems | PdfUi (→ PdfActivity, PdfUiFragment), DocumentState |
ToolbarItem (Button/Toggle/Submenu), ToolbarItemState, ToolbarIcon, ItemEdge, OverflowBehavior | com.pspdfkit.compose.toolbar.state |
NutrientMainToolbar(coordinator, modifier, navigationIcon, title, actions, searchBar, shape, shadowElevation, windowInsets, colors, itemIndication, enter, exit) and the content overload; NutrientMainToolbarDefaults; NutrientToolbarScope; NutrientMainToolbarTitle, NutrientToolbarItems, NutrientToolbarSearchBar, NutrientMainToolbarContent | com.pspdfkit.compose.toolbar.composables |
DocumentState.toolbarCoordinator: StateFlow<ToolbarCoordinator?> | com.pspdfkit.jetpack.compose.interactors |
ToolbarCoordinator.setMainToolbarInsets(ToolbarInsets), .searchSession, .frame | com.pspdfkit.compose.toolbar.adapter |
ToolbarInsets (StatusBars/None/Custom, fixedTop) | com.pspdfkit.compose.toolbar.state |
PdfUi.isInlineSearchActive() | com.pspdfkit.ui |
PdfActivity.setMainToolbarColorScheme(UiColorScheme) | com.pspdfkit.ui |
MainToolbarColors.documentTitleOverlay: DocumentTitleOverlayColors | com.pspdfkit.compose.theme |
ComponentActivity.nutrientToolbarCoordinator(), Fragment.nutrientToolbarCoordinator(), nutrientToolbarsComposeView, PdfFragmentToolbarBinder, AnnotationToolbarBinder | com.pspdfkit.jetpack.compose.toolbar |
ToolbarCoordinator.contextualToolbar, addContextualToolbarChangeListener, updateContextualItems, addActionListener, addActionInterceptor, setContextualToolbarPosition, setContextualToolbarDraggable, setContextualToolbarGroupingProvider; ToolbarSubscription | com.pspdfkit.compose.toolbar.adapter |
ContextualToolbarState, ContextualToolbarKind, ToolbarPosition, ToolbarAction, ToolbarPresetEntry, ContextualToolbarGroupingProvider | com.pspdfkit.compose.toolbar.state |
ToolbarDockPosition | com.pspdfkit.ui.toolbar |
NutrientToolbars, NutrientToolbarScaffold, LocalToolbarContentInset, rememberNutrientToolbarCoordinator, DocumentAnchoredPopup, NutrientContextualToolbarDragListener, nutrientContextualToolbarComposeView | com.pspdfkit.compose.toolbar.composables |
NutrientContextualToolbar(coordinator, modifier, closeButton, shape, border, sideDockedShape, sideDockedBorder, shadowElevation, windowInsets, colors, itemIndication, enter, exit, …); NutrientContextualToolbarScope; NutrientContextualToolbarCloseButton; NutrientContextualToolbarDefaults | com.pspdfkit.compose.toolbar.composables |
ToolbarItemIndication, LocalToolbarItemIndication; NutrientToolbarItemButton(item, onAction, modifier, onLongClick, invertedSelection, toolbarPosition, indication) | com.pspdfkit.compose.toolbar.composables |
setToolbarNavigationIcon(ToolbarIcon?, String?)/setOnToolbarNavigationClickListener(OnToolbarNavigationClickListener?) | PdfUi (→ PdfActivity, PdfUiFragment), DocumentState |
ToolbarCoordinator.setMainToolbarNavigationIcon, .addNavigationClickHandler, ToolbarFrame.navigationIcon/navigationContentDescription; NutrientMainToolbarNavigationIcon(coordinator) | com.pspdfkit.compose.toolbar.adapter/.state/.composables |
ToolbarCoordinator.setMainToolbarHostVisible(Boolean), .setContextualToolbarAllowedPositions(List<ToolbarPosition>?) | com.pspdfkit.compose.toolbar.adapter |
NutrientStatusBarScrim, NutrientStatusBarScrimDefaults, NutrientBackdropState, rememberNutrientBackdropState, Modifier.nutrientBackdropSource | com.pspdfkit.compose.toolbar.composables |
NutrientDocumentStatusBarScrim(documentState, state, …); DocumentState.contentTopInWindowPx | com.pspdfkit.jetpack.compose.toolbar/.interactors |
NutrientTabBar(documentCoordinator, …) and NutrientTabBar(tabs, selectedTabId, onTabClick, onCloseClick, …, onTabMove, tabContent); NutrientTabBarTab, NutrientTabBarDimensions, NutrientTabBarTabScope; NutrientTabBarDefaults (dimensions(), TabContent, CloseButton) | com.pspdfkit.compose.tabs.composables |
TabBarColors, TabBarColors.from(MainToolbarColors), UiColorScheme.tabBar; getTabBarColors(...) | com.pspdfkit.compose.theme; io.nutrient.ui.theme |
DocumentState.documentCoordinator: StateFlow<DocumentCoordinator?> | com.pspdfkit.jetpack.compose.interactors |
NutrientFloatingToolbar, NutrientFloatingContextualToolbar, FloatingToolbarState, FloatingToolbarEdge, rememberFloatingToolbarState, NutrientFloatingToolbarItemButton, NutrientFloatingToolbarMenuItem, NutrientFloatingToolbarSearchTheme | com.pspdfkit.compose.toolbar.composables |
onConfigurationUpdated(PdfActivityConfiguration) | PdfActivityListener (→ PdfActivity, PdfUiFragment) |
pspdf__sheetHeight theme attribute | PSPDFKit.SettingsDialog style |
Working examples
All of these examples are in the Catalog app, under Toolbars and Menus unless noted. That section is ordered by the surface being customized: main toolbar, annotation toolbar, popup toolbar, and then the examples that host every toolbar themselves.
MainToolbarCustomizationExample(Main Toolbar: Custom Layout and Items) —NutrientMainToolbarin a rounded, elevated container. It adds a custom color scheme, custom items, click routing, inset opt-out, search and immersive animations, and the status bar scrim.MainToolbarFloatingExample(Main Toolbar: Floating Pill) —NutrientFloatingToolbarandNutrientFloatingContextualToolbar, with the collapsible variant behind a toggle.MainToolbarFabMenuExample(Main Toolbar: FAB Menu) — The coordinator’s items as a Material 3 Expressive FAB menu.AnnotationToolbarUserEditableExample,AnnotationToolbarCustomItemsExample, andAnnotationToolbarGroupingExample— User-arranged tools saved across sessions, custom items bound to tools and variants, and grouping on the contextual bars.ConstructionExample(Industry) registers a preset grouping rule through the bridge.PopupToolbarCustomItemsExample— The text selection, annotation selection, long-press, and content editing popup toolbars.CustomToolbarsPdfActivityExample,CustomToolbarsPdfFragmentExample,CustomToolbarsPdfFragmentViewExample, andCustomToolbarsDocumentViewExample— The hosting recipe for each host type: SDK bars off with host Compose bars on the activity coordinator,NutrientToolbarsaround a barePdfFragment, the toolbar stack as aComposeViewin a classic View layout, and both bars replaced over the ComposeDocumentView.ComposePdfViewerExample(Jetpack Compose) — A complete Compose viewer app with a host-renderedNutrientMainToolbaroverDocumentView. See the simple-compose example moved into the Catalog.DocumentPagerExample(Multi-document) — CollectingdocumentState.toolbarCoordinatorin a Compose-first host.DocumentTabsComposeExample(Multi-document, Tabbed Documents (Compose)) — A floating-pillNutrientMainToolbarwith aNutrientTabBarbelow it. Both overlay aDocumentViewand animate out together in immersive mode.
The pre-12 examples were consolidated into these. Custom ActionBar Actions, Custom Main Toolbar, and Floating Toolbar Layout became MainToolbarCustomizationExample. The Java Custom Activity Toolbars example became CustomToolbarsPdfFragmentExample, and Collapsible Floating Toolbar became MainToolbarFloatingExample. The example res/menu/*.xml files were deleted in favor of the items API.
Annotation inspector color picker
This change is independent of the toolbar rewrite above, and like it, it’s a clean break rather than a deprecation.
The annotation inspector used to ship two separate color-picker detail views, and which one you got depended on whether custom colors were enabled:
ColorPickerInspectorDetailView— A flat grid of the available colors.CustomColorPickerInspectorDetailView— A palette with recently used colors, color variations, and Hex/HSL/RGB custom entry.
They’re now one. CustomColorPickerInspectorDetailView is removed, and ColorPickerInspectorDetailView is reimplemented in Jetpack Compose to cover both. The name stays, but the constructor changes. The old class took a trailing singleLine flag and the new one takes no fourth argument. Any call still using the old form fails to compile rather than changing behavior silently.
Do I need to do anything?
If you never touched the color picker’s configuration or classes: no. The inspector picks the new view up automatically. You’ll see a redesigned picker: one view with a palette (recents, defaults, variations) and a “Custom” tab. The tab carries the Hex/HSL/RGB entry that used to be a separate screen.
You need to act if either of these applies:
- You called
setCustomColorPickerEnabled(...)on any color configuration builder, or read or implementedcustomColorPickerEnabled(). They’re replaced bysetRestrictToAvailableColors(...)andrestrictToAvailableColors(), with the opposite meaning. - You construct either detail-view class directly, typically to pass a custom detail view to
ColorPickerInspectorView.
Both are compile errors, so the build will find every affected call site for you.
setCustomColorPickerEnabled is now setRestrictToAvailableColors
This affects every integration that configured the flag, not just those constructing the views directly.
setCustomColorPickerEnabled(boolean) is removed from AnnotationColorConfiguration.Builder, AnnotationFillColorConfiguration.Builder, and AnnotationOutlineColorConfiguration.Builder, and setRestrictToAvailableColors(boolean) replaces it, with the meaning inverted and the default flipped from true to false:
// Before — hold the user to the configured palette..setAvailableColors(brandColors).setCustomColorPickerEnabled(false)
// After:.setAvailableColors(brandColors).setRestrictToAvailableColors(true)If you passed true, the old default that allowed custom colors, drop the call, since false is the new default.
The getter is renamed the same way. boolean customColorPickerEnabled() on AnnotationColorConfiguration, AnnotationFillColorConfiguration, AnnotationOutlineColorConfiguration, and ContentEditingFillColorConfiguration is now boolean restrictToAvailableColors(). Code that reads the flag must invert the check. A class implementing one of these interfaces, including a custom ContentEditingFillColorConfiguration, must rename the method and return the inverse of its old value.
The rename follows a behavior fix worth knowing about. customColorPickerEnabled = false only hid the custom-entry UI. The recently used colors and the generated color variations stayed onscreen, and both offer colors outside availableColors. Restricting the palette therefore never actually restricted it. setRestrictToAvailableColors(true) hides all three, so the picker offers the available colors and nothing else. If you used the old flag to hold annotations to an approved brand or accessibility palette, that starts working as intended in this release.
Replacing CustomColorPickerInspectorDetailView
Its behavior is the surviving class’s default, so no flag call is needed:
// Before:final var detailView = new CustomColorPickerInspectorDetailView(context, colors, defaultColor);
// After:final var detailView = new ColorPickerInspectorDetailView(context, colors, defaultColor);Updating ColorPickerInspectorDetailView call sites
The class keeps its name but drops the trailing singleLine argument. To get the old flat grid, call setRestrictToAvailableColors(true):
// Before:final var detailView = new ColorPickerInspectorDetailView(context, colors, defaultColor, /* singleLine = */ false);detailView.setShowSelectionIndicator(false);
// After:final var detailView = new ColorPickerInspectorDetailView(context, colors, defaultColor);detailView.setRestrictToAvailableColors(true);detailView.setShowSelectionIndicator(false);There’s no singleLine equivalent. The old flat grid could be forced onto one row, whereas the new swatch grid always wraps to a fixed column count. Drop the argument rather than translating it.
setShowSelectionIndicator(boolean) carries over unchanged, as do setOnColorPickedListener, getState/setState, and the rest of the ColorPickerInspectorView.ColorPickerDetailView contract.
Both old classes accepted the color list as either int[] or List<Integer>. The surviving class takes a List<Integer> only, so int[] call sites need updating.
Document overview grid
The document overview’s page grid (PdfThumbnailGrid) is rendered in Jetpack Compose. Its public methods, its RelativeLayout base class, and the pspdf__ThumbnailGrid theme attributes are unchanged. Code that reached into its views or relied on its floating action button needs updating:
- The grid content is no longer a
RecyclerView. The view with thepspdf__thumbnail_grid_recycler_viewID stays for layout-based repositioning but is now aComposeView. Code that casts it toRecyclerView, or attaches adapters, item decorations, or anItemTouchHelperto it, fails at runtime. Cast it toViewinstead. - The floating action button is gone. In
PdfActivity, the main toolbar shows a document editor item (PdfActivity.MENU_OPTION_DOCUMENT_EDITOR) while the grid is displayed. Theme it throughpspdf__documentEditorIconandpspdf__documentEditorIconActivated. The document editing toolbar gains an Add Page item. APdfThumbnailGridused outsidePdfActivityshows no entry point at all, so start editing from your own UI. - The button’s theming no longer applies.
pspdf_fabIconColorand thePSPDFKit.FabStyleshape overlay are still declared, so existing themes compile but have no effect.
// Before: the grid's FAB entered document editing, and the grid was a `RecyclerView`.val list = thumbnailGrid.findViewById<RecyclerView>(R.id.pspdf__thumbnail_grid_recycler_view)
// After: start editing and add pages from your own UI.val content: View = thumbnailGrid.findViewById(R.id.pspdf__thumbnail_grid_recycler_view)editButton.setOnClickListener { thumbnailGrid.enterDocumentEditingMode() }addPageButton.setOnClickListener { thumbnailGrid.createNewPage() }val editing = thumbnailGrid.isDocumentEditingModeActive()PdfThumbnailGrid.setDocumentEditorEnabled(false) can now be called while the grid is displayed: It leaves document editing mode and removes the document editor item. Reordering pages by drag creates a single undo step instead of one per swap.
Settings dialog
The settings sheet is simpler in 12.0, and its layout settings take effect without a restart.
Layout changes apply without a restart
Changing the scroll direction, page transition, or page layout in the settings sheet updates the open document as the user taps. The activity no longer restarts when the sheet closes. PdfUi.setConfiguration() does the same when only scrollDirection, scrollMode, or layoutMode differ from the current configuration. Any other change — the theme for example — still restarts the activity.
So these three values can now change while the activity is running. If you read one of them once, in onCreate for example, override the new PdfActivityListener.onConfigurationUpdated() to read it again. PdfActivity and PdfUiFragment implement PdfActivityListener, so the callback is available in a subclass of either:
// Before: correct only until the user changed the scroll direction.override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) scrollBar.setScrollDirection(configuration.configuration.scrollDirection)}
// After: also follow changes made while the activity is running.override fun onConfigurationUpdated(configuration: PdfActivityConfiguration) { super.onConfigurationUpdated(configuration) scrollBar.setScrollDirection(configuration.configuration.scrollDirection)}A change that still restarts the activity doesn’t call onConfigurationUpdated(). The activity is recreated with the new configuration instead.
The presets and the keep-screen-on switch are removed
The vertical and horizontal preset buttons at the top of the sheet are gone. They were shortcuts for the page transition and scroll direction rows below them. The keep-screen-on switch is gone too.
SettingsMenuItemType.PRESETS and SettingsMenuItemType.SCREEN_AWAKE are deprecated and ignored, so a configuration that lists them keeps compiling and working. A set with only these two no longer shows the settings item. To keep the screen on, set the timeout yourself:
// Before: listing SCREEN_AWAKE gave users a keep-screen-on switch in the settings sheet.PdfActivityConfiguration.Builder(context) .setSettingsMenuItems(EnumSet.of(SettingsMenuItemType.THEME, SettingsMenuItemType.SCREEN_AWAKE))
// After: keep the screen on from your own code or UI.pdfUi.setScreenTimeout(PdfUi.TIMEOUT_INFINITE)The presets’ theme attributes are removed:
pspdf__settings_preset_animation_url_horizontalpspdf__settings_preset_animation_url_verticalpspdf__settings_preset_still_image_horizontalpspdf__settings_preset_still_image_vertical
If your theme sets any of them, remove those entries. pspdf__settings_preset_unselected_border_color, pspdf__settings_preset_selected_border_width, and pspdf__settings_preset_unselected_border_width are public, so they’re still declared and themes that set them still build, but they have no effect. pspdf__settings_preset_label_textcolor still sets SettingsColorScheme.unselectedTextColor for your own settings UI, but the built-in sheet no longer uses it.
Snapping moved to the annotation inspector
The Snap to Point, Snap to Self, and Show Smart Guides switches moved out of the settings sheet. All three are in the measurement tools inspector. Snap to Self and Show Smart Guides also apply to drawing lines, polylines, and polygons, so the inspectors for those tools show them too.
SettingsOptions.snapToPoint, snapToSelf, and showSmartGuides — along with their constructor parameters — are removed. Read and change snapping through PSPDFKitPreferences instead:
// Before:val options = SettingsOptions( scrollDirection, scrollMode, layoutMode, themeMode, screenTimeoutMillis, visibleItems, snapToPoint = true, snapToSelf = true, showSmartGuides = false,)
// After:val options = SettingsOptions(scrollDirection, scrollMode, layoutMode, themeMode, screenTimeoutMillis, visibleItems)
PSPDFKitPreferences.get(context).apply { setMeasurementSnappingEnabled(true) setSnapToSelfEnabled(true) setSmartGuidesEnabled(false)}Smaller changes
- The sheet fits its controls instead of opening at the full window height. The new
pspdf__sheetHeightattribute onPSPDFKit.SettingsDialoggives it a fixed height instead. - Scroll direction is listed right after page transition.
- The page appearance is picked with color circles, as on iOS, instead of segmented buttons.
- The automatic page layout is labeled “Adaptive,” which uses the new
pspdf__adaptivestring.pspdf__settings_menu_autoandpspdf__settings_menu_keep_screen_onare public, so the SDK keeps them but no longer uses them.
Material 3 modal surfaces
This change is independent of the ones above. Four surfaces moved from Android Views to Jetpack Compose and Material 3. All four now present as modal sheets: the document info screen, the outline container, the settings dialog, and the share menu. The outline container’s tabs are outline, bookmarks, annotations, document info, and attachments.
PdfOutlineView, SettingsDialog, ActionMenu, SharingMenu, and DefaultSharingMenu keep their public API, so most integrations need no change. The items below are the ones that do.
The outline content is no longer in the view hierarchy
This is the change most likely to break an integration quietly, because it still compiles.
A modal sheet in its own window, not PdfOutlineView, now presents the outline container’s content. The IDs are still in R, so code referencing them compiles exactly as before, and findViewById returns null:
// Before: returned the pager.// After: compiles, returns `null`.val pager = pdfOutlineView.findViewById<ViewPager>(R.id.pspdf__outline_pager)R.id.pspdf__activity_outline_view, R.id.pspdf__outline_list_view, and R.id.pspdf__outline_pager all still exist, so nothing fails at build time. There’s no public replacement for reaching into the pager. If you depend on that, open an issue describing what you need. We can then add API for it rather than have integrations rely on the internal view tree.
R.id.pspdf__outline_list_search_view is removed, because the outline’s search field is a Compose TextField now. It was never a <public> ID, but it did exist in R and compiled.
Espresso matchers scoped to the activity’s root no longer find the outline’s views, and Compose text is invisible to onView(withText(...)) regardless. Assert against Compose semantics instead:
// Before:onView(withId(R.id.pspdf__outline_list_view)).check(matches(isDisplayed()))
// After:composeTestRule.onNodeWithTag("Outline_List").assertIsDisplayed()The search field’s test tag is Outline_Search.
Sheet width and presentation
The outline, document info, and settings present as a Material 3 modal bottom sheet. On expanded windows (840dp wide and up, such as a landscape tablet or a large foldable), they present as an end-anchored side sheet. It has full height and a clamped width, and slides in from the trailing edge. Users dismiss it by dragging it back off that edge, tapping the scrim, or pressing back. In a right-to-left layout, the side sheet anchors to the left and the drag direction mirrors with it.
The share menu stays a bottom sheet at every width and is clamped to the same width. On an expanded window, it’s centered rather than stretched.
Two theme attributes configure this, both on the outline and settings styles:
| Attribute | Meaning |
|---|---|
pspdf__maxWidth | The width the sheet is clamped to: the side sheet’s exact width, and the bottom sheet’s maximum. Defaults to @dimen/pspdf__sheet_max_width (480dp). |
pspdf__sheetPresentationMode | One of auto (the default), bottom, or side. |
<style name="MyOutlineView" parent="PSPDFKit.OutlineView"> <!-- Keep the pre-12.0 shape: always a bottom sheet. --> <item name="pspdf__sheetPresentationMode">bottom</item> <item name="pspdf__maxWidth">640dp</item></style>Width resolves in three tiers, and the first one silently wins, which makes the other two look broken if your theme already sets it:
pspdf__widthon the outline style, if set to a positive value. This is the attribute the old end-anchored panel used. A theme written for that panel almost certainly sets it, so it still wins and your existing sidebar width keeps working. Its meaning has narrowed, from sizing a panel directly to clamping the sheet. Set it to-1dpto fall through.pspdf__maxWidthon the outline or settings style. Use this for new themes. It’s the only one of the three that applies to the settings sheet.- The
pspdf__sheet_max_widthdimension, overridden in your owndimens.xml, moves both sheets at once. Prefer this over overridingpspdf__bottom_sheet_max_width, because the action menu, annotation inspector, modular search view, and AI assistant also read it.
The outline tab bar shows labels only when the widest visible label fits the width each tab gets. Otherwise it shows icons only, with the tab names as the icons’ accessibility labels. The labels are measured rather than compared against a fixed width per tab, so the decision is correct in every locale.
Theming that changed default
The bars at the bottom of the annotations and bookmarks tabs are surface-colored with a top divider rather than solid primary. Four public attributes kept their names and changed their defaults, so these bars look different if you never set them:
| Attribute | Before | After |
|---|---|---|
pspdf__annotationsBarBackgroundColor | ?colorPrimary | ?android:colorBackground |
pspdf__annotationsBarIconColor | @color/pspdf__onPrimaryLight | ?colorPrimary |
pspdf__bookmarksBarBackgroundColor | ?colorPrimary | ?android:colorBackground |
pspdf__bookmarksBarIconColor | @color/pspdf__onPrimaryLight | ?colorPrimary |
In the dark theme, the two backgrounds resolve to @color/pspdf__dark and the two icon colors to ?colorAccent. Set all four to the “Before” values to keep the old look. Anything you set explicitly still wins.
Removed theming and API
SettingsTopbar is removed from com.pspdfkit.ui.settings.components. The Material 3 settings sheet uses a drag handle and its own title, so it renders no top bar. It was public, but its only parameter type was internal, so there was no supported way to call it.
The settings presets are removed, along with their theme attributes. See information about the presets and the keep-screen-on switch above.
The settings dialog’s floating window mode is gone. It used to size its window to a centered panel on large screens and go fullscreen on phones. The window is now fullscreen and transparent in both cases, and the sheet supplies its own scrim and sizing. Use pspdf__maxWidth and pspdf__sheetPresentationMode instead of sizing that window.
The page label API is nullable
PdfDocument.getPageLabel has always documented a null return. It has always returned null for a document with no label for the page when substituteWithPlainLabel is false. It carried no @Nullable, so Kotlin callers saw String and received a null through a non-null type. It’s annotated now, which is a Kotlin source break:
// Before: compiled, and threw at runtime on a document with no page labels.val label: String = document.getPageLabel(3, false)
// After: pick what suits the call site.val label: String? = document.getPageLabel(3, false)val label: String = document.getPageLabel(3, false) ?: ""val label: String = document.getPageLabel(3, true)!! // Substituted: never `null` at runtime, but still typed `String?`.Java callers are unaffected, since the annotation only adds a nullability warning.
OutlineElement.Builder.setPageLabel takes String? now, so you can pass that result straight in, and null means “this element has no page label.” Widening a parameter breaks no caller. The Java virtual machine (JVM) signature is unchanged, so that half is source and binary compatible in both languages.
Reusing the settings content with NutrientSettingsView
Three approaches put settings in front of a user, and the middle one is new in 12.0.
- Show the built-in sheet. Leave
settingsMenuEnabledon and let the toolbar’s settings item open it. - Compose
NutrientSettingsView. It keeps the SDK’s rows and theming while you own where they appear. You can also replace or decorate an individual row. - Render your own UI from the same data, reading and writing through
SettingsOptions.
NutrientSettingsView is public in io.nutrient.ui.settings, next to SettingsOptions, and customizing the settings UI covers all three approaches. It renders content only, with no sheet, scrim, or dialog, so presentation stays yours. Give it a bounded height, because the rows scroll inside it.
NutrientSettingsView( options = options, onOptionsChanged = { options = it }, // Replace just the scroll direction row, and keep every other row as the SDK draws it. settingsItem = { type, default -> if (type == SettingsMenuItemType.SCROLL_DIRECTION) MyOwnScrollDirectionRow() else default() },)SettingsOptions.visibleItems decides which rows appear, the same way it does for the built-in sheet. settingsItem receives each row’s SettingsMenuItemType and the SDK’s default rendering for it. Returning default() keeps the row, ignoring it replaces the row, and calling default() inside your own layout decorates it. title is a slot too, defaulting to the localized “Settings” title.
Custom DocumentOutlineProvider
Three behavior changes, all of them fixes:
- The outline is requeried when the document changes. The load is keyed on the document instance. Reopening a document after a save therefore reloads the outline instead of keeping the presave titles and page targets.
- A provider that reports an error no longer crashes. The tab shows its empty state. It tries again the next time the outline is opened. This covers a provider that throws on the call as well as one that signals
Single.error. - The provider isn’t retained.
setDocumentOutlineProvideragain keeps its documented promise not to retain the provider across an activity restart. An anonymous provider capturing your activity no longer leaks it across a rotation.
What didn’t change
PdfOutlineView’s public API:show(),hide(),isDisplayed, the visibility listeners,setDocumentOutlineProvider,setBookmarkViewAdapter, and the tab-enablement setters.SettingsDialog’s public API, and theActionMenu,SharingMenu, andDefaultSharingMenuclasses.PdfActivityConfiguration’s outline, bookmark, annotation list, and document info flags.- Outline state still survives configuration changes. It deliberately doesn’t survive process death, since the outline is a transient modal sheet.
The simple-compose example moved into the Catalog
The standalone simple-compose example app, and the pspdfkit-jetpack-compose-pdf-viewer repository that mirrored it, are retired. The app’s functionality ships inside the Catalog app as the Compose PDF Viewer example (ComposePdfViewerExample, under Jetpack Compose). No SDK API changed, so nothing in your integration code needs to change. What changes is where you look for a complete, copy-pasteable Compose viewer.
simple-compose was a separate Gradle build with its own dependency set, Koin for dependency injection (DI), and Room for persistence, and its own app shell. That made it expensive to keep compiling against each SDK release. Because the integration was spread across repositories, it was also a poor answer to “show me how to use DocumentView.” Inside the Catalog, it builds and is smoke-tested on every SDK change alongside every other example. It also demonstrates the toolbar hosting APIs the standalone app predated.
Old (examples/simple-compose) | New (examples/catalog) |
|---|---|
Separate Gradle project with its own gradlew | Part of the Catalog build |
pspdfkit-jetpack-compose-pdf-viewer repository | Catalog → Jetpack Compose → Compose PDF Viewer |
com.pspdfkit.example | com.pspdfkit.catalog.examples.kotlin.composeviewer |
MainActivity, Root, Navigation | ComposePdfViewerActivity and ViewerNavHost in ComposePdfViewerExample.kt |
MainScreen, PdfScreen, SettingsScreen, and their view models | LibraryScreen.kt, ViewerScreen.kt, SettingsScreen.kt, and one ComposeViewerViewModel |
CustomTopbar | NutrientMainToolbar rendered by the host in ViewerScreen.kt |
Koin modules, Room AppDatabase, the repositories | No DI framework; recents persist as a JSON array in a DataStore preference |
ui/theme/* | ComposeViewerTheme: baseline Material 3 plus dynamic color |
assets/demo.pdf | The Catalog’s bundled welcome document |
The example keeps everything the old app did: a recents library with page thumbnails; file import through the system picker; and light, dark, and dynamic-color settings. It also covers Compose integration surface the standalone app never did. A host-rendered NutrientMainToolbar sits over DocumentView with the inset bookkeeping a custom main toolbar needs. The example also shows custom ToolbarItems, DefaultListeners wiring, and DocumentState.setCustomPdfSource with its asynchronous completion contract. Two behaviors differ from the old app. Persistence is a DataStore preference rather than a Room database, so it has no schema and no migration. Clearing app data resets the library. Clear imported documents in the settings removes only documents imported through the file picker. It keeps the bundled demo document, so the library never lands on an empty screen.
If you forked pspdfkit-jetpack-compose-pdf-viewer, the fork keeps working against the SDK version it pinned but receives no updates. Clone the Catalog(opens in a new tab) instead and open the Compose PDF Viewer example.
Document tab bar
PdfActivity shows the document tab bar, a row of tabs, above a document when several documents are open through a DocumentCoordinator. Jetpack Compose now renders it. PdfTabBar keeps its public API, its LinearLayout base class, and the pspdf__TabBar theme attributes. The RecyclerView under it is gone. The same Compose UI is public as NutrientTabBar. An app that renders its own chrome around a DocumentView can show a tab bar that follows the same theming and slot conventions as NutrientMainToolbar.
| Old | New |
|---|---|
RecyclerView under the pspdf__tabs_bar_list ID inside PdfTabBar | A ComposeView; the ID is still declared but no view carries it |
pspdf__tab_item.xml layout and its view IDs | Removed; tab content is a Compose slot |
No tab bar for Compose DocumentView hosts | NutrientTabBar(documentCoordinator = …), bound through DocumentState.documentCoordinator |
Styling only through the pspdf__TabBar XML attributes | Still the default, plus TabBarColors (UiColorScheme.tabBar), NutrientTabBarDefaults.dimensions(), shape, spacing |
pspdf__tabIconColor declared but never applied | Tints the close button of unselected tabs (visible in PdfTabBarCloseMode.CLOSE_ENABLED only) |
| Rapid taps on several tabs loaded each document in turn | Only the last tapped document is loaded |
PdfTabBar keeps working
PdfTabBar is still a LinearLayout with the same public methods (bindToDocumentCoordinator, setCloseMode, getTabs, getTabBarItem, the OnTabClickedListener and OnTabsChangedListener collections). Java subclasses and the documented ways of extending it compile unchanged. PdfTabBar is now written in Kotlin, so a Kotlin subclass that overrode getTabs() or getSize() overrides the tabs and size properties instead. See PdfFragment and PdfTabBar are Kotlin classes. Inserting your own view into the bar keeps working, because the Compose child is measured at the width left after its siblings:
val addTabButton: View = …pspdfKitViews.tabBar?.addView(addTabButton, 0)What no longer works is reaching into the bar for a RecyclerView: findViewById(R.id.pspdf__tabs_bar_list) returns null, and adapters, layout managers, item decorations, and ItemTouchHelper have nothing to attach to. The tab item layout pspdf__tab_item.xml and its IDs are gone as well. Everything those hooks were used for (colors, dimensions, and the tab interior) has a themed or Compose counterpart below.
Two behavior changes to be aware of:
pspdf__tabIconColoris honored. The View bar tinted every close button withpspdf__tabIconColorSelected. Now, unselected tabs usepspdf__tabIconColor, which only shows inPdfTabBarCloseMode.CLOSE_ENABLED, where unselected tabs have a close button. Set both attributes to the same color to keep the old look.- Rapid taps switch to the last tapped document only. Tapping tab B and then tab C loads C alone. The document switch follows the tab animation by 250 ms. Closing a tab drops a pending switch to it.
A tab bar for Compose DocumentView hosts
Previously, the Compose DocumentView had no tab bar. DocumentState now publishes the fragment’s DocumentCoordinator once the UI is ready (null until then), and NutrientTabBar binds to it:
val documentState = rememberDocumentState(uri, configuration)val coordinator by documentState.documentCoordinator.collectAsState()
Box(Modifier.fillMaxSize()) { DocumentView(documentState = documentState, modifier = Modifier.fillMaxSize())
coordinator?.let { NutrientTabBar(documentCoordinator = it, modifier = Modifier.align(Alignment.TopCenter)) }}Hide the SDK’s own bar with PdfActivityConfiguration.Builder.setTabBarHidingMode(TabBarHidingMode.HIDE) when rendering your own. Otherwise, both show. Add documents to the coordinator the same way as before. The bar mirrors it, and a tap or a close calls setVisibleDocument and removeDocument on it.
The bound bar doesn’t hide itself in immersive mode. Tie it to the same visibility as the rest of your chrome. The DocumentTabsComposeExample in the Catalog app observes ToolbarCoordinator.frame.isVisible and animates the main toolbar and the tab bar out together.
The stateless overload takes the tabs and reports what the user did for a bar that isn’t backed by a DocumentCoordinator:
var tabs by remember { mutableStateOf(listOf(NutrientTabBarTab(id = 1L, title = "Report"), NutrientTabBarTab(id = 2L, title = "Invoice"))) }var selectedId by remember { mutableStateOf<Long?>(1L) }
NutrientTabBar( tabs = tabs, selectedTabId = selectedId, onTabClick = { selectedId = it.id }, onCloseClick = { tab -> tabs = tabs - tab }, onTabMove = { from, to -> tabs = tabs.toMutableList().apply { add(to, removeAt(from)) }; true },)NutrientTabBarTab.id must be unique within the bar and stable across reorders and title changes. onTabMove is called live during a long-press drag and returns whether the move was applied; null disables drag-reorder.
Theming
The pspdf__TabBar XML attributes remain the default for PdfTabBar and for a NutrientTabBar inside an SDK-themed context: getUiColors() reads them into UiColorScheme.tabBar. New on top:
TabBarColors(com.pspdfkit.compose.theme) —backgroundColorbehind the tabs,tabColorfor unselected tabs, andselectedTabColorfor the selected tab and the thin separator between touching tabs. The bar also takestextColor/selectedTextColorandiconColor/selectedIconColorfor the close button, defaulting to the text colors.TabBarColors.from(mainToolbarColors)derives a bar that blends with a main toolbar.UiColorScheme.tabBardefaults to that, so existingUiColorScheme(...)callers compile unchanged.getTabBarColors(...)builds one from the Material theme.NutrientTabBarDefaults.dimensions(...)—height,tabMinWidth,textSize,separatorWidth,tabHorizontalPadding, andcloseButtonPadding, each defaulting to thepspdf__tab_bar_*value the SDK bar uses.tabShape,tabSpacing, andcontentPaddingon the composable. A non-zero spacing replaces the separator between touching tabs.windowInsetsdefaults to the horizontal safe-drawing insets, like Material’s app bars: The background extends into them, and the tabs stay clear. PassWindowInsets(0, 0, 0, 0)when your layout already handles them.
NutrientTabBar( documentCoordinator = coordinator, colors = getTabBarColors( backgroundColor = Color.Transparent, tabColor = MaterialTheme.colorScheme.surface, selectedTabColor = MaterialTheme.colorScheme.primary, selectedTextColor = MaterialTheme.colorScheme.onPrimary, ), dimensions = NutrientTabBarDefaults.dimensions(height = 52.dp, tabMinWidth = 96.dp), tabShape = RoundedCornerShape(28.dp), tabSpacing = 8.dp, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),)Custom tab content
tabContent replaces the inside of every tab; the bar keeps drawing the background, shape, selection, separator, ripple, and drag handling. The slot receives a NutrientTabBarTabScope with the tab, isSelected, isCloseable, the bar’s colors and dimensions, and closeTab(). Calling closeTab() is a no-op for tabs the close mode doesn’t make closeable.
To change only the close button, keep the stock content, NutrientTabBarDefaults.TabContent, and pass it a closeButton. NutrientTabBarDefaults.CloseButton keeps the tap handling, ripple, and accessibility semantics of the stock button while taking your own painter, contentDescription, tint, and size. Add spacing through its modifier:
NutrientTabBar( documentCoordinator = coordinator, tabContent = { NutrientTabBarDefaults.TabContent( this, closeButton = { NutrientTabBarDefaults.CloseButton( this, painter = rememberVectorPainter(Icons.Rounded.Cancel), tint = if (isSelected) colors.selectedIconColor else colors.iconColor, modifier = Modifier.padding(end = 8.dp), ) }, ) },)The bar sizes its tabs for the stock 18dp button plus closeButtonPadding. A much larger button should come with a larger tabMinWidth.
PdfFragment and PdfTabBar are Kotlin classes
PdfFragment and PdfTabBar were converted from Java to Kotlin. The SDK keeps the JVM signatures and the static constants, such as PdfFragment.DOCUMENT_VIEW_ID, so Java callers and Java subclasses are unaffected. Kotlin code sees two differences:
- Nullability is explicit. Members that were platform types now declare nullability (for example,
PdfFragment.documentisPdfDocument?). Kotlin code that treated a result as non-null needs a null check. - Getters are properties, and some members stay functions. A Kotlin subclass overrides properties with
override val(for exampledocument,undoManager, andPdfTabBar.tabs). It setsisAnnotationOverlayEnabledinstead of callingsetAnnotationOverlayEnabled().isImageDocument(),getImageDocument(),getContentEditingUndoManager(), andgetAudioModeManager()remain functions, so property syntax such asfragment.isImageDocumentno longer compiles. Call them as functions.
// Before (`PdfFragment` in Java, seen from Kotlin):class MyFragment : PdfFragment() { override fun getDocument(): PdfDocument? = super.getDocument()}val isImage = fragment.isImageDocument
// After:class MyFragment : PdfFragment() { override val document: PdfDocument? get() = super.document}val isImage = fragment.isImageDocument()Instant: Editing comments
Instant comments can be edited when the layer’s collaboration permissions grant comments:edit. To report the edit, InstantAnnotationProvider.NonAnnotationChange gains a COMMENT_UPDATED case. A Kotlin when over that enum used as an expression must handle it, or it stops compiling:
when (change) { // ...existing cases... NonAnnotationChange.COMMENT_UPDATED -> refreshComments()}For more information, refer to the changelog.