1007 lines
36 KiB
Dart
1007 lines
36 KiB
Dart
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<Vector3> closestPoints = <Vector3>[
|
|
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<Zoom> createState() => _ZoomState();
|
|
}
|
|
|
|
class _ZoomState extends State<Zoom> with TickerProviderStateMixin, WidgetsBindingObserver {
|
|
TransformationController? _transformationController;
|
|
|
|
final GlobalKey _childKey = GlobalKey();
|
|
final GlobalKey _parentKey = GlobalKey();
|
|
Animation<Offset>? _animation;
|
|
late AnimationController _controller;
|
|
Animation<double>? _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<Offset>(
|
|
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<double>(
|
|
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<SizeChangedLayoutNotification>(
|
|
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<Matrix4> {
|
|
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<Vector3> viewportPoints = <Vector3>[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;
|
|
}
|