Unverified Commit c940282b authored by Justin McCandless's avatar Justin McCandless Committed by GitHub

InteractiveViewer Widget (#56409)

parent 37a86c48
// 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)),
);
}
...@@ -56,6 +56,7 @@ export 'src/widgets/implicit_animations.dart'; ...@@ -56,6 +56,7 @@ export 'src/widgets/implicit_animations.dart';
export 'src/widgets/inherited_model.dart'; export 'src/widgets/inherited_model.dart';
export 'src/widgets/inherited_notifier.dart'; export 'src/widgets/inherited_notifier.dart';
export 'src/widgets/inherited_theme.dart'; export 'src/widgets/inherited_theme.dart';
export 'src/widgets/interactive_viewer.dart';
export 'src/widgets/layout_builder.dart'; export 'src/widgets/layout_builder.dart';
export 'src/widgets/list_wheel_scroll_view.dart'; export 'src/widgets/list_wheel_scroll_view.dart';
export 'src/widgets/localizations.dart'; export 'src/widgets/localizations.dart';
......
// 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 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:vector_math/vector_math_64.dart' show Quad, Vector3, Matrix4;
void main() {
group('InteractiveViewer', () {
testWidgets('child fits in viewport', (WidgetTester tester) async {
final TransformationController transformationController = TransformationController();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: InteractiveViewer(
transformationController: transformationController,
child: Container(width: 200.0, height: 200.0),
),
),
),
),
);
expect(transformationController.value, equals(Matrix4.identity()));
// Attempting to drag to pan doesn't work because the child fits inside
// the viewport and has a tight boundary.
final Offset childOffset = tester.getTopLeft(find.byType(Container));
final Offset childInterior = Offset(
childOffset.dx + 20.0,
childOffset.dy + 20.0,
);
TestGesture gesture = await tester.startGesture(childInterior);
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.moveTo(childOffset);
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
expect(transformationController.value, equals(Matrix4.identity()));
// Pinch to zoom works.
final Offset scaleStart1 = childInterior;
final Offset scaleStart2 = Offset(childInterior.dx + 10.0, childInterior.dy);
final Offset scaleEnd1 = Offset(childInterior.dx - 10.0, childInterior.dy);
final Offset scaleEnd2 = Offset(childInterior.dx + 20.0, childInterior.dy);
gesture = await tester.createGesture();
final TestGesture gesture2 = await tester.createGesture();
await gesture.down(scaleStart1);
await gesture2.down(scaleStart2);
await tester.pump();
await gesture.moveTo(scaleEnd1);
await gesture2.moveTo(scaleEnd2);
await tester.pump();
await gesture.up();
await gesture2.up();
await tester.pumpAndSettle();
expect(transformationController.value, isNot(equals(Matrix4.identity())));
});
testWidgets('boundary slightly bigger than child', (WidgetTester tester) async {
final TransformationController transformationController = TransformationController();
const double boundaryMargin = 10.0;
const double minScale = 0.8;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: InteractiveViewer(
boundaryMargin: const EdgeInsets.all(boundaryMargin),
minScale: minScale,
transformationController: transformationController,
child: Container(width: 200.0, height: 200.0),
),
),
),
),
);
expect(transformationController.value, equals(Matrix4.identity()));
// Dragging to pan works only until it hits the boundary.
final Offset childOffset = tester.getTopLeft(find.byType(Container));
final Offset childInterior = Offset(
childOffset.dx + 20.0,
childOffset.dy + 20.0,
);
TestGesture gesture = await tester.startGesture(childInterior);
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.moveTo(childOffset);
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
final Vector3 translation = transformationController.value.getTranslation();
expect(translation.x, -boundaryMargin);
expect(translation.y, -boundaryMargin);
// Pinch to zoom also only works until expanding to the boundary.
final Offset scaleStart1 = childInterior;
final Offset scaleStart2 = Offset(childInterior.dx + 20.0, childInterior.dy);
final Offset scaleEnd1 = Offset(scaleStart1.dx + 5.0, scaleStart1.dy);
final Offset scaleEnd2 = Offset(scaleStart2.dx - 5.0, scaleStart2.dy);
gesture = await tester.createGesture();
final TestGesture gesture2 = await tester.createGesture();
await gesture.down(scaleStart1);
await gesture2.down(scaleStart2);
await tester.pump();
await gesture.moveTo(scaleEnd1);
await gesture2.moveTo(scaleEnd2);
await tester.pump();
await gesture.up();
await gesture2.up();
await tester.pumpAndSettle();
// The new scale is the scale that makes the original size (200.0) as big
// as the boundary (220.0).
expect(transformationController.value.getMaxScaleOnAxis(), 200.0 / 220.0);
});
testWidgets('child bigger than viewport', (WidgetTester tester) async {
final TransformationController transformationController = TransformationController();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: InteractiveViewer(
constrained: false,
scaleEnabled: false,
transformationController: transformationController,
child: Container(width: 2000.0, height: 2000.0),
),
),
),
),
);
expect(transformationController.value, equals(Matrix4.identity()));
// Attempting to move against the boundary doesn't work.
final Offset childOffset = tester.getTopLeft(find.byType(Container));
final Offset childInterior = Offset(
childOffset.dx + 20.0,
childOffset.dy + 20.0,
);
TestGesture gesture = await tester.startGesture(childOffset);
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.moveTo(childInterior);
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
expect(transformationController.value, equals(Matrix4.identity()));
// Attempting to pinch to zoom doens't work because it's disabled.
final Offset scaleStart1 = childInterior;
final Offset scaleStart2 = Offset(childInterior.dx + 10.0, childInterior.dy);
final Offset scaleEnd1 = Offset(childInterior.dx - 10.0, childInterior.dy);
final Offset scaleEnd2 = Offset(childInterior.dx + 20.0, childInterior.dy);
gesture = await tester.startGesture(scaleStart1);
TestGesture gesture2 = await tester.startGesture(scaleStart2);
addTearDown(gesture2.removePointer);
await tester.pump();
await gesture.moveTo(scaleEnd1);
await gesture2.moveTo(scaleEnd2);
await tester.pump();
await gesture.up();
await gesture2.up();
await tester.pumpAndSettle();
expect(transformationController.value, equals(Matrix4.identity()));
// Attempting to pinch to rotate doesn't work because it's disabled.
final Offset rotateStart1 = childInterior;
final Offset rotateStart2 = Offset(childInterior.dx + 10.0, childInterior.dy);
final Offset rotateEnd1 = Offset(childInterior.dx + 5.0, childInterior.dy + 5.0);
final Offset rotateEnd2 = Offset(childInterior.dx - 5.0, childInterior.dy - 5.0);
gesture = await tester.startGesture(rotateStart1);
gesture2 = await tester.startGesture(rotateStart2);
await tester.pump();
await gesture.moveTo(rotateEnd1);
await gesture2.moveTo(rotateEnd2);
await tester.pump();
await gesture.up();
await gesture2.up();
await tester.pumpAndSettle();
expect(transformationController.value, equals(Matrix4.identity()));
// Drag to pan away from the boundary.
gesture = await tester.startGesture(childInterior);
await tester.pump();
await gesture.moveTo(childOffset);
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
expect(transformationController.value, isNot(equals(Matrix4.identity())));
});
testWidgets('no boundary', (WidgetTester tester) async {
final TransformationController transformationController = TransformationController();
const double minScale = 0.8;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: InteractiveViewer(
boundaryMargin: const EdgeInsets.all(double.infinity),
minScale: minScale,
transformationController: transformationController,
child: Container(width: 200.0, height: 200.0),
),
),
),
),
);
expect(transformationController.value, equals(Matrix4.identity()));
// Drag to pan works because even though the viewport fits perfectly
// around the child, there is no boundary.
final Offset childOffset = tester.getTopLeft(find.byType(Container));
final Offset childInterior = Offset(
childOffset.dx + 20.0,
childOffset.dy + 20.0,
);
TestGesture gesture = await tester.startGesture(childInterior);
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.moveTo(childOffset);
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
final Vector3 translation = transformationController.value.getTranslation();
expect(translation.x, childOffset.dx - childInterior.dx);
expect(translation.y, childOffset.dy - childInterior.dy);
// It's also possible to zoom out and view beyond the child because there
// is no boundary.
final Offset scaleStart1 = childInterior;
final Offset scaleStart2 = Offset(childInterior.dx + 20.0, childInterior.dy);
final Offset scaleEnd1 = Offset(childInterior.dx + 5.0, childInterior.dy);
final Offset scaleEnd2 = Offset(childInterior.dx - 5.0, childInterior.dy);
gesture = await tester.createGesture();
final TestGesture gesture2 = await tester.createGesture();
await gesture.down(scaleStart1);
await gesture2.down(scaleStart2);
await tester.pump();
await gesture.moveTo(scaleEnd1);
await gesture2.moveTo(scaleEnd2);
await tester.pump();
await gesture.up();
await gesture2.up();
await tester.pumpAndSettle();
expect(transformationController.value.getMaxScaleOnAxis(), minScale);
});
testWidgets('inertia fling and boundary sliding', (WidgetTester tester) async {
final TransformationController transformationController = TransformationController();
const double boundaryMargin = 50.0;
const double minScale = 0.8;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: InteractiveViewer(
boundaryMargin: const EdgeInsets.all(boundaryMargin),
minScale: minScale,
transformationController: transformationController,
child: Container(width: 200.0, height: 200.0),
),
),
),
),
);
// Fling the child.
final Offset childOffset = tester.getTopLeft(find.byType(Container));
const Offset flingEnd = Offset(20.0, 15.0);
await tester.flingFrom(childOffset, flingEnd, 1000.0);
await tester.pump();
// Immediately after the gesture, the child has moved to exactly follow
// the gesture.
Vector3 translation = transformationController.value.getTranslation();
expect(translation.x, flingEnd.dx);
expect(translation.y, flingEnd.dy);
// A short time after the gesture was released, it continues to move with
// inertia.
await tester.pump(const Duration(milliseconds: 10));
translation = transformationController.value.getTranslation();
expect(translation.x, greaterThan(20.0));
expect(translation.y, greaterThan(10.0));
expect(translation.x, lessThan(boundaryMargin));
expect(translation.y, lessThan(boundaryMargin));
// It hits the boundary in the x direction first.
await tester.pump(const Duration(milliseconds: 60));
translation = transformationController.value.getTranslation();
expect(translation.x, closeTo(boundaryMargin, .000000001));
expect(translation.y, lessThan(boundaryMargin));
final double yWhenXHits = translation.y;
// x is held to the boundary while y slides along.
await tester.pump(const Duration(milliseconds: 50));
translation = transformationController.value.getTranslation();
expect(translation.x, closeTo(boundaryMargin, .000000001));
expect(translation.y, greaterThan(yWhenXHits));
expect(translation.y, lessThan(boundaryMargin));
// Eventually it ends up in the corner.
await tester.pumpAndSettle();
translation = transformationController.value.getTranslation();
expect(translation.x, closeTo(boundaryMargin, .000000001));
expect(translation.y, closeTo(boundaryMargin, .000000001));
});
testWidgets('Scaling automatically causes a centering translation', (WidgetTester tester) async {
final TransformationController transformationController = TransformationController();
const double boundaryMargin = 50.0;
const double minScale = 0.1;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: InteractiveViewer(
boundaryMargin: const EdgeInsets.all(boundaryMargin),
minScale: minScale,
transformationController: transformationController,
child: Container(width: 200.0, height: 200.0),
),
),
),
),
);
Vector3 translation = transformationController.value.getTranslation();
expect(translation.x, 0.0);
expect(translation.y, 0.0);
// Pan into the corner of the boundaries.
final Offset childOffset = tester.getTopLeft(find.byType(Container));
const Offset flingEnd = Offset(20.0, 15.0);
await tester.flingFrom(childOffset, flingEnd, 1000.0);
await tester.pumpAndSettle();
translation = transformationController.value.getTranslation();
expect(translation.x, closeTo(boundaryMargin, .000000001));
expect(translation.y, closeTo(boundaryMargin, .000000001));
// Zoom out so the entire child is visible. The child will also be
// translated in order to keep it inside the boundaries.
final Offset childCenter = tester.getCenter(find.byType(Container));
Offset scaleStart1 = Offset(childCenter.dx - 40.0, childCenter.dy);
Offset scaleStart2 = Offset(childCenter.dx + 40.0, childCenter.dy);
Offset scaleEnd1 = Offset(childCenter.dx - 10.0, childCenter.dy);
Offset scaleEnd2 = Offset(childCenter.dx + 10.0, childCenter.dy);
TestGesture gesture = await tester.createGesture();
TestGesture gesture2 = await tester.createGesture();
await gesture.down(scaleStart1);
await gesture2.down(scaleStart2);
await tester.pump();
await gesture.moveTo(scaleEnd1);
await gesture2.moveTo(scaleEnd2);
await tester.pump();
await gesture.up();
await gesture2.up();
await tester.pumpAndSettle();
expect(transformationController.value.getMaxScaleOnAxis(), lessThan(1.0));
translation = transformationController.value.getTranslation();
expect(translation.x, lessThan(boundaryMargin));
expect(translation.y, lessThan(boundaryMargin));
expect(translation.x, greaterThan(0.0));
expect(translation.y, greaterThan(0.0));
expect(translation.x, closeTo(translation.y, .000000001));
// Zoom in on a point that's not the center, and see that it remains at
// roughly the same location in the viewport after the zoom.
scaleStart1 = Offset(childCenter.dx - 50.0, childCenter.dy);
scaleStart2 = Offset(childCenter.dx - 30.0, childCenter.dy);
scaleEnd1 = Offset(childCenter.dx - 51.0, childCenter.dy);
scaleEnd2 = Offset(childCenter.dx - 29.0, childCenter.dy);
final Offset viewportFocalPoint = Offset(
childCenter.dx - 40.0 - childOffset.dx,
childCenter.dy - childOffset.dy,
);
final Offset sceneFocalPoint = transformationController.toScene(viewportFocalPoint);
gesture = await tester.createGesture();
gesture2 = await tester.createGesture();
await gesture.down(scaleStart1);
await gesture2.down(scaleStart2);
await tester.pump();
await gesture.moveTo(scaleEnd1);
await gesture2.moveTo(scaleEnd2);
await tester.pump();
await gesture.up();
await gesture2.up();
await tester.pumpAndSettle();
final Offset newSceneFocalPoint = transformationController.toScene(viewportFocalPoint);
expect(newSceneFocalPoint.dx, closeTo(sceneFocalPoint.dx, 1.0));
expect(newSceneFocalPoint.dy, closeTo(sceneFocalPoint.dy, 1.0));
});
});
group('getNearestPointOnLine', () {
test('does not modify parameters', () {
final Vector3 point = Vector3(5.0, 5.0, 0.0);
final Vector3 a = Vector3(0.0, 0.0, 0.0);
final Vector3 b = Vector3(10.0, 0.0, 0.0);
final Vector3 closestPoint = InteractiveViewer.getNearestPointOnLine(point, a , b);
expect(closestPoint, Vector3(5.0, 0.0, 0.0));
expect(point, Vector3(5.0, 5.0, 0.0));
expect(a, Vector3(0.0, 0.0, 0.0));
expect(b, Vector3(10.0, 0.0, 0.0));
});
test('simple example', () {
final Vector3 point = Vector3(0.0, 5.0, 0.0);
final Vector3 a = Vector3(0.0, 0.0, 0.0);
final Vector3 b = Vector3(5.0, 5.0, 0.0);
expect(InteractiveViewer.getNearestPointOnLine(point, a, b), Vector3(2.5, 2.5, 0.0));
});
test('closest to a', () {
final Vector3 point = Vector3(-1.0, -1.0, 0.0);
final Vector3 a = Vector3(0.0, 0.0, 0.0);
final Vector3 b = Vector3(5.0, 5.0, 0.0);
expect(InteractiveViewer.getNearestPointOnLine(point, a, b), a);
});
test('closest to b', () {
final Vector3 point = Vector3(6.0, 6.0, 0.0);
final Vector3 a = Vector3(0.0, 0.0, 0.0);
final Vector3 b = Vector3(5.0, 5.0, 0.0);
expect(InteractiveViewer.getNearestPointOnLine(point, a, b), b);
});
test('point already on the line returns the point', () {
final Vector3 point = Vector3(2.0, 2.0, 0.0);
final Vector3 a = Vector3(0.0, 0.0, 0.0);
final Vector3 b = Vector3(5.0, 5.0, 0.0);
expect(InteractiveViewer.getNearestPointOnLine(point, a, b), point);
});
test('real example', () {
final Vector3 point = Vector3(-436.9, 433.6, 0.0);
final Vector3 a = Vector3(-1114.0, -60.3, 0.0);
final Vector3 b = Vector3(288.8, 432.7, 0.0);
final Vector3 closestPoint = InteractiveViewer.getNearestPointOnLine(point, a , b);
expect(closestPoint.x, closeTo(-356.8, 0.1));
expect(closestPoint.y, closeTo(205.8, 0.1));
});
});
group('getAxisAlignedBoundingBox', () {
test('rectangle already axis aligned returns the rectangle', () {
final Quad quad = Quad.points(
Vector3(0.0, 0.0, 0.0),
Vector3(10.0, 0.0, 0.0),
Vector3(10.0, 10.0, 0.0),
Vector3(0.0, 10.0, 0.0),
);
final Quad aabb = InteractiveViewer.getAxisAlignedBoundingBox(quad);
expect(aabb.point0, quad.point0);
expect(aabb.point1, quad.point1);
expect(aabb.point2, quad.point2);
expect(aabb.point3, quad.point3);
});
test('rectangle rotated by 45 degrees', () {
final Quad quad = Quad.points(
Vector3(0.0, 5.0, 0.0),
Vector3(5.0, 10.0, 0.0),
Vector3(10.0, 5.0, 0.0),
Vector3(5.0, 0.0, 0.0),
);
final Quad aabb = InteractiveViewer.getAxisAlignedBoundingBox(quad);
expect(aabb.point0, Vector3(0.0, 0.0, 0.0));
expect(aabb.point1, Vector3(10.0, 0.0, 0.0));
expect(aabb.point2, Vector3(10.0, 10.0, 0.0));
expect(aabb.point3, Vector3(0.0, 10.0, 0.0));
});
test('rectangle rotated very slightly', () {
final Quad quad = Quad.points(
Vector3(0.0, 1.0, 0.0),
Vector3(1.0, 11.0, 0.0),
Vector3(11.0, 9.0, 0.0),
Vector3(9.0, -1.0, 0.0),
);
final Quad aabb = InteractiveViewer.getAxisAlignedBoundingBox(quad);
expect(aabb.point0, Vector3(0.0, -1.0, 0.0));
expect(aabb.point1, Vector3(11.0, -1.0, 0.0));
expect(aabb.point2, Vector3(11.0, 11.0, 0.0));
expect(aabb.point3, Vector3(0.0, 11.0, 0.0));
});
test('example from hexagon board', () {
final Quad quad = Quad.points(
Vector3(-462.7, 165.9, 0.0),
Vector3(690.6, -576.7, 0.0),
Vector3(1188.1, 196.0, 0.0),
Vector3(34.9, 938.6, 0.0),
);
final Quad aabb = InteractiveViewer.getAxisAlignedBoundingBox(quad);
expect(aabb.point0, Vector3(-462.7, -576.7, 0.0));
expect(aabb.point1, Vector3(1188.1, -576.7, 0.0));
expect(aabb.point2, Vector3(1188.1, 938.6, 0.0));
expect(aabb.point3, Vector3(-462.7, 938.6, 0.0));
});
});
group('pointIsInside', () {
test('inside', () {
final Quad quad = Quad.points(
Vector3(0.0, 0.0, 0.0),
Vector3(0.0, 10.0, 0.0),
Vector3(10.0, 10.0, 0.0),
Vector3(10.0, 0.0, 0.0),
);
final Vector3 point = Vector3(5.0, 5.0, 0.0);
expect(InteractiveViewer.pointIsInside(point, quad), true);
});
test('outside', () {
final Quad quad = Quad.points(
Vector3(0.0, 0.0, 0.0),
Vector3(0.0, 10.0, 0.0),
Vector3(10.0, 10.0, 0.0),
Vector3(10.0, 0.0, 0.0),
);
final Vector3 point = Vector3(12.0, 0.0, 0.0);
expect(InteractiveViewer.pointIsInside(point, quad), false);
});
test('on the edge', () {
final Quad quad = Quad.points(
Vector3(0.0, 0.0, 0.0),
Vector3(0.0, 10.0, 0.0),
Vector3(10.0, 10.0, 0.0),
Vector3(10.0, 0.0, 0.0),
);
final Vector3 point = Vector3(0.0, 0.0, 0.0);
expect(InteractiveViewer.pointIsInside(point, quad), true);
});
});
group('getNearestPointInside', () {
test('point already inside quad', () {
final Vector3 point = Vector3(5.0, 5.0, 0.0);
final Quad quad = Quad.points(
Vector3(0.0, 0.0, 0.0),
Vector3(0.0, 10.0, 0.0),
Vector3(10.0, 10.0, 0.0),
Vector3(10.0, 0.0, 0.0),
);
final Vector3 nearestPoint = InteractiveViewer.getNearestPointInside(point, quad);
expect(nearestPoint, point);
});
test('axis aligned quad', () {
final Vector3 point = Vector3(5.0, 15.0, 0.0);
final Quad quad = Quad.points(
Vector3(0.0, 0.0, 0.0),
Vector3(0.0, 10.0, 0.0),
Vector3(10.0, 10.0, 0.0),
Vector3(10.0, 0.0, 0.0),
);
final Vector3 nearestPoint = InteractiveViewer.getNearestPointInside(point, quad);
expect(nearestPoint, Vector3(5.0, 10.0, 0.0));
});
test('not axis aligned quad', () {
final Vector3 point = Vector3(5.0, 15.0, 0.0);
final Quad quad = Quad.points(
Vector3(0.0, 0.0, 0.0),
Vector3(2.0, 10.0, 0.0),
Vector3(12.0, 12.0, 0.0),
Vector3(10.0, 2.0, 0.0),
);
final Vector3 nearestPoint = InteractiveViewer.getNearestPointInside(point, quad);
expect(nearestPoint.x, closeTo(5.8, 0.1));
expect(nearestPoint.y, closeTo(10.8, 0.1));
});
});
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment