Instantly share code, notes, and snippets.
Created
May 21, 2026 07:33
-
Star
0
(0)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
-
Save fabriciovergara/d140d478ac97ea9845a1522122c1ad6e to your computer and use it in GitHub Desktop.
Jetpack compose implementation of ContextMenu with shared element API
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import androidx.compose.animation.AnimatedVisibility | |
| import androidx.compose.animation.AnimatedVisibilityScope | |
| import androidx.compose.animation.BoundsTransform | |
| import androidx.compose.animation.EnterTransition | |
| import androidx.compose.animation.ExitTransition | |
| import androidx.compose.animation.ExperimentalSharedTransitionApi | |
| import androidx.compose.animation.SharedTransitionLayout | |
| import androidx.compose.animation.SharedTransitionScope | |
| import androidx.compose.animation.core.Animatable | |
| import androidx.compose.animation.core.tween | |
| import androidx.compose.animation.fadeIn | |
| import androidx.compose.animation.fadeOut | |
| import androidx.compose.animation.scaleIn | |
| import androidx.compose.animation.scaleOut | |
| import androidx.compose.animation.slideInVertically | |
| import androidx.compose.animation.slideOutVertically | |
| import androidx.compose.foundation.background | |
| import androidx.compose.foundation.clickable | |
| import androidx.compose.foundation.gestures.detectTapGestures | |
| import androidx.compose.foundation.gestures.detectVerticalDragGestures | |
| import androidx.compose.foundation.layout.Arrangement | |
| import androidx.compose.foundation.layout.Box | |
| import androidx.compose.foundation.layout.Column | |
| import androidx.compose.foundation.layout.ColumnScope | |
| import androidx.compose.foundation.layout.Row | |
| import androidx.compose.foundation.layout.Spacer | |
| import androidx.compose.foundation.layout.fillMaxSize | |
| import androidx.compose.foundation.layout.fillMaxWidth | |
| import androidx.compose.foundation.layout.height | |
| import androidx.compose.foundation.layout.offset | |
| import androidx.compose.foundation.layout.padding | |
| import androidx.compose.foundation.layout.size | |
| import androidx.compose.foundation.layout.widthIn | |
| import androidx.compose.foundation.shape.RoundedCornerShape | |
| import androidx.compose.material3.HorizontalDivider | |
| import androidx.compose.material3.Icon | |
| import androidx.compose.material3.MaterialTheme | |
| import androidx.compose.material3.Text | |
| import androidx.compose.runtime.Composable | |
| import androidx.compose.runtime.CompositionLocalProvider | |
| import androidx.compose.runtime.DisposableEffect | |
| import androidx.compose.runtime.Stable | |
| import androidx.compose.runtime.compositionLocalOf | |
| import androidx.compose.runtime.derivedStateOf | |
| import androidx.compose.runtime.getValue | |
| import androidx.compose.runtime.key | |
| import androidx.compose.runtime.mutableFloatStateOf | |
| import androidx.compose.runtime.mutableStateListOf | |
| import androidx.compose.runtime.mutableStateOf | |
| import androidx.compose.runtime.remember | |
| import androidx.compose.runtime.rememberCoroutineScope | |
| import androidx.compose.runtime.setValue | |
| import androidx.compose.ui.Alignment | |
| import androidx.compose.ui.Modifier | |
| import androidx.compose.ui.draw.clip | |
| import androidx.compose.ui.draw.drawBehind | |
| import androidx.compose.ui.draw.shadow | |
| import androidx.compose.ui.graphics.Color | |
| import androidx.compose.ui.graphics.TransformOrigin | |
| import androidx.compose.ui.graphics.graphicsLayer | |
| import androidx.compose.ui.graphics.painter.Painter | |
| import androidx.compose.ui.input.pointer.pointerInput | |
| import androidx.compose.ui.platform.LocalDensity | |
| import androidx.compose.ui.tooling.preview.Preview | |
| import androidx.compose.ui.unit.IntOffset | |
| import androidx.compose.ui.unit.dp | |
| import kotlinx.coroutines.launch | |
| /* | |
| * Open-source-friendly context menu with stackable overlays and shared-element transitions. | |
| * | |
| * - OverlayHost / Overlay: screen-level slot system that animates overlays on top of the | |
| * host while keeping them in the same composition (so shared-element transitions work). | |
| * - SharedElementContextMenu: iOS-style context menu — a preview floats above a menu list, | |
| * morphing from an anchor via Compose's shared-element machinery. | |
| * - SharedElementContextMenuItem: a basic row used inside the menu slot. | |
| * | |
| * All theming uses Material 3 (MaterialTheme.colorScheme / typography) so the file drops | |
| * into any Compose app without further setup. | |
| */ | |
| private const val FastDurationMs = 200 | |
| private const val MediumDurationMs = 300 | |
| private val MenuShape = RoundedCornerShape(16.dp) | |
| // region OverlayHost -------------------------------------------------------------------- | |
| /** | |
| * Screen-level host for stacking overlays — context menus, custom bottom sheets, in-app | |
| * notifications, anything that floats on top of the screen while still participating in the | |
| * same composition. | |
| * | |
| * Each [Overlay] descendant registers a slot that the host renders in its own fullscreen | |
| * [AnimatedVisibility]. Overlays stack in registration order: one declared inside another | |
| * overlay's content composes after its parent and sits on top. | |
| * | |
| * The host wraps the screen in a [SharedTransitionLayout] so overlays that need shared | |
| * element transitions can morph an anchor into their content. Overlays that don't use | |
| * shared elements just ignore it. | |
| * | |
| * Prefer this over [androidx.compose.ui.window.Dialog] for screen-level overlays — Dialog | |
| * renders in a separate window, which breaks shared element transitions and prevents | |
| * stacking with other in-window overlays. | |
| */ | |
| @OptIn(ExperimentalSharedTransitionApi::class) | |
| @Composable | |
| fun OverlayHost( | |
| modifier: Modifier = Modifier, | |
| content: @Composable () -> Unit | |
| ) { | |
| val state = remember { OverlayHostState() } | |
| SharedTransitionLayout(modifier = modifier.fillMaxSize()) { | |
| CompositionLocalProvider( | |
| LocalOverlayHostState provides state, | |
| LocalSharedTransitionScope provides this@SharedTransitionLayout | |
| ) { | |
| Box(Modifier.fillMaxSize()) { | |
| content() | |
| OverlayLayer(state = state) | |
| } | |
| } | |
| } | |
| } | |
| /** | |
| * Registers an overlay slot with the surrounding [OverlayHost] for the lifetime of this | |
| * composable. The host renders [content] inside a fullscreen [AnimatedVisibility] driven by | |
| * [visible], using [enter] and [exit]. Overlays declared inside another overlay's content | |
| * stack on top of it. | |
| * | |
| * Align or size the overlay UI inside [content] (e.g. with a nested [Box] and | |
| * `Modifier.align`) — the surrounding [AnimatedVisibility] always covers the host. | |
| */ | |
| @Composable | |
| fun Overlay( | |
| visible: () -> Boolean, | |
| enter: EnterTransition = fadeIn(), | |
| exit: ExitTransition = fadeOut(), | |
| content: @Composable AnimatedVisibilityScope.() -> Unit | |
| ) { | |
| val host = checkNotNull(LocalOverlayHostState.current) { | |
| "Overlay must be used inside an OverlayHost" | |
| } | |
| val entry = remember { OverlayEntry(visible, enter, exit, content) }.also { | |
| it.visibleProvider = visible | |
| it.enter = enter | |
| it.exit = exit | |
| it.content = content | |
| } | |
| DisposableEffect(host, entry) { | |
| host.entries += entry | |
| onDispose { host.entries -= entry } | |
| } | |
| } | |
| @Stable | |
| internal class OverlayHostState { | |
| val entries = mutableStateListOf<OverlayEntry>() | |
| } | |
| internal class OverlayEntry( | |
| visibleProvider: () -> Boolean, | |
| enter: EnterTransition, | |
| exit: ExitTransition, | |
| content: @Composable AnimatedVisibilityScope.() -> Unit | |
| ) { | |
| var visibleProvider: () -> Boolean by mutableStateOf(visibleProvider) | |
| var enter: EnterTransition by mutableStateOf(enter) | |
| var exit: ExitTransition by mutableStateOf(exit) | |
| var content: @Composable AnimatedVisibilityScope.() -> Unit by mutableStateOf(content) | |
| } | |
| internal val LocalOverlayHostState = compositionLocalOf<OverlayHostState?> { null } | |
| @OptIn(ExperimentalSharedTransitionApi::class) | |
| internal val LocalSharedTransitionScope = compositionLocalOf<SharedTransitionScope?> { null } | |
| @Composable | |
| private fun OverlayLayer(state: OverlayHostState) { | |
| Box(Modifier.fillMaxSize()) { | |
| state.entries.forEach { entry -> | |
| key(entry) { | |
| AnimatedVisibility( | |
| modifier = Modifier.fillMaxSize(), | |
| visible = entry.visibleProvider(), | |
| enter = entry.enter, | |
| exit = entry.exit | |
| ) { | |
| entry.content(this) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // endregion ---------------------------------------------------------------------------- | |
| // region SharedElementContextMenu ------------------------------------------------------ | |
| /** | |
| * iOS-style context menu, mimicking `View.contextMenu(...) { ... } preview: { ... }`. | |
| * | |
| * The [content] anchor lives in normal layout flow. When [isPresented] returns true, the | |
| * surrounding [OverlayHost] renders [preview] above [menu] on top of the screen. Elements | |
| * decorated with [SharedElementContextMenuScope.sharedElement] in both the anchor and the | |
| * preview will morph via Compose's official shared element transitions. | |
| * | |
| * Presentation state is owned by the caller. Wire any trigger (long-press, tap, button…) | |
| * to flip [isPresented] to true; the menu calls [onDismissRequest] when the scrim is | |
| * tapped, a menu item is clicked, or the preview is dragged past the dismiss threshold. | |
| * Must be hosted inside an [OverlayHost] — a missing host throws at composition. | |
| * | |
| * Stacking: placing a [SharedElementContextMenu] inside [preview] or [menu] opens the | |
| * inner menu on top while the outer one stays visible underneath. Use distinct `key`s | |
| * for shared elements across the stack to avoid pairing collisions. | |
| */ | |
| @OptIn(ExperimentalSharedTransitionApi::class) | |
| @Composable | |
| fun SharedElementContextMenu( | |
| isPresented: () -> Boolean, | |
| onDismissRequest: () -> Unit, | |
| modifier: Modifier = Modifier, | |
| enableDragToDismiss: Boolean = true, | |
| scrimColor: Color = Color.Black.copy(alpha = 0.4f), | |
| preview: @Composable SharedElementContextMenuScope.() -> Unit, | |
| menu: @Composable ColumnScope.() -> Unit, | |
| content: @Composable SharedElementContextMenuScope.() -> Unit | |
| ) { | |
| val sharedScope = checkNotNull(LocalSharedTransitionScope.current) { | |
| "SharedElementContextMenu must be used inside an OverlayHost" | |
| } | |
| Overlay( | |
| visible = isPresented, | |
| enter = fadeIn(tween(MediumDurationMs)), | |
| exit = fadeOut(tween(MediumDurationMs)) | |
| ) { | |
| val overlayScope = remember(sharedScope, this) { | |
| SharedElementContextMenuScopeImpl(sharedScope, this) | |
| } | |
| ContextMenuOverlayContent( | |
| onDismissRequest = onDismissRequest, | |
| enableDragToDismiss = enableDragToDismiss, | |
| scrimColor = scrimColor, | |
| scope = overlayScope, | |
| preview = preview, | |
| menu = menu | |
| ) | |
| } | |
| AnimatedVisibility( | |
| modifier = modifier, | |
| visible = !isPresented(), | |
| enter = fadeIn(tween(MediumDurationMs)), | |
| exit = fadeOut(tween(MediumDurationMs)) | |
| ) { | |
| val anchorScope = remember(sharedScope, this@AnimatedVisibility) { | |
| SharedElementContextMenuScopeImpl(sharedScope, this@AnimatedVisibility) | |
| } | |
| anchorScope.content() | |
| } | |
| } | |
| /** | |
| * Scope that exposes Compose's official shared element transition between the anchor | |
| * and the preview. Apply [sharedElement] to an element in `content` and to its | |
| * counterpart in `preview` with the same `key` to morph between them when the menu | |
| * opens or closes. | |
| */ | |
| @Stable | |
| interface SharedElementContextMenuScope { | |
| @Composable | |
| fun Modifier.sharedElement(key: Any): Modifier | |
| } | |
| @OptIn(ExperimentalSharedTransitionApi::class) | |
| internal class SharedElementContextMenuScopeImpl( | |
| private val sharedScope: SharedTransitionScope, | |
| private val animatedScope: AnimatedVisibilityScope | |
| ) : SharedElementContextMenuScope { | |
| @Composable | |
| override fun Modifier.sharedElement(key: Any): Modifier = with(sharedScope) { | |
| this@sharedElement.sharedElement( | |
| sharedContentState = rememberSharedContentState(key), | |
| animatedVisibilityScope = animatedScope, | |
| boundsTransform = ContextMenuBoundsTransform | |
| ) | |
| } | |
| } | |
| @OptIn(ExperimentalSharedTransitionApi::class) | |
| private val ContextMenuBoundsTransform = BoundsTransform { _, _ -> tween(MediumDurationMs) } | |
| @Composable | |
| private fun ContextMenuOverlayContent( | |
| onDismissRequest: () -> Unit, | |
| enableDragToDismiss: Boolean, | |
| scrimColor: Color, | |
| scope: SharedElementContextMenuScope, | |
| preview: @Composable SharedElementContextMenuScope.() -> Unit, | |
| menu: @Composable ColumnScope.() -> Unit | |
| ) { | |
| val density = LocalDensity.current | |
| val dragOffset = remember { mutableFloatStateOf(0f) } | |
| val dismissThresholdPx = remember(density) { with(density) { 120.dp.toPx() } } | |
| val coroutineScope = rememberCoroutineScope() | |
| val menuVisible by remember(dismissThresholdPx) { | |
| derivedStateOf { dragOffset.floatValue < dismissThresholdPx / 2f } | |
| } | |
| Box( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .drawBehind { | |
| val alphaFactor = 1f - (dragOffset.floatValue / (dismissThresholdPx * 3f)).coerceIn(0f, 0.6f) | |
| drawRect(scrimColor.copy(alpha = scrimColor.alpha * alphaFactor)) | |
| } | |
| .pointerInput(Unit) { | |
| detectTapGestures(onTap = { onDismissRequest() }) | |
| } | |
| ) { | |
| Column( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .padding(24.dp), | |
| verticalArrangement = Arrangement.Center, | |
| horizontalAlignment = Alignment.CenterHorizontally | |
| ) { | |
| Box( | |
| modifier = Modifier | |
| .offset { IntOffset(0, dragOffset.floatValue.toInt()) } | |
| .graphicsLayer { | |
| val scale = 1f - (dragOffset.floatValue / (dismissThresholdPx * 4f)).coerceIn(0f, 0.25f) | |
| scaleX = scale | |
| scaleY = scale | |
| transformOrigin = TransformOrigin(0.5f, 0f) | |
| alpha = 1f - (dragOffset.floatValue / (dismissThresholdPx * 3f)).coerceIn(0f, 0.6f) | |
| } | |
| .then( | |
| if (enableDragToDismiss) { | |
| Modifier.pointerInput(Unit) { | |
| detectVerticalDragGestures( | |
| onDragEnd = { | |
| if (dragOffset.floatValue > dismissThresholdPx) { | |
| onDismissRequest() | |
| } else { | |
| coroutineScope.launch { | |
| val anim = Animatable(dragOffset.floatValue) | |
| anim.animateTo(0f, tween(FastDurationMs)) { | |
| dragOffset.floatValue = value | |
| } | |
| } | |
| } | |
| }, | |
| onDragCancel = { dragOffset.floatValue = 0f } | |
| ) { _, dragAmount -> | |
| dragOffset.floatValue = (dragOffset.floatValue + dragAmount).coerceAtLeast(0f) | |
| } | |
| } | |
| } else { | |
| Modifier | |
| } | |
| ) | |
| ) { | |
| preview(scope) | |
| } | |
| Spacer(Modifier.height(16.dp)) | |
| AnimatedVisibility( | |
| visible = menuVisible, | |
| enter = fadeIn(tween(FastDurationMs)) + | |
| scaleIn(tween(FastDurationMs), initialScale = 0.9f) + | |
| slideInVertically(tween(FastDurationMs)) { -it / 4 }, | |
| exit = fadeOut(tween(FastDurationMs)) + | |
| scaleOut(tween(FastDurationMs), targetScale = 0.9f) + | |
| slideOutVertically(tween(FastDurationMs)) { -it / 4 } | |
| ) { | |
| Column( | |
| modifier = Modifier | |
| .widthIn(min = 220.dp, max = 280.dp) | |
| .shadow(elevation = 16.dp, shape = MenuShape) | |
| .clip(MenuShape) | |
| .background(MaterialTheme.colorScheme.surface) | |
| ) { | |
| menu(this) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // endregion ---------------------------------------------------------------------------- | |
| // region SharedElementContextMenuItem -------------------------------------------------- | |
| @Composable | |
| fun SharedElementContextMenuItem( | |
| text: String, | |
| modifier: Modifier = Modifier, | |
| icon: Painter? = null, | |
| enabled: Boolean = true, | |
| destructive: Boolean = false, | |
| onClick: () -> Unit | |
| ) { | |
| val tint = when { | |
| !enabled -> MaterialTheme.colorScheme.onSurfaceVariant | |
| destructive -> MaterialTheme.colorScheme.error | |
| else -> MaterialTheme.colorScheme.onSurface | |
| } | |
| Row( | |
| modifier = modifier | |
| .fillMaxWidth() | |
| .clickable(enabled = enabled) { | |
| onClick() | |
| } | |
| .padding(horizontal = 16.dp, vertical = 12.dp), | |
| verticalAlignment = Alignment.CenterVertically, | |
| horizontalArrangement = Arrangement.SpaceBetween | |
| ) { | |
| Text( | |
| modifier = Modifier.padding(end = 16.dp), | |
| text = text, | |
| color = tint, | |
| style = MaterialTheme.typography.bodyMedium | |
| ) | |
| if (icon != null) { | |
| Icon( | |
| modifier = Modifier.size(20.dp), | |
| painter = icon, | |
| contentDescription = null, | |
| tint = tint | |
| ) | |
| } | |
| } | |
| } | |
| // endregion ---------------------------------------------------------------------------- | |
| // region Preview ----------------------------------------------------------------------- | |
| @Preview | |
| @Composable | |
| private fun ContextMenuStackedPreview() { | |
| MaterialTheme { | |
| // Both menus are presented from the initial composition so the preview captures the | |
| // stacked state. In a real app these would flip via long-press / tap / button. | |
| var isOuterPresented by remember { mutableStateOf(true) } | |
| var isInnerPresented by remember { mutableStateOf(true) } | |
| OverlayHost { | |
| Box( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .background(MaterialTheme.colorScheme.background), | |
| contentAlignment = Alignment.Center | |
| ) { | |
| SharedElementContextMenu( | |
| isPresented = { isOuterPresented }, | |
| onDismissRequest = { isOuterPresented = false }, | |
| preview = { | |
| // Nest a second SharedElementContextMenu inside the outer preview to | |
| // exercise stacking — the inner overlay registers after the outer | |
| // and sits on top while the outer stays visible underneath. | |
| Box( | |
| modifier = Modifier | |
| .sharedElement("outer-rect") | |
| .size(width = 260.dp, height = 160.dp) | |
| .background(Color(0xFF3B5BFE), RoundedCornerShape(16.dp)), | |
| contentAlignment = Alignment.Center | |
| ) { | |
| SharedElementContextMenu( | |
| isPresented = { isInnerPresented }, | |
| onDismissRequest = { isInnerPresented = false }, | |
| preview = { | |
| Box( | |
| modifier = Modifier | |
| .sharedElement("inner-rect") | |
| .size(width = 220.dp, height = 120.dp) | |
| .background(Color(0xFFFFAA00), RoundedCornerShape(14.dp)) | |
| ) | |
| }, | |
| menu = { | |
| SharedElementContextMenuItem(text = "Inner option A", onClick = { | |
| isInnerPresented = false | |
| }) | |
| HorizontalDivider(thickness = 0.5.dp, color = Color.White.copy(alpha = 0.1f)) | |
| SharedElementContextMenuItem(text = "Inner option B", onClick = { | |
| isInnerPresented = false | |
| }) | |
| } | |
| ) { | |
| Box( | |
| modifier = Modifier | |
| .sharedElement("inner-rect") | |
| .size(width = 120.dp, height = 70.dp) | |
| .background(Color(0xFFFFAA00), RoundedCornerShape(10.dp)) | |
| .clickable { isInnerPresented = true } | |
| ) | |
| } | |
| } | |
| }, | |
| menu = { | |
| SharedElementContextMenuItem(text = "Edit", onClick = { | |
| isOuterPresented = false | |
| }) | |
| HorizontalDivider(thickness = 0.5.dp, color = Color.White.copy(alpha = 0.1f)) | |
| SharedElementContextMenuItem(text = "Share", onClick = { | |
| isOuterPresented = false | |
| }) | |
| HorizontalDivider(thickness = 0.5.dp, color = Color.White.copy(alpha = 0.1f)) | |
| SharedElementContextMenuItem(text = "Delete", destructive = true, onClick = { | |
| isOuterPresented = false | |
| }) | |
| } | |
| ) { | |
| Box( | |
| modifier = Modifier | |
| .sharedElement("outer-rect") | |
| .size(110.dp) | |
| .background(Color(0xFF3B5BFE), RoundedCornerShape(12.dp)) | |
| .clickable { isOuterPresented = true } | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| } |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Screen.Recording.2026-05-21.at.09.32.25.mov