feat: sharedElements transition for cast avatars

This commit is contained in:
tapframe 2026-04-11 18:45:23 +05:30
parent c95c498d9b
commit ff61659e6b
6 changed files with 165 additions and 32 deletions

View file

@ -1,6 +1,8 @@
package com.nuvio.app package com.nuvio.app
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
@ -168,6 +170,7 @@ data class DetailRoute(val type: String, val id: String)
data class PersonDetailRoute( data class PersonDetailRoute(
val personId: Int, val personId: Int,
val personName: String, val personName: String,
val personPhoto: String? = null,
val preferCrew: Boolean = false, val preferCrew: Boolean = false,
) )
@ -387,7 +390,7 @@ fun App() {
} }
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class)
@Composable @Composable
private fun MainAppContent( private fun MainAppContent(
onSwitchProfile: () -> Unit = {}, onSwitchProfile: () -> Unit = {},
@ -619,11 +622,12 @@ private fun MainAppContent(
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.background), .background(MaterialTheme.colorScheme.background),
) { ) {
NavHost( SharedTransitionLayout {
navController = navController, NavHost(
startDestination = TabsRoute, navController = navController,
modifier = Modifier.fillMaxSize(), startDestination = TabsRoute,
) { modifier = Modifier.fillMaxSize(),
) {
composable<TabsRoute> { composable<TabsRoute> {
PlatformBackHandler( PlatformBackHandler(
enabled = selectedTab != AppScreenTab.Home, enabled = selectedTab != AppScreenTab.Home,
@ -769,6 +773,7 @@ private fun MainAppContent(
PersonDetailRoute( PersonDetailRoute(
personId = tmdbId, personId = tmdbId,
personName = person.name, personName = person.name,
personPhoto = person.photo,
preferCrew = person.role?.let { preferCrew = person.role?.let {
it.equals("Director", ignoreCase = true) || it.equals("Director", ignoreCase = true) ||
it.equals("Writer", ignoreCase = true) || it.equals("Writer", ignoreCase = true) ||
@ -791,6 +796,8 @@ private fun MainAppContent(
) )
} }
}, },
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} }
@ -799,6 +806,7 @@ private fun MainAppContent(
PersonDetailScreen( PersonDetailScreen(
personId = route.personId, personId = route.personId,
personName = route.personName, personName = route.personName,
initialProfilePhoto = route.personPhoto,
preferCrew = route.preferCrew, preferCrew = route.preferCrew,
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
onOpenMeta = { preview -> onOpenMeta = { preview ->
@ -822,6 +830,8 @@ private fun MainAppContent(
) )
} }
}, },
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} }
@ -1290,6 +1300,7 @@ private fun MainAppContent(
}, },
) )
} }
}
} }
NuvioPosterActionSheet( NuvioPosterActionSheet(

View file

@ -0,0 +1,3 @@
package com.nuvio.app.features.details
internal fun castAvatarSharedTransitionKey(personId: Int): String = "cast-avatar:$personId"

View file

@ -1,6 +1,9 @@
package com.nuvio.app.features.details package com.nuvio.app.features.details
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.LinearOutSlowInEasing import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
@ -96,6 +99,7 @@ import com.nuvio.app.features.watching.application.WatchingState
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
@OptIn(ExperimentalSharedTransitionApi::class)
fun MetaDetailsScreen( fun MetaDetailsScreen(
type: String, type: String,
id: String, id: String,
@ -105,6 +109,8 @@ fun MetaDetailsScreen(
onOpenMeta: ((MetaPreview) -> Unit)? = null, onOpenMeta: ((MetaPreview) -> Unit)? = null,
onCastClick: ((MetaPerson) -> Unit)? = null, onCastClick: ((MetaPerson) -> Unit)? = null,
onCompanyClick: ((MetaCompany, String) -> Unit)? = null, onCompanyClick: ((MetaCompany, String) -> Unit)? = null,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val uiState by MetaDetailsRepository.uiState.collectAsStateWithLifecycle() val uiState by MetaDetailsRepository.uiState.collectAsStateWithLifecycle()
@ -639,6 +645,8 @@ fun MetaDetailsScreen(
onOpenMeta = onOpenMeta, onOpenMeta = onOpenMeta,
onCastClick = onCastClick, onCastClick = onCastClick,
onCompanyClick = onCompanyClick, onCompanyClick = onCompanyClick,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
) )
Spacer(modifier = Modifier.height(32.dp + nuvioPlatformExtraBottomPadding)) Spacer(modifier = Modifier.height(32.dp + nuvioPlatformExtraBottomPadding))
@ -877,6 +885,7 @@ fun MetaDetailsScreen(
} }
@Composable @Composable
@OptIn(ExperimentalSharedTransitionApi::class)
private fun ConfiguredMetaSections( private fun ConfiguredMetaSections(
settings: MetaScreenSettingsUiState, settings: MetaScreenSettingsUiState,
meta: MetaDetails, meta: MetaDetails,
@ -912,6 +921,8 @@ private fun ConfiguredMetaSections(
onOpenMeta: ((MetaPreview) -> Unit)?, onOpenMeta: ((MetaPreview) -> Unit)?,
onCastClick: ((MetaPerson) -> Unit)?, onCastClick: ((MetaPerson) -> Unit)?,
onCompanyClick: ((MetaCompany, String) -> Unit)?, onCompanyClick: ((MetaCompany, String) -> Unit)?,
sharedTransitionScope: SharedTransitionScope?,
animatedVisibilityScope: AnimatedVisibilityScope?,
) { ) {
val enabledItems = settings.items.filter { it.enabled } val enabledItems = settings.items.filter { it.enabled }
@ -954,7 +965,13 @@ private fun ConfiguredMetaSections(
} }
} }
MetaScreenSectionKey.CAST -> { MetaScreenSectionKey.CAST -> {
DetailCastSection(cast = meta.cast, showHeader = showHeader, onCastClick = onCastClick) DetailCastSection(
cast = meta.cast,
showHeader = showHeader,
onCastClick = onCastClick,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
)
} }
MetaScreenSectionKey.COMMENTS -> { MetaScreenSectionKey.COMMENTS -> {
if (shouldShowComments && (isCommentsLoading || comments.isNotEmpty() || !commentsError.isNullOrBlank())) { if (shouldShowComments && (isCommentsLoading || comments.isNotEmpty() || !commentsError.isNullOrBlank())) {

View file

@ -1,7 +1,9 @@
package com.nuvio.app.features.details package com.nuvio.app.features.details
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@ -64,12 +66,16 @@ private sealed interface PersonDetailUiState {
} }
@Composable @Composable
@OptIn(ExperimentalSharedTransitionApi::class)
fun PersonDetailScreen( fun PersonDetailScreen(
personId: Int, personId: Int,
personName: String, personName: String,
initialProfilePhoto: String? = null,
preferCrew: Boolean = false, preferCrew: Boolean = false,
onBack: () -> Unit, onBack: () -> Unit,
onOpenMeta: (MetaPreview) -> Unit, onOpenMeta: (MetaPreview) -> Unit,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var uiState by remember(personId) { mutableStateOf<PersonDetailUiState>(PersonDetailUiState.Loading) } var uiState by remember(personId) { mutableStateOf<PersonDetailUiState>(PersonDetailUiState.Loading) }
@ -92,25 +98,29 @@ fun PersonDetailScreen(
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.background), .background(MaterialTheme.colorScheme.background),
) { ) {
Crossfade( when (val state = uiState) {
targetState = uiState, is PersonDetailUiState.Loading -> PersonDetailSkeleton(
label = "PersonDetailCrossfade", personId = personId,
) { state -> personName = personName,
when (state) { profilePhoto = initialProfilePhoto,
is PersonDetailUiState.Loading -> PersonDetailSkeleton(personName = personName) sharedTransitionScope = sharedTransitionScope,
is PersonDetailUiState.Error -> PersonDetailError( animatedVisibilityScope = animatedVisibilityScope,
message = state.message, )
onRetry = { is PersonDetailUiState.Error -> PersonDetailError(
uiState = PersonDetailUiState.Loading message = state.message,
// Retry will be triggered by the LaunchedEffect above if we reset onRetry = {
}, uiState = PersonDetailUiState.Loading
) // Retry will be triggered by the LaunchedEffect above if we reset
is PersonDetailUiState.Success -> PersonDetailContent( },
person = state.personDetail, )
onOpenMeta = onOpenMeta, is PersonDetailUiState.Success -> PersonDetailContent(
) person = state.personDetail,
onOpenMeta = onOpenMeta,
initialProfilePhoto = initialProfilePhoto,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
)
} }
}
// Back button overlaid on top // Back button overlaid on top
IconButton( IconButton(
@ -130,9 +140,13 @@ fun PersonDetailScreen(
} }
@Composable @Composable
@OptIn(ExperimentalSharedTransitionApi::class)
private fun PersonDetailContent( private fun PersonDetailContent(
person: PersonDetail, person: PersonDetail,
onOpenMeta: (MetaPreview) -> Unit, onOpenMeta: (MetaPreview) -> Unit,
initialProfilePhoto: String? = null,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
) { ) {
val accentColor = MaterialTheme.colorScheme.primary val accentColor = MaterialTheme.colorScheme.primary
@ -223,6 +237,9 @@ private fun PersonDetailContent(
HeroSection( HeroSection(
person = person, person = person,
collapseProgress = collapseProgress, collapseProgress = collapseProgress,
fallbackProfilePhoto = initialProfilePhoto,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
) )
if (popularCredits.isNotEmpty()) { if (popularCredits.isNotEmpty()) {
@ -268,13 +285,30 @@ private const val HERO_COLLAPSE_SCROLL_RANGE = 220f
private const val HAPTIC_TRIGGER_SCROLL_THRESHOLD_PX = 56 private const val HAPTIC_TRIGGER_SCROLL_THRESHOLD_PX = 56
@Composable @Composable
@OptIn(ExperimentalSharedTransitionApi::class)
private fun HeroSection( private fun HeroSection(
person: PersonDetail, person: PersonDetail,
collapseProgress: Float = 0f, collapseProgress: Float = 0f,
fallbackProfilePhoto: String? = null,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
) { ) {
val avatarSize = lerp(140.dp, 72.dp, collapseProgress) val avatarSize = lerp(140.dp, 72.dp, collapseProgress)
val heroScale = 1f - (collapseProgress * 0.12f) val heroScale = 1f - (collapseProgress * 0.12f)
val heroAlpha = 1f - (collapseProgress * 0.35f) val heroAlpha = 1f - (collapseProgress * 0.35f)
val avatarUrl = person.profilePhoto?.takeIf { it.isNotBlank() } ?: fallbackProfilePhoto
val avatarSharedElementModifier = if (sharedTransitionScope != null && animatedVisibilityScope != null) {
with(sharedTransitionScope) {
Modifier.sharedElement(
sharedContentState = rememberSharedContentState(
key = castAvatarSharedTransitionKey(person.tmdbId),
),
animatedVisibilityScope = animatedVisibilityScope,
)
}
} else {
Modifier
}
Column( Column(
modifier = Modifier modifier = Modifier
@ -291,14 +325,15 @@ private fun HeroSection(
// Profile Photo // Profile Photo
Box( Box(
modifier = Modifier modifier = Modifier
.then(avatarSharedElementModifier)
.size(avatarSize) .size(avatarSize)
.clip(CircleShape) .clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant), .background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
if (!person.profilePhoto.isNullOrBlank()) { if (!avatarUrl.isNullOrBlank()) {
AsyncImage( AsyncImage(
model = person.profilePhoto, model = avatarUrl,
contentDescription = person.name, contentDescription = person.name,
modifier = Modifier.matchParentSize(), modifier = Modifier.matchParentSize(),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
@ -378,7 +413,14 @@ private fun HeroSection(
// ─── Loading / Error States ─── // ─── Loading / Error States ───
@Composable @Composable
private fun PersonDetailSkeleton(personName: String) { @OptIn(ExperimentalSharedTransitionApi::class)
private fun PersonDetailSkeleton(
personId: Int,
personName: String,
profilePhoto: String? = null,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
) {
val accentColor = MaterialTheme.colorScheme.primary val accentColor = MaterialTheme.colorScheme.primary
val accentGradient = remember(accentColor) { val accentGradient = remember(accentColor) {
Brush.verticalGradient( Brush.verticalGradient(
@ -416,12 +458,41 @@ private fun PersonDetailSkeleton(personName: String) {
.padding(horizontal = 20.dp), .padding(horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
val avatarSharedElementModifier = if (sharedTransitionScope != null && animatedVisibilityScope != null) {
with(sharedTransitionScope) {
Modifier.sharedElement(
sharedContentState = rememberSharedContentState(
key = castAvatarSharedTransitionKey(personId),
),
animatedVisibilityScope = animatedVisibilityScope,
)
}
} else {
Modifier
}
Box( Box(
modifier = Modifier modifier = Modifier
.then(avatarSharedElementModifier)
.size(140.dp) .size(140.dp)
.clip(CircleShape) .clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant), .background(MaterialTheme.colorScheme.surfaceVariant),
) contentAlignment = Alignment.Center,
) {
if (!profilePhoto.isNullOrBlank()) {
AsyncImage(
model = profilePhoto,
contentDescription = personName,
modifier = Modifier.matchParentSize(),
contentScale = ContentScale.Crop,
)
} else {
Text(
text = personName.firstOrNull()?.uppercase() ?: "?",
style = MaterialTheme.typography.displayMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(

View file

@ -1,5 +1,8 @@
package com.nuvio.app.features.details.components package com.nuvio.app.features.details.components
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@ -27,13 +30,17 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.nuvio.app.features.details.MetaPerson import com.nuvio.app.features.details.MetaPerson
import com.nuvio.app.features.details.castAvatarSharedTransitionKey
@Composable @Composable
@OptIn(ExperimentalSharedTransitionApi::class)
fun DetailCastSection( fun DetailCastSection(
cast: List<MetaPerson>, cast: List<MetaPerson>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
showHeader: Boolean = true, showHeader: Boolean = true,
onCastClick: ((MetaPerson) -> Unit)? = null, onCastClick: ((MetaPerson) -> Unit)? = null,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
) { ) {
if (cast.isEmpty()) return if (cast.isEmpty()) return
@ -55,6 +62,8 @@ fun DetailCastSection(
CastItem( CastItem(
person = person, person = person,
sizing = sizing, sizing = sizing,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
onClick = if (onCastClick != null && person.tmdbId != null && person.tmdbId > 0) { onClick = if (onCastClick != null && person.tmdbId != null && person.tmdbId > 0) {
{ onCastClick(person) } { onCastClick(person) }
} else { } else {
@ -68,12 +77,33 @@ fun DetailCastSection(
} }
@Composable @Composable
@OptIn(ExperimentalSharedTransitionApi::class)
private fun CastItem( private fun CastItem(
person: MetaPerson, person: MetaPerson,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
sizing: CastSectionSizing, sizing: CastSectionSizing,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
) { ) {
val avatarSharedElementModifier = if (
sharedTransitionScope != null &&
animatedVisibilityScope != null &&
person.tmdbId != null &&
person.tmdbId > 0
) {
with(sharedTransitionScope) {
Modifier.sharedElement(
sharedContentState = rememberSharedContentState(
key = castAvatarSharedTransitionKey(person.tmdbId),
),
animatedVisibilityScope = animatedVisibilityScope,
)
}
} else {
Modifier
}
Column( Column(
modifier = modifier modifier = modifier
.width(sizing.itemWidth) .width(sizing.itemWidth)
@ -83,6 +113,7 @@ private fun CastItem(
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.then(avatarSharedElementModifier)
.size(sizing.avatarSize) .size(sizing.avatarSize)
.clip(CircleShape) .clip(CircleShape)
.background( .background(

View file

@ -1,2 +1,2 @@
CURRENT_PROJECT_VERSION=18 CURRENT_PROJECT_VERSION=19
MARKETING_VERSION=0.1.0-alpha18 MARKETING_VERSION=0.1.0-alpha19