From 736f808f9e27b7a81ddfeff115635b2078c74c27 Mon Sep 17 00:00:00 2001 From: Edouard Marquez Date: Thu, 30 Jan 2025 23:14:01 +0100 Subject: [PATCH] InteractiveViewer with double tap to zoom in/out --- .../product/edit_ocr/edit_ocr_image.dart | 3 +- .../product/edit_product_image_viewer.dart | 3 +- .../widgets/smooth_interactive_viewer.dart | 93 +++++++++++++++++++ 3 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 packages/smooth_app/lib/widgets/smooth_interactive_viewer.dart diff --git a/packages/smooth_app/lib/pages/product/edit_ocr/edit_ocr_image.dart b/packages/smooth_app/lib/pages/product/edit_ocr/edit_ocr_image.dart index 9ccd9a83df7..54b17d86cfb 100644 --- a/packages/smooth_app/lib/pages/product/edit_ocr/edit_ocr_image.dart +++ b/packages/smooth_app/lib/pages/product/edit_ocr/edit_ocr_image.dart @@ -21,6 +21,7 @@ import 'package:smooth_app/themes/smooth_theme.dart'; import 'package:smooth_app/themes/smooth_theme_colors.dart'; import 'package:smooth_app/themes/theme_provider.dart'; import 'package:smooth_app/widgets/smooth_indicator_icon.dart'; +import 'package:smooth_app/widgets/smooth_interactive_viewer.dart'; import 'package:smooth_app/widgets/smooth_text.dart'; class EditOCRImageWidget extends StatefulWidget { @@ -265,7 +266,7 @@ class _EditOCRImageFoundState extends State<_EditOCRImageFound> { Positioned.fill( child: AbsorbPointer( absorbing: state == OcrState.EXTRACTING_DATA, - child: InteractiveViewer( + child: SmoothInteractiveViewer( interactionEndFrictionCoefficient: double.infinity, minScale: 0.1, maxScale: 5.0, diff --git a/packages/smooth_app/lib/pages/product/edit_product_image_viewer.dart b/packages/smooth_app/lib/pages/product/edit_product_image_viewer.dart index 1d9e9d479e5..9fc8b048227 100644 --- a/packages/smooth_app/lib/pages/product/edit_product_image_viewer.dart +++ b/packages/smooth_app/lib/pages/product/edit_product_image_viewer.dart @@ -11,6 +11,7 @@ import 'package:smooth_app/query/product_query.dart'; import 'package:smooth_app/resources/app_icons.dart' as icons; import 'package:smooth_app/themes/theme_provider.dart'; import 'package:smooth_app/widgets/smooth_indicator_icon.dart'; +import 'package:smooth_app/widgets/smooth_interactive_viewer.dart'; class EditProductImageViewer extends StatefulWidget { const EditProductImageViewer({ @@ -81,7 +82,7 @@ class _EditProductImageViewerState extends State child: Stack( children: [ Positioned.fill( - child: InteractiveViewer( + child: SmoothInteractiveViewer( child: Image( fit: BoxFit.contain, image: TransientFile.fromProduct( diff --git a/packages/smooth_app/lib/widgets/smooth_interactive_viewer.dart b/packages/smooth_app/lib/widgets/smooth_interactive_viewer.dart new file mode 100644 index 00000000000..ef8bfedaa13 --- /dev/null +++ b/packages/smooth_app/lib/widgets/smooth_interactive_viewer.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:smooth_app/generic_lib/duration_constants.dart'; + +/// A custom [InteractiveViewer] with a double-tap zoom in/out animation. +class SmoothInteractiveViewer extends StatefulWidget { + const SmoothInteractiveViewer({ + required this.child, + this.interactionEndFrictionCoefficient, + this.minScale, + this.maxScale, + super.key, + }); + + final Widget child; + final double? interactionEndFrictionCoefficient; + final double? minScale; + final double? maxScale; + + @override + State createState() => + _SmoothInteractiveViewerState(); +} + +class _SmoothInteractiveViewerState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _animationController; + late Animation _animation; + + final TransformationController _transformationController = + TransformationController(); + + late TapDownDetails _doubleTapDetails; + + @override + void initState() { + super.initState(); + _animationController = AnimationController(vsync: this) + ..addListener(() { + _transformationController.value = _animation.value; + }); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onDoubleTapDown: _onDoubleTapDown, + onDoubleTap: _onDoubleTap, + child: InteractiveViewer( + interactionEndFrictionCoefficient: + widget.interactionEndFrictionCoefficient ?? 0.0000135, + maxScale: widget.maxScale ?? 2.5, + minScale: widget.minScale ?? 0.8, + transformationController: _transformationController, + child: widget.child, + ), + ); + } + + void _onDoubleTapDown(TapDownDetails details) { + _doubleTapDetails = details; + } + + void _onDoubleTap() { + Matrix4 matrix; + // Reset zoom + if (_transformationController.value != Matrix4.identity()) { + matrix = Matrix4.identity(); + _animationController.duration = const Duration(milliseconds: 300); + } else { + // Zoom x2 + final Offset position = _doubleTapDetails.localPosition; + matrix = Matrix4.identity() + ..translate(-position.dx, -position.dy) + ..scale(2.0); + _animationController.duration = SmoothAnimationsDuration.short; + } + + _animation = Matrix4Tween( + begin: _transformationController.value, + end: matrix, + ).animate( + CurveTween(curve: Curves.easeInCubic).animate(_animationController), + ); + _animationController.forward(from: 0); + } + + @override + void dispose() { + _animationController.dispose(); + _transformationController.dispose(); + super.dispose(); + } +}