From 1fdd785ee52eaaccd6a055e5e2b9251e4e213ede Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 2 Jun 2026 10:07:10 -0400 Subject: [PATCH] fix(android): key UiState Crossfades on state::class to stop refresh flicker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../minstrel/library/ui/AlbumDetailScreen.kt | 7 +++++-- .../minstrel/library/ui/ArtistDetailScreen.kt | 7 +++++-- .../minstrel/library/ui/LibraryScreen.kt | 13 +++++++++---- .../minstrel/playlists/ui/PlaylistDetailScreen.kt | 5 ++++- 4 files changed, 23 insertions(+), 9 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt index 2d07aa7b..79776ba0 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt @@ -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( diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt index 01fa2b7e..335cbf4f 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt @@ -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) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt index c0971a5b..ee290b44 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt @@ -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", diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt index 1504c0a4..99250266 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt @@ -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)