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
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.fadeIn
import androidx.compose.animation.fadeOut
@ -168,6 +170,7 @@ data class DetailRoute(val type: String, val id: String)
data class PersonDetailRoute(
val personId: Int,
val personName: String,
val personPhoto: String? = null,
val preferCrew: Boolean = false,
)
@ -387,7 +390,7 @@ fun App() {
}
}
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class)
@Composable
private fun MainAppContent(
onSwitchProfile: () -> Unit = {},
@ -619,11 +622,12 @@ private fun MainAppContent(
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
) {
NavHost(
navController = navController,
startDestination = TabsRoute,
modifier = Modifier.fillMaxSize(),
) {
SharedTransitionLayout {
NavHost(
navController = navController,
startDestination = TabsRoute,
modifier = Modifier.fillMaxSize(),
) {
composable<TabsRoute> {
PlatformBackHandler(
enabled = selectedTab != AppScreenTab.Home,
@ -769,6 +773,7 @@ private fun MainAppContent(
PersonDetailRoute(
personId = tmdbId,
personName = person.name,
personPhoto = person.photo,
preferCrew = person.role?.let {
it.equals("Director", ignoreCase = true) ||
it.equals("Writer", ignoreCase = true) ||
@ -791,6 +796,8 @@ private fun MainAppContent(
)
}
},
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this,
modifier = Modifier.fillMaxSize(),
)
}
@ -799,6 +806,7 @@ private fun MainAppContent(
PersonDetailScreen(
personId = route.personId,
personName = route.personName,
initialProfilePhoto = route.personPhoto,
preferCrew = route.preferCrew,
onBack = { navController.popBackStack() },
onOpenMeta = { preview ->
@ -822,6 +830,8 @@ private fun MainAppContent(
)
}
},
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this,
modifier = Modifier.fillMaxSize(),
)
}
@ -1290,6 +1300,7 @@ private fun MainAppContent(
},
)
}
}
}
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
import androidx.compose.animation.AnimatedVisibilityScope
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.animateFloatAsState
import androidx.compose.animation.core.tween
@ -96,6 +99,7 @@ import com.nuvio.app.features.watching.application.WatchingState
import kotlinx.coroutines.launch
@Composable
@OptIn(ExperimentalSharedTransitionApi::class)
fun MetaDetailsScreen(
type: String,
id: String,
@ -105,6 +109,8 @@ fun MetaDetailsScreen(
onOpenMeta: ((MetaPreview) -> Unit)? = null,
onCastClick: ((MetaPerson) -> Unit)? = null,
onCompanyClick: ((MetaCompany, String) -> Unit)? = null,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
modifier: Modifier = Modifier,
) {
val uiState by MetaDetailsRepository.uiState.collectAsStateWithLifecycle()
@ -639,6 +645,8 @@ fun MetaDetailsScreen(
onOpenMeta = onOpenMeta,
onCastClick = onCastClick,
onCompanyClick = onCompanyClick,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
)
Spacer(modifier = Modifier.height(32.dp + nuvioPlatformExtraBottomPadding))
@ -877,6 +885,7 @@ fun MetaDetailsScreen(
}
@Composable
@OptIn(ExperimentalSharedTransitionApi::class)
private fun ConfiguredMetaSections(
settings: MetaScreenSettingsUiState,
meta: MetaDetails,
@ -912,6 +921,8 @@ private fun ConfiguredMetaSections(
onOpenMeta: ((MetaPreview) -> Unit)?,
onCastClick: ((MetaPerson) -> Unit)?,
onCompanyClick: ((MetaCompany, String) -> Unit)?,
sharedTransitionScope: SharedTransitionScope?,
animatedVisibilityScope: AnimatedVisibilityScope?,
) {
val enabledItems = settings.items.filter { it.enabled }
@ -954,7 +965,13 @@ private fun ConfiguredMetaSections(
}
}
MetaScreenSectionKey.CAST -> {
DetailCastSection(cast = meta.cast, showHeader = showHeader, onCastClick = onCastClick)
DetailCastSection(
cast = meta.cast,
showHeader = showHeader,
onCastClick = onCastClick,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
)
}
MetaScreenSectionKey.COMMENTS -> {
if (shouldShowComments && (isCommentsLoading || comments.isNotEmpty() || !commentsError.isNullOrBlank())) {

View file

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

View file

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

View file

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