From 7a54c14e21b37df9a364e3304fc596212f9c9e8e Mon Sep 17 00:00:00 2001 From: Bronson Date: Wed, 17 Dec 2025 23:01:18 +1030 Subject: [PATCH] fix: improve image viewer gestures (driver approach) Replaces gesture arena logic with a driver approach where InteractiveViewer handles all touches. Adds manual scroll driving, double-tap zoom, and reliable pinch-to-zoom transition. --- lib/pages/image_viewer/image_viewer.dart | 53 ++++++++- lib/pages/image_viewer/image_viewer_view.dart | 17 ++- lib/pages/image_viewer/zoomable_image.dart | 108 ++++++++++++++++++ linux/CMakeLists.txt | 2 +- 4 files changed, 174 insertions(+), 6 deletions(-) create mode 100644 lib/pages/image_viewer/zoomable_image.dart diff --git a/lib/pages/image_viewer/image_viewer.dart b/lib/pages/image_viewer/image_viewer.dart index 1dba46b28..c58ebfb9a 100644 --- a/lib/pages/image_viewer/image_viewer.dart +++ b/lib/pages/image_viewer/image_viewer.dart @@ -56,13 +56,62 @@ class ImageViewerController extends State { late final List allEvents; + bool isZoomed = false; + + void setZoomed(bool value) { + if (isZoomed == value) return; + setState(() { + isZoomed = value; + }); + } + + void handleDriveScroll(double delta) { + if (pageController.positions.isEmpty) return; + final maxScrollExtent = pageController.position.maxScrollExtent; + final minScrollExtent = pageController.position.minScrollExtent; + final newOffset = (pageController.offset - delta).clamp(minScrollExtent, maxScrollExtent); + pageController.jumpTo(newOffset); + } + + void handleDriveScrollEnd(ScaleEndDetails details) { + if (pageController.positions.isEmpty) return; + + // Determine the target page based on velocity and current offset + final velocity = details.velocity.pixelsPerSecond.dy; + final currentOffset = pageController.offset; + final screenHeight = MediaQuery.sizeOf(context).height; + + // Default to current page + int targetDisplayIndex = (currentOffset / screenHeight).round(); + + // Adjust target page based on fling velocity + if (velocity < -500) { + // Swiped up (Next image) + targetDisplayIndex = (currentOffset / screenHeight).ceil(); + } else if (velocity > 500) { + // Swiped down (Prev image) + targetDisplayIndex = (currentOffset / screenHeight).floor(); + } + + // Ensure bounds + if (targetDisplayIndex < 0) targetDisplayIndex = 0; + if (targetDisplayIndex >= allEvents.length) targetDisplayIndex = allEvents.length - 1; + + + pageController.animateToPage( + targetDisplayIndex, + duration: FluffyThemes.animationDuration, + curve: FluffyThemes.animationCurve, + ); + } + void onKeyEvent(KeyEvent event) { switch (event.logicalKey) { case LogicalKeyboardKey.arrowUp: - if (canGoBack) prevImage(); + if (canGoBack && !isZoomed) prevImage(); break; case LogicalKeyboardKey.arrowDown: - if (canGoNext) nextImage(); + if (canGoNext && !isZoomed) nextImage(); break; } } diff --git a/lib/pages/image_viewer/image_viewer_view.dart b/lib/pages/image_viewer/image_viewer_view.dart index 7eb529494..03ae2e2da 100644 --- a/lib/pages/image_viewer/image_viewer_view.dart +++ b/lib/pages/image_viewer/image_viewer_view.dart @@ -8,6 +8,7 @@ import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/widgets/hover_builder.dart'; import 'package:fluffychat/widgets/mxc_image.dart'; import 'image_viewer.dart'; +import 'zoomable_image.dart'; class ImageViewerView extends StatelessWidget { final ImageViewerController controller; @@ -74,8 +75,13 @@ class ImageViewerView extends StatelessWidget { KeyboardListener( focusNode: controller.focusNode, onKeyEvent: controller.onKeyEvent, + // We disable the intrinsic scrolling of the PageView because we want + // the [ZoomableImage] to drive the scrolling manually. + // This allows us to seamlessly switch between scrolling (1 finger) + // and zooming (2 fingers) without the Gesture Arena locking us out. child: PageView.builder( scrollDirection: Axis.vertical, + physics: const NeverScrollableScrollPhysics(), controller: controller.pageController, itemCount: controller.allEvents.length, itemBuilder: (context, i) { @@ -95,10 +101,15 @@ class ImageViewerView extends StatelessWidget { case MessageTypes.Image: case MessageTypes.Sticker: default: - return InteractiveViewer( - minScale: 1.0, - maxScale: 10.0, + // The ZoomableImage handles all gestures: + // - Double tap to zoom + // - Pinch to zoom + // - Drag to scroll (drives the PageView manually) + return ZoomableImage( + onZoomChanged: controller.setZoomed, onInteractionEnd: controller.onInteractionEnds, + onDriveScroll: controller.handleDriveScroll, + onDriveScrollEnd: controller.handleDriveScrollEnd, child: Center( child: Hero( tag: event.eventId, diff --git a/lib/pages/image_viewer/zoomable_image.dart b/lib/pages/image_viewer/zoomable_image.dart new file mode 100644 index 000000000..62e3e4e3e --- /dev/null +++ b/lib/pages/image_viewer/zoomable_image.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; + +class ZoomableImage extends StatefulWidget { + final Widget child; + final Function(bool isZoomed) onZoomChanged; + final Function(ScaleEndDetails)? onInteractionEnd; + final Function(double delta) onDriveScroll; + final Function(ScaleEndDetails details) onDriveScrollEnd; + + const ZoomableImage({ + super.key, + required this.child, + required this.onZoomChanged, + required this.onDriveScroll, + required this.onDriveScrollEnd, + this.onInteractionEnd, + }); + + @override + State createState() => _ZoomableImageState(); +} + +class _ZoomableImageState extends State + with TickerProviderStateMixin { + late final TransformationController _transformationController; + late final AnimationController _animationController; + Animation? _animation; + TapDownDetails? _doubleTapDetails; + + @override + void initState() { + super.initState(); + _transformationController = TransformationController(); + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 200), + )..addListener(() { + _transformationController.value = _animation!.value; + }); + } + + @override + void dispose() { + _transformationController.dispose(); + _animationController.dispose(); + super.dispose(); + } + + void _handleDoubleTap() { + Matrix4 currentMatrix = _transformationController.value; + double currentScale = currentMatrix.getMaxScaleOnAxis(); + + Matrix4 endMatrix; + if (currentScale > 1.0) { + // Zoom out to 1.0 + endMatrix = Matrix4.identity(); + widget.onZoomChanged(false); // We are animating to zoomed out + } else { + // Zoom in to 3.0 centered on the tap position + final position = _doubleTapDetails?.localPosition ?? + Offset(context.size!.width / 2, context.size!.height / 2); + + endMatrix = Matrix4.identity() + ..translate(-position.dx * 2, -position.dy * 2) + ..scale(3.0); + widget.onZoomChanged(true); + } + + _animation = Matrix4Tween( + begin: currentMatrix, + end: endMatrix, + ).animate(CurveTween(curve: Curves.easeInOut).animate(_animationController)); + + _animationController.forward(from: 0); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onDoubleTapDown: (details) => _doubleTapDetails = details, + onDoubleTap: _handleDoubleTap, + child: InteractiveViewer( + transformationController: _transformationController, + minScale: 1.0, + maxScale: 10.0, + onInteractionStart: (_) { + _animationController.stop(); + widget.onZoomChanged(true); + }, + onInteractionUpdate: (details) { + // If we are fully zoomed out, we drive the scroll + if (_transformationController.value.getMaxScaleOnAxis() <= 1.0 && details.scale == 1.0) { + widget.onDriveScroll(details.focalPointDelta.dy); + } + }, + onInteractionEnd: (details) { + if (_transformationController.value.getMaxScaleOnAxis() <= 1.0) { + widget.onZoomChanged(false); + // Snap the scroll + widget.onDriveScrollEnd(details); + } + widget.onInteractionEnd?.call(details); + }, + child: widget.child, + ), + ); + } +} diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index ecb5b8764..fab9bdf21 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -43,7 +43,7 @@ endif() # of modifying this function. function(APPLY_STANDARD_SETTINGS TARGET) target_compile_features(${TARGET} PUBLIC cxx_std_14) - target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE -Wall) target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") endfunction()