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:
2026-05-13 22:08:34 -04:00
parent 158a5d7506
commit 7367595e71
5 changed files with 115 additions and 58 deletions
@@ -208,7 +208,7 @@ class _ResultTile extends StatelessWidget {
: CachedNetworkImage(
imageUrl: row.imageUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeInDuration: const Duration(milliseconds: 120),
fadeOutDuration: Duration.zero,
errorWidget: (_, __, ___) =>
Icon(Icons.album, color: fs.ash),
+42 -18
View File
@@ -83,6 +83,13 @@ class HomeScreen extends ConsumerWidget {
// ─── Per-tile widgets ────────────────────────────────────────────────
/// Duration of the skeleton→content cross-fade. 220ms reads as "tile
/// settled into place" — longer drags, shorter feels like a hard cut.
/// Each tile cross-fades independently when its data lands, so the
/// natural cascade from the hydration queue's bounded concurrency
/// produces a staged-reveal feel without any per-tile delay math.
const Duration _tileRevealDuration = Duration(milliseconds: 220);
/// Album tile: skeleton until albumTileProvider yields a populated row.
class _AlbumTile extends ConsumerWidget {
const _AlbumTile({required this.id});
@@ -92,10 +99,17 @@ class _AlbumTile extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final asyncAlbum = ref.watch(albumTileProvider(id));
final album = asyncAlbum.asData?.value;
if (album == null) return const SkeletonAlbumTile();
return AlbumCard(
album: album,
onTap: () => context.push('/albums/${album.id}', extra: album),
return AnimatedSwitcher(
duration: _tileRevealDuration,
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),
),
);
}
}
@@ -109,10 +123,17 @@ class _ArtistTile extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final asyncArtist = ref.watch(artistTileProvider(id));
final artist = asyncArtist.asData?.value;
if (artist == null) return const SkeletonArtistTile();
return ArtistCard(
artist: artist,
onTap: () => context.push('/artists/${artist.id}', extra: artist),
return AnimatedSwitcher(
duration: _tileRevealDuration,
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),
),
);
}
}
@@ -131,15 +152,18 @@ class _TrackTile extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final asyncTrack = ref.watch(trackTileProvider(id));
final track = asyncTrack.asData?.value;
if (track == null) {
// Compact-track placeholder: same 56dp footprint as the real card
// so the row's intrinsic width doesn't change as tiles hydrate.
return const _CompactTrackSkeleton();
}
return CompactTrackCard(
track: track,
sectionTracks: _resolveSectionTracks(ref, sectionIds),
index: sectionIds.indexOf(id).clamp(0, sectionIds.length - 1),
return AnimatedSwitcher(
duration: _tileRevealDuration,
switchInCurve: Curves.easeOut,
child: track == null
? const _CompactTrackSkeleton(key: ValueKey('skeleton'))
: CompactTrackCard(
key: ValueKey('track-${track.id}'),
track: track,
sectionTracks: _resolveSectionTracks(ref, sectionIds),
index:
sectionIds.indexOf(id).clamp(0, sectionIds.length - 1),
),
);
}
@@ -161,7 +185,7 @@ class _TrackTile extends ConsumerWidget {
/// CompactTrackCard so swapping in the real card doesn't shift the
/// row's height.
class _CompactTrackSkeleton extends StatelessWidget {
const _CompactTrackSkeleton();
const _CompactTrackSkeleton({super.key});
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
+47 -23
View File
@@ -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,
);
},
),
);
}
}
@@ -67,16 +67,23 @@ class PlaylistDetailScreen extends ConsumerWidget {
overflow: TextOverflow.ellipsis,
),
),
body: detail.when(
// Loading from a seed: render the header immediately + the
// exact track-count of skeleton rows, instead of an opaque
// full-screen spinner. Cold-visit feel: shaped page with
// shimmering rows that swap in for real ones as the bulk
// detail fetch lands.
loading: () => _SkeletonBody(seed: seed),
error: (e, _) =>
Center(child: Text('$e', style: TextStyle(color: fs.error))),
data: (d) => _Body(detail: d),
// AnimatedSwitcher between the skeleton body and the real body
// smooths the cold-visit moment when bulk detail lands. 220ms
// matches the per-tile reveal feel used on home / liked tabs.
body: AnimatedSwitcher(
duration: const Duration(milliseconds: 220),
switchInCurve: Curves.easeOut,
child: detail.when(
loading: () => _SkeletonBody(
key: const ValueKey('skeleton'),
seed: seed,
),
error: (e, _) => Center(
key: const ValueKey('error'),
child: Text('$e', style: TextStyle(color: fs.error)),
),
data: (d) => _Body(key: const ValueKey('body'), detail: d),
),
),
);
}
@@ -88,7 +95,7 @@ class PlaylistDetailScreen extends ConsumerWidget {
/// (deep link straight to a playlist with no prior cache) the
/// skeleton renders a small default and grows when real data arrives.
class _SkeletonBody extends StatelessWidget {
const _SkeletonBody({this.seed});
const _SkeletonBody({super.key, this.seed});
final Playlist? seed;
static const _defaultSkeletonCount = 8;
@@ -163,7 +170,7 @@ class _PlaylistTrackSkeleton extends StatelessWidget {
}
class _Body extends ConsumerWidget {
const _Body({required this.detail});
const _Body({super.key, required this.detail});
final PlaylistDetail detail;
@override
@@ -61,10 +61,12 @@ class ServerImage extends ConsumerWidget {
imageUrl: resolved,
httpHeaders: headers,
fit: fit,
// No fadeIn — covers paint instantly once cached, and the
// default 500ms fade looks like a regression on a populated
// grid.
fadeInDuration: Duration.zero,
// 120ms feels like cover bytes settling in on a cache miss
// (smoother than the abrupt zero-fade); on cache hits the
// image is decoded synchronously so the fade is imperceptible.
// The default 500ms is too long — looks like a regression on
// a populated grid.
fadeInDuration: const Duration(milliseconds: 120),
fadeOutDuration: Duration.zero,
// Keep failures local — a single 401/timeout shouldn't dump
// a stack trace or replace the parent Container's background.