/* * This file is part of PiliPlus * * PiliPlus is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * PiliPlus is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with PiliPlus. If not, see . */ import 'dart:math' as math; import 'package:PiliPlus/common/widgets/gesture/horizontal_drag_gesture_recognizer.dart' show touchSlopH; import 'package:PiliPlus/common/widgets/gesture/image_horizontal_drag_gesture_recognizer.dart'; import 'package:PiliPlus/utils/extension/num_ext.dart'; import 'package:easy_debounce/easy_throttle.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/physics.dart' show FrictionSimulation; import 'package:flutter/scheduler.dart' show SchedulerBinding; import 'package:flutter/services.dart' show HardwareKeyboard; /// /// created by dom on 2026/02/14 /// class Viewer extends StatefulWidget { const Viewer({ super.key, required this.minScale, required this.maxScale, this.isLongPic = false, required this.containerSize, required this.childSize, required this.onDragStart, required this.onDragUpdate, required this.onDragEnd, required this.doubleTapGestureRecognizer, required this.horizontalDragGestureRecognizer, required this.onChangePage, required this.child, }); final double minScale; final double maxScale; final bool isLongPic; final Size containerSize; final Size childSize; final Widget child; final ValueChanged? onDragStart; final ValueChanged? onDragUpdate; final ValueChanged? onDragEnd; final ValueChanged? onChangePage; final DoubleTapGestureRecognizer doubleTapGestureRecognizer; final ImageHorizontalDragGestureRecognizer horizontalDragGestureRecognizer; @override State createState() => _ViewerState(); } class _ViewerState extends State with SingleTickerProviderStateMixin { static const double _scaleFactor = kDefaultMouseScrollToScaleFactor; _GestureType? _gestureType; final Matrix4 _matrix = Matrix4.identity(); late double __scale; double get _scale => __scale; set _scale(double value) { __scale = value; _matrix[0] = _matrix[5] = _matrix[10] = value; } late Offset __position; Offset get _position => __position; set _position(Offset value) { __position = value; _matrix ..[12] = value.dx ..[13] = value.dy; } Offset? _scalePos; double? _scaleStart; Offset? _referenceFocalPoint; late Size _imageSize; late final DoubleTapGestureRecognizer _doubleTapGestureRecognizer; late final ImageHorizontalDragGestureRecognizer _horizontalDragGestureRecognizer; late final ScaleGestureRecognizer _scaleGestureRecognizer; Offset? _downPos; late final AnimationController _animationController; late double _scaleFrom, _scaleTo; late Offset _positionFrom, _positionTo; void _listener() { final t = Curves.easeOut.transform(_animationController.value); _scale = t.lerp(_scaleFrom, _scaleTo); _position = Offset.lerp(_positionFrom, _positionTo, t)!; setState(() {}); } void _reset() { _scale = widget.minScale; _position = .zero; } void _initSize() { _reset(); _imageSize = applyBoxFit( .scaleDown, widget.childSize, widget.containerSize, ).destination; if (widget.isLongPic) { final containerWidth = widget.containerSize.width; final containerHeight = widget.containerSize.height; final imageHeight = _imageSize.height * _scale; _position = Offset( (1 - _scale) * containerWidth / 2, (imageHeight - _scale * containerHeight) / 2, ); } } @override void initState() { super.initState(); _initSize(); _animationController = AnimationController( vsync: this, duration: const Duration(milliseconds: 300), )..addListener(_listener); _doubleTapGestureRecognizer = widget.doubleTapGestureRecognizer; _horizontalDragGestureRecognizer = widget.horizontalDragGestureRecognizer; _scaleGestureRecognizer = ScaleGestureRecognizer(debugOwner: this) ..dragStartBehavior = .start ..onStart = _onScaleStart ..onUpdate = _onScaleUpdate ..onEnd = _onScaleEnd ..gestureSettings = DeviceGestureSettings(touchSlop: touchSlopH); } @override void didUpdateWidget(Viewer oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.containerSize != widget.containerSize || oldWidget.childSize != widget.childSize) { _initSize(); } } @override void dispose() { _stopFling(); _animationController ..removeListener(_listener) ..dispose(); _scaleGestureRecognizer.dispose(); super.dispose(); } Offset _toScene(Offset localFocalPoint) { return (localFocalPoint - _position) / _scale; } Offset _clampPosition(Offset offset, double scale) { final containerSize = widget.containerSize; final imageWidth = _imageSize.width * scale; final imageHeight = _imageSize.height * scale; final center = containerSize * (1 - scale) / 2; final dxOffset = (imageWidth - containerSize.width) / 2; final dyOffset = (imageHeight - containerSize.height) / 2; return Offset( imageWidth > containerSize.width ? clampDouble( offset.dx, center.width - dxOffset, center.width + dxOffset, ) : center.width, imageHeight > containerSize.height ? clampDouble( offset.dy, center.height - dyOffset, center.height + dyOffset, ) : center.height, ); } Offset _matrixTranslate(Offset translation) { if (translation == .zero) { return _position; } return _clampPosition(_position + translation * _scale, _scale); } void _onDoubleTapDown(TapDownDetails details) { _downPos = details.localPosition; } void _onDoubleTap() { if (!mounted) return; if (_animationController.isAnimating) return; EasyThrottle.throttle( 'VIEWER_TAP', const Duration(milliseconds: 555), _handleDoubleTap, ); } void _handleDoubleTap() { if (!mounted) return; if (_animationController.isAnimating) return; _stopFling(); _scaleFrom = _scale; _positionFrom = _position; double endScale; if (_scale == widget.minScale) { endScale = widget.maxScale * 0.6; if (endScale <= widget.minScale) { endScale = widget.maxScale; } } else { endScale = widget.minScale; } final position = _clampPosition( Offset.lerp(_downPos!, _position, endScale / _scale)!, endScale, ); _scaleTo = endScale; _positionTo = position; _animationController ..duration = const Duration(milliseconds: 300) ..forward(from: 0); } static bool _calc(Offset initialPosition, Offset lastPosition) { final offset = lastPosition - initialPosition; return offset.dy.abs() > offset.dx.abs(); } void _onScaleStart(ScaleStartDetails details) { _stopFling(); if (_animationController.isAnimating) { _animationController.stop(); } if (details.pointerCount == 1) { if (widget.isLongPic) { final imageHeight = _scale * _imageSize.height; final containerHeight = widget.containerSize.height; if (_scalePos != null && _calc(_scalePos!, details.focalPoint)) { final bool drag; if (details.focalPoint.dy > _scalePos!.dy) { drag = _position.dy.equals( (imageHeight - _scale * containerHeight) / 2, 1e-6, ); } else { drag = _position.dy.equals(containerHeight - imageHeight, 1e-6); } if (drag) { _gestureType = .drag; widget.onDragStart?.call(details); return; } } } else if (_scale == widget.minScale) { _gestureType = .drag; widget.onDragStart?.call(details); return; } } _scaleStart = _scale; _referenceFocalPoint = _toScene(details.localFocalPoint); } void _onScaleUpdate(ScaleUpdateDetails details) { if (_gestureType == .drag) { widget.onDragUpdate?.call(details); return; } if (details.scale != 1.0) { _gestureType = .scale; _scale = clampDouble( _scaleStart! * details.scale, widget.minScale, widget.maxScale, ); final Offset focalPointSceneScaled = _toScene(details.localFocalPoint); _position = _matrixTranslate( focalPointSceneScaled - _referenceFocalPoint!, ); setState(() {}); } else { _gestureType = .pan; final Offset focalPointScene = _toScene(details.localFocalPoint); final Offset translationChange = focalPointScene - _referenceFocalPoint!; _position = _matrixTranslate(translationChange); _referenceFocalPoint = _toScene(details.localFocalPoint); setState(() {}); } } /// ref https://github.com/ahnaineh/custom_interactive_viewer int? _flingFrameCallbackId; Simulation? _flingSimulation; Duration? _flingStartTime; double _lastFlingElapsedSeconds = 0.0; Offset _flingDirection = Offset.zero; /// Calculate appropriate friction based on velocity magnitude double _calculateDynamicFriction(double velocityMagnitude) { // Use higher friction for faster flicks // These values can be tuned for the feel you want if (velocityMagnitude > 5000) { return 0.03; // Higher friction for very fast flicks } else if (velocityMagnitude > 3000) { return 0.02; // Medium friction for moderate flicks } else { return 0.01; // Lower friction for gentle movements } } void _startFling(Velocity velocity) { _stopFling(); final double velocityMagnitude = velocity.pixelsPerSecond.distance; final double frictionCoefficient = _calculateDynamicFriction( velocityMagnitude, ); _flingSimulation = FrictionSimulation( frictionCoefficient, 0.0, velocityMagnitude, ); _flingDirection = velocityMagnitude > 0 ? velocity.pixelsPerSecond / velocityMagnitude : Offset.zero; _flingStartTime = null; _lastFlingElapsedSeconds = 0.0; _scheduleFlingFrame(); } void _scheduleFlingFrame() { _flingFrameCallbackId = SchedulerBinding.instance.scheduleFrameCallback( _handleFlingFrame, ); } void _handleFlingFrame(Duration timeStamp) { if (_flingSimulation == null) return; _flingStartTime ??= timeStamp; final double elapsedSeconds = (timeStamp - _flingStartTime!).inMicroseconds / 1e6; final double distance = _flingSimulation!.x(elapsedSeconds); final double prevDistance = _flingSimulation!.x(_lastFlingElapsedSeconds); final double delta = distance - prevDistance; _lastFlingElapsedSeconds = elapsedSeconds; if ((prevDistance != 0.0 && delta.abs() < 0.1) || _flingSimulation!.isDone(elapsedSeconds)) { _stopFling(); return; } final Offset movement = _flingDirection * delta; _position = _clampPosition(_position + movement, _scale); setState(() {}); if (_flingSimulation!.isDone(elapsedSeconds)) { _stopFling(); } else { _scheduleFlingFrame(); } } void _stopFling() { if (_flingFrameCallbackId != null) { SchedulerBinding.instance.cancelFrameCallbackWithId( _flingFrameCallbackId!, ); _flingFrameCallbackId = null; } _flingStartTime = null; _lastFlingElapsedSeconds = 0.0; _flingSimulation = null; } /// ref [InteractiveViewer] void _onScaleEnd(ScaleEndDetails details) { switch (_gestureType) { case _GestureType.pan: final double velocityMagnitude = details.velocity.pixelsPerSecond.distance; if (velocityMagnitude >= 200.0) { _startFling(details.velocity); } case _GestureType.scale: // if (details.scaleVelocity.abs() < 0.1) { // return; // } // final double scale = _scale; // final FrictionSimulation frictionSimulation = FrictionSimulation( // _interactionEndFrictionCoefficient * _scaleFactor, // scale, // details.scaleVelocity / 10, // ); // final double tFinal = _getFinalTime( // details.scaleVelocity.abs(), // _interactionEndFrictionCoefficient, // effectivelyMotionless: 0.1, // ); // _scaleAnimation = _scaleController.drive( // Tween( // begin: scale, // end: frictionSimulation.x(tFinal), // ).chain(CurveTween(curve: Curves.decelerate)), // )..addListener(_handleScaleAnimation); // _animationController // ..duration = Duration(milliseconds: (tFinal * 1000).round()) // ..forward(from: 0); break; case _GestureType.drag: widget.onDragEnd?.call(details); case null: } _scalePos = null; _gestureType = null; } @override Widget build(BuildContext context) { return Listener( behavior: .opaque, onPointerDown: _onPointerDown, onPointerPanZoomStart: _onPointerPanZoomStart, onPointerSignal: _onPointerSignal, child: ClipRect( child: Transform( transform: _matrix, child: widget.child, ), ), ); } void _onPointerDown(PointerDownEvent event) { _scalePos = event.position; _doubleTapGestureRecognizer ..onDoubleTapDown = _onDoubleTapDown ..onDoubleTap = _onDoubleTap; _horizontalDragGestureRecognizer.addPointer( event, isPointerAllowed: _isAtEdge(event.localPosition), ); _scaleGestureRecognizer.addPointer(event); } void _onPointerPanZoomStart(PointerPanZoomStartEvent event) { _scaleGestureRecognizer.addPointerPanZoom(event); } bool _isAtEdge(Offset position) { if (_scale <= widget.minScale) { _horizontalDragGestureRecognizer.setAtBothEdges(); return true; } final containerWidth = widget.containerSize.width; final imageWidth = _imageSize.width * _scale; if (imageWidth <= containerWidth) { _horizontalDragGestureRecognizer.setAtBothEdges(); return true; } final dx = (1 - _scale) * containerWidth / 2; final dxOffset = (imageWidth - containerWidth) / 2; if (_position.dx.equals(dx + dxOffset, 1e-6)) { _horizontalDragGestureRecognizer.isAtLeftEdge = true; return true; } if (_position.dx.equals(dx - dxOffset, 1e-6)) { _horizontalDragGestureRecognizer.isAtRightEdge = true; return true; } return false; } void _onPointerSignal(PointerSignalEvent event) { if (event is PointerScrollEvent) { if (widget.onChangePage != null && !HardwareKeyboard.instance.isControlPressed) { widget.onChangePage!.call(event.scrollDelta.dy < 0 ? -1 : 1); return; } _stopFling(); final double scaleChange = math.exp(-event.scrollDelta.dy / _scaleFactor); final Offset local = event.localPosition; final Offset focalPointScene = _toScene(local); _scale = clampDouble( _scale * scaleChange, widget.minScale, widget.maxScale, ); final Offset focalPointSceneScaled = _toScene(local); _position = _matrixTranslate(focalPointSceneScaled - focalPointScene); setState(() {}); } } } enum _GestureType { pan, scale, drag }