From 16e2046d0fa295b1a060a3fb1c9cf4acc0f4d47f Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 13 Jul 2026 21:34:22 +0700 Subject: [PATCH] fix(ui): defer loaded-screen swap until the push transition settles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With cached albums the fetch finishes mid-Hero-flight; the crossfade then mounted a second hero with the same tag, which the flight doesn't adopt — a static cover appeared next to the flying one. The extension screens now keep the loading header until the route animation completes. --- lib/screens/home_tab_widgets.dart | 51 ++++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/lib/screens/home_tab_widgets.dart b/lib/screens/home_tab_widgets.dart index bc5b0c31..56eba353 100644 --- a/lib/screens/home_tab_widgets.dart +++ b/lib/screens/home_tab_widgets.dart @@ -919,6 +919,47 @@ class _LoadingOrErrorScaffold extends StatelessWidget { } } +/// Defers swapping the loading scaffold for the loaded screen until the push +/// transition (and its Hero flight) has finished. Swapping mid-flight puts a +/// second hero with the same tag on screen — the flight doesn't adopt it, so +/// the user sees a static cover next to the flying one. +mixin _RouteSettled on State { + bool _routeSettled = false; + Animation? _routeAnimation; + + bool get routeSettled => _routeSettled; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_routeSettled) return; + final animation = ModalRoute.of(context)?.animation; + if (animation == null || animation.isCompleted) { + _routeSettled = true; + return; + } + if (!identical(animation, _routeAnimation)) { + _routeAnimation?.removeStatusListener(_onRouteStatus); + _routeAnimation = animation; + animation.addStatusListener(_onRouteStatus); + } + } + + void _onRouteStatus(AnimationStatus status) { + if (status == AnimationStatus.completed) { + _routeAnimation?.removeStatusListener(_onRouteStatus); + _routeAnimation = null; + setState(() => _routeSettled = true); + } + } + + @override + void dispose() { + _routeAnimation?.removeStatusListener(_onRouteStatus); + super.dispose(); + } +} + /// Loading state for [ExtensionAlbumScreen]: the real collection header with /// the Hero cover already in its final slot (so the flight from the tapped /// list item lands correctly) above a shimmering track list. @@ -1132,7 +1173,8 @@ class ExtensionAlbumScreen extends ConsumerStatefulWidget { _ExtensionAlbumScreenState(); } -class _ExtensionAlbumScreenState extends ConsumerState { +class _ExtensionAlbumScreenState extends ConsumerState + with _RouteSettled { List? _tracks; bool _isLoading = true; String? _error; @@ -1277,7 +1319,7 @@ class _ExtensionAlbumScreenState extends ConsumerState { ); } - if (_isLoading) { + if (_isLoading || !routeSettled) { return SkeletonCrossfade( child: _AlbumLoadingScaffold( title: widget.albumName, @@ -1465,7 +1507,8 @@ class ExtensionArtistScreen extends ConsumerStatefulWidget { _ExtensionArtistScreenState(); } -class _ExtensionArtistScreenState extends ConsumerState { +class _ExtensionArtistScreenState extends ConsumerState + with _RouteSettled { List? _albums; List? _topTracks; String? _headerImageUrl; @@ -1590,7 +1633,7 @@ class _ExtensionArtistScreenState extends ConsumerState { ); } - if (_isLoading) { + if (_isLoading || !routeSettled) { return SkeletonCrossfade( child: _ArtistLoadingScaffold( artistName: widget.artistName,