From a31a88f87a70fd643195f1ae70d87517723afaae Mon Sep 17 00:00:00 2001 From: Welton Moura Date: Fri, 25 Sep 2026 14:41:13 -0300 Subject: [PATCH] feat: zoom widget de imagem --- lib/pages/image_viewer/image_viewer.dart | 83 ++++++++++++++++- lib/pages/image_viewer/image_viewer_view.dart | 90 ++++++++++--------- lib/widgets/mxc_image_viewer.dart | 82 ++++++++++------- 3 files changed, 179 insertions(+), 76 deletions(-) diff --git a/lib/pages/image_viewer/image_viewer.dart b/lib/pages/image_viewer/image_viewer.dart index d49aa77..4ad0279 100644 --- a/lib/pages/image_viewer/image_viewer.dart +++ b/lib/pages/image_viewer/image_viewer.dart @@ -33,6 +33,57 @@ class ImageViewer extends StatefulWidget { class ImageViewerController extends State { final FocusNode focusNode = FocusNode(); + /// One [TransformationController] per image, so the zoom of one page never + /// leaks into its neighbours. + final Map _transformations = {}; + + int _pointerCount = 0; + bool _isZoomed = false; + + /// True while an image is zoomed in, so panning moves the image instead of + /// switching to the next one. + bool get isZoomed => _isZoomed; + + /// The [PageView] must not compete for the gesture while pinching or while + /// an image is zoomed, otherwise the swipe recognizer wins the gesture arena + /// and pinch-to-zoom never happens. + bool get canSwipeToNextImage => _pointerCount < 2 && !_isZoomed; + + TransformationController transformationControllerFor(Event event) => + _transformations.putIfAbsent(event.eventId, () { + final controller = TransformationController(); + controller.addListener(_onTransformChanged); + return controller; + }); + + void onPointerDown() { + if (!mounted) return; + setState(() => _pointerCount++); + } + + void onPointerUp() { + if (!mounted || _pointerCount == 0) return; + setState(() => _pointerCount--); + } + + void _onTransformChanged() { + if (!mounted) return; + final zoomed = _transformations.values.any( + (controller) => controller.value.getMaxScaleOnAxis() > 1.01, + ); + if (zoomed == _isZoomed) return; + setState(() => _isZoomed = zoomed); + } + + /// Resets the zoom of every image, e.g. after moving to another one. + void resetZooms() { + for (final controller in _transformations.values) { + if (!controller.value.isIdentity()) { + controller.value = Matrix4.identity(); + } + } + } + @override void initState() { super.initState(); @@ -72,6 +123,8 @@ class ImageViewerController extends State { } Future prevImage() async { + if (!canGoBack) return; + resetZooms(); await pageController.previousPage( duration: FluffyThemes.animationDuration, curve: FluffyThemes.animationCurve, @@ -81,6 +134,8 @@ class ImageViewerController extends State { } Future nextImage() async { + if (!canGoNext) return; + resetZooms(); await pageController.nextPage( duration: FluffyThemes.animationDuration, curve: FluffyThemes.animationCurve, @@ -112,23 +167,47 @@ class ImageViewerController extends State { static const maxScaleFactor = 1.5; + /// Closes the viewer. + /// + /// Always targets the root navigator, because the viewer is opened with + /// `showDialog` on the root navigator while the chat room lives in a nested + /// navigator of the go_router shell. Using a relative navigator here would + /// either pop nothing or pop the wrong route. + void closeAction() => Navigator.of(context, rootNavigator: true).pop(); + /// Go back if user swiped it away void onInteractionEnds(ScaleEndDetails endDetails) { if (PlatformInfos.usesTouchscreen == false) { + if (isZoomed) return; if (endDetails.velocity.pixelsPerSecond.dy > MediaQuery.sizeOf(context).height * maxScaleFactor) { - Navigator.of(context, rootNavigator: false).pop(); + closeAction(); } } } @override void dispose() { + for (final controller in _transformations.values) { + controller.removeListener(_onTransformChanged); + controller.dispose(); + } + _transformations.clear(); focusNode.dispose(); pageController.dispose(); super.dispose(); } @override - Widget build(BuildContext context) => ImageViewerView(this); + Widget build(BuildContext context) => PopScope( + // Claim the system back button (and predictive back gesture) for the + // viewer. Without this, the request can reach go_router instead, which + // pops the chat room out of the shell and leaves the image on screen. + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; + closeAction(); + }, + child: ImageViewerView(this), + ); } diff --git a/lib/pages/image_viewer/image_viewer_view.dart b/lib/pages/image_viewer/image_viewer_view.dart index fe659b6..4cfca41 100644 --- a/lib/pages/image_viewer/image_viewer_view.dart +++ b/lib/pages/image_viewer/image_viewer_view.dart @@ -25,7 +25,7 @@ class ImageViewerView extends StatelessWidget { foregroundColor: Colors.white, ); return GestureDetector( - onTap: () => Navigator.of(context).pop(), + onTap: controller.closeAction, child: Scaffold( backgroundColor: Colors.black.withAlpha(128), extendBodyBehindAppBar: true, @@ -34,7 +34,7 @@ class ImageViewerView extends StatelessWidget { leading: IconButton( style: iconButtonStyle, icon: const Icon(Icons.close), - onPressed: Navigator.of(context).pop, + onPressed: controller.closeAction, color: Colors.white, tooltip: L10n.of(context).close, ), @@ -78,50 +78,60 @@ class ImageViewerView extends StatelessWidget { KeyboardListener( focusNode: controller.focusNode, onKeyEvent: controller.onKeyEvent, - child: PageView.builder( - scrollDirection: Axis.vertical, - controller: controller.pageController, - itemCount: controller.allEvents.length, - itemBuilder: (context, i) { - final event = controller.allEvents[i]; - switch (event.messageType) { - case MessageTypes.Video: - return Padding( - padding: const EdgeInsets.only(top: 52.0), - child: Center( - child: GestureDetector( - // Ignore taps to not go back here: - onTap: () {}, - child: EventVideoPlayer(event), - ), - ), - ); - case MessageTypes.Image: - case MessageTypes.Sticker: - default: - return InteractiveViewer( - minScale: 1.0, - maxScale: 10.0, - onInteractionEnd: controller.onInteractionEnds, - child: Center( - child: Hero( - tag: event.eventId, + child: Listener( + onPointerDown: (_) => controller.onPointerDown(), + onPointerUp: (_) => controller.onPointerUp(), + onPointerCancel: (_) => controller.onPointerUp(), + child: PageView.builder( + scrollDirection: Axis.vertical, + controller: controller.pageController, + physics: controller.canSwipeToNextImage + ? const PageScrollPhysics() + : const NeverScrollableScrollPhysics(), + itemCount: controller.allEvents.length, + itemBuilder: (context, i) { + final event = controller.allEvents[i]; + switch (event.messageType) { + case MessageTypes.Video: + return Padding( + padding: const EdgeInsets.only(top: 52.0), + child: Center( child: GestureDetector( // Ignore taps to not go back here: onTap: () {}, - child: MxcImage( - key: ValueKey(event.eventId), - event: event, - fit: BoxFit.contain, - isThumbnail: false, - animated: true, + child: EventVideoPlayer(event), + ), + ), + ); + case MessageTypes.Image: + case MessageTypes.Sticker: + default: + return InteractiveViewer( + transformationController: controller + .transformationControllerFor(event), + minScale: 1.0, + maxScale: 10.0, + onInteractionEnd: controller.onInteractionEnds, + child: Center( + child: Hero( + tag: event.eventId, + child: GestureDetector( + // Ignore taps to not go back here: + onTap: () {}, + child: MxcImage( + key: ValueKey(event.eventId), + event: event, + fit: BoxFit.contain, + isThumbnail: false, + animated: true, + ), ), ), ), - ), - ); - } - }, + ); + } + }, + ), ), ), if (hovered) diff --git a/lib/widgets/mxc_image_viewer.dart b/lib/widgets/mxc_image_viewer.dart index a1c1418..7ea6ba8 100644 --- a/lib/widgets/mxc_image_viewer.dart +++ b/lib/widgets/mxc_image_viewer.dart @@ -13,47 +13,61 @@ class MxcImageViewer extends StatelessWidget { const MxcImageViewer(this.mxContent, {super.key}); + /// The viewer is opened with `showDialog` on the root navigator, so closing + /// it must always target the root navigator. + static void _close(BuildContext context) => + Navigator.of(context, rootNavigator: true).pop(); + @override Widget build(BuildContext context) { final iconButtonStyle = IconButton.styleFrom( backgroundColor: Colors.black.withAlpha(200), foregroundColor: Colors.white, ); - return GestureDetector( - onTap: () => Navigator.of(context).pop(), - child: Scaffold( - backgroundColor: Colors.black.withAlpha(128), - extendBodyBehindAppBar: true, - appBar: AppBar( - elevation: 0, - leading: IconButton( - style: iconButtonStyle, - icon: const Icon(Icons.close), - onPressed: Navigator.of(context).pop, - color: Colors.white, - tooltip: L10n.of(context).close, + return PopScope( + // Claim the system back button for the viewer so that it is dismissed + // instead of letting the router navigate away underneath it. + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; + _close(context); + }, + child: GestureDetector( + onTap: () => _close(context), + child: Scaffold( + backgroundColor: Colors.black.withAlpha(128), + extendBodyBehindAppBar: true, + appBar: AppBar( + elevation: 0, + leading: IconButton( + style: iconButtonStyle, + icon: const Icon(Icons.close), + onPressed: () => _close(context), + color: Colors.white, + tooltip: L10n.of(context).close, + ), + backgroundColor: Colors.transparent, ), - backgroundColor: Colors.transparent, - ), - body: InteractiveViewer( - minScale: 1.0, - maxScale: 10.0, - onInteractionEnd: (endDetails) { - if (endDetails.velocity.pixelsPerSecond.dy > - MediaQuery.sizeOf(context).height * 1.5) { - Navigator.of(context, rootNavigator: false).pop(); - } - }, - child: Center( - child: GestureDetector( - // Ignore taps to not go back here: - onTap: () {}, - child: MxcImage( - key: ValueKey(mxContent.toString()), - uri: mxContent, - fit: BoxFit.contain, - isThumbnail: false, - animated: true, + body: InteractiveViewer( + minScale: 1.0, + maxScale: 10.0, + onInteractionEnd: (endDetails) { + if (endDetails.velocity.pixelsPerSecond.dy > + MediaQuery.sizeOf(context).height * 1.5) { + _close(context); + } + }, + child: Center( + child: GestureDetector( + // Ignore taps to not go back here: + onTap: () {}, + child: MxcImage( + key: ValueKey(mxContent.toString()), + uri: mxContent, + fit: BoxFit.contain, + isThumbnail: false, + animated: true, + ), ), ), ),