blob: 48903e7658557035b1e329e934a927c7d71855fc [file] [log] [blame]
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:math' as math;
import 'package:flutter/gestures.dart';
import 'package:flutter/physics.dart';
import 'package:vector_math/vector_math_64.dart' show Quad, Vector3, Matrix4;
import 'basic.dart';
import 'framework.dart';
import 'gesture_detector.dart';
import 'ticker_provider.dart';
/// A widget that enables pan and zoom interactions with its child.
///
/// The user can transform the child by dragging to pan or pinching to zoom.
///
/// The [child] must not be null.
///
/// {@tool dartpad --template=stateless_widget_scaffold}
/// This example shows a simple Container that can be panned and zoomed.
///
/// ```dart
/// Widget build(BuildContext context) {
/// return Center(
/// child: InteractiveViewer(
/// boundaryMargin: EdgeInsets.all(20.0),
/// minScale: 0.1,
/// maxScale: 1.6,
/// child: Container(
/// decoration: BoxDecoration(
/// gradient: LinearGradient(
/// begin: Alignment.topCenter,
/// end: Alignment.bottomCenter,
/// colors: <Color>[Colors.orange, Colors.red],
/// stops: <double>[0.0, 1.0],
/// ),
/// ),
/// ),
/// ),
/// );
/// }
/// ```
/// {@end-tool}
@immutable
class InteractiveViewer extends StatefulWidget {
/// Create an InteractiveViewer.
///
/// The [child] parameter must not be null.
InteractiveViewer({
Key key,
this.boundaryMargin = EdgeInsets.zero,
this.constrained = true,
// These default scale values were eyeballed as reasonable limits for common
// use cases.
this.maxScale = 2.5,
this.minScale = 0.8,
this.onInteractionEnd,
this.onInteractionStart,
this.onInteractionUpdate,
this.panEnabled = true,
this.scaleEnabled = true,
this.transformationController,
@required this.child,
}) : assert(child != null),
assert(constrained != null),
assert(minScale != null),
assert(minScale > 0),
assert(minScale.isFinite),
assert(maxScale != null),
assert(maxScale > 0),
assert(!maxScale.isNaN),
assert(maxScale >= minScale),
assert(panEnabled != null),
assert(scaleEnabled != null),
// boundaryMargin must be either fully infinite or fully finite, but not
// a mix of both.
assert((boundaryMargin.horizontal.isInfinite
&& boundaryMargin.vertical.isInfinite) || (boundaryMargin.top.isFinite
&& boundaryMargin.right.isFinite && boundaryMargin.bottom.isFinite
&& boundaryMargin.left.isFinite)),
super(key: key);
/// A margin for the visible boundaries of the child.
///
/// Any transformation that results in the viewport being able to view outside
/// of the boundaries will be stopped at the boundary. The boundaries do not
/// rotate with the rest of the scene, so they are always aligned with the
/// viewport.
///
/// To produce no boundaries at all, pass infinite [EdgeInsets], such as
/// `EdgeInsets.all(double.infinity)`.
///
/// No edge can be NaN.
///
/// Defaults to [EdgeInsets.zero], which results in boundaries that are the
/// exact same size and position as the [child].
final EdgeInsets boundaryMargin;
/// The Widget to perform the transformations on.
///
/// Cannot be null.
final Widget child;
/// Whether the normal size constraints at this point in the widget tree are
/// applied to the child.
///
/// If set to false, then the child will be given infinite constraints. This
/// is often useful when a child should be bigger than the InteractiveViewer.
///
/// Defaults to true.
///
/// {@tool dartpad --template=stateless_widget_scaffold}
/// This example shows how to create a pannable table. Because the table is
/// larger than the entire screen, setting `constrained` to false is necessary
/// to allow it to be drawn to its full size. The parts of the table that
/// exceed the screen size can then be panned into view.
///
/// ```dart
/// Widget build(BuildContext context) {
/// const int _rowCount = 20;
/// const int _columnCount = 3;
///
/// return Scaffold(
/// appBar: AppBar(
/// title: const Text('Pannable Table'),
/// ),
/// body: InteractiveViewer(
/// constrained: false,
/// scaleEnabled: false,
/// child: Table(
/// columnWidths: <int, TableColumnWidth>{
/// for (int column = 0; column < _columnCount; column += 1)
/// column: const FixedColumnWidth(300.0),
/// },
/// children: <TableRow>[
/// for (int row = 0; row < _rowCount; row += 1)
/// TableRow(
/// children: <Widget>[
/// for (int column = 0; column < _columnCount; column += 1)
/// Container(
/// height: 100,
/// color: row % 2 + column % 2 == 1 ? Colors.red : Colors.green,
/// ),
/// ],
/// ),
/// ],
/// ),
/// ),
/// );
/// }
/// ```
/// {@end-tool}
final bool constrained;
/// If false, the user will be prevented from panning.
///
/// Defaults to true.
///
/// See also:
///
/// * [scaleEnabled], which is similar but for scale.
final bool panEnabled;
/// If false, the user will be prevented from scaling.
///
/// Defaults to true.
///
/// See also:
///
/// * [panEnabled], which is similar but for panning.
final bool scaleEnabled;
/// The maximum allowed scale.
///
/// The scale will be clamped between this and [minScale] inclusively.
///
/// Defaults to 2.5.
///
/// Cannot be null, and must be greater than zero and greater than minScale.
final double maxScale;
/// The minimum allowed scale.
///
/// The scale will be clamped between this and [maxScale] inclusively.
///
/// Defaults to 0.8.
///
/// Cannot be null, and must be a finite number greater than zero and less
/// than maxScale.
final double minScale;
/// Called when the user ends a pan or scale gesture on the widget.
///
/// {@template flutter.widgets.interactiveViewer.onInteraction}
/// Will be called even if the interaction is disabled with
/// [panEnabled] or [scaleEnabled].
///
/// A [GestureDetector] wrapping the InteractiveViewer will not respond to
/// [GestureDetector.onScaleStart], [GestureDetector.onScaleUpdate], and
/// [GestureDetector.onScaleEnd]. Use [onInteractionStart],
/// [onInteractionUpdate], and [onInteractionEnd] to respond to those
/// gestures.
///
/// The coordinates returned in the details are viewport coordinates relative
/// to the parent. See [TransformationController.toScene] for how to
/// convert the coordinates to scene coordinates relative to the child.
/// {@endtemplate}
///
/// See also:
///
/// * [onInteractionStart], which handles the start of the same interaction.
/// * [onInteractionUpdate], which handles an update to the same interaction.
final GestureScaleEndCallback onInteractionEnd;
/// Called when the user begins a pan or scale gesture on the widget.
///
/// {@macro flutter.widgets.interactiveViewer.onInteraction}
///
/// See also:
///
/// * [onInteractionUpdate], which handles an update to the same interaction.
/// * [onInteractionEnd], which handles the end of the same interaction.
final GestureScaleStartCallback onInteractionStart;
/// Called when the user updates a pan or scale gesture on the widget.
///
/// {@macro flutter.widgets.interactiveViewer.onInteraction}
///
/// See also:
///
/// * [onInteractionStart], which handles the start of the same interaction.
/// * [onInteractionEnd], which handles the end of the same interaction.
final GestureScaleUpdateCallback onInteractionUpdate;
/// A [TransformationController] for the transformation performed on the
/// child.
///
/// Whenever the child is transformed, the [Matrix4] value is updated and all
/// listeners are notified. If the value is set, InteractiveViewer will update
/// to respect the new value.
///
/// {@tool dartpad --template=stateful_widget_material_ticker}
/// This example shows how transformationController can be used to animate the
/// transformation back to its starting position.
///
/// ```dart
/// final TransformationController _transformationController = TransformationController();
/// Animation<Matrix4> _animationReset;
/// AnimationController _controllerReset;
///
/// void _onAnimateReset() {
/// _transformationController.value = _animationReset.value;
/// if (!_controllerReset.isAnimating) {
/// _animationReset?.removeListener(_onAnimateReset);
/// _animationReset = null;
/// _controllerReset.reset();
/// }
/// }
///
/// void _animateResetInitialize() {
/// _controllerReset.reset();
/// _animationReset = Matrix4Tween(
/// begin: _transformationController.value,
/// end: Matrix4.identity(),
/// ).animate(_controllerReset);
/// _animationReset.addListener(_onAnimateReset);
/// _controllerReset.forward();
/// }
///
/// // Stop a running reset to home transform animation.
/// void _animateResetStop() {
/// _controllerReset.stop();
/// _animationReset?.removeListener(_onAnimateReset);
/// _animationReset = null;
/// _controllerReset.reset();
/// }
///
/// void _onInteractionStart(ScaleStartDetails details) {
/// // If the user tries to cause a transformation while the reset animation is
/// // running, cancel the reset animation.
/// if (_controllerReset.status == AnimationStatus.forward) {
/// _animateResetStop();
/// }
/// }
///
/// @override
/// void initState() {
/// super.initState();
/// _controllerReset = AnimationController(
/// vsync: this,
/// duration: const Duration(milliseconds: 400),
/// );
/// }
///
/// @override
/// void dispose() {
/// _controllerReset.dispose();
/// super.dispose();
/// }
///
/// @override
/// Widget build(BuildContext context) {
/// return Scaffold(
/// backgroundColor: Theme.of(context).colorScheme.primary,
/// appBar: AppBar(
/// automaticallyImplyLeading: false,
/// title: const Text('Controller demo'),
/// ),
/// body: Center(
/// child: InteractiveViewer(
/// boundaryMargin: EdgeInsets.all(double.infinity),
/// transformationController: _transformationController,
/// minScale: 0.1,
/// maxScale: 1.0,
/// onInteractionStart: _onInteractionStart,
/// child: Container(
/// decoration: BoxDecoration(
/// gradient: LinearGradient(
/// begin: Alignment.topCenter,
/// end: Alignment.bottomCenter,
/// colors: <Color>[Colors.orange, Colors.red],
/// stops: <double>[0.0, 1.0],
/// ),
/// ),
/// ),
/// ),
/// ),
/// persistentFooterButtons: [
/// IconButton(
/// onPressed: _animateResetInitialize,
/// tooltip: 'Reset',
/// color: Theme.of(context).colorScheme.surface,
/// icon: const Icon(Icons.replay),
/// ),
/// ],
/// );
/// }
/// ```
/// {@end-tool}
///
/// See also:
///
/// * [ValueNotifier], the parent class of TransformationController.
/// * [TextEditingController] for an example of another similar pattern.
final TransformationController transformationController;
/// Returns the closest point to the given point on the given line segment.
@visibleForTesting
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();
// In this case, l1 == l2.
if (lengthSquared == 0) {
return l1;
}
// Calculate how far down the line segment the closest point is and return
// the point.
final Vector3 l1P = point - l1;
final Vector3 l1L2 = l2 - l1;
final double fraction = (l1P.dot(l1L2) / lengthSquared).clamp(0.0, 1.0).toDouble();
return l1 + l1L2 * fraction;
}
/// Given a quad, return its axis aligned bounding box.
@visibleForTesting
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),
);
}
/// Returns true iff the point is inside the rectangle given by the Quad,
/// inclusively.
/// Algorithm from https://math.stackexchange.com/a/190373.
@visibleForTesting
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;
}
/// Get the point inside (inclusively) the given Quad that is nearest to the
/// given Vector3.
@visibleForTesting
static Vector3 getNearestPointInside(Vector3 point, Quad quad) {
// If the point is inside the axis aligned bounding box, then it's ok where
// it is.
if (pointIsInside(point, quad)) {
return point;
}
// Otherwise, return the nearest point on the quad.
final List<Vector3> closestPoints = <Vector3>[
InteractiveViewer.getNearestPointOnLine(point, quad.point0, quad.point1),
InteractiveViewer.getNearestPointOnLine(point, quad.point1, quad.point2),
InteractiveViewer.getNearestPointOnLine(point, quad.point2, quad.point3),
InteractiveViewer.getNearestPointOnLine(point, quad.point3, quad.point0),
];
double minDistance = double.infinity;
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 _InteractiveViewerState createState() => _InteractiveViewerState();
}
class _InteractiveViewerState extends State<InteractiveViewer> with TickerProviderStateMixin {
TransformationController _transformationController;
final GlobalKey _childKey = GlobalKey();
final GlobalKey _parentKey = GlobalKey();
Animation<Offset> _animation;
AnimationController _controller;
Offset _referenceFocalPoint; // Point where the current gesture began.
double _scaleStart; // Scale value at start of scaling gesture.
double _rotationStart = 0.0; // Rotation at start of rotation gesture.
double _currentRotation = 0.0; // Rotation of _transformationController.value.
_GestureType _gestureType;
// TODO(justinmc): Add rotateEnabled parameter to the widget and remove this
// hardcoded value when the rotation feature is implemented.
// https://github.com/flutter/flutter/issues/57698
final bool _rotateEnabled = false;
// Used as the coefficient of friction in the inertial translation animation.
// This value was eyeballed to give a feel similar to Google Photos.
static const double _kDrag = 0.0000135;
// The _boundaryRect is calculated by adding the boundaryMargin to the size of
// the child.
Rect _boundaryRectCached;
Rect get _boundaryRect {
if (_boundaryRectCached != null) {
return _boundaryRectCached;
}
assert(_childKey.currentContext != null);
assert(!widget.boundaryMargin.left.isNaN);
assert(!widget.boundaryMargin.right.isNaN);
assert(!widget.boundaryMargin.top.isNaN);
assert(!widget.boundaryMargin.bottom.isNaN);
final RenderBox childRenderBox = _childKey.currentContext.findRenderObject() as RenderBox;
final Size childSize = childRenderBox.size;
_boundaryRectCached = widget.boundaryMargin.inflateRect(Offset.zero & childSize);
// Boundaries that are partially infinite are not allowed because Matrix4's
// rotation and translation methods don't handle infinites well.
assert(_boundaryRectCached.isFinite ||
(_boundaryRectCached.left.isInfinite
&& _boundaryRectCached.top.isInfinite
&& _boundaryRectCached.right.isInfinite
&& _boundaryRectCached.bottom.isInfinite), 'boundaryRect must either be infinite in all directions or finite in all directions.');
return _boundaryRectCached;
}
// The Rect representing the child's parent.
Rect get _viewport {
assert(_parentKey.currentContext != null);
final RenderBox parentRenderBox = _parentKey.currentContext.findRenderObject() as RenderBox;
return Offset.zero & parentRenderBox.size;
}
// Return a new matrix representing the given matrix after applying the given
// translation.
Matrix4 _matrixTranslate(Matrix4 matrix, Offset translation) {
if (translation == Offset.zero) {
return matrix.clone();
}
final Matrix4 nextMatrix = matrix.clone()..translate(
translation.dx,
translation.dy,
);
// Transform the viewport to determine where its four corners will be after
// the child has been transformed.
final Quad nextViewport = _transformViewport(nextMatrix, _viewport);
// If the boundaries are infinite, then no need to check if the translation
// fits within them.
if (_boundaryRect.isInfinite) {
return nextMatrix;
}
// Expand the boundaries with rotation. This prevents the problem where a
// mismatch in orientation between the viewport and boundaries effectively
// limits translation. With this approach, all points that are visible with
// no rotation are visible after rotation.
final Quad boundariesAabbQuad = _getAxisAlignedBoundingBoxWithRotation(
_boundaryRect,
_currentRotation,
);
// If the given translation fits completely within the boundaries, allow it.
final Offset offendingDistance = _exceedsBy(boundariesAabbQuad, nextViewport);
if (offendingDistance == Offset.zero) {
return nextMatrix;
}
// Desired translation goes out of bounds, so translate to the nearest
// in-bounds point instead.
final Offset nextTotalTranslation = _getMatrixTranslation(nextMatrix);
final double currentScale = matrix.getMaxScaleOnAxis();
final Offset correctedTotalTranslation = Offset(
nextTotalTranslation.dx - offendingDistance.dx * currentScale,
nextTotalTranslation.dy - offendingDistance.dy * currentScale,
);
// TODO(justinmc): This needs some work to handle rotation properly. The
// idea is that the boundaries are axis aligned (boundariesAabbQuad), but
// calculating the translation to put the viewport inside that Quad is more
// complicated than this when rotated.
// https://github.com/flutter/flutter/issues/57698
final Matrix4 correctedMatrix = matrix.clone()..setTranslation(Vector3(
correctedTotalTranslation.dx,
correctedTotalTranslation.dy,
0.0,
));
// Double check that the corrected translation fits.
final Quad correctedViewport = _transformViewport(correctedMatrix, _viewport);
final Offset offendingCorrectedDistance = _exceedsBy(boundariesAabbQuad, correctedViewport);
if (offendingCorrectedDistance == Offset.zero) {
return correctedMatrix;
}
// If the corrected translation doesn't fit in either direction, don't allow
// any translation at all. This happens when the viewport is larger than the
// entire boundary.
if (offendingCorrectedDistance.dx != 0.0 && offendingCorrectedDistance.dy != 0.0) {
return matrix.clone();
}
// Otherwise, allow translation in only the direction that fits. This
// happens when the viewport is larger than the boundary in one direction.
final Offset unidirectionalCorrectedTotalTranslation = Offset(
offendingCorrectedDistance.dx == 0.0 ? correctedTotalTranslation.dx : 0.0,
offendingCorrectedDistance.dy == 0.0 ? correctedTotalTranslation.dy : 0.0,
);
return matrix.clone()..setTranslation(Vector3(
unidirectionalCorrectedTotalTranslation.dx,
unidirectionalCorrectedTotalTranslation.dy,
0.0,
));
}
// Return a new matrix representing the given matrix after applying the given
// scale.
Matrix4 _matrixScale(Matrix4 matrix, double scale) {
if (scale == 1.0) {
return matrix.clone();
}
assert(scale != 0.0);
// Don't allow a scale that results in an overall scale beyond min/max
// scale.
final double currentScale = _transformationController.value.getMaxScaleOnAxis();
final double totalScale = currentScale * scale;
final double clampedTotalScale = totalScale.clamp(
widget.minScale,
widget.maxScale,
) as double;
final double clampedScale = clampedTotalScale / currentScale;
final Matrix4 nextMatrix = matrix.clone()..scale(clampedScale);
// Ensure that the scale cannot make the child so big that it can't fit
// inside the boundaries (in either direction).
final double minScale = math.max(
_viewport.width / _boundaryRect.width,
_viewport.height / _boundaryRect.height,
);
if (clampedTotalScale < minScale) {
final double minCurrentScale = minScale / currentScale;
return matrix.clone()..scale(minCurrentScale);
}
return nextMatrix;
}
// Return a new matrix representing the given matrix after applying the given
// rotation.
Matrix4 _matrixRotate(Matrix4 matrix, double rotation, Offset focalPoint) {
if (rotation == 0) {
return matrix.clone();
}
final Offset focalPointScene = _transformationController.toScene(
focalPoint,
);
return matrix
.clone()
..translate(focalPointScene.dx, focalPointScene.dy)
..rotateZ(-rotation)
..translate(-focalPointScene.dx, -focalPointScene.dy);
}
// Returns true iff the given _GestureType is enabled.
bool _gestureIsSupported(_GestureType gestureType) {
if (_gestureType == _GestureType.pan && !widget.panEnabled) {
return false;
}
if (_gestureType == _GestureType.scale && !widget.scaleEnabled) {
return false;
}
if (_gestureType == _GestureType.rotate && !_rotateEnabled) {
return false;
}
return true;
}
// Handle the start of a gesture. All of pan, scale, and rotate are handled
// with GestureDetector's scale gesture.
void _onScaleStart(ScaleStartDetails details) {
if (widget.onInteractionStart != null) {
widget.onInteractionStart(details);
}
if (_controller.isAnimating) {
_controller.stop();
_controller.reset();
_animation?.removeListener(_onAnimate);
_animation = null;
}
_gestureType = null;
_scaleStart = _transformationController.value.getMaxScaleOnAxis();
_referenceFocalPoint = _transformationController.toScene(
details.localFocalPoint,
);
_rotationStart = _currentRotation;
}
// Handle an update to an ongoing gesture. All of pan, scale, and rotate are
// handled with GestureDetector's scale gesture.
void _onScaleUpdate(ScaleUpdateDetails details) {
final double scale = _transformationController.value.getMaxScaleOnAxis();
if (widget.onInteractionUpdate != null) {
widget.onInteractionUpdate(ScaleUpdateDetails(
focalPoint: _transformationController.toScene(
details.localFocalPoint,
),
scale: details.scale,
rotation: details.rotation,
));
}
final Offset focalPointScene = _transformationController.toScene(
details.localFocalPoint,
);
_gestureType ??= _getGestureType(
!widget.scaleEnabled ? 1.0 : details.scale,
!_rotateEnabled ? 0.0 : details.rotation,
);
if (!_gestureIsSupported(_gestureType)) {
return;
}
switch (_gestureType) {
case _GestureType.scale:
if (_scaleStart == null) {
return;
}
// details.scale gives us the amount to change the scale as of the
// start of this gesture, so calculate the amount to scale as of the
// previous call to _onScaleUpdate.
final double desiredScale = _scaleStart * details.scale;
final double scaleChange = desiredScale / scale;
_transformationController.value = _matrixScale(
_transformationController.value,
scaleChange,
);
// While scaling, translate such that the user's two fingers stay on
// the same places in the scene. That means that the focal point of
// the scale should be on the same place in the scene before and after
// the scale.
final Offset focalPointSceneScaled = _transformationController.toScene(
details.localFocalPoint,
);
_transformationController.value = _matrixTranslate(
_transformationController.value,
focalPointSceneScaled - _referenceFocalPoint,
);
// details.localFocalPoint should now be at the same location as the
// original _referenceFocalPoint point. If it's not, that's because
// the translate came in contact with a boundary. In that case, update
// _referenceFocalPoint so subsequent updates happen in relation to
// the new effective focal point.
final Offset focalPointSceneCheck = _transformationController.toScene(
details.localFocalPoint,
);
if (_round(_referenceFocalPoint) != _round(focalPointSceneCheck)) {
_referenceFocalPoint = focalPointSceneCheck;
}
return;
case _GestureType.rotate:
if (details.rotation == 0.0) {
return;
}
final double desiredRotation = _rotationStart + details.rotation;
_transformationController.value = _matrixRotate(
_transformationController.value,
_currentRotation - desiredRotation,
details.localFocalPoint,
);
_currentRotation = desiredRotation;
return;
case _GestureType.pan:
if (_referenceFocalPoint == null || details.scale != 1.0) {
return;
}
// Translate so that the same point in the scene is underneath the
// focal point before and after the movement.
final Offset translationChange = focalPointScene - _referenceFocalPoint;
_transformationController.value = _matrixTranslate(
_transformationController.value,
translationChange,
);
_referenceFocalPoint = _transformationController.toScene(
details.localFocalPoint,
);
return;
}
}
// Handle the end of a gesture of _GestureType. All of pan, scale, and rotate
// are handled with GestureDetector's scale gesture.
void _onScaleEnd(ScaleEndDetails details) {
if (widget.onInteractionEnd != null) {
widget.onInteractionEnd(details);
}
_scaleStart = null;
_rotationStart = null;
_referenceFocalPoint = null;
_animation?.removeListener(_onAnimate);
_controller.reset();
if (!_gestureIsSupported(_gestureType)) {
return;
}
// If the scale ended with enough velocity, animate inertial movement.
if (_gestureType != _GestureType.pan || details.velocity.pixelsPerSecond.distance < kMinFlingVelocity) {
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();
}
// Handle mousewheel scroll events.
void _receivedPointerSignal(PointerSignalEvent event) {
if (event is PointerScrollEvent) {
final RenderBox childRenderBox = _childKey.currentContext.findRenderObject() as RenderBox;
final Size childSize = childRenderBox.size;
final double scaleChange = 1.0 + event.scrollDelta.dy / childSize.height;
if (scaleChange == 0.0) {
return;
}
final Offset focalPointScene = _transformationController.toScene(
event.localPosition,
);
_transformationController.value = _matrixScale(
_transformationController.value,
scaleChange,
);
// After scaling, translate such that the event's position is at the
// same scene point before and after the scale.
final Offset focalPointSceneScaled = _transformationController.toScene(
event.localPosition,
);
_transformationController.value = _matrixTranslate(
_transformationController.value,
focalPointSceneScaled - focalPointScene,
);
}
}
// Handle inertia drag animation.
void _onAnimate() {
if (!_controller.isAnimating) {
_animation?.removeListener(_onAnimate);
_animation = null;
_controller.reset();
return;
}
// Translate such that the resulting translation is _animation.value.
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 _onTransformationControllerChange() {
// A change to the TransformationController's value is a change to the
// state.
setState(() {});
}
@override
void initState() {
super.initState();
_transformationController = widget.transformationController
?? TransformationController();
_transformationController.addListener(_onTransformationControllerChange);
_controller = AnimationController(
vsync: this,
);
}
@override
void didUpdateWidget(InteractiveViewer oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.child != oldWidget.child || widget.boundaryMargin != oldWidget.boundaryMargin) {
_boundaryRectCached = null;
}
// Handle all cases of needing to dispose and initialize
// transformationControllers.
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();
_transformationController.removeListener(_onTransformationControllerChange);
if (widget.transformationController == null) {
_transformationController.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
Widget child = Transform(
transform: _transformationController.value,
child: KeyedSubtree(
key: _childKey,
child: widget.child,
),
);
if (!widget.constrained) {
child = ClipRect(
child: OverflowBox(
alignment: Alignment.topLeft,
minWidth: 0.0,
minHeight: 0.0,
maxWidth: double.infinity,
maxHeight: double.infinity,
child: child,
),
);
}
// A GestureDetector allows the detection of panning and zooming gestures on
// the child.
return Listener(
key: _parentKey,
onPointerSignal: _receivedPointerSignal,
child: GestureDetector(
behavior: HitTestBehavior.opaque, // Necessary when panning off screen.
onScaleEnd: _onScaleEnd,
onScaleStart: _onScaleStart,
onScaleUpdate: _onScaleUpdate,
child: child,
),
);
}
}
/// A thin wrapper on [ValueNotifier] whose value is a [Matrix4] representing a
/// transformation.
///
/// The [value] defaults to the identity matrix, which corresponds to no
/// transformation.
///
/// See also:
///
/// * [InteractiveViewer.transformationController] for detailed documentation
/// on how to use TransformationController with [InteractiveViewer].
class TransformationController extends ValueNotifier<Matrix4> {
/// Create an instance of [TransformationController].
///
/// The [value] defaults to the identity matrix, which corresponds to no
/// transformation.
TransformationController([Matrix4 value]) : super(value ?? Matrix4.identity());
/// Return the scene point at the given viewport point.
///
/// A viewport point is relative to the parent while a scene point is relative
/// to the child, regardless of transformation. Calling toScene with a
/// viewport point essentially returns the scene coordinate that lies
/// underneath the viewport point given the transform.
///
/// The viewport transforms as the inverse of the child (i.e. moving the child
/// left is equivalent to moving the viewport right).
///
/// This method is often useful when determining where an event on the parent
/// occurs on the child. This example shows how to determine where a tap on
/// the parent occurred on the child.
///
/// ```dart
/// @override
/// void build(BuildContext context) {
/// return GestureDetector(
/// onTapUp: (TapUpDetails details) {
/// _childWasTappedAt = _transformationController.toScene(
/// details.localPosition,
/// );
/// },
/// child: InteractiveViewer(
/// transformationController: _transformationController,
/// child: child,
/// ),
/// );
/// }
/// ```
Offset toScene(Offset viewportPoint) {
// On viewportPoint, perform the inverse transformation of the scene to get
// where the point would be in the scene before the transformation.
final Matrix4 inverseMatrix = Matrix4.inverted(value);
final Vector3 untransformed = inverseMatrix.transform3(Vector3(
viewportPoint.dx,
viewportPoint.dy,
0,
));
return Offset(untransformed.x, untransformed.y);
}
}
// A classification of relevant user gestures. Each contiguous user gesture is
// represented by exactly one _GestureType.
enum _GestureType {
pan,
scale,
rotate,
}
// Given a velocity and drag, calculate the time at which motion will come to
// a stop, within the margin of effectivelyMotionless.
double _getFinalTime(double velocity, double drag) {
const double effectivelyMotionless = 10.0;
return math.log(effectivelyMotionless / velocity) / math.log(drag / 100);
}
// Decide which type of gesture this is by comparing the amount of scale
// and rotation in the gesture, if any. Scale starts at 1 and rotation
// starts at 0. Pan will have 0 scale and 0 rotation because it uses only one
// finger.
_GestureType _getGestureType(double scale, double rotation) {
if ((scale - 1).abs() > rotation.abs()) {
return _GestureType.scale;
} else if (rotation != 0) {
return _GestureType.rotate;
} else {
return _GestureType.pan;
}
}
// Return the translation from the given Matrix4 as an Offset.
Offset _getMatrixTranslation(Matrix4 matrix) {
final Vector3 nextTranslation = matrix.getTranslation();
return Offset(nextTranslation.x, nextTranslation.y);
}
// Transform the four corners of the viewport by the inverse of the given
// matrix. This gives the viewport after the child has been transformed by the
// given matrix. The viewport transforms as the inverse of the child (i.e.
// moving the child left is equivalent to moving the viewport right).
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,
)),
);
}
// Find the axis aligned bounding box for the rect rotated about its center by
// the given amount.
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 InteractiveViewer.getAxisAlignedBoundingBox(boundariesRotated);
}
// Return the amount that viewport lies outside of boundary. If the viewport
// is completely contained within the boundary (inclusively), then returns
// Offset.zero.
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 = InteractiveViewer.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);
}
// Round the output values. This works around a precision problem where
// values that should have been zero were given as within 10^-10 of zero.
Offset _round(Offset offset) {
return Offset(
double.parse(offset.dx.toStringAsFixed(9)),
double.parse(offset.dy.toStringAsFixed(9)),
);
}