Skip to content

Instantly share code, notes, and snippets.

@Kyriakos-Georgiopoulos
Created December 24, 2025 13:53
Show Gist options
  • Select an option

  • Save Kyriakos-Georgiopoulos/203e010ba98c2c5ca9bf1a1912033558 to your computer and use it in GitHub Desktop.

Select an option

Save Kyriakos-Georgiopoulos/203e010ba98c2c5ca9bf1a1912033558 to your computer and use it in GitHub Desktop.
/*
* Copyright 2025 Kyriakos Georgiopoulos
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import androidx.annotation.DrawableRes
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.BoundsTransform
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.animateColor
import androidx.compose.animation.core.ArcMode
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.Transition
import androidx.compose.animation.core.keyframes
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.with
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.zengrip.R
/**
* Simple UI model representing a product item that can be displayed and animated
* across the list and detail screens.
*/
data class Product(
val id: String,
val title: String,
val subtitle: String,
@DrawableRes val imageRes: Int,
)
/**
* Represents the high-level navigation state of the product screen.
*/
sealed interface ScreenState {
data object List : ScreenState
data class Detail(val product: Product) : ScreenState
}
private const val SCREEN_DURATION = 850
private const val SHARED_DURATION = 1050
private const val DETAILS_DURATION = 900
private val ArcLinearPunchEasing = CubicBezierEasing(0.00f, 0.00f, 0.0f, 1.0f)
private val CinematicEasing = CubicBezierEasing(0.18f, 0.82f, 0.23f, 1.02f)
private val PlayfulEasingTitle = CubicBezierEasing(0.15f, 0.9f, 0.25f, 1.05f)
private val PlayfulEasingSubtitle = CubicBezierEasing(0.20f, 0.8f, 0.25f, 1.05f)
/**
* Root composable that renders either the product list or a single product detail screen
* using shared element transitions between them.
*
* @param products The list of products to display.
*/
@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalAnimationApi::class)
@Composable
fun ProductScreen(products: List<Product>) {
var state by remember { mutableStateOf<ScreenState>(ScreenState.List) }
val listState = rememberLazyListState()
SharedTransitionLayout {
val sharedScope = this
val screenTransition = updateTransition(
targetState = state,
label = "screenTransition"
)
val sharedConfig = remember(screenTransition) {
directionAwareSharedConfig(screenTransition)
}
val backgroundColor by screenTransition.animateColor(label = "backgroundColor") { target ->
when (target) {
ScreenState.List -> MaterialTheme.colorScheme.surface
is ScreenState.Detail ->
MaterialTheme.colorScheme.surface.copy(alpha = 1f)
}
}
Surface(
modifier = Modifier.fillMaxSize(),
color = backgroundColor
) {
screenTransition.AnimatedContent(
transitionSpec = {
val forward =
initialState is ScreenState.List && targetState is ScreenState.Detail
val backward =
initialState is ScreenState.Detail && targetState is ScreenState.List
when {
forward -> {
(slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.Left,
animationSpec = tween(SCREEN_DURATION, easing = CinematicEasing)
) + fadeIn(tween(SCREEN_DURATION, easing = LinearOutSlowInEasing))
+ scaleIn(
initialScale = 0.98f,
animationSpec = tween(SCREEN_DURATION, easing = CinematicEasing)
)) with
(slideOutOfContainer(
AnimatedContentTransitionScope.SlideDirection.Left,
animationSpec = tween(
SCREEN_DURATION,
easing = CinematicEasing
)
) + fadeOut(
tween(
SCREEN_DURATION - 150,
easing = FastOutSlowInEasing
)
))
}
backward -> {
(slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.Right,
animationSpec = tween(
SCREEN_DURATION - 80,
easing = CinematicEasing
)
) + fadeIn(tween(SCREEN_DURATION - 80))) with
(slideOutOfContainer(
AnimatedContentTransitionScope.SlideDirection.Right,
animationSpec = tween(
SCREEN_DURATION - 80,
easing = CinematicEasing
)
) + fadeOut(tween(SCREEN_DURATION - 150)))
}
else -> {
fadeIn(tween(240)) with fadeOut(tween(200))
}
}.using(SizeTransform(clip = false))
},
) { target ->
when (target) {
ScreenState.List -> ProductList(
products = products,
onProductClick = { product ->
state = ScreenState.Detail(product)
},
sharedScope = sharedScope,
animatedVisibilityScope = this,
sharedConfig = sharedConfig,
listState = listState
)
is ScreenState.Detail -> ProductDetail(
product = target.product,
onBack = { state = ScreenState.List },
sharedScope = sharedScope,
animatedVisibilityScope = this,
sharedConfig = sharedConfig
)
}
}
}
}
}
/**
* Shared element configuration that enables transitions in both directions
* between the list and detail screens.
*/
@Stable
@OptIn(ExperimentalSharedTransitionApi::class)
private fun directionAwareSharedConfig(
transition: Transition<ScreenState>
): SharedTransitionScope.SharedContentConfig {
return object : SharedTransitionScope.SharedContentConfig {
override val SharedTransitionScope.SharedContentState.isEnabled: Boolean
get() {
val current = transition.currentState
val target = transition.targetState
return (current is ScreenState.List && target is ScreenState.Detail) ||
(current is ScreenState.Detail && target is ScreenState.List)
}
override val shouldKeepEnabledForOngoingAnimation: Boolean
get() = false
}
}
/**
* Linear bounds transform used for shared elements that should move
* smoothly between their start and end positions.
*/
private fun linearBoundsTransform(
durationMillis: Int = SHARED_DURATION
): BoundsTransform = BoundsTransform { _, _ ->
tween(
durationMillis,
easing = CubicBezierEasing(0.2f, 0.9f, 0.3f, 1.15f)
)
}
/**
* Arc-based bounds transform that moves the shared element along an arc path.
*
* @param durationMillis Duration of the animation in milliseconds.
* @param arcMode The direction of the arc path.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
private fun arcBoundsTransform(
durationMillis: Int = SHARED_DURATION,
arcMode: ArcMode = ArcMode.ArcAbove
): BoundsTransform = BoundsTransform { initial, target ->
keyframes {
this.durationMillis = durationMillis
initial at 0 using arcMode using ArcLinearPunchEasing
target at durationMillis
}
}
/**
* Arc-based bounds transform tuned for the title text shared element.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
private fun arcTitleTransform(
durationMillis: Int = SHARED_DURATION
): BoundsTransform = BoundsTransform { initial, target ->
keyframes {
this.durationMillis = durationMillis
initial at 0 using ArcMode.ArcAbove using PlayfulEasingTitle
target at durationMillis
}
}
/**
* Arc-based bounds transform tuned for the subtitle text shared element.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
private fun arcSubtitleTransform(
durationMillis: Int = SHARED_DURATION
): BoundsTransform = BoundsTransform { initial, target ->
keyframes {
this.durationMillis = durationMillis
initial at 0 using ArcMode.ArcBelow using PlayfulEasingSubtitle
target at durationMillis
}
}
/**
* List screen displaying all products in a vertically scrolling list.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
private fun AnimatedContentScope.ProductList(
products: List<Product>,
onProductClick: (Product) -> Unit,
sharedScope: SharedTransitionScope,
animatedVisibilityScope: AnimatedVisibilityScope,
sharedConfig: SharedTransitionScope.SharedContentConfig,
listState: LazyListState
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(top = 24.dp),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
state = listState
) {
items(products, key = { it.id }) { product ->
with(sharedScope) {
ProductListItem(
product = product,
onClick = { onProductClick(product) },
animatedVisibilityScope = animatedVisibilityScope,
sharedConfig = sharedConfig
)
}
}
}
}
/**
* Single product row used in the list screen with shared elements for image and text.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun SharedTransitionScope.ProductListItem(
product: Product,
onClick: () -> Unit,
animatedVisibilityScope: AnimatedVisibilityScope,
sharedConfig: SharedTransitionScope.SharedContentConfig
) {
val shape = RoundedCornerShape(20.dp)
Row(
modifier = Modifier
.fillMaxWidth()
.clip(shape)
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
.clickable(onClick = onClick)
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = painterResource(id = product.imageRes),
contentDescription = product.title,
modifier = Modifier
.size(72.dp)
.sharedElement(
sharedContentState = rememberSharedContentState(
key = "image-${product.id}",
config = sharedConfig
),
animatedVisibilityScope = animatedVisibilityScope,
boundsTransform = linearBoundsTransform(),
)
.clip(RoundedCornerShape(16.dp)),
contentScale = ContentScale.Crop
)
Spacer(Modifier.width(16.dp))
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = product.title,
fontSize = 18.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.sharedElement(
sharedContentState = rememberSharedContentState(
key = "title-${product.id}",
config = sharedConfig
),
animatedVisibilityScope = animatedVisibilityScope,
boundsTransform = arcTitleTransform()
)
.skipToLookaheadSize()
)
Spacer(Modifier.height(4.dp))
Text(
text = product.subtitle,
style = MaterialTheme.typography.bodySmall,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.sharedElement(
sharedContentState = rememberSharedContentState(
key = "subtitle-${product.id}",
config = sharedConfig
),
animatedVisibilityScope = animatedVisibilityScope,
boundsTransform = arcSubtitleTransform()
)
.skipToLookaheadSize()
)
}
}
}
/**
* Detail screen for a single product, showing a large header image and description,
* with shared element transitions from the list card.
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class)
@Composable
private fun AnimatedContentScope.ProductDetail(
product: Product,
onBack: () -> Unit,
sharedScope: SharedTransitionScope,
animatedVisibilityScope: AnimatedVisibilityScope,
sharedConfig: SharedTransitionScope.SharedContentConfig
) {
Column(
modifier = Modifier.fillMaxSize()
) {
TopAppBar(
title = {},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Filled.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent
)
)
with(sharedScope) {
val headerShape = RoundedCornerShape(bottomStart = 42.dp, bottomEnd = 42.dp)
Box(
modifier = Modifier
.fillMaxWidth()
.height(320.dp)
.sharedBounds(
sharedContentState = rememberSharedContentState(
key = "imageBounds-${product.id}",
config = sharedConfig
),
animatedVisibilityScope = animatedVisibilityScope,
resizeMode = SharedTransitionScope.ResizeMode.scaleToBounds(
contentScale = ContentScale.Fit,
alignment = Alignment.Center
),
boundsTransform = arcBoundsTransform(),
),
contentAlignment = Alignment.Center
) {
Image(
painter = painterResource(id = product.imageRes),
contentDescription = product.title,
modifier = Modifier
.size(240.dp)
.align(Alignment.Center)
.offset(y = (-40).dp)
.sharedElement(
sharedContentState = rememberSharedContentState(
key = "image-${product.id}",
config = sharedConfig
),
animatedVisibilityScope = animatedVisibilityScope,
boundsTransform = linearBoundsTransform(),
)
.graphicsLayer {
shape = headerShape
clip = !isTransitionActive
},
contentScale = ContentScale.Crop
)
Box(
modifier = Modifier
.matchParentSize()
.background(
brush = androidx.compose.ui.graphics.Brush.verticalGradient(
0f to Color.Transparent,
0.6f to Color.Black.copy(alpha = 0.18f),
1f to Color.Black.copy(alpha = 0.58f)
)
)
)
Column(
modifier = Modifier
.align(Alignment.BottomStart)
.padding(20.dp)
) {
Text(
text = product.title,
color = Color.White,
fontSize = 20.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.sharedElement(
sharedContentState = rememberSharedContentState(
key = "title-${product.id}",
config = sharedConfig
),
animatedVisibilityScope = animatedVisibilityScope,
boundsTransform = arcTitleTransform()
)
.skipToLookaheadSize()
)
Spacer(Modifier.height(6.dp))
Text(
text = product.subtitle,
color = Color.White.copy(alpha = 0.75f),
style = MaterialTheme.typography.bodyMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.sharedElement(
sharedContentState = rememberSharedContentState(
key = "subtitle-${product.id}",
config = sharedConfig
),
animatedVisibilityScope = animatedVisibilityScope,
boundsTransform = arcSubtitleTransform()
)
.skipToLookaheadSize()
)
}
}
}
Column(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.padding(20.dp)
.animateEnterExit(
enter = fadeIn(
animationSpec = tween(
durationMillis = DETAILS_DURATION,
delayMillis = 120,
easing = LinearOutSlowInEasing
)
) + slideInVertically(
animationSpec = tween(
durationMillis = DETAILS_DURATION,
delayMillis = 120,
easing = CinematicEasing
)
) { it / 5 },
exit = fadeOut(
animationSpec = tween(260)
) + slideOutVertically(
animationSpec = tween(260)
) { it / 5 }
),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "Details",
style = MaterialTheme.typography.titleMedium
)
Text(
text = loremLong,
style = MaterialTheme.typography.bodyMedium,
lineHeight = 20.sp
)
}
}
}
/**
* Placeholder long-form description used for the product details section.
*/
private val loremLong = """
This is where you put your long-form product description.
In a real app this would come from your backend or local DB.
The important part for our transition is that the header image
and title feel physically connected to the list card the user tapped.
""".trimIndent()
/**
* Sample product data for previews or manual testing.
*/
val sampleProducts: List<Product> = listOf(
Product(
id = "espresso",
title = "Espresso",
subtitle = "Strong, short, and bold – the purest coffee shot.",
imageRes = R.drawable.product_espresso,
),
Product(
id = "cappuccino",
title = "Cappuccino",
subtitle = "Velvety milk foam over a rich espresso base.",
imageRes = R.drawable.product_cappuccino,
),
Product(
id = "latte",
title = "Caffè Latte",
subtitle = "Smooth and milky, perfect for a slow morning.",
imageRes = R.drawable.product_latte,
),
Product(
id = "mocha",
title = "Mocha",
subtitle = "Chocolate meets espresso – a sweet classic.",
imageRes = R.drawable.product_mocha,
),
Product(
id = "iced",
title = "Iced Coffee",
subtitle = "Cold, refreshing, and perfect for summer days.",
imageRes = R.drawable.product_iced_coffee,
),
Product(
id = "cookies",
title = "Fresh Cookies",
subtitle = "Warm, gooey cookies – perfect with any drink.",
imageRes = R.drawable.product_cookies,
),
Product(
id = "flat_white",
title = "Flat White",
subtitle = "Silky microfoam over a rich double espresso.",
imageRes = R.drawable.product_flat_white,
),
Product(
id = "filter",
title = "Filter Coffee",
subtitle = "Slow-brewed, clean and bright in flavor.",
imageRes = R.drawable.product_filter_coffee,
),
Product(
id = "toast",
title = "Buttered Toast",
subtitle = "Golden, crispy slices with melted butter.",
imageRes = R.drawable.product_toast,
),
Product(
id = "tea",
title = "Herbal Tea",
subtitle = "Calming blend of herbs, no caffeine, all comfort.",
imageRes = R.drawable.product_tea,
),
Product(
id = "cake",
title = "Slice of Cake",
subtitle = "Rich, moist cake – your afternoon treat.",
imageRes = R.drawable.product_cake,
),
Product(
id = "cupcake",
title = "Cupcake",
subtitle = "Small, sweet, and topped with creamy frosting.",
imageRes = R.drawable.product_cupcake,
),
Product(
id = "takeaway",
title = "Takeaway Coffee",
subtitle = "Your favorite brew, ready to go.",
imageRes = R.drawable.product_takeaway_coffee,
)
)
@Itkaushal
Copy link

👍 very helpful

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