Created
October 1, 2025 13:06
-
-
Save sdetilly/d56e9e6af602a1d89ed8e5706333c34e to your computer and use it in GitHub Desktop.
Shared Element Demo
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 android.os.Bundle | |
| import androidx.activity.ComponentActivity | |
| import androidx.activity.compose.setContent | |
| import androidx.activity.enableEdgeToEdge | |
| import androidx.compose.animation.AnimatedContent | |
| import androidx.compose.animation.AnimatedVisibilityScope | |
| import androidx.compose.animation.ExperimentalSharedTransitionApi | |
| import androidx.compose.animation.SharedTransitionLayout | |
| import androidx.compose.animation.SharedTransitionScope | |
| import androidx.compose.animation.core.tween | |
| import androidx.compose.animation.fadeIn | |
| import androidx.compose.animation.fadeOut | |
| import androidx.compose.animation.slideInVertically | |
| import androidx.compose.animation.slideOutVertically | |
| import androidx.compose.animation.togetherWith | |
| import androidx.compose.foundation.BorderStroke | |
| 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.aspectRatio | |
| import androidx.compose.foundation.layout.fillMaxSize | |
| import androidx.compose.foundation.layout.fillMaxWidth | |
| import androidx.compose.foundation.layout.height | |
| import androidx.compose.foundation.layout.padding | |
| import androidx.compose.foundation.layout.size | |
| import androidx.compose.foundation.layout.width | |
| import androidx.compose.foundation.lazy.LazyColumn | |
| import androidx.compose.foundation.lazy.grid.GridCells | |
| import androidx.compose.foundation.lazy.grid.LazyVerticalGrid | |
| import androidx.compose.foundation.lazy.grid.items | |
| import androidx.compose.foundation.shape.CircleShape | |
| import androidx.compose.foundation.shape.RoundedCornerShape | |
| import androidx.compose.material.icons.Icons | |
| import androidx.compose.material.icons.automirrored.filled.ArrowBack | |
| import androidx.compose.material.icons.filled.Favorite | |
| import androidx.compose.material.icons.filled.Person | |
| import androidx.compose.material.icons.filled.Share | |
| import androidx.compose.material.icons.filled.Star | |
| import androidx.compose.material3.Button | |
| import androidx.compose.material3.Card | |
| import androidx.compose.material3.CardDefaults | |
| import androidx.compose.material3.ExperimentalMaterial3Api | |
| import androidx.compose.material3.Icon | |
| import androidx.compose.material3.IconButton | |
| import androidx.compose.material3.MaterialTheme | |
| import androidx.compose.material3.OutlinedButton | |
| import androidx.compose.material3.Scaffold | |
| import androidx.compose.material3.Text | |
| import androidx.compose.material3.TopAppBar | |
| import androidx.compose.material3.TopAppBarDefaults | |
| import androidx.compose.runtime.Composable | |
| 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.graphics.Brush | |
| import androidx.compose.ui.graphics.Color | |
| import androidx.compose.ui.graphics.vector.ImageVector | |
| import androidx.compose.ui.text.font.FontWeight | |
| import androidx.compose.ui.unit.dp | |
| import androidx.compose.ui.unit.sp | |
| import com.tillylabs.sharedelementdemo.ui.theme.SharedElementDemoTheme | |
| class MainActivity : ComponentActivity() { | |
| override fun onCreate(savedInstanceState: Bundle?) { | |
| super.onCreate(savedInstanceState) | |
| enableEdgeToEdge() | |
| setContent { | |
| SharedElementDemoTheme { | |
| SharedElementDemo() | |
| } | |
| } | |
| } | |
| } | |
| data class DemoItem( | |
| val id: String, | |
| val title: String, | |
| val subtitle: String, | |
| val description: String, | |
| val color: Color, | |
| val icon: ImageVector, | |
| val rating: Float = 4.5f | |
| ) | |
| enum class DemoScreen { | |
| Main, Detail | |
| } | |
| @OptIn(ExperimentalSharedTransitionApi::class, ExperimentalMaterial3Api::class) | |
| @Composable | |
| fun SharedElementDemo() { | |
| var currentScreen by remember { mutableStateOf(DemoScreen.Main) } | |
| var selectedItem by remember { mutableStateOf<DemoItem?>(null) } | |
| val demoItems = listOf( | |
| DemoItem("1", "Mountain Adventures", "Outdoor Experience", "Discover breathtaking mountain views and challenging hiking trails perfect for adventure seekers.", Color(0xFF4CAF50), Icons.Default.Star), | |
| DemoItem("2", "Ocean Paradise", "Beach Resort", "Relax on pristine beaches with crystal clear waters and luxurious amenities.", Color(0xFF2196F3), Icons.Default.Favorite), | |
| DemoItem("3", "City Lights", "Urban Exploration", "Experience the vibrant nightlife and cultural richness of metropolitan cities.", Color(0xFF9C27B0), Icons.Default.Person), | |
| DemoItem("4", "Forest Retreat", "Nature Escape", "Immerse yourself in lush forests and reconnect with nature's tranquility.", Color(0xFF795548), Icons.Default.Share), | |
| DemoItem("5", "Desert Oasis", "Adventure Tour", "Journey through vast deserts and discover hidden oases in this unique experience.", Color(0xFFFF9800), Icons.Default.Star), | |
| DemoItem("6", "Arctic Wonder", "Winter Adventure", "Explore the beauty of frozen landscapes and witness the northern lights.", Color(0xFF607D8B), Icons.Default.Favorite) | |
| ) | |
| SharedTransitionLayout { | |
| AnimatedContent( | |
| targetState = currentScreen, | |
| transitionSpec = { | |
| fadeIn(animationSpec = tween(300)) + slideInVertically( | |
| animationSpec = tween(300), | |
| initialOffsetY = { if (targetState == DemoScreen.Detail) it else -it } | |
| ) togetherWith | |
| fadeOut(animationSpec = tween(300)) + slideOutVertically( | |
| animationSpec = tween(300), | |
| targetOffsetY = { if (targetState == DemoScreen.Detail) -it else it } | |
| ) | |
| }, | |
| label = "screen_transition" | |
| ) { screen -> | |
| when (screen) { | |
| DemoScreen.Main -> MainScreen( | |
| items = demoItems, | |
| onItemClick = { item -> | |
| selectedItem = item | |
| currentScreen = DemoScreen.Detail | |
| }, | |
| sharedTransitionScope = this@SharedTransitionLayout, | |
| animatedVisibilityScope = this | |
| ) | |
| DemoScreen.Detail -> selectedItem?.let { item -> | |
| DetailScreen( | |
| item = item, | |
| onBack = { | |
| currentScreen = DemoScreen.Main | |
| }, | |
| sharedTransitionScope = this@SharedTransitionLayout, | |
| animatedVisibilityScope = this | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| @OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) | |
| @Composable | |
| fun MainScreen( | |
| items: List<DemoItem>, | |
| onItemClick: (DemoItem) -> Unit, | |
| sharedTransitionScope: SharedTransitionScope, | |
| animatedVisibilityScope: AnimatedVisibilityScope | |
| ) { | |
| Scaffold( | |
| topBar = { | |
| TopAppBar( | |
| title = { | |
| Text( | |
| "Shared Elements Demo", | |
| fontWeight = FontWeight.Bold, | |
| color = MaterialTheme.colorScheme.onSurface | |
| ) | |
| }, | |
| colors = TopAppBarDefaults.topAppBarColors( | |
| containerColor = Color.Transparent | |
| ) | |
| ) | |
| } | |
| ) { paddingValues -> | |
| LazyVerticalGrid( | |
| columns = GridCells.Fixed(2), | |
| contentPadding = PaddingValues(16.dp), | |
| horizontalArrangement = Arrangement.spacedBy(12.dp), | |
| verticalArrangement = Arrangement.spacedBy(12.dp), | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .padding(paddingValues) | |
| ) { | |
| items(items) { item -> | |
| with(sharedTransitionScope) { | |
| Card( | |
| modifier = Modifier | |
| .aspectRatio(0.8f) | |
| .clickable { onItemClick(item) } | |
| .sharedElement( | |
| sharedContentState = rememberSharedContentState(key = "card-${item.id}"), | |
| animatedVisibilityScope = animatedVisibilityScope, | |
| boundsTransform = { _, _ -> | |
| tween(durationMillis = 500) | |
| } | |
| ), | |
| elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), | |
| shape = RoundedCornerShape(16.dp) | |
| ) { | |
| Box( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .background( | |
| Brush.verticalGradient( | |
| colors = listOf( | |
| item.color.copy(alpha = 0.8f), | |
| item.color | |
| ) | |
| ) | |
| ) | |
| ) { | |
| Column( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .padding(16.dp), | |
| verticalArrangement = Arrangement.SpaceBetween | |
| ) { | |
| Box( | |
| modifier = Modifier | |
| .sharedElement( | |
| sharedContentState = rememberSharedContentState(key = "icon-container-${item.id}"), | |
| animatedVisibilityScope = animatedVisibilityScope, | |
| boundsTransform = { _, _ -> | |
| tween(durationMillis = 400) | |
| } | |
| ) | |
| .size(48.dp) | |
| .background( | |
| Color.White.copy(alpha = 0.2f), | |
| CircleShape | |
| ), | |
| contentAlignment = Alignment.Center | |
| ) { | |
| Icon( | |
| imageVector = item.icon, | |
| contentDescription = null, | |
| tint = Color.White, | |
| modifier = Modifier.size(24.dp) | |
| ) | |
| } | |
| Column { | |
| Text( | |
| text = item.title, | |
| fontSize = 16.sp, | |
| fontWeight = FontWeight.Bold, | |
| color = Color.White, | |
| modifier = Modifier | |
| .sharedBounds( | |
| sharedContentState = rememberSharedContentState(key = "title-${item.id}"), | |
| animatedVisibilityScope = animatedVisibilityScope, | |
| boundsTransform = { _, _ -> | |
| tween(durationMillis = 400) | |
| } | |
| ) | |
| ) | |
| Text( | |
| text = item.subtitle, | |
| fontSize = 12.sp, | |
| color = Color.White.copy(alpha = 0.8f), | |
| modifier = Modifier | |
| .sharedBounds( | |
| sharedContentState = rememberSharedContentState(key = "subtitle-${item.id}"), | |
| animatedVisibilityScope = animatedVisibilityScope, | |
| boundsTransform = { _, _ -> | |
| tween(durationMillis = 450) | |
| } | |
| ) | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| @OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) | |
| @Composable | |
| fun DetailScreen( | |
| item: DemoItem, | |
| onBack: () -> Unit, | |
| sharedTransitionScope: SharedTransitionScope, | |
| animatedVisibilityScope: AnimatedVisibilityScope | |
| ) { | |
| Scaffold( | |
| topBar = { | |
| TopAppBar( | |
| title = { Text("") }, | |
| navigationIcon = { | |
| IconButton(onClick = onBack) { | |
| Icon( | |
| imageVector = Icons.AutoMirrored.Filled.ArrowBack, | |
| contentDescription = "Back", | |
| tint = Color.White | |
| ) | |
| } | |
| }, | |
| colors = TopAppBarDefaults.topAppBarColors( | |
| containerColor = item.color | |
| ) | |
| ) | |
| }, | |
| ) { paddingValues -> | |
| with(sharedTransitionScope) { | |
| LazyColumn( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .padding(paddingValues), | |
| contentPadding = PaddingValues(16.dp) | |
| ) { | |
| item { | |
| Card( | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .height(300.dp) | |
| .sharedElement( | |
| sharedContentState = rememberSharedContentState(key = "card-${item.id}"), | |
| animatedVisibilityScope = animatedVisibilityScope, | |
| boundsTransform = { _, _ -> | |
| tween(durationMillis = 500) | |
| } | |
| ), | |
| elevation = CardDefaults.cardElevation(defaultElevation = 12.dp), | |
| shape = RoundedCornerShape(20.dp) | |
| ) { | |
| Box( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .background( | |
| Brush.verticalGradient( | |
| colors = listOf( | |
| item.color.copy(alpha = 0.7f), | |
| item.color | |
| ) | |
| ) | |
| ) | |
| ) { | |
| Box( | |
| modifier = Modifier | |
| .align(Alignment.Center) | |
| .sharedElement( | |
| sharedContentState = rememberSharedContentState(key = "icon-container-${item.id}"), | |
| animatedVisibilityScope = animatedVisibilityScope, | |
| boundsTransform = { _, _ -> | |
| tween(durationMillis = 400) | |
| } | |
| ) | |
| .size(80.dp) | |
| .background( | |
| Color.White.copy(alpha = 0.2f), | |
| CircleShape | |
| ), | |
| contentAlignment = Alignment.Center | |
| ) { | |
| Icon( | |
| imageVector = item.icon, | |
| contentDescription = null, | |
| tint = Color.White, | |
| modifier = Modifier.size(40.dp) | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| item { Spacer(modifier = Modifier.height(24.dp)) } | |
| item { | |
| Text( | |
| text = item.title, | |
| fontSize = 32.sp, | |
| fontWeight = FontWeight.Bold, | |
| color = MaterialTheme.colorScheme.onBackground, | |
| modifier = Modifier | |
| .sharedBounds( | |
| sharedContentState = rememberSharedContentState(key = "title-${item.id}"), | |
| animatedVisibilityScope = animatedVisibilityScope, | |
| boundsTransform = { _, _ -> | |
| tween(durationMillis = 400) | |
| } | |
| ) | |
| ) | |
| } | |
| item { Spacer(modifier = Modifier.height(8.dp)) } | |
| item { | |
| Text( | |
| text = item.subtitle, | |
| fontSize = 18.sp, | |
| color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), | |
| modifier = Modifier | |
| .sharedBounds( | |
| sharedContentState = rememberSharedContentState(key = "subtitle-${item.id}"), | |
| animatedVisibilityScope = animatedVisibilityScope, | |
| boundsTransform = { _, _ -> | |
| tween(durationMillis = 450) | |
| } | |
| ) | |
| ) | |
| } | |
| item { Spacer(modifier = Modifier.height(16.dp)) } | |
| item { | |
| Row( | |
| verticalAlignment = Alignment.CenterVertically | |
| ) { | |
| repeat(5) { index -> | |
| Icon( | |
| imageVector = Icons.Default.Star, | |
| contentDescription = null, | |
| tint = if (index < item.rating) Color(0xFFFFC107) else Color.Gray, | |
| modifier = Modifier.size(20.dp) | |
| ) | |
| } | |
| Spacer(modifier = Modifier.width(8.dp)) | |
| Text( | |
| text = "${item.rating}/5.0", | |
| fontSize = 14.sp, | |
| color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f) | |
| ) | |
| } | |
| } | |
| item { Spacer(modifier = Modifier.height(24.dp)) } | |
| item { | |
| Text( | |
| text = "Description", | |
| fontSize = 20.sp, | |
| fontWeight = FontWeight.Bold, | |
| color = MaterialTheme.colorScheme.onBackground | |
| ) | |
| } | |
| item { Spacer(modifier = Modifier.height(8.dp)) } | |
| item { | |
| Text( | |
| text = item.description, | |
| fontSize = 16.sp, | |
| lineHeight = 24.sp, | |
| color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f) | |
| ) | |
| } | |
| item { Spacer(modifier = Modifier.height(32.dp)) } | |
| item { | |
| Row( | |
| modifier = Modifier.fillMaxWidth(), | |
| horizontalArrangement = Arrangement.spacedBy(16.dp) | |
| ) { | |
| OutlinedButton( | |
| onClick = { /* No action */ }, | |
| modifier = Modifier.weight(1f), | |
| border = BorderStroke(1.dp, item.color) | |
| ) { | |
| Text("Learn More", color = item.color) | |
| } | |
| Button( | |
| onClick = { /* No action */ }, | |
| modifier = Modifier.weight(1f), | |
| colors = androidx.compose.material3.ButtonDefaults.buttonColors( | |
| containerColor = item.color | |
| ) | |
| ) { | |
| Text("Book Now", color = Color.White) | |
| } | |
| } | |
| } | |
| item { Spacer(modifier = Modifier.height(100.dp)) } | |
| } | |
| } | |
| } | |
| } | |
| // Theme | |
| private val DarkColorScheme = darkColorScheme( | |
| primary = Purple80, | |
| secondary = PurpleGrey80, | |
| tertiary = Pink80 | |
| ) | |
| private val LightColorScheme = lightColorScheme( | |
| primary = Purple40, | |
| secondary = PurpleGrey40, | |
| tertiary = Pink40 | |
| ) | |
| @Composable | |
| fun SharedElementDemoTheme( | |
| darkTheme: Boolean = isSystemInDarkTheme(), | |
| // Dynamic color is available on Android 12+ | |
| dynamicColor: Boolean = true, | |
| content: @Composable () -> Unit | |
| ) { | |
| val colorScheme = when { | |
| dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { | |
| val context = LocalContext.current | |
| if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) | |
| } | |
| darkTheme -> DarkColorScheme | |
| else -> LightColorScheme | |
| } | |
| MaterialTheme( | |
| colorScheme = colorScheme, | |
| typography = Typography, | |
| content = content | |
| ) | |
| } | |
| // Color | |
| val Purple80 = Color(0xFFD0BCFF) | |
| val PurpleGrey80 = Color(0xFFCCC2DC) | |
| val Pink80 = Color(0xFFEFB8C8) | |
| val Purple40 = Color(0xFF6650a4) | |
| val PurpleGrey40 = Color(0xFF625b71) | |
| val Pink40 = Color(0xFF7D5260) | |
| // Type | |
| val Typography = Typography( | |
| bodyLarge = TextStyle( | |
| fontFamily = FontFamily.Default, | |
| fontWeight = FontWeight.Normal, | |
| fontSize = 16.sp, | |
| lineHeight = 24.sp, | |
| letterSpacing = 0.5.sp | |
| ) | |
| ) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment