Main Toolbar: FAB Menu (M3 Expressive)
Toolbar actions as a Material 3 Expressive FAB menu: the collapsed FAB shows the active item, tapping it expands the labeled action list, and long-pressing drags it between the bottom corners.
/* * Copyright © 2026 PSPDFKit GmbH. All rights reserved. * * The PSPDFKit Sample applications are licensed with a modified BSD license. * Please see License for details. This notice may not be removed from this file. */
package com.pspdfkit.catalog.examples.kotlin
import android.content.Contextimport android.content.Intentimport android.net.Uriimport android.os.Buildimport android.os.Bundleimport androidx.activity.compose.BackHandlerimport androidx.activity.compose.setContentimport androidx.appcompat.app.AppCompatActivityimport androidx.compose.animation.AnimatedContentimport androidx.compose.animation.AnimatedVisibilityimport androidx.compose.animation.core.Animatableimport androidx.compose.animation.core.FiniteAnimationSpecimport androidx.compose.animation.core.VectorConverterimport androidx.compose.animation.core.springimport androidx.compose.animation.expandVerticallyimport androidx.compose.animation.fadeInimport androidx.compose.animation.fadeOutimport androidx.compose.animation.scaleInimport androidx.compose.animation.scaleOutimport androidx.compose.animation.shrinkVerticallyimport androidx.compose.animation.slideInVerticallyimport androidx.compose.animation.slideOutVerticallyimport androidx.compose.animation.togetherWithimport androidx.compose.foundation.backgroundimport androidx.compose.foundation.gestures.detectDragGesturesAfterLongPressimport androidx.compose.foundation.gestures.detectTapGesturesimport androidx.compose.foundation.isSystemInDarkThemeimport androidx.compose.foundation.layout.Arrangementimport androidx.compose.foundation.layout.Boximport androidx.compose.foundation.layout.BoxWithConstraintsimport androidx.compose.foundation.layout.Columnimport androidx.compose.foundation.layout.Rowimport androidx.compose.foundation.layout.Spacerimport androidx.compose.foundation.layout.WindowInsetsimport androidx.compose.foundation.layout.absoluteOffsetimport androidx.compose.foundation.layout.fillMaxSizeimport androidx.compose.foundation.layout.heightInimport androidx.compose.foundation.layout.paddingimport androidx.compose.foundation.layout.safeDrawingPaddingimport androidx.compose.foundation.layout.sizeimport androidx.compose.foundation.layout.widthimport androidx.compose.foundation.rememberScrollStateimport androidx.compose.foundation.shape.RoundedCornerShapeimport androidx.compose.foundation.verticalScrollimport androidx.compose.material3.ColorSchemeimport androidx.compose.material3.FloatingActionButtonimport androidx.compose.material3.Iconimport androidx.compose.material3.LocalContentColorimport androidx.compose.material3.MaterialThemeimport androidx.compose.material3.Surfaceimport androidx.compose.material3.Textimport androidx.compose.material3.darkColorSchemeimport androidx.compose.material3.dynamicDarkColorSchemeimport androidx.compose.material3.dynamicLightColorSchemeimport androidx.compose.material3.lightColorSchemeimport androidx.compose.runtime.Composableimport androidx.compose.runtime.collectAsStateimport androidx.compose.runtime.getValueimport androidx.compose.runtime.mutableStateOfimport androidx.compose.runtime.rememberimport androidx.compose.runtime.rememberCoroutineScopeimport androidx.compose.runtime.saveable.rememberSaveableimport androidx.compose.runtime.setValueimport androidx.compose.ui.AbsoluteAlignmentimport androidx.compose.ui.Alignmentimport androidx.compose.ui.Modifierimport androidx.compose.ui.geometry.Offsetimport androidx.compose.ui.hapticfeedback.HapticFeedbackTypeimport androidx.compose.ui.input.pointer.pointerInputimport androidx.compose.ui.layout.onSizeChangedimport androidx.compose.ui.platform.LocalContextimport androidx.compose.ui.platform.LocalDensityimport androidx.compose.ui.platform.LocalHapticFeedbackimport androidx.compose.ui.res.painterResourceimport androidx.compose.ui.res.stringResourceimport androidx.compose.ui.unit.IntOffsetimport androidx.compose.ui.unit.IntSizeimport androidx.compose.ui.unit.dpimport com.pspdfkit.catalog.Rimport com.pspdfkit.catalog.SdkExampleimport com.pspdfkit.catalog.tasks.ExtractAssetTaskimport com.pspdfkit.catalog.ui.theming.enableConfigurationEdgeToEdgeimport com.pspdfkit.compose.search.composables.NutrientInlineSearchBarimport com.pspdfkit.compose.theme.NutrientThemeimport com.pspdfkit.compose.toolbar.adapter.ToolbarCoordinatorimport com.pspdfkit.compose.toolbar.state.ToolbarActionimport com.pspdfkit.compose.toolbar.state.ToolbarIconimport com.pspdfkit.compose.toolbar.state.ToolbarItemimport com.pspdfkit.configuration.activity.PdfActivityConfigurationimport com.pspdfkit.configuration.search.SearchTypeimport com.pspdfkit.jetpack.compose.interactors.rememberDocumentStateimport com.pspdfkit.jetpack.compose.views.DocumentViewimport com.pspdfkit.utils.getSupportParcelableExtraimport kotlinx.coroutines.launchimport kotlin.math.roundToInt
/** * Renders the main-toolbar model as a **Material 3 Expressive FAB menu** driven directly by the * document's [ToolbarCoordinator]: * * - Collapsed, a single FAB shows the *active* item's icon (the last action the user ran). * - Tapping the FAB springs open a labeled menu of every toolbar action, staggered expressive-style; * the FAB morphs to a close affordance and a scrim dims the document behind the menu. * - Tapping a menu item dispatches its [ToolbarAction] through the coordinator, remembers it as * the active item, and collapses the menu back to the FAB. Pressing back or tapping the scrim * collapses the menu without dispatching. * - **Long-pressing the FAB starts a drag**: it follows the finger and, on release, springs into * the nearer bottom corner — bottom-right or bottom-left. The menu opens upward from either * corner, with its labels aligned toward the docked side. */class MainToolbarFabMenuExample(context: Context) : SdkExample(context, R.string.mainToolbarFabMenuExampleTitle, R.string.mainToolbarFabMenuExampleDescription) { override fun launchExample(context: Context, configuration: PdfActivityConfiguration.Builder) { ExtractAssetTask.extract(WELCOME_DOC, title, context) { documentFile -> val intent = Intent(context, MainToolbarFabMenuActivity::class.java) intent.putExtra(MainToolbarFabMenuActivity.EXTRA_URI, Uri.fromFile(documentFile)) context.startActivity(intent) } }}
class MainToolbarFabMenuActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState)
val uri = intent.getSupportParcelableExtra(EXTRA_URI, Uri::class.java)!! val configuration = PdfActivityConfiguration.Builder(this) .defaultToolbarEnabled(false) .documentTitleOverlayEnabled(false) .setSearchType(SearchType.INLINE) .build()
enableConfigurationEdgeToEdge(configuration) setContent { MaterialTheme(colorScheme = expressiveColorScheme()) { FabMenuDocument(uri, configuration) } } }
companion object { const val EXTRA_URI = "MainToolbarFabMenuActivity.DocumentUri" }}
/** Dynamic (Material You) color when available (API 31+), falling back to the baseline schemes. */@Composableprivate fun expressiveColorScheme(): ColorScheme { val dark = isSystemInDarkTheme() return when { Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { val context = LocalContext.current if (dark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) }
dark -> darkColorScheme()
else -> lightColorScheme() }}
@Composableprivate fun FabMenuDocument(uri: Uri, configuration: PdfActivityConfiguration) { val documentState = rememberDocumentState(uri, configuration) val coordinator by documentState.toolbarCoordinator.collectAsState()
Box(modifier = Modifier.fillMaxSize()) { DocumentView( documentState = documentState, modifier = Modifier.fillMaxSize(), )
coordinator?.let { c -> NutrientTheme { ExpressiveFabMenu( coordinator = c, modifier = Modifier.fillMaxSize(), ) InlineSearchTakeover( coordinator = c, modifier = Modifier.align(Alignment.TopCenter), ) } } }}
/** * The FAB menu: a primary-container FAB that expands into a labeled action list. All items — * visible and overflow buckets alike — flatten into one menu, since the FAB is the single entry * point. While open, a scrim covers the document; tapping it (or pressing back) collapses the * menu, and the item list scrolls if it outgrows the space above the FAB. Long-pressing the FAB * drags it; release springs it into the nearer bottom corner (right or left). */@Composableprivate fun ExpressiveFabMenu(coordinator: ToolbarCoordinator, modifier: Modifier = Modifier) { val items by coordinator.items.collectAsState() val frame by coordinator.frame.collectAsState() val searchSession by coordinator.searchSession.collectAsState() val searchActive = searchSession?.isActive?.collectAsState()?.value ?: false val allItems = (items.visible + items.overflow).filter { it.state.isVisible } var expanded by rememberSaveable { mutableStateOf(false) } var activeItemId by rememberSaveable { mutableStateOf(NO_ACTIVE_ITEM) }
// Which bottom corner the FAB is docked in (survives rotation and process death), plus the // live pixel offset of an in-flight drag / settle spring. var dockedLeft by rememberSaveable { mutableStateOf(false) } val dragOffset = remember { Animatable(Offset.Zero, Offset.VectorConverter) } val scope = rememberCoroutineScope() val haptics = LocalHapticFeedback.current
val activeItem = allItems.firstOrNull { it.id == activeItemId } ?: allItems.firstOrNull { it.state.isSelected }
BackHandler(enabled = expanded) { expanded = false }
Box(modifier = modifier) { // Scrim: dims the document while the menu is open; tapping it collapses the menu. AnimatedVisibility( visible = expanded, enter = fadeIn(expressiveEffects()), exit = fadeOut(expressiveEffects()), ) { Box( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.32f)) .pointerInput(Unit) { detectTapGestures { expanded = false } }, ) }
// The FAB (and its menu) live in the safe area inset by the edge margin, pinned to one of // the two bottom corners, plus the drag offset while a drag is in flight. BoxWithConstraints( modifier = Modifier .fillMaxSize() .safeDrawingPadding() .padding(FabEdgeMargin), ) { val density = LocalDensity.current val fabPx = with(density) { FabSize.toPx() } val areaWidth = constraints.maxWidth.toFloat() val areaHeight = constraints.maxHeight.toFloat()
/** Top-left pixel position of the FAB when docked in the [left] bottom corner. */ fun dockedTopLeft(left: Boolean): Offset = Offset(if (left) 0f else areaWidth - fabPx, areaHeight - fabPx)
/** Docks into whichever bottom corner is nearer to the released position. */ fun settleAfterDrag() { val current = dockedTopLeft(dockedLeft) + dragOffset.value val targetLeft = current.x + fabPx / 2 < areaWidth / 2 dockedLeft = targetLeft scope.launch { dragOffset.snapTo(current - dockedTopLeft(targetLeft)) dragOffset.animateTo(Offset.Zero, expressiveSpatial()) } }
val menuAlignment = if (dockedLeft) AbsoluteAlignment.Left else AbsoluteAlignment.Right val menuMaxHeight = with(density) { (areaHeight - fabPx).toDp() } - MenuSpacing var contentSize by remember { mutableStateOf(IntSize.Zero) }
AnimatedVisibility( visible = frame.isVisible && !searchActive && allItems.isNotEmpty(), enter = fadeIn(expressiveEffects()) + scaleIn(expressiveSpatial(), initialScale = 0.6f), exit = fadeOut(expressiveEffects()) + scaleOut(expressiveSpatial(), targetScale = 0.6f), modifier = Modifier // Anchor the whole column so its FAB corner stays at the docked position while // the menu grows upward. .absoluteOffset { val position = dockedTopLeft(dockedLeft) + dragOffset.value IntOffset( x = (if (dockedLeft) position.x else position.x + fabPx - contentSize.width).roundToInt(), y = (position.y + fabPx - contentSize.height).roundToInt(), ) } .onSizeChanged { contentSize = it }, ) { Column( horizontalAlignment = menuAlignment, verticalArrangement = Arrangement.spacedBy(MenuSpacing), ) { // The menu items spring open above the FAB, expanding from the bottom. The list // takes at most the height left over by the FAB and scrolls beyond that. AnimatedVisibility( visible = expanded, enter = fadeIn(expressiveEffects()) + expandVertically(expressiveSpatial(), expandFrom = Alignment.Bottom) + slideInVertically(expressiveSpatial()) { it / 4 }, exit = fadeOut(expressiveEffects()) + shrinkVertically(expressiveSpatial(), shrinkTowards = Alignment.Bottom) + slideOutVertically(expressiveSpatial()) { it / 4 }, ) { Column( horizontalAlignment = menuAlignment, verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier .heightIn(max = menuMaxHeight) .verticalScroll(rememberScrollState()), ) { allItems.forEach { item -> FabMenuItem( item = item, onClick = { expanded = false activeItemId = item.id coordinator.dispatch(ToolbarAction.ItemClicked(item.id)) }, ) } } }
// The FAB itself: active item's icon while collapsed, close affordance while // open. Long-press starts the drag; release snaps to the nearer bottom corner. FloatingActionButton( onClick = { expanded = !expanded }, containerColor = MaterialTheme.colorScheme.primaryContainer, contentColor = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier .size(FabSize) .pointerInput(areaWidth, areaHeight) { detectDragGesturesAfterLongPress( onDragStart = { haptics.performHapticFeedback(HapticFeedbackType.LongPress) expanded = false }, onDrag = { change, delta -> change.consume() scope.launch { dragOffset.snapTo(dragOffset.value + delta) } }, onDragEnd = { settleAfterDrag() }, onDragCancel = { scope.launch { dragOffset.animateTo(Offset.Zero, expressiveSpatial()) } }, ) }, ) { AnimatedContent( targetState = expanded, transitionSpec = { (fadeIn(expressiveEffects()) + scaleIn(expressiveSpatial(), initialScale = 0.4f)) .togetherWith(fadeOut(expressiveEffects()) + scaleOut(expressiveSpatial(), targetScale = 0.4f)) }, label = "fabIcon", ) { open -> when { open -> Icon( painter = painterResource(com.pspdfkit.R.drawable.pspdf__ic_close), contentDescription = stringResource(R.string.mainToolbarFabMenuClose), )
activeItem != null -> FabItemIcon(activeItem.icon)
else -> Icon( painter = painterResource(com.pspdfkit.R.drawable.pspdf__ic_more), contentDescription = stringResource(R.string.mainToolbarFabMenuOpen), ) } } } } } } }}
/** * One menu row: a full-corner pill with the item's icon and label. Selected items (e.g. the active * annotation tool) render on the tertiary container so the coordinator's state stays visible. */@Composableprivate fun FabMenuItem(item: ToolbarItem, onClick: () -> Unit) { val selected = item.state.isSelected Surface( onClick = onClick, enabled = item.state.isEnabled, shape = RoundedCornerShape(percent = 50), color = if (selected) MaterialTheme.colorScheme.tertiaryContainer else MaterialTheme.colorScheme.secondaryContainer, contentColor = if (selected) MaterialTheme.colorScheme.onTertiaryContainer else MaterialTheme.colorScheme.onSecondaryContainer, shadowElevation = 2.dp, ) { Row( modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { FabItemIcon(item.icon) Spacer(modifier = Modifier.width(8.dp)) Text(text = item.contentDescription, style = MaterialTheme.typography.labelLarge) } }}
/** Renders a [ToolbarIcon] with the ambient content color (baked tints ignored — strictly M3). */@Composableprivate fun FabItemIcon(icon: ToolbarIcon) { when (icon) { is ToolbarIcon.Resource -> Icon( painter = painterResource(icon.id), contentDescription = null, modifier = Modifier.size(24.dp), )
is ToolbarIcon.Vector -> Icon( imageVector = icon.image, contentDescription = null, modifier = Modifier.size(24.dp), )
is ToolbarIcon.Composed -> icon.content(LocalContentColor.current)
is ToolbarIcon.None -> Icon( painter = painterResource(com.pspdfkit.R.drawable.pspdf__ic_more), contentDescription = null, modifier = Modifier.size(24.dp), ) }}
/** * While the coordinator's inline-search session is active, the FAB menu hides and this full-width * search pill springs in at the top edge (same contract as NutrientFloatingToolbar). */@Composableprivate fun InlineSearchTakeover(coordinator: ToolbarCoordinator, modifier: Modifier = Modifier) { val searchSession by coordinator.searchSession.collectAsState() val searchActive = searchSession?.isActive?.collectAsState()?.value ?: false
AnimatedVisibility( visible = searchActive, enter = slideInVertically(expressiveSpatial()) { -2 * it } + fadeIn(expressiveEffects()), exit = slideOutVertically(expressiveSpatial()) { -2 * it } + fadeOut(expressiveEffects()), modifier = modifier.safeDrawingPadding(), ) { searchSession?.let { session -> val searchState by session.state.collectAsState() Surface( shape = RoundedCornerShape(percent = 50), color = MaterialTheme.colorScheme.surfaceContainer, shadowElevation = 3.dp, modifier = Modifier.padding(16.dp), ) { NutrientInlineSearchBar( state = searchState, actions = session.actions, windowInsets = WindowInsets(0, 0, 0, 0), ) } } }}
/** Sentinel for "no active item yet" — toolbar item ids are `R.id` values, never negative. */private const val NO_ACTIVE_ITEM = -1
private val FabSize = 56.dpprivate val FabEdgeMargin = 16.dpprivate val MenuSpacing = 16.dp
/** M3 expressive motion tokens — default spatial spring (position/size/scale, visible bounce). */private fun <T> expressiveSpatial(): FiniteAnimationSpec<T> = spring(dampingRatio = 0.8f, stiffness = 380f)
/** M3 expressive motion tokens — default effects spring (fades, critically damped). */private fun <T> expressiveEffects(): FiniteAnimationSpec<T> = spring(dampingRatio = 1f, stiffness = 1600f)This code sample is an example that illustrates how to use our SDK. Please adapt it to your specific use case.