From 9d08a66096ed6f64bfb66b40488e7f2d965c681a Mon Sep 17 00:00:00 2001 From: Bretton Date: Wed, 5 Aug 2026 23:22:20 -0700 Subject: [PATCH] fix(media): replace the image viewer's zoom transition with a plain fade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android's Material 3 default (ZoomPageTransitionsBuilder) scale-fades the incoming route. On the viewer — a black page holding a single image — that reads as a ghost of the image growing into place on open and shrinking on close. Push through a PageRouteBuilder with a quick 150ms FadeTransition instead, scoped to the viewer only: the zoom remains correct for regular screens, and the custom route is the same one the filed backdrop-fade follow-up needs anyway. The route observer in the detail tests now matches PageRoute rather than MaterialPageRoute; the video test casts back for its builder access. Co-Authored-By: Claude Fable 5 --- lib/widgets/image_viewer.dart | 15 +++++++++++++-- test/widgets/detailed_post_view_media_test.dart | 16 ++++++++++------ 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/lib/widgets/image_viewer.dart b/lib/widgets/image_viewer.dart index 12317a4..81ca4a8 100644 --- a/lib/widgets/image_viewer.dart +++ b/lib/widgets/image_viewer.dart @@ -31,9 +31,20 @@ class ImageViewer extends StatefulWidget { // an indicator like "5/3". Clamp instead of trusting the caller. final index = initialIndex.clamp(0, images.length - 1); Navigator.of(context, rootNavigator: true).push( - MaterialPageRoute( - builder: (context) => ImageViewer(images: images, initialIndex: index), + PageRouteBuilder( fullscreenDialog: true, + // Not a MaterialPageRoute: Android's M3 zoom transition scale-fades + // the incoming route, which on a black page holding one image reads + // as a ghost of the image growing into place. A quick plain fade + // fits a lightbox. + transitionDuration: const Duration(milliseconds: 150), + reverseTransitionDuration: const Duration(milliseconds: 150), + pageBuilder: + (context, animation, secondaryAnimation) => + ImageViewer(images: images, initialIndex: index), + transitionsBuilder: + (context, animation, secondaryAnimation, child) => + FadeTransition(opacity: animation, child: child), ), ); } diff --git a/test/widgets/detailed_post_view_media_test.dart b/test/widgets/detailed_post_view_media_test.dart index 53fa113..6035603 100644 --- a/test/widgets/detailed_post_view_media_test.dart +++ b/test/widgets/detailed_post_view_media_test.dart @@ -49,10 +49,12 @@ class _RecordingObserver extends NavigatorObserver { /// /// MaterialApp's own initial `/` route is a `MaterialPageRoute`, /// and Dart treats `dynamic` and `void` as mutual subtypes — so a plain - /// `whereType>()` would count it too. Callers - /// clear the log after pumping, and this getter is what they assert on. - List> get pushedPages => - pushed.whereType>().toList(); + /// `whereType>()` would count it too. Callers clear the + /// log after pumping, and this getter is what they assert on. PageRoute + /// rather than MaterialPageRoute: the image viewer pushes a + /// PageRouteBuilder to opt out of the M3 zoom transition. + List> get pushedPages => + pushed.whereType>().toList(); /// Forgets routes recorded during setup, so only taps are counted. void reset() => pushed.clear(); @@ -903,7 +905,7 @@ void main() { expect( observer.pushedPages, hasLength(1), - reason: 'image tap pushes exactly one MaterialPageRoute', + reason: 'image tap pushes exactly one page route', ); await tester.pumpAndSettle(); @@ -1264,7 +1266,9 @@ void main() { final videoRoutes = observer.pushedPages; expect(videoRoutes, hasLength(1)); - final page = videoRoutes.single.builder( + // The video player still pushes a MaterialPageRoute; the cast is what + // exposes `builder` for building the page without mounting it. + final page = (videoRoutes.single as MaterialPageRoute).builder( tester.element(find.byKey(_videoKey)), ); expect(page, isA()); -- 2.51.2