feat(flutter): cosmetic reveal animations (Slice F)
Final slice of the per-item rendering pass. Wraps every tile widget in an AnimatedSwitcher between the skeleton placeholder and the real card. 220ms cross-fade with easeOut: tiles "settle into place" rather than hard-cutting from shimmer to content. Since each tile fades independently as its data lands — and the HydrationQueue's concurrency cap drains in a natural cascade — the overall feel is the staged "page builds piece by piece" effect we wanted, with no per-tile position math required. Bumps CachedNetworkImage fadeInDuration from zero to 120ms (server_ image.dart, discover_screen.dart). Imperceptible on cache hits since the image decodes synchronously; on cache misses the bytes fade in smoothly instead of popping. Slice 1's "zero fade" call was right about the 500ms default being a regression, but 120ms threads the needle. Playlist detail wraps its body in the same AnimatedSwitcher so the cold-load skeleton page cross-fades into the real track list. Tiles affected: home _AlbumTile / _ArtistTile / _TrackTile + liked _LikedAlbumTile / _LikedArtistTile / _LikedTrackRow + playlist _SkeletonBody / _Body. All keyed via ValueKey so AnimatedSwitcher detects the transition. End of the per-item pass. Net behavior: cold visits paint shaped pages instantly with skeletons, content cascades in as hydration lands; warm visits paint fully from drift in the first frame.
This commit is contained in:
@@ -631,6 +631,11 @@ class _LikedTab extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Skeleton→content cross-fade duration. Matches home_screen so the
|
||||
/// reveal feel is consistent across surfaces. See _tileRevealDuration
|
||||
/// in home_screen.dart for the rationale.
|
||||
const Duration _likedTileReveal = Duration(milliseconds: 220);
|
||||
|
||||
/// Liked-Artists carousel tile. Skeleton until artistTileProvider
|
||||
/// yields a populated row.
|
||||
class _LikedArtistTile extends ConsumerWidget {
|
||||
@@ -640,10 +645,17 @@ class _LikedArtistTile extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final artist = ref.watch(artistTileProvider(id)).asData?.value;
|
||||
if (artist == null) return const SkeletonArtistTile();
|
||||
return ArtistCard(
|
||||
artist: artist,
|
||||
onTap: () => context.push('/artists/${artist.id}', extra: artist),
|
||||
return AnimatedSwitcher(
|
||||
duration: _likedTileReveal,
|
||||
switchInCurve: Curves.easeOut,
|
||||
child: artist == null
|
||||
? const SkeletonArtistTile(key: ValueKey('skeleton'))
|
||||
: ArtistCard(
|
||||
key: ValueKey('artist-${artist.id}'),
|
||||
artist: artist,
|
||||
onTap: () =>
|
||||
context.push('/artists/${artist.id}', extra: artist),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -657,10 +669,16 @@ class _LikedAlbumTile extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final album = ref.watch(albumTileProvider(id)).asData?.value;
|
||||
if (album == null) return const SkeletonAlbumTile();
|
||||
return AlbumCard(
|
||||
album: album,
|
||||
onTap: () => context.push('/albums/${album.id}', extra: album),
|
||||
return AnimatedSwitcher(
|
||||
duration: _likedTileReveal,
|
||||
switchInCurve: Curves.easeOut,
|
||||
child: album == null
|
||||
? const SkeletonAlbumTile(key: ValueKey('skeleton'))
|
||||
: AlbumCard(
|
||||
key: ValueKey('album-${album.id}'),
|
||||
album: album,
|
||||
onTap: () => context.push('/albums/${album.id}', extra: album),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -677,21 +695,27 @@ class _LikedTrackRow extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final track = ref.watch(trackTileProvider(id)).asData?.value;
|
||||
if (track == null) return const SkeletonTrackRow();
|
||||
return TrackRow(
|
||||
track: track,
|
||||
onTap: () {
|
||||
final hydrated = <TrackRef>[];
|
||||
for (final i in sectionIds) {
|
||||
final v = ref.read(trackTileProvider(i)).asData?.value;
|
||||
if (v != null) hydrated.add(v);
|
||||
}
|
||||
final start = hydrated.indexWhere((t) => t.id == id);
|
||||
ref.read(playerActionsProvider).playTracks(
|
||||
hydrated,
|
||||
initialIndex: start < 0 ? 0 : start,
|
||||
);
|
||||
},
|
||||
return AnimatedSwitcher(
|
||||
duration: _likedTileReveal,
|
||||
switchInCurve: Curves.easeOut,
|
||||
child: track == null
|
||||
? const SkeletonTrackRow(key: ValueKey('skeleton'))
|
||||
: TrackRow(
|
||||
key: ValueKey('track-${track.id}'),
|
||||
track: track,
|
||||
onTap: () {
|
||||
final hydrated = <TrackRef>[];
|
||||
for (final i in sectionIds) {
|
||||
final v = ref.read(trackTileProvider(i)).asData?.value;
|
||||
if (v != null) hydrated.add(v);
|
||||
}
|
||||
final start = hydrated.indexWhere((t) => t.id == id);
|
||||
ref.read(playerActionsProvider).playTracks(
|
||||
hydrated,
|
||||
initialIndex: start < 0 ? 0 : start,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user