Instantly share code, notes, and snippets.
Last active
December 8, 2025 14:00
-
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 tunjid/42e1ac1a93bab82bc5d6fcc1bee21815 to your computer and use it in GitHub Desktop.
A snippet showing shared element transitions on large screen devices
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
| 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