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:
@@ -208,7 +208,7 @@ class _ResultTile extends StatelessWidget {
|
|||||||
: CachedNetworkImage(
|
: CachedNetworkImage(
|
||||||
imageUrl: row.imageUrl,
|
imageUrl: row.imageUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
fadeInDuration: Duration.zero,
|
fadeInDuration: const Duration(milliseconds: 120),
|
||||||
fadeOutDuration: Duration.zero,
|
fadeOutDuration: Duration.zero,
|
||||||
errorWidget: (_, __, ___) =>
|
errorWidget: (_, __, ___) =>
|
||||||
Icon(Icons.album, color: fs.ash),
|
Icon(Icons.album, color: fs.ash),
|
||||||
|
|||||||
@@ -83,6 +83,13 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
|
|
||||||
// ─── Per-tile widgets ────────────────────────────────────────────────
|
// ─── 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.
|
/// Album tile: skeleton until albumTileProvider yields a populated row.
|
||||||
class _AlbumTile extends ConsumerWidget {
|
class _AlbumTile extends ConsumerWidget {
|
||||||
const _AlbumTile({required this.id});
|
const _AlbumTile({required this.id});
|
||||||
@@ -92,10 +99,17 @@ class _AlbumTile extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final asyncAlbum = ref.watch(albumTileProvider(id));
|
final asyncAlbum = ref.watch(albumTileProvider(id));
|
||||||
final album = asyncAlbum.asData?.value;
|
final album = asyncAlbum.asData?.value;
|
||||||
if (album == null) return const SkeletonAlbumTile();
|
return AnimatedSwitcher(
|
||||||
return AlbumCard(
|
duration: _tileRevealDuration,
|
||||||
album: album,
|
switchInCurve: Curves.easeOut,
|
||||||
onTap: () => context.push('/albums/${album.id}', extra: album),
|
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) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final asyncArtist = ref.watch(artistTileProvider(id));
|
final asyncArtist = ref.watch(artistTileProvider(id));
|
||||||
final artist = asyncArtist.asData?.value;
|
final artist = asyncArtist.asData?.value;
|
||||||
if (artist == null) return const SkeletonArtistTile();
|
return AnimatedSwitcher(
|
||||||
return ArtistCard(
|
duration: _tileRevealDuration,
|
||||||
artist: artist,
|
switchInCurve: Curves.easeOut,
|
||||||
onTap: () => context.push('/artists/${artist.id}', extra: artist),
|
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) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final asyncTrack = ref.watch(trackTileProvider(id));
|
final asyncTrack = ref.watch(trackTileProvider(id));
|
||||||
final track = asyncTrack.asData?.value;
|
final track = asyncTrack.asData?.value;
|
||||||
if (track == null) {
|
return AnimatedSwitcher(
|
||||||
// Compact-track placeholder: same 56dp footprint as the real card
|
duration: _tileRevealDuration,
|
||||||
// so the row's intrinsic width doesn't change as tiles hydrate.
|
switchInCurve: Curves.easeOut,
|
||||||
return const _CompactTrackSkeleton();
|
child: track == null
|
||||||
}
|
? const _CompactTrackSkeleton(key: ValueKey('skeleton'))
|
||||||
return CompactTrackCard(
|
: CompactTrackCard(
|
||||||
track: track,
|
key: ValueKey('track-${track.id}'),
|
||||||
sectionTracks: _resolveSectionTracks(ref, sectionIds),
|
track: track,
|
||||||
index: sectionIds.indexOf(id).clamp(0, sectionIds.length - 1),
|
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
|
/// CompactTrackCard so swapping in the real card doesn't shift the
|
||||||
/// row's height.
|
/// row's height.
|
||||||
class _CompactTrackSkeleton extends StatelessWidget {
|
class _CompactTrackSkeleton extends StatelessWidget {
|
||||||
const _CompactTrackSkeleton();
|
const _CompactTrackSkeleton({super.key});
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
|||||||
@@ -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
|
/// Liked-Artists carousel tile. Skeleton until artistTileProvider
|
||||||
/// yields a populated row.
|
/// yields a populated row.
|
||||||
class _LikedArtistTile extends ConsumerWidget {
|
class _LikedArtistTile extends ConsumerWidget {
|
||||||
@@ -640,10 +645,17 @@ class _LikedArtistTile extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final artist = ref.watch(artistTileProvider(id)).asData?.value;
|
final artist = ref.watch(artistTileProvider(id)).asData?.value;
|
||||||
if (artist == null) return const SkeletonArtistTile();
|
return AnimatedSwitcher(
|
||||||
return ArtistCard(
|
duration: _likedTileReveal,
|
||||||
artist: artist,
|
switchInCurve: Curves.easeOut,
|
||||||
onTap: () => context.push('/artists/${artist.id}', extra: artist),
|
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
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final album = ref.watch(albumTileProvider(id)).asData?.value;
|
final album = ref.watch(albumTileProvider(id)).asData?.value;
|
||||||
if (album == null) return const SkeletonAlbumTile();
|
return AnimatedSwitcher(
|
||||||
return AlbumCard(
|
duration: _likedTileReveal,
|
||||||
album: album,
|
switchInCurve: Curves.easeOut,
|
||||||
onTap: () => context.push('/albums/${album.id}', extra: album),
|
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
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final track = ref.watch(trackTileProvider(id)).asData?.value;
|
final track = ref.watch(trackTileProvider(id)).asData?.value;
|
||||||
if (track == null) return const SkeletonTrackRow();
|
return AnimatedSwitcher(
|
||||||
return TrackRow(
|
duration: _likedTileReveal,
|
||||||
track: track,
|
switchInCurve: Curves.easeOut,
|
||||||
onTap: () {
|
child: track == null
|
||||||
final hydrated = <TrackRef>[];
|
? const SkeletonTrackRow(key: ValueKey('skeleton'))
|
||||||
for (final i in sectionIds) {
|
: TrackRow(
|
||||||
final v = ref.read(trackTileProvider(i)).asData?.value;
|
key: ValueKey('track-${track.id}'),
|
||||||
if (v != null) hydrated.add(v);
|
track: track,
|
||||||
}
|
onTap: () {
|
||||||
final start = hydrated.indexWhere((t) => t.id == id);
|
final hydrated = <TrackRef>[];
|
||||||
ref.read(playerActionsProvider).playTracks(
|
for (final i in sectionIds) {
|
||||||
hydrated,
|
final v = ref.read(trackTileProvider(i)).asData?.value;
|
||||||
initialIndex: start < 0 ? 0 : start,
|
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,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
body: detail.when(
|
// AnimatedSwitcher between the skeleton body and the real body
|
||||||
// Loading from a seed: render the header immediately + the
|
// smooths the cold-visit moment when bulk detail lands. 220ms
|
||||||
// exact track-count of skeleton rows, instead of an opaque
|
// matches the per-tile reveal feel used on home / liked tabs.
|
||||||
// full-screen spinner. Cold-visit feel: shaped page with
|
body: AnimatedSwitcher(
|
||||||
// shimmering rows that swap in for real ones as the bulk
|
duration: const Duration(milliseconds: 220),
|
||||||
// detail fetch lands.
|
switchInCurve: Curves.easeOut,
|
||||||
loading: () => _SkeletonBody(seed: seed),
|
child: detail.when(
|
||||||
error: (e, _) =>
|
loading: () => _SkeletonBody(
|
||||||
Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
key: const ValueKey('skeleton'),
|
||||||
data: (d) => _Body(detail: d),
|
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
|
/// (deep link straight to a playlist with no prior cache) the
|
||||||
/// skeleton renders a small default and grows when real data arrives.
|
/// skeleton renders a small default and grows when real data arrives.
|
||||||
class _SkeletonBody extends StatelessWidget {
|
class _SkeletonBody extends StatelessWidget {
|
||||||
const _SkeletonBody({this.seed});
|
const _SkeletonBody({super.key, this.seed});
|
||||||
final Playlist? seed;
|
final Playlist? seed;
|
||||||
|
|
||||||
static const _defaultSkeletonCount = 8;
|
static const _defaultSkeletonCount = 8;
|
||||||
@@ -163,7 +170,7 @@ class _PlaylistTrackSkeleton extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _Body extends ConsumerWidget {
|
class _Body extends ConsumerWidget {
|
||||||
const _Body({required this.detail});
|
const _Body({super.key, required this.detail});
|
||||||
final PlaylistDetail detail;
|
final PlaylistDetail detail;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -61,10 +61,12 @@ class ServerImage extends ConsumerWidget {
|
|||||||
imageUrl: resolved,
|
imageUrl: resolved,
|
||||||
httpHeaders: headers,
|
httpHeaders: headers,
|
||||||
fit: fit,
|
fit: fit,
|
||||||
// No fadeIn — covers paint instantly once cached, and the
|
// 120ms feels like cover bytes settling in on a cache miss
|
||||||
// default 500ms fade looks like a regression on a populated
|
// (smoother than the abrupt zero-fade); on cache hits the
|
||||||
// grid.
|
// image is decoded synchronously so the fade is imperceptible.
|
||||||
fadeInDuration: Duration.zero,
|
// The default 500ms is too long — looks like a regression on
|
||||||
|
// a populated grid.
|
||||||
|
fadeInDuration: const Duration(milliseconds: 120),
|
||||||
fadeOutDuration: Duration.zero,
|
fadeOutDuration: Duration.zero,
|
||||||
// Keep failures local — a single 401/timeout shouldn't dump
|
// Keep failures local — a single 401/timeout shouldn't dump
|
||||||
// a stack trace or replace the parent Container's background.
|
// a stack trace or replace the parent Container's background.
|
||||||
|
|||||||
Reference in New Issue
Block a user