From 764c512572c1cf151effc34425fccfceadfc8725 Mon Sep 17 00:00:00 2001 From: foxyz Date: Wed, 2 Sep 2026 23:11:05 +0300 Subject: [PATCH] fix: LeafViewerHero gates heroes to the settled page (keep-alive neighbours flew too); video controls bar bottom-anchored with natural height (was stretched full-screen, blocking gestures) --- lib/src/video/leaf_video_controls.dart | 14 +++++++--- lib/src/viewer/leaf_viewer.dart | 32 +++++++++++++++++++++++ test/viewer/leaf_chrome_test.dart | 36 ++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 3 deletions(-) diff --git a/lib/src/video/leaf_video_controls.dart b/lib/src/video/leaf_video_controls.dart index 3421219..6eb0d93 100644 --- a/lib/src/video/leaf_video_controls.dart +++ b/lib/src/video/leaf_video_controls.dart @@ -109,17 +109,24 @@ class _LeafVideoControlsState extends State { onDoubleTap: _handleDoubleTap, ), ), - // Bottom bar — fades with the viewer's chrome state. + // Bottom bar — fades with the viewer's chrome state. Bottom-anchored + // with its natural height: inside a StackFit.expand stack a bare + // child stretches full-screen and eats every gesture meant for the + // tap detector above it. if (chrome != null) ValueListenableBuilder( valueListenable: chrome.visibleListenable, builder: (context, visible, _) => IgnorePointer( ignoring: !visible, - child: AnimatedOpacity(duration: const Duration(milliseconds: 200), opacity: visible ? 1.0 : 0.0, child: _buildBar(context)), + child: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: visible ? 1.0 : 0.0, + child: Align(alignment: Alignment.bottomCenter, child: SafeArea(child: _buildBar(context))), + ), ), ) else - _buildBar(context), + Align(alignment: Alignment.bottomCenter, child: SafeArea(child: _buildBar(context))), // Center play badge while paused. Center( child: IgnorePointer( @@ -175,6 +182,7 @@ class _LeafVideoControlsState extends State { ), padding: const EdgeInsets.all(10), child: Column( + mainAxisSize: MainAxisSize.min, children: [ const SizedBox(height: 50), Row( diff --git a/lib/src/viewer/leaf_viewer.dart b/lib/src/viewer/leaf_viewer.dart index 2d35cfe..3f43857 100644 --- a/lib/src/viewer/leaf_viewer.dart +++ b/lib/src/viewer/leaf_viewer.dart @@ -94,6 +94,11 @@ class LeafViewer extends StatefulWidget { return LeafViewerScope.maybeOf(context)?.registry.activeController; } + /// The index of the page this context belongs to, if inside a viewer. + static int? pageIndexOf(BuildContext context) { + return LeafPageIndexScope.maybeOf(context); + } + /// Reactive index of the currently settled page, if inside a viewer. static ValueListenable? currentIndexListOf(BuildContext context) { return LeafViewerScope.maybeOf(context)?.currentIndex; @@ -393,6 +398,33 @@ class _LeafViewerState extends State with TickerProviderStateMixin i } } +/// A [Hero] that only participates on the viewer's currently active page. +/// +/// LeafViewer keeps neighbour pages alive, so an unconditional hero on every +/// page registers several per route — popping the viewer then flies the +/// active page AND its neighbours back to the grid. This gates the hero on +/// "this page is the settled one", so exactly one flight happens. The child +/// instance is passed through untouched, so providers stay stable across +/// page changes. Outside a viewer it behaves like a plain [Hero]. +class LeafViewerHero extends StatelessWidget { + const LeafViewerHero({super.key, required this.tag, required this.child}); + + final Object tag; + final Widget child; + + @override + Widget build(BuildContext context) { + final index = LeafViewer.pageIndexOf(context); + final currentIndex = LeafViewer.currentIndexListOf(context); + if (index == null || currentIndex == null) return Hero(tag: tag, child: child); + return ValueListenableBuilder( + valueListenable: currentIndex, + child: child, + builder: (context, current, child) => current == index ? Hero(tag: tag, child: child!) : child!, + ); + } +} + class _LeafViewerDefaultControls extends StatelessWidget { const _LeafViewerDefaultControls({required this.controls, this.showPageCounter = true}); diff --git a/test/viewer/leaf_chrome_test.dart b/test/viewer/leaf_chrome_test.dart index 9114270..e4c6f1b 100644 --- a/test/viewer/leaf_chrome_test.dart +++ b/test/viewer/leaf_chrome_test.dart @@ -141,6 +141,42 @@ void main() { debugDefaultTargetPlatformOverride = null; }); }); + + group('LeafViewerHero', () { + testWidgets('only the settled page registers a hero', (tester) async { + final controller = PageController(); + addTearDown(controller.dispose); + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: LeafViewer( + pageCount: 3, + pageController: controller, + pageBuilder: (context, i) => + LeafViewerHero(tag: 'post_$i', child: SizedBox.expand(key: ValueKey('page-$i'))), + ), + ), + )); + await tester.pumpAndSettle(); + // Neighbours are alive but must not carry heroes. + expect(find.byType(Hero), findsOneWidget); + expect(find.byKey(const ValueKey('page-0')), findsOneWidget); + + controller.jumpToPage(1); + await tester.pumpAndSettle(); + expect(find.byType(Hero), findsOneWidget); + expect(tester.widget(find.byType(Hero)).tag, 'post_1'); + }); + + testWidgets('behaves like a plain hero outside a viewer', (tester) async { + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: LeafViewerHero(tag: 'x', child: const SizedBox.expand()), + ), + )); + await tester.pumpAndSettle(); + expect(find.byType(Hero), findsOneWidget); + }); + }); } String chromeState(BuildContext context) {