import 'dart:math' as math; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/physics.dart'; import 'package:vector_math/vector_math_64.dart' show Quad, Vector3, Matrix4; typedef ZoomWidgetBuilder = Widget Function(BuildContext context, Quad viewport); @immutable class Zoom extends StatefulWidget { Zoom({ this.backgroundColor = Colors.grey, this.canvasColor = Colors.white, this.centerOnScale = true, required this.child, this.colorScrollBars = Colors.black12, this.doubleTapAnimDuration = const Duration(milliseconds: 300), this.doubleTapScaleChange = 1.1, this.doubleTapZoom = true, this.enableScroll = true, this.initPosition, this.initScale, this.initTotalZoomOut = false, Key? key, this.maxScale = 2.5, this.minScale = 1.0, this.maxZoomHeight, this.maxZoomWidth, this.onPositionUpdate, this.onScaleUpdate, this.onPanUpPosition, this.onMinZoom, this.onTap, this.opacityScrollBars = 0.5, this.radiusScrollBars = 4, this.scrollWeight = 10, this.transformationController, this.zoomSensibility = 1.0, }) : assert(maxScale > 0), assert(!maxScale.isNaN), super(key: key); final Color backgroundColor; final Color canvasColor; final bool centerOnScale; final Widget child; final Color colorScrollBars; final Duration doubleTapAnimDuration; final double doubleTapScaleChange; final bool doubleTapZoom; final bool enableScroll; final Offset? initPosition; final double? initScale; final bool initTotalZoomOut; final double maxScale; final double minScale; final double? maxZoomHeight; final double? maxZoomWidth; final Function(Offset)? onPositionUpdate; final Function(double, double)? onScaleUpdate; final Function(Offset)? onPanUpPosition; final Function(bool)? onMinZoom; final Function()? onTap; final double opacityScrollBars; final double radiusScrollBars; final double scrollWeight; final TransformationController? transformationController; final double zoomSensibility; static Vector3 getNearestPointOnLine(Vector3 point, Vector3 l1, Vector3 l2) { final double lengthSquared = math.pow(l2.x - l1.x, 2.0).toDouble() + math.pow(l2.y - l1.y, 2.0).toDouble(); if (lengthSquared == 0) { return l1; } final Vector3 l1P = point - l1; final Vector3 l1L2 = l2 - l1; final double fraction = (l1P.dot(l1L2) / lengthSquared).clamp(0.0, 1.0); return l1 + l1L2 * fraction; } static Quad getAxisAlignedBoundingBox(Quad quad) { final double minX = math.min(quad.point0.x, math.min(quad.point1.x, math.min(quad.point2.x, quad.point3.x))); final double minY = math.min(quad.point0.y, math.min(quad.point1.y, math.min(quad.point2.y, quad.point3.y))); final double maxX = math.max(quad.point0.x, math.max(quad.point1.x, math.max(quad.point2.x, quad.point3.x))); final double maxY = math.max(quad.point0.y, math.max(quad.point1.y, math.max(quad.point2.y, quad.point3.y))); return Quad.points(Vector3(minX, minY, 0), Vector3(maxX, minY, 0), Vector3(maxX, maxY, 0), Vector3(minX, maxY, 0)); } static bool pointIsInside(Vector3 point, Quad quad) { final Vector3 aM = point - quad.point0; final Vector3 aB = quad.point1 - quad.point0; final Vector3 aD = quad.point3 - quad.point0; final double aMAB = aM.dot(aB); final double aBAB = aB.dot(aB); final double aMAD = aM.dot(aD); final double aDAD = aD.dot(aD); return 0 <= aMAB && aMAB <= aBAB && 0 <= aMAD && aMAD <= aDAD; } static Vector3 getNearestPointInside(Vector3 point, Quad quad) { if (pointIsInside(point, quad)) { return point; } final List closestPoints = [ Zoom.getNearestPointOnLine(point, quad.point0, quad.point1), Zoom.getNearestPointOnLine(point, quad.point1, quad.point2), Zoom.getNearestPointOnLine(point, quad.point2, quad.point3), Zoom.getNearestPointOnLine(point, quad.point3, quad.point0), ]; double minDistance = double.infinity; late Vector3 closestOverall; for (final Vector3 closePoint in closestPoints) { final double distance = math.sqrt(math.pow(point.x - closePoint.x, 2) + math.pow(point.y - closePoint.y, 2)); if (distance < minDistance) { minDistance = distance; closestOverall = closePoint; } } return closestOverall; } @override State createState() => _ZoomState(); } class _ZoomState extends State with TickerProviderStateMixin, WidgetsBindingObserver { TransformationController? _transformationController; final GlobalKey _childKey = GlobalKey(); final GlobalKey _parentKey = GlobalKey(); Animation? _animation; late AnimationController _controller; Animation? _scaleAnimation; late AnimationController _scaleController; Axis? _panAxis; Offset? _referenceFocalPoint; double? _scaleStart; _GestureType? _gestureType; ValueNotifier<_ScrollBarData> verticalScrollNotifier = ValueNotifier(_ScrollBarData(length: 0, position: 0)); ValueNotifier<_ScrollBarData> horizontalScrollNotifier = ValueNotifier(_ScrollBarData(length: 0, position: 0)); Size parentSize = Size.zero; Size childSize = Size.zero; Orientation? _orientation; Offset? _doubleTapFocalPoint; bool doubleTapZoomIn = true; bool firstDraw = true; static const double _kDrag = 0.0000135; Rect get _boundaryRect { assert(_childKey.currentContext != null); final Rect boundaryRect = EdgeInsets.zero.inflateRect(Offset.zero & childSize); assert(!boundaryRect.isEmpty, "Zoom's child must have nonzero dimensions."); assert( boundaryRect.isFinite || (boundaryRect.left.isInfinite && boundaryRect.top.isInfinite && boundaryRect.right.isInfinite && boundaryRect.bottom.isInfinite), 'boundaryRect must either be infinite in all directions or finite in all directions.', ); return boundaryRect; } Rect get _viewport { assert(_parentKey.currentContext != null); final RenderBox parentRenderBox = _parentKey.currentContext!.findRenderObject()! as RenderBox; return Offset.zero & parentRenderBox.size; } double _getScrollPercent(Matrix4 matrix, {required _ScrollType scrollType}) { switch (scrollType) { case _ScrollType.horizontal: return _getMatrixTranslation(matrix).dx.abs() / ((childSize.width * matrix.getMaxScaleOnAxis()) - parentSize.width) * 100.0; case _ScrollType.vertical: return _getMatrixTranslation(matrix).dy.abs() / ((childSize.height * matrix.getMaxScaleOnAxis()) - parentSize.height) * 100.0; } } double _getScrollBarLength(Matrix4 matrix, {required _ScrollType scrollType}) { double percent = 0; switch (scrollType) { case _ScrollType.horizontal: percent = (parentSize.width / (childSize.width * matrix.getMaxScaleOnAxis())); return parentSize.width * percent; case _ScrollType.vertical: percent = (parentSize.height / (childSize.height * matrix.getMaxScaleOnAxis())); return parentSize.height * percent; } } void onDisabledScrolls() { if (horizontalScrollNotifier.value.length == 0 && horizontalScrollNotifier.value.position == 0 && verticalScrollNotifier.value.length == 0 && verticalScrollNotifier.value.position == 0) { widget.onMinZoom?.call(true); } else { widget.onMinZoom?.call(false); } } void _updateScroll(Matrix4 matrix) { if (childSize.width * matrix.getMaxScaleOnAxis() > parentSize.width + (parentSize.width * 0.01)) { var horizontalPercent = _getScrollPercent(matrix, scrollType: _ScrollType.horizontal); final horizontalLength = _getScrollBarLength(matrix, scrollType: _ScrollType.horizontal); horizontalScrollNotifier.value = _ScrollBarData( length: horizontalLength, position: (horizontalPercent / 100.0) * (parentSize.width - horizontalLength), ); } else { horizontalScrollNotifier.value = _ScrollBarData(length: 0, position: 0); onDisabledScrolls(); } if (childSize.height * matrix.getMaxScaleOnAxis() > parentSize.height + (parentSize.height * 0.01)) { final verticalPercent = _getScrollPercent(matrix, scrollType: _ScrollType.vertical); final verticalLength = _getScrollBarLength(matrix, scrollType: _ScrollType.vertical); verticalScrollNotifier.value = _ScrollBarData( length: verticalLength, position: (verticalPercent / 100) * (parentSize.height - verticalLength), ); } else { verticalScrollNotifier.value = _ScrollBarData(length: 0, position: 0); onDisabledScrolls(); } } Matrix4 _matrixTranslate(Matrix4 matrix, Offset translation, {bool fixOffset = false}) { if (translation == Offset.zero) { return matrix.clone(); } final Offset alignedTranslation = translation; final Matrix4 nextMatrix = matrix.clone()..translate(alignedTranslation.dx, alignedTranslation.dy); final Quad nextViewport = _transformViewport(nextMatrix, _viewport); if (_boundaryRect.isInfinite && !fixOffset) { _updateScroll(nextMatrix); widget.onPositionUpdate?.call(_getMatrixTranslation(nextMatrix)); return nextMatrix; } final Quad boundariesAabbQuad = _getAxisAlignedBoundingBoxWithRotation(_boundaryRect, 0.0); final Offset offendingDistance = _exceedsBy(boundariesAabbQuad, nextViewport); if (offendingDistance == Offset.zero) { _updateScroll(nextMatrix); widget.onPositionUpdate?.call(_getMatrixTranslation(nextMatrix)); return nextMatrix; } final Offset nextTotalTranslation = _getMatrixTranslation(nextMatrix); final double currentScale = matrix.getMaxScaleOnAxis(); final Offset correctedTotalTranslation = Offset( nextTotalTranslation.dx - offendingDistance.dx * currentScale, nextTotalTranslation.dy - offendingDistance.dy * currentScale, ); final Matrix4 correctedMatrix = matrix.clone() ..setTranslation(Vector3(correctedTotalTranslation.dx, correctedTotalTranslation.dy, 0.0)); final Quad correctedViewport = _transformViewport(correctedMatrix, _viewport); final Offset offendingCorrectedDistance = _exceedsBy(boundariesAabbQuad, correctedViewport); if (offendingCorrectedDistance == Offset.zero && !fixOffset) { _updateScroll(correctedMatrix); widget.onPositionUpdate?.call(_getMatrixTranslation(correctedMatrix)); return correctedMatrix; } if (offendingCorrectedDistance.dx != 0.0 && offendingCorrectedDistance.dy != 0.0 && !fixOffset && (childSize.width > parentSize.width || childSize.height > parentSize.height)) { return matrix.clone(); } final Offset unidirectionalCorrectedTotalTranslation = Offset( offendingCorrectedDistance.dx == 0.0 ? correctedTotalTranslation.dx : 0.0, offendingCorrectedDistance.dy == 0.0 ? correctedTotalTranslation.dy : 0.0, ); final verticalMidLength = (parentSize.height - childSize.height * matrix.getMaxScaleOnAxis()) / 2; final horizontalMidLength = (parentSize.width - (childSize.width * matrix.getMaxScaleOnAxis())) / 2; double horizontalMid = 0; double verticalMid = 0; void calculateMids(bool sizeCondition) { if (sizeCondition) { verticalMid = verticalMidLength; if (childSize.width < parentSize.width) { horizontalMid = horizontalMidLength; } } else { horizontalMid = horizontalMidLength; if (childSize.height < parentSize.height) { verticalMid = verticalMidLength; } } } if (childSize.width == childSize.height) { calculateMids(parentSize.height > parentSize.width); } else { calculateMids(childSize.height < childSize.width); } final midMatrix = matrix.clone() ..setTranslation( Vector3( unidirectionalCorrectedTotalTranslation.dx + (widget.centerOnScale ? horizontalMid < 0 ? 0 : horizontalMid : 0), unidirectionalCorrectedTotalTranslation.dy + (widget.centerOnScale ? verticalMid < 0 ? 0 : verticalMid : 0), 0.0, ), ); _updateScroll(midMatrix); widget.onPositionUpdate?.call(_getMatrixTranslation(midMatrix)); return midMatrix; } Matrix4 _matrixScale(Matrix4 matrix, double scale, {bool fixScale = false}) { double sensibleScale = scale > 1.0 ? 1.0 + ((scale - 1.0) * widget.zoomSensibility) : 1.0 - ((1.0 - scale) * widget.zoomSensibility); if (scale == 1.0) { return matrix.clone(); } assert(scale != 0.0); final nextScale = (matrix.clone()..scale(sensibleScale)).getMaxScaleOnAxis(); if (nextScale < widget.minScale) { // 如果即将缩放到的比例小于最小值,则拒绝此次缩放操作,返回当前矩阵 return matrix.clone(); } if (childSize.width == childSize.height) { if (parentSize.height > parentSize.width) { if ((childSize.width * nextScale) < parentSize.width && nextScale < 1.0) { return matrix.clone(); } } else { if ((childSize.height * nextScale) < parentSize.height && nextScale < 1.0) { return matrix.clone(); } } } else { if (childSize.height < childSize.width) { if ((childSize.width * nextScale) < parentSize.width && nextScale < 1.0) { return matrix.clone(); } } else { if ((childSize.height * nextScale) < parentSize.height && nextScale < 1.0) { return matrix.clone(); } } } if (matrix.getMaxScaleOnAxis() > widget.maxScale && sensibleScale > 1) { return matrix.clone(); } final newMatrix = matrix.clone()..scale(fixScale ? scale : sensibleScale.abs()); widget.onScaleUpdate?.call(fixScale ? scale : sensibleScale.abs(), newMatrix.getMaxScaleOnAxis()); return newMatrix; } bool _gestureIsSupported(_GestureType? gestureType) { switch (gestureType) { case _GestureType.scale: return true; case _GestureType.pan: case null: return true; } } _GestureType _getGestureType(ScaleUpdateDetails details) { final double scale = details.scale; if ((scale - 1).abs() != 0) { return _GestureType.scale; } else { return _GestureType.pan; } } void _onScaleStart(ScaleStartDetails details) { if (_controller.isAnimating) { _controller.stop(); _controller.reset(); _animation?.removeListener(_onAnimate); _animation = null; } _gestureType = null; _panAxis = null; _scaleStart = _transformationController!.value.getMaxScaleOnAxis(); _referenceFocalPoint = _transformationController!.toScene(details.localFocalPoint); } void _onScaleUpdate(ScaleUpdateDetails details) { final double scale = _transformationController!.value.getMaxScaleOnAxis(); final Offset focalPointScene = _transformationController!.toScene(details.localFocalPoint); if (_gestureType == _GestureType.pan) { _gestureType = _getGestureType(details); } else { _gestureType ??= _getGestureType(details); } switch (_gestureType!) { case _GestureType.scale: assert(_scaleStart != null); final double desiredScale = _scaleStart! * details.scale; final double scaleChange = desiredScale / scale; _transformationController!.value = _matrixScale(_transformationController!.value, scaleChange); final Offset focalPointSceneScaled = _transformationController!.toScene(details.localFocalPoint); _transformationController!.value = _matrixTranslate( _transformationController!.value, focalPointSceneScaled - _referenceFocalPoint!, ); final Offset focalPointSceneCheck = _transformationController!.toScene(details.localFocalPoint); if (_round(_referenceFocalPoint!) != _round(focalPointSceneCheck)) { _referenceFocalPoint = focalPointSceneCheck; } break; case _GestureType.pan: assert(_referenceFocalPoint != null); _panAxis ??= _getPanAxis(_referenceFocalPoint!, focalPointScene); final Offset translationChange = focalPointScene - _referenceFocalPoint!; _transformationController!.value = _matrixTranslate(_transformationController!.value, translationChange); _referenceFocalPoint = _transformationController!.toScene(details.localFocalPoint); break; } } void _onScaleEnd(ScaleEndDetails details) { _scaleStart = null; _animation?.removeListener(_onAnimate); _controller.reset(); if (!_gestureIsSupported(_gestureType)) { _panAxis = null; return; } if (_gestureType != _GestureType.pan || details.velocity.pixelsPerSecond.distance < kMinFlingVelocity) { _panAxis = null; return; } final Vector3 translationVector = _transformationController!.value.getTranslation(); final Offset translation = Offset(translationVector.x, translationVector.y); final FrictionSimulation frictionSimulationX = FrictionSimulation( _kDrag, translation.dx, details.velocity.pixelsPerSecond.dx, ); final FrictionSimulation frictionSimulationY = FrictionSimulation( _kDrag, translation.dy, details.velocity.pixelsPerSecond.dy, ); final double tFinal = _getFinalTime(details.velocity.pixelsPerSecond.distance, _kDrag); _animation = Tween( begin: translation, end: Offset(frictionSimulationX.finalX, frictionSimulationY.finalX), ).animate(CurvedAnimation(parent: _controller, curve: Curves.decelerate)); _controller.duration = Duration(milliseconds: (tFinal * 1000).round()); _animation!.addListener(_onAnimate); _controller.forward(); } void _receivedPointerSignal(PointerSignalEvent event) { if (event is PointerScrollEvent) { if (event.scrollDelta.dy == 0.0) { return; } final double scaleChange = math.exp(-event.scrollDelta.dy / 200); final Offset focalPointScene = _transformationController!.toScene(event.localPosition); _transformationController!.value = _matrixScale(_transformationController!.value, scaleChange); final Offset focalPointSceneScaled = _transformationController!.toScene(event.localPosition); _transformationController!.value = _matrixTranslate( _transformationController!.value, focalPointSceneScaled - focalPointScene, ); } } void _onDoubleTap() { if (!_scaleController.isAnimating && widget.doubleTapZoom) { doubleTapZoomIn = _transformationController!.value.getMaxScaleOnAxis() < widget.maxScale; _scaleAnimation = Tween( begin: _transformationController!.value.getMaxScaleOnAxis(), end: widget.maxScale, ).animate(CurvedAnimation(parent: _scaleController, curve: Curves.decelerate)); _scaleController.duration = doubleTapZoomIn ? Duration(milliseconds: 100 + widget.doubleTapAnimDuration.inMilliseconds) : widget.doubleTapAnimDuration; _scaleAnimation!.addListener(_onAnimateScale); _scaleController.forward(); } } void _onAnimate() { if (!_controller.isAnimating) { _panAxis = null; _animation?.removeListener(_onAnimate); _animation = null; _controller.reset(); return; } final Vector3 translationVector = _transformationController!.value.getTranslation(); final Offset translation = Offset(translationVector.x, translationVector.y); final Offset translationScene = _transformationController!.toScene(translation); final Offset animationScene = _transformationController!.toScene(_animation!.value); final Offset translationChangeScene = animationScene - translationScene; _transformationController!.value = _matrixTranslate(_transformationController!.value, translationChangeScene); } void _onAnimateScale() { if (!_scaleController.isAnimating) { _scaleAnimation?.removeListener(_onAnimateScale); _scaleAnimation = null; _scaleController.reset(); return; } double scaleChange; if (widget.doubleTapScaleChange < 1.0) { scaleChange = doubleTapZoomIn ? 1.01 : 0.99; } else { scaleChange = doubleTapZoomIn ? widget.doubleTapScaleChange : 1 - (widget.doubleTapScaleChange - 1); } final Offset focalPointScene = _transformationController!.toScene(_doubleTapFocalPoint ?? Offset.zero); _transformationController!.value = _matrixScale(_transformationController!.value, scaleChange, fixScale: true); final Offset focalPointSceneScaled = _transformationController!.toScene(_doubleTapFocalPoint ?? Offset.zero); Offset diference = focalPointSceneScaled - focalPointScene; _transformationController!.value = _matrixTranslate(_transformationController!.value, diference); } void _onTransformationControllerChange() { // [修复] 当外部 controller 更新时,调用 _updateScroll 来同步滚动条状态。 // 增加 mounted 和 context 的检查是为了确保在调用时 Widget 已经完成布局, // 因为 _updateScroll 依赖于 child 和 parent 的尺寸,这让代码更健壮。 if (mounted && _parentKey.currentContext != null && _childKey.currentContext != null) { _updateScroll(_transformationController!.value); } setState(() {}); } @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); _transformationController = widget.transformationController ?? TransformationController(); _transformationController!.addListener(_onTransformationControllerChange); _controller = AnimationController(vsync: this); _scaleController = AnimationController(vsync: this); } @override void didChangeMetrics() { setState(() { recalculateSizes(); }); } @override void didUpdateWidget(Zoom oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.transformationController == null) { if (widget.transformationController != null) { _transformationController!.removeListener(_onTransformationControllerChange); _transformationController!.dispose(); _transformationController = widget.transformationController; _transformationController!.addListener(_onTransformationControllerChange); } } else { if (widget.transformationController == null) { _transformationController!.removeListener(_onTransformationControllerChange); _transformationController = TransformationController(); _transformationController!.addListener(_onTransformationControllerChange); } else if (widget.transformationController != oldWidget.transformationController) { _transformationController!.removeListener(_onTransformationControllerChange); _transformationController = widget.transformationController; _transformationController!.addListener(_onTransformationControllerChange); } } } @override void dispose() { _controller.dispose(); _scaleController.dispose(); _transformationController!.removeListener(_onTransformationControllerChange); WidgetsBinding.instance.removeObserver(this); if (widget.transformationController == null) { _transformationController!.dispose(); } super.dispose(); } void fixScale(double scale) { _transformationController!.value = _matrixScale(_transformationController!.value, scale, fixScale: true); _transformationController!.toScene(_referenceFocalPoint ?? Offset.zero); _transformationController!.toScene(_referenceFocalPoint ?? Offset.zero); } void recalculateSizes() { WidgetsBinding.instance.addPostFrameCallback((_) { if (_parentKey.currentContext?.findRenderObject() == null) { return; } final RenderBox parentRenderBox = _parentKey.currentContext!.findRenderObject()! as RenderBox; parentSize = parentRenderBox.size; final RenderBox childRenderBox = _childKey.currentContext!.findRenderObject()! as RenderBox; childSize = childRenderBox.size; double scale = 0; final currentScale = _transformationController!.value.getMaxScaleOnAxis(); _transformationController!.value = _matrixTranslate( _transformationController!.value, Offset(-0.01, -0.01), fixOffset: true, ); if (childSize.width == childSize.height) { if (childSize.width > parentSize.width && ((childSize.width * currentScale) < parentSize.width || (childSize.height * currentScale) < parentSize.height)) { scale = parentSize.width / (childSize.width * currentScale); fixScale(scale); } } else { if (childSize.width > childSize.height) { if (childSize.width > parentSize.width && (childSize.width * currentScale) < parentSize.width) { scale = parentSize.width / (childSize.width * currentScale); fixScale(scale); } } else { if (childSize.height > parentSize.height && (childSize.height * currentScale) < parentSize.height) { scale = parentSize.height / (childSize.height * currentScale); fixScale(scale); } } } _transformationController!.value = _matrixTranslate(_transformationController!.value, Offset(-0.01, -0.01)); void fitChild(bool condition) { if (condition) { _transformationController!.value = _matrixScale( _transformationController!.value, parentSize.height / childSize.height, fixScale: true, ); _transformationController!.value = _matrixTranslate( _transformationController!.value, Offset(-0.01, -0.01), fixOffset: true, ); } else { _transformationController!.value = _matrixScale( _transformationController!.value, parentSize.width / childSize.width, fixScale: true, ); _transformationController!.value = _matrixTranslate( _transformationController!.value, Offset(-0.01, -0.01), fixOffset: true, ); } } if (widget.initTotalZoomOut) { if (firstDraw && (childSize.width > parentSize.width || childSize.height > parentSize.height)) { if (childSize.width == childSize.height) { fitChild(parentSize.width > parentSize.height); } else { fitChild(childSize.width < childSize.height); } firstDraw = false; } } else { if (widget.initScale != null) { _transformationController!.value = _matrixScale( _transformationController!.value, widget.initScale ?? 0.0, fixScale: true, ); _transformationController!.value = _matrixTranslate( _transformationController!.value, Offset(-0.01, -0.01), fixOffset: true, ); } if (widget.initPosition != null) { _transformationController!.value = _matrixTranslate( _transformationController!.value, widget.initPosition ?? Offset.zero, ); _referenceFocalPoint = _transformationController!.toScene(widget.initPosition ?? Offset.zero); } } }); } @override Widget build(BuildContext context) { Widget child; child = _ZoomBuilt( childKey: _childKey, constrained: false, matrix: _transformationController!.value, child: Listener( onPointerUp: (event) { if (widget.onPanUpPosition != null) { widget.onPanUpPosition!(event.localPosition); } }, child: (widget.maxZoomWidth == null || widget.maxZoomHeight == null) ? Container(color: widget.canvasColor, child: widget.child) : Center( child: Container( width: widget.maxZoomWidth, height: widget.maxZoomHeight, color: widget.canvasColor, child: widget.child, ), ), ), ); return NotificationListener( onNotification: (notification) { recalculateSizes(); return true; }, child: OrientationBuilder( builder: (context, orientation) { if (_orientation != orientation) { _orientation = orientation; recalculateSizes(); } double opacity = widget.opacityScrollBars < 0 ? 0 : widget.opacityScrollBars > 1 ? 1 : widget.opacityScrollBars; return ClipRect( child: Container( color: widget.backgroundColor, child: Listener( key: _parentKey, onPointerSignal: _receivedPointerSignal, onPointerDown: (PointerDownEvent event) { _doubleTapFocalPoint = event.localPosition; }, child: GestureDetector( behavior: HitTestBehavior.opaque, onScaleEnd: _onScaleEnd, onScaleStart: _onScaleStart, onScaleUpdate: _onScaleUpdate, onDoubleTap: _onDoubleTap, onTap: widget.onTap, child: widget.enableScroll ? Stack( children: [ child, ValueListenableBuilder<_ScrollBarData>( valueListenable: horizontalScrollNotifier, builder: (_, scrollData, __) { return scrollData.length == 0 ? Container() : Positioned( top: parentSize.height - widget.scrollWeight, left: scrollData.position, child: Container( decoration: BoxDecoration( color: widget.colorScrollBars.withAlpha((opacity * 255).toInt()), borderRadius: BorderRadius.only( topLeft: Radius.circular(widget.radiusScrollBars), topRight: Radius.circular(widget.radiusScrollBars), ), ), height: widget.scrollWeight, width: scrollData.length, ), ); }, ), ValueListenableBuilder<_ScrollBarData>( valueListenable: verticalScrollNotifier, builder: (_, scrollData, __) { return Positioned( left: parentSize.width - widget.scrollWeight, top: scrollData.position, child: Container( decoration: BoxDecoration( color: widget.colorScrollBars.withAlpha((opacity * 255).toInt()), borderRadius: BorderRadius.only( topLeft: Radius.circular(widget.radiusScrollBars), bottomLeft: Radius.circular(widget.radiusScrollBars), ), ), height: scrollData.length, width: widget.scrollWeight, ), ); }, ), ], ) : child, ), ), ), ); }, ), ); } } class _ZoomBuilt extends StatelessWidget { const _ZoomBuilt({ Key? key, required this.child, required this.childKey, required this.constrained, required this.matrix, }) : super(key: key); final Widget child; final GlobalKey childKey; final bool constrained; final Matrix4 matrix; @override Widget build(BuildContext context) { Widget child = Transform( transform: matrix, child: KeyedSubtree(key: childKey, child: this.child), ); if (!constrained) { child = OverflowBox( alignment: Alignment.topLeft, minWidth: 0.0, minHeight: 0.0, maxWidth: double.infinity, maxHeight: double.infinity, child: child, ); } return child; } } class TransformationController extends ValueNotifier { TransformationController([Matrix4? value]) : super(value ?? Matrix4.identity()); Offset toScene(Offset viewportPoint) { final Matrix4 inverseMatrix = Matrix4.inverted(value); final Vector3 untransformed = inverseMatrix.transform3(Vector3(viewportPoint.dx, viewportPoint.dy, 0)); return Offset(untransformed.x, untransformed.y); } } enum _GestureType { pan, scale } enum _ScrollType { horizontal, vertical } class _ScrollBarData { _ScrollBarData({required this.length, required this.position}); final double position; final double length; } double _getFinalTime(double velocity, double drag) { const double effectivelyMotionless = 10.0; return math.log(effectivelyMotionless / velocity) / math.log(drag / 100); } Offset _getMatrixTranslation(Matrix4 matrix) { final Vector3 nextTranslation = matrix.getTranslation(); return Offset(nextTranslation.x, nextTranslation.y); } Quad _transformViewport(Matrix4 matrix, Rect viewport) { final Matrix4 inverseMatrix = matrix.clone()..invert(); return Quad.points( inverseMatrix.transform3(Vector3(viewport.topLeft.dx, viewport.topLeft.dy, 0.0)), inverseMatrix.transform3(Vector3(viewport.topRight.dx, viewport.topRight.dy, 0.0)), inverseMatrix.transform3(Vector3(viewport.bottomRight.dx, viewport.bottomRight.dy, 0.0)), inverseMatrix.transform3(Vector3(viewport.bottomLeft.dx, viewport.bottomLeft.dy, 0.0)), ); } Quad _getAxisAlignedBoundingBoxWithRotation(Rect rect, double rotation) { final Matrix4 rotationMatrix = Matrix4.identity() ..translate(rect.size.width / 2, rect.size.height / 2) ..rotateZ(rotation) ..translate(-rect.size.width / 2, -rect.size.height / 2); final Quad boundariesRotated = Quad.points( rotationMatrix.transform3(Vector3(rect.left, rect.top, 0.0)), rotationMatrix.transform3(Vector3(rect.right, rect.top, 0.0)), rotationMatrix.transform3(Vector3(rect.right, rect.bottom, 0.0)), rotationMatrix.transform3(Vector3(rect.left, rect.bottom, 0.0)), ); return Zoom.getAxisAlignedBoundingBox(boundariesRotated); } Offset _exceedsBy(Quad boundary, Quad viewport) { final List viewportPoints = [viewport.point0, viewport.point1, viewport.point2, viewport.point3]; Offset largestExcess = Offset.zero; for (final Vector3 point in viewportPoints) { final Vector3 pointInside = Zoom.getNearestPointInside(point, boundary); final Offset excess = Offset(pointInside.x - point.x, pointInside.y - point.y); if (excess.dx.abs() > largestExcess.dx.abs()) { largestExcess = Offset(excess.dx, largestExcess.dy); } if (excess.dy.abs() > largestExcess.dy.abs()) { largestExcess = Offset(largestExcess.dx, excess.dy); } } return _round(largestExcess); } Offset _round(Offset offset) { return Offset(double.parse(offset.dx.toStringAsFixed(9)), double.parse(offset.dy.toStringAsFixed(9))); } Axis? _getPanAxis(Offset point1, Offset point2) { if (point1 == point2) { return null; } final double x = point2.dx - point1.dx; final double y = point2.dy - point1.dy; return x.abs() > y.abs() ? Axis.horizontal : Axis.vertical; }