Skip to content

Instantly share code, notes, and snippets.

@tunjid
Last active December 8, 2025 14:00
Show Gist options
  • Select an option

  • Save tunjid/42e1ac1a93bab82bc5d6fcc1bee21815 to your computer and use it in GitHub Desktop.

Select an option

Save tunjid/42e1ac1a93bab82bc5d6fcc1bee21815 to your computer and use it in GitHub Desktop.
A snippet showing shared element transitions on large screen devices
package com.example.sharedelementsplayground
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.BoundsTransform
import androidx.compose.animation.EnterExitState
import androidx.compose.animation.SharedTransitionDefaults
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.SharedTransitionScope.OverlayClip
import androidx.compose.animation.SharedTransitionScope.PlaceholderSize
import androidx.compose.animation.SharedTransitionScope.SharedContentState
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.Transition
import androidx.compose.animation.core.rememberTransition
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.layout.layout
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
object LargeScreenSharedElements {
@Composable
fun Show() {
SharedTransitionLayout {
var navigation by remember { mutableStateOf<Navigation>(Navigation.Start) }
val saveableStateHolder = rememberSaveableStateHolder()
Scaffold { paddingValues ->
AnimatedContent(
modifier = Modifier
.padding(paddingValues),
targetState = navigation,
) { targetState ->
saveableStateHolder.SaveableStateProvider(targetState.id) {
when (targetState) {
Navigation.Start -> {
Start(
modifier = Modifier,
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this@AnimatedContent,
visible = true,
onClick = { selectedNavigation ->
navigation = selectedNavigation
}
)
}
is Navigation.End -> Row {
Start(
modifier = Modifier
.weight(1f),
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this@AnimatedContent,
visible = false,
onClick = { selectedNavigation ->
navigation = selectedNavigation
}
)
End(
modifier = Modifier
.weight(1f),
navigation = targetState,
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this@AnimatedContent,
onClick = {
navigation = Navigation.Start
}
)
}
}
}
}
}
}
}
private sealed class Navigation {
data object Start : Navigation()
data class End(
val index: Int,
val color: Color,
) : Navigation()
val id
get() = when (this) {
is End -> index
Start -> -1
}
}
@Composable
private fun Start(
modifier: Modifier = Modifier,
visible: Boolean,
sharedTransitionScope: SharedTransitionScope,
animatedVisibilityScope: AnimatedVisibilityScope,
onClick: (Navigation.End) -> Unit,
) = with(sharedTransitionScope) {
val state = rememberLazyListState()
LazyColumn(
modifier = modifier
.fillMaxSize(),
state = state,
) {
items(
items = items,
key = { it },
itemContent = { index ->
val color = colors[index % colors.size]
Row(
modifier = Modifier
.padding(
horizontal = 16.dp,
vertical = 4.dp,
)
.fillMaxWidth()
.border(
width = 1.dp,
color = MaterialTheme.colorScheme.onSurface,
shape = CircleShape,
)
.clickable {
onClick(Navigation.End(index, color))
},
horizontalArrangement = Arrangement.spacedBy(24.dp)
) {
// Uncomment this and comment out below to see behavior without sandwiching.
// Box(
// modifier = Modifier
// .sharedElement(
// sharedContentState = rememberSharedContentState(
// key = index,
// ),
// animatedVisibilityScope = animatedVisibilityScope
// )
// .background(
// color = color,
// shape = CircleShape
// )
// .size(56.dp)
// )
SharedElement(
modifier = Modifier
.size(56.dp),
sharedContentState = rememberSharedContentState(
key = index,
),
animatedVisibilityScope =
if (visible) animatedVisibilityScope
else rememberStaticExitedAnimatedVisibilityScope(),
) {
Box(
modifier = Modifier
.background(
color = color,
shape = CircleShape
)
.fillMaxSize()
)
}
Text(text = index.toString())
}
}
)
}
}
@Composable
private fun End(
modifier: Modifier = Modifier,
navigation: Navigation.End,
sharedTransitionScope: SharedTransitionScope,
animatedVisibilityScope: AnimatedVisibilityScope,
onClick: () -> Unit,
) = with(sharedTransitionScope) {
Box(
modifier = modifier
.fillMaxSize()
.clickable { onClick() },
) {
// Uncomment this and comment out below to see behavior without sandwiching.
// Box(
// modifier = Modifier
// .align(Alignment.Center)
// .sharedElement(
// sharedContentState = rememberSharedContentState(
// key = navigation.index,
// ),
// animatedVisibilityScope = animatedVisibilityScope
// )
// .background(
// color = navigation.color,
// shape = CircleShape
// )
// .size(140.dp)
// )
SharedElement(
modifier = Modifier
.align(Alignment.Center)
.size(140.dp),
sharedContentState = sharedTransitionScope.rememberSharedContentState(
navigation.index
),
animatedVisibilityScope = animatedVisibilityScope,
) {
Box(
modifier = Modifier
.background(
color = navigation.color,
shape = CircleShape
)
.fillMaxSize()
)
}
}
}
private val items = (0..100).toList()
private val colors = listOf(
Color.Blue,
Color.Yellow,
Color.Red,
Color.Gray,
Color.Cyan,
Color.Magenta,
Color.Green,
)
@Composable
private fun rememberStaticExitedAnimatedVisibilityScope(): AnimatedVisibilityScope {
val transition = rememberTransition(
MutableTransitionState(
initialState = EnterExitState.PostExit
)
)
return remember(transition) {
object : AnimatedVisibilityScope {
override val transition: Transition<EnterExitState>
get() = transition
}
}
}
}
@Composable
inline fun SharedTransitionScope.SharedElement(
modifier: Modifier,
sharedContentState: SharedContentState,
animatedVisibilityScope: AnimatedVisibilityScope,
boundsTransform: BoundsTransform = SharedTransitionDefaults.BoundsTransform,
placeholderSize: PlaceholderSize = PlaceholderSize.ContentSize,
renderInOverlayDuringTransition: Boolean = true,
zIndexInOverlay: Float = 0f,
clipInOverlayDuringTransition: OverlayClip = ParentClip,
crossinline content: @Composable () -> Unit,
) {
// 1. The Wrapper: Handles placement and sizing in the layout
Box(modifier) {
val visible = animatedVisibilityScope.transition.targetState == EnterExitState.Visible
Box(
// 2. The Tracker: Holds the shared element key and bounds
Modifier
.sharedElement(
sharedContentState = sharedContentState,
animatedVisibilityScope = animatedVisibilityScope,
boundsTransform = boundsTransform,
placeholderSize = placeholderSize,
renderInOverlayDuringTransition = renderInOverlayDuringTransition,
zIndexInOverlay = zIndexInOverlay,
clipInOverlayDuringTransition = clipInOverlayDuringTransition,
)
.fillSharedElement()
) {
// 3a. The shared element if it is visible and animating
if (visible) content()
}
// 3b. The shared element if it is just visible
if (!visible) content()
}
}
@Composable
inline fun SharedTransitionScope.SharedElementWithCallerManagedVisibility(
modifier: Modifier,
sharedContentState: SharedContentState,
placeholderSize: PlaceholderSize = PlaceholderSize.ContentSize,
renderInOverlayDuringTransition: Boolean = true,
zIndexInOverlay: Float = 0f,
clipInOverlayDuringTransition: OverlayClip = ParentClip,
isVisible: () -> Boolean,
crossinline content: @Composable () -> Unit,
) {
// 1. The Wrapper: Handles placement and sizing in the layout
Box(modifier) {
val visible = isVisible()
Box(
// 2. The Tracker: Holds the shared element key and bounds
Modifier
.sharedElementWithCallerManagedVisibility(
sharedContentState = sharedContentState,
visible = visible,
placeholderSize = placeholderSize,
renderInOverlayDuringTransition = renderInOverlayDuringTransition,
zIndexInOverlay = zIndexInOverlay,
clipInOverlayDuringTransition = clipInOverlayDuringTransition,
)
.fillSharedElement()
) {
// 3a. The shared element if it is visible and animating
if (visible) content()
}
// 3b. The shared element if it is just visible
if (!visible) content()
}
}
val ParentClip: OverlayClip =
object : OverlayClip {
override fun getClipPath(
sharedContentState: SharedContentState,
bounds: Rect,
layoutDirection: LayoutDirection,
density: Density,
): Path? {
return sharedContentState.parentSharedContentState?.clipPathInOverlay
}
}
fun Modifier.fillSharedElement() = this.then(FillSharedElement)
val FillSharedElement = Modifier
.layout { measurable, constraints ->
val placeable = measurable.measure(
constraints.copy(
minWidth = when {
constraints.hasBoundedWidth -> constraints.maxWidth
else -> constraints.minWidth
},
minHeight = when {
constraints.hasBoundedHeight -> constraints.maxHeight
else -> constraints.minHeight
}
)
)
layout(
width = placeable.width,
height = placeable.height
) {
placeable.place(0, 0)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment