From 4f23e0eb5400b296f9c9acfbbd39fcdfa4f89105 Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Wed, 8 Jul 2026 16:23:13 +0200 Subject: [PATCH] refactor: improve navigation animations --- .../ajaury/chombev/player/ui/PlayerScreen.kt | 50 ++++++++++++------- .../ajaury/chombev/player/ui/SubtitleList.kt | 10 ++-- .../kotlin/bzh/ajaury/chombev/App.kt | 29 ++++++++++- 3 files changed, 66 insertions(+), 23 deletions(-) diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlayerScreen.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlayerScreen.kt index ecb4558..f2f70f6 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlayerScreen.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlayerScreen.kt @@ -1,5 +1,10 @@ package bzh.ajaury.chombev.player.ui +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -178,30 +183,41 @@ private fun PlayerTopBar( TopAppBar( modifier = Modifier.shadow(elevation = 2.dp), title = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = uiState.recordEmoji, - style = MaterialTheme.typography.headlineSmall, - ) - Column { + // Animate the title in as it loads, and between records: the record data isn't there on + // the first frame, so fade + rise it into place once uiState carries it. + AnimatedContent( + targetState = uiState.recordEmoji to uiState.recordTitle, + transitionSpec = { + fadeIn(tween()) togetherWith + fadeOut(tween()) + }, + label = "PlayerTitle", + ) { (emoji, title) -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { Text( - text = uiState.recordTitle.transcription, + text = emoji, style = MaterialTheme.typography.headlineSmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, ) - - uiState.recordTitle.translation?.let { translation -> + Column { Text( - text = translation, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + text = title.transcription, + style = MaterialTheme.typography.headlineSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, ) + + title.translation?.let { translation -> + Text( + text = translation, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } } diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/SubtitleList.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/SubtitleList.kt index 15cae37..5c1fe3a 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/SubtitleList.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/SubtitleList.kt @@ -69,15 +69,15 @@ fun SubtitleList( targetIndex = it } } else { - currentIndex ?: -1 + currentIndex ?: 0 } } } // Auto-follow: re-center the playing line when it changes, unless the user is scrolling. LaunchedEffect(currentIndex) { - if (currentIndex != null && !listState.isScrollInProgress) { - listState.centerItem(currentIndex) + if (!listState.isScrollInProgress) { + listState.centerItem(currentIndex ?: 0) } } @@ -134,7 +134,7 @@ private fun LazyListState.getCenteredIndex(): Int { val viewportCenter = (info.viewportStartOffset + info.viewportEndOffset) / 2f info.visibleItemsInfo .minByOrNull { - abs((it.offset + it.size / 2f) - viewportCenter) + abs((it.offset) - viewportCenter) }?.index ?: -1 } @@ -147,7 +147,7 @@ private suspend fun LazyListState.centerItem(index: Int) { val viewportCenter = (info.viewportStartOffset + info.viewportEndOffset) / 2 val item = info.visibleItemsInfo.firstOrNull { it.index == index } if (item != null) { - this.animateScrollBy((item.offset + item.size / 2 - viewportCenter).toFloat()) + this.animateScrollBy((item.offset - viewportCenter).toFloat()) } else { this.animateScrollToItem(index) } diff --git a/shared/src/commonMain/kotlin/bzh/ajaury/chombev/App.kt b/shared/src/commonMain/kotlin/bzh/ajaury/chombev/App.kt index 31ddf72..ef04c07 100644 --- a/shared/src/commonMain/kotlin/bzh/ajaury/chombev/App.kt +++ b/shared/src/commonMain/kotlin/bzh/ajaury/chombev/App.kt @@ -1,11 +1,17 @@ package bzh.ajaury.chombev +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.saveable.rememberSerializable import androidx.compose.ui.tooling.preview.Preview import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.metadata import androidx.navigation3.ui.NavDisplay import androidx.savedstate.compose.serialization.serializers.SnapshotStateListSerializer import bzh.ajaury.chombev.core.coroutines.di.coroutinesModule @@ -70,7 +76,28 @@ fun App() { } } - is Route.Player -> NavEntry(key) { + is Route.Player -> NavEntry( + key, + metadata = metadata { + put(NavDisplay.TransitionKey) { + // Slide new content up, keeping the old content in place underneath + slideInHorizontally( + initialOffsetX = { it }, + animationSpec = tween(400), + ) togetherWith ExitTransition.KeepUntilTransitionsFinished + } + put(NavDisplay.PopTransitionKey) { + // Slide in from left when navigating back + slideInHorizontally(initialOffsetX = { -it }) togetherWith + slideOutHorizontally(targetOffsetX = { it }) + } + put(NavDisplay.PredictivePopTransitionKey) { + // Slide in from left when navigating back + slideInHorizontally(initialOffsetX = { -it }) togetherWith + slideOutHorizontally(targetOffsetX = { it }) + } + }, + ) { PlayerScreen( recordId = key.recordId, onBackClicked = { backStack.removeLastOrNull() },