fix(android): key UiState Crossfades on state::class to stop refresh flicker
android / Build + lint + test (push) Has been cancelled

Pull-to-refresh produced a strong full-screen fade on Library
(both tabs) and the Album / Artist / Playlist detail screens
because their Crossfades were keyed on the entire state value.
A refresh emits a fresh UiState.Success with a NEW data instance
(same kind, different content) so Crossfade animated old grid →
new grid even though both are the same Success branch — the
visible result was a flash that read as "broken/heavy."

HomeScreen already keys on `state::class` (4b9d-ish prior fix);
apply the same pattern to the four screens that still flicker.
Inner content reads the outer `state` directly via `val s = state`
so the branch still has access to the typed value. Row-level diffs
are owned by LazyVerticalGrid / LazyColumn via item keys, so the
visual update is smooth and granular instead of a full fade.

Only Loading ↔ Success ↔ Error ↔ Empty transitions animate now —
the intended use of Crossfade. Same-kind state updates flow
through Compose's normal recomposition.
This commit is contained in:
2026-06-02 10:07:10 -04:00
parent 9b3ec65476
commit 1fdd785ee5
4 changed files with 23 additions and 9 deletions
@@ -109,8 +109,11 @@ private fun AlbumDetailStateContent(
playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel,
navController: NavHostController,
) {
Crossfade(targetState = state, label = "album-detail") { s ->
when (s) {
// Crossfade keyed on `state::class` so a Success → fresh Success
// refresh (same kind, new detail) doesn't re-fade the whole body;
// only Loading ↔ Success ↔ Error transitions animate.
Crossfade(targetState = state::class, label = "album-detail") { _ ->
when (val s = state) {
is AlbumDetailUiState.Loading ->
if (s.seed != null) SeededAlbumLoading(s.seed) else SkeletonTrackList()
is AlbumDetailUiState.Error -> EmptyState(
@@ -87,8 +87,11 @@ fun ArtistDetailScreen(
onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner),
) {
Crossfade(targetState = state, label = "artist-detail") { s ->
when (s) {
// Crossfade keyed on `state::class` so a Success → fresh
// Success refresh (same kind, new detail) doesn't re-fade
// the body; only Loading ↔ Success ↔ Error animate.
Crossfade(targetState = state::class, label = "artist-detail") { _ ->
when (val s = state) {
is ArtistDetailUiState.Loading ->
if (s.seed != null) {
SeededArtistLoading(s.seed)
@@ -142,8 +142,13 @@ private fun ArtistsTab(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
Crossfade(targetState = state, label = "library-artists") { s ->
when (s) {
// Crossfade is keyed on `state::class` (not the whole state)
// so a refresh that emits a fresh UiState.Success — same kind,
// new data — doesn't re-fade the entire grid. Only Loading ↔
// Success ↔ Error ↔ Empty transitions animate; row-level diff
// is owned by LazyVerticalGrid via its item keys.
Crossfade(targetState = state::class, label = "library-artists") { _ ->
when (val s = state) {
UiState.Loading -> SkeletonArtistsGrid()
UiState.Empty -> EmptyState(
title = "No artists yet",
@@ -170,8 +175,8 @@ private fun AlbumsTab(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
Crossfade(targetState = state, label = "library-albums") { s ->
when (s) {
Crossfade(targetState = state::class, label = "library-albums") { _ ->
when (val s = state) {
UiState.Loading -> SkeletonAlbumsGrid()
UiState.Empty -> EmptyState(
title = "No albums yet",
@@ -288,7 +288,10 @@ private fun PlaylistDetailContent(
playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel,
navController: NavHostController,
) {
Crossfade(targetState = state, label = "playlist-detail") { s -> when (s) {
// Crossfade keyed on `state::class` so refreshes that emit a
// fresh Success (same kind, new detail) don't re-fade the body;
// only Loading ↔ Success ↔ Error transitions animate.
Crossfade(targetState = state::class, label = "playlist-detail") { _ -> when (val s = state) {
is PlaylistDetailUiState.Loading ->
s.seed?.let { SeededPlaylistLoading(it) } ?: SkeletonPlaylistTrackList()
is PlaylistDetailUiState.Error -> ErrorBlock(s.message, viewModel::refresh)