InteractiveViewer Widget (#56409)

diff --git a/packages/flutter/lib/src/widgets/interactive_viewer.dart b/packages/flutter/lib/src/widgets/interactive_viewer.dart
new file mode 100644
index 0000000..48903e7
--- /dev/null
+++ b/packages/flutter/lib/src/widgets/interactive_viewer.dart
@@ -0,0 +1,1162 @@
+// 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)),
+  );
+}
diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart
index 38b3b5f..2ef12f1 100644
--- a/packages/flutter/lib/widgets.dart
+++ b/packages/flutter/lib/widgets.dart
@@ -56,6 +56,7 @@
 export 'src/widgets/inherited_model.dart';
 export 'src/widgets/inherited_notifier.dart';
 export 'src/widgets/inherited_theme.dart';
+export 'src/widgets/interactive_viewer.dart';
 export 'src/widgets/layout_builder.dart';
 export 'src/widgets/list_wheel_scroll_view.dart';
 export 'src/widgets/localizations.dart';
diff --git a/packages/flutter/test/widgets/interactive_viewer_test.dart b/packages/flutter/test/widgets/interactive_viewer_test.dart
new file mode 100644
index 0000000..5552e1b
--- /dev/null
+++ b/packages/flutter/test/widgets/interactive_viewer_test.dart
@@ -0,0 +1,611 @@
+// 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));
+    });
+  });
+}