refactor: improve navigation animations

This commit is contained in:
2026-07-08 16:23:13 +02:00
parent d6d99fb142
commit 4f23e0eb54
3 changed files with 66 additions and 23 deletions
@@ -1,5 +1,10 @@
package bzh.ajaury.chombev.player.ui 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -178,30 +183,41 @@ private fun PlayerTopBar(
TopAppBar( TopAppBar(
modifier = Modifier.shadow(elevation = 2.dp), modifier = Modifier.shadow(elevation = 2.dp),
title = { title = {
Row( // Animate the title in as it loads, and between records: the record data isn't there on
verticalAlignment = Alignment.CenterVertically, // the first frame, so fade + rise it into place once uiState carries it.
horizontalArrangement = Arrangement.spacedBy(8.dp), AnimatedContent(
) { targetState = uiState.recordEmoji to uiState.recordTitle,
Text( transitionSpec = {
text = uiState.recordEmoji, fadeIn(tween()) togetherWith
style = MaterialTheme.typography.headlineSmall, fadeOut(tween())
) },
Column { label = "PlayerTitle",
) { (emoji, title) ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text( Text(
text = uiState.recordTitle.transcription, text = emoji,
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
) )
Column {
uiState.recordTitle.translation?.let { translation ->
Text( Text(
text = translation, text = title.transcription,
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
title.translation?.let { translation ->
Text(
text = translation,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
} }
} }
} }
@@ -69,15 +69,15 @@ fun SubtitleList(
targetIndex = it targetIndex = it
} }
} else { } else {
currentIndex ?: -1 currentIndex ?: 0
} }
} }
} }
// Auto-follow: re-center the playing line when it changes, unless the user is scrolling. // Auto-follow: re-center the playing line when it changes, unless the user is scrolling.
LaunchedEffect(currentIndex) { LaunchedEffect(currentIndex) {
if (currentIndex != null && !listState.isScrollInProgress) { if (!listState.isScrollInProgress) {
listState.centerItem(currentIndex) listState.centerItem(currentIndex ?: 0)
} }
} }
@@ -134,7 +134,7 @@ private fun LazyListState.getCenteredIndex(): Int {
val viewportCenter = (info.viewportStartOffset + info.viewportEndOffset) / 2f val viewportCenter = (info.viewportStartOffset + info.viewportEndOffset) / 2f
info.visibleItemsInfo info.visibleItemsInfo
.minByOrNull { .minByOrNull {
abs((it.offset + it.size / 2f) - viewportCenter) abs((it.offset) - viewportCenter)
}?.index }?.index
?: -1 ?: -1
} }
@@ -147,7 +147,7 @@ private suspend fun LazyListState.centerItem(index: Int) {
val viewportCenter = (info.viewportStartOffset + info.viewportEndOffset) / 2 val viewportCenter = (info.viewportStartOffset + info.viewportEndOffset) / 2
val item = info.visibleItemsInfo.firstOrNull { it.index == index } val item = info.visibleItemsInfo.firstOrNull { it.index == index }
if (item != null) { if (item != null) {
this.animateScrollBy((item.offset + item.size / 2 - viewportCenter).toFloat()) this.animateScrollBy((item.offset - viewportCenter).toFloat())
} else { } else {
this.animateScrollToItem(index) this.animateScrollToItem(index)
} }
@@ -1,11 +1,17 @@
package bzh.ajaury.chombev 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.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.saveable.rememberSerializable import androidx.compose.runtime.saveable.rememberSerializable
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.metadata
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
import androidx.savedstate.compose.serialization.serializers.SnapshotStateListSerializer import androidx.savedstate.compose.serialization.serializers.SnapshotStateListSerializer
import bzh.ajaury.chombev.core.coroutines.di.coroutinesModule 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( PlayerScreen(
recordId = key.recordId, recordId = key.recordId,
onBackClicked = { backStack.removeLastOrNull() }, onBackClicked = { backStack.removeLastOrNull() },