Skip to content

Instantly share code, notes, and snippets.

@fabriciovergara
Created May 21, 2026 07:33
Show Gist options
  • Select an option

  • Save fabriciovergara/d140d478ac97ea9845a1522122c1ad6e to your computer and use it in GitHub Desktop.

Select an option

Save fabriciovergara/d140d478ac97ea9845a1522122c1ad6e to your computer and use it in GitHub Desktop.
Jetpack compose implementation of ContextMenu with shared element API
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 }
)
}
}
}
}
}
@fabriciovergara
Copy link
Copy Markdown
Author

Screen.Recording.2026-05-21.at.09.32.25.mov

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment