// 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 'layout_builder.dart'; import 'ticker_provider.dart'; /// A signature for widget builders that take a [Quad] of the current viewport. /// /// See also: /// /// * [InteractiveViewer.builder], whose builder is of this type. /// * [WidgetBuilder], which is similar, but takes no viewport. typedef InteractiveViewerWidgetBuilder = Widget Function(BuildContext context, Quad viewport); /// A widget that enables pan and zoom interactions with its child. /// /// {@youtube 560 315 https://www.youtube.com/watch?v=zrn7V3bMJvg} /// /// The user can transform the child by dragging to pan or pinching to zoom. /// /// By default, InteractiveViewer clips its child using [Clip.hardEdge]. /// To prevent this behavior, consider setting [clipBehavior] to [Clip.none]. /// When [clipBehavior] is [Clip.none], InteractiveViewer may draw outside of /// its original area of the screen, such as when a child is zoomed in and /// increases in size. However, it will not receive gestures outside of its original area. /// To prevent dead areas where InteractiveViewer does not receive gestures, /// don't set [clipBehavior] or be sure that the InteractiveViewer widget is the /// size of the area that should be interactive. /// /// See [flutter-go](https://github.com/justinmc/flutter-go) for an example of /// robust positioning of an InteractiveViewer child that works for all screen /// sizes and child sizes. /// /// The [child] must not be null. /// /// See also: /// * The [Flutter Gallery's transformations demo](https://github.com/flutter/gallery/blob/master/lib/demos/reference/transformations_demo.dart), /// which includes the use of InteractiveViewer. /// /// {@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: const EdgeInsets.all(20.0), /// minScale: 0.1, /// maxScale: 1.6, /// child: Container( /// decoration: const 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.clipBehavior = Clip.hardEdge, this.alignPanAxis = false, 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 Widget this.child, }) : assert(alignPanAxis != null), 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), ), builder = null, super(key: key); /// Creates an InteractiveViewer for a child that is created on demand. /// /// Can be used to render a child that changes in response to the current /// transformation. /// /// The [builder] parameter must not be null. See its docs for an example of /// using it to optimize a large child. InteractiveViewer.builder({ Key? key, this.clipBehavior = Clip.hardEdge, this.alignPanAxis = false, this.boundaryMargin = EdgeInsets.zero, // 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 InteractiveViewerWidgetBuilder this.builder, }) : assert(alignPanAxis != null), assert(builder != 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), ), constrained = false, child = null, super(key: key); /// If set to [Clip.none], the child may extend beyond the size of the InteractiveViewer, /// but it will not receive gestures in these areas. /// Be sure that the InteractiveViewer is the desired size when using [Clip.none]. /// /// Defaults to [Clip.hardEdge]. final Clip clipBehavior; /// If true, panning is only allowed in the direction of the horizontal axis /// or the vertical axis. /// /// In other words, when this is true, diagonal panning is not allowed. A /// single gesture begun along one axis cannot also cause panning along the /// other axis without stopping and beginning a new gesture. This is a common /// pattern in tables where data is displayed in columns and rows. /// /// See also: /// * [constrained], which has an example of creating a table that uses /// alignPanAxis. final bool alignPanAxis; /// 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; /// Builds the child of this widget. /// /// Passed with the [InteractiveViewer.builder] constructor. Otherwise, the /// [child] parameter must be passed directly, and this is null. /// /// {@tool dartpad --template=freeform} /// /// This example shows how to use builder to create a [Table] whose cell /// contents are only built when they are visible. Built and remove cells are /// logged in the console for illustration. /// /// ```dart main /// import 'package:vector_math/vector_math_64.dart' show Quad, Vector3; /// /// import 'package:flutter/material.dart'; /// import 'package:flutter/widgets.dart'; /// /// void main() => runApp(const IVBuilderExampleApp()); /// /// class IVBuilderExampleApp extends StatelessWidget { /// const IVBuilderExampleApp({Key? key}) : super(key: key); /// /// @override /// Widget build(BuildContext context) { /// return MaterialApp( /// home: Scaffold( /// appBar: AppBar( /// title: const Text('IV Builder Example'), /// ), /// body: _IVBuilderExample(), /// ), /// ); /// } /// } /// /// class _IVBuilderExample extends StatefulWidget { /// @override /// _IVBuilderExampleState createState() => _IVBuilderExampleState(); /// } /// /// class _IVBuilderExampleState extends State<_IVBuilderExample> { /// final TransformationController _transformationController = TransformationController(); /// /// static const double _cellWidth = 200.0; /// static const double _cellHeight = 26.0; /// /// // Returns true iff the given cell is currently visible. Caches viewport /// // calculations. /// late Quad _cachedViewport; /// late int _firstVisibleRow; /// late int _firstVisibleColumn; /// late int _lastVisibleRow; /// late int _lastVisibleColumn; /// bool _isCellVisible(int row, int column, Quad viewport) { /// if (viewport != _cachedViewport) { /// final Rect aabb = _axisAlignedBoundingBox(viewport); /// _cachedViewport = viewport; /// _firstVisibleRow = (aabb.top / _cellHeight).floor(); /// _firstVisibleColumn = (aabb.left / _cellWidth).floor(); /// _lastVisibleRow = (aabb.bottom / _cellHeight).floor(); /// _lastVisibleColumn = (aabb.right / _cellWidth).floor(); /// } /// return row >= _firstVisibleRow && row <= _lastVisibleRow /// && column >= _firstVisibleColumn && column <= _lastVisibleColumn; /// } /// /// // Returns the axis aligned bounding box for the given Quad, which might not /// // be axis aligned. /// Rect _axisAlignedBoundingBox(Quad quad) { /// double? xMin; /// double? xMax; /// double? yMin; /// double? yMax; /// for (final Vector3 point in <Vector3>[quad.point0, quad.point1, quad.point2, quad.point3]) { /// if (xMin == null || point.x < xMin) { /// xMin = point.x; /// } /// if (xMax == null || point.x > xMax) { /// xMax = point.x; /// } /// if (yMin == null || point.y < yMin) { /// yMin = point.y; /// } /// if (yMax == null || point.y > yMax) { /// yMax = point.y; /// } /// } /// return Rect.fromLTRB(xMin!, yMin!, xMax!, yMax!); /// } /// /// void _onChangeTransformation() { /// setState(() {}); /// } /// /// @override /// void initState() { /// super.initState(); /// _transformationController.addListener(_onChangeTransformation); /// } /// /// @override /// void dispose() { /// _transformationController.removeListener(_onChangeTransformation); /// super.dispose(); /// } /// /// @override /// Widget build(BuildContext context) { /// return Center( /// child: LayoutBuilder( /// builder: (BuildContext context, BoxConstraints constraints) { /// return InteractiveViewer.builder( /// alignPanAxis: true, /// scaleEnabled: false, /// transformationController: _transformationController, /// builder: (BuildContext context, Quad viewport) { /// // A simple extension of Table that builds cells. /// return _TableBuilder( /// rowCount: 60, /// columnCount: 6, /// cellWidth: _cellWidth, /// builder: (BuildContext context, int row, int column) { /// if (!_isCellVisible(row, column, viewport)) { /// print('removing cell ($row, $column)'); /// return Container(height: _cellHeight); /// } /// print('building cell ($row, $column)'); /// return Container( /// height: _cellHeight, /// color: row % 2 + column % 2 == 1 ? Colors.white : Colors.grey.withOpacity(0.1), /// child: Align( /// alignment: Alignment.centerLeft, /// child: Text('$row x $column'), /// ), /// ); /// } /// ); /// }, /// ); /// }, /// ), /// ); /// } /// } /// /// typedef _CellBuilder = Widget Function(BuildContext context, int row, int column); /// /// class _TableBuilder extends StatelessWidget { /// const _TableBuilder({ /// required this.rowCount, /// required this.columnCount, /// required this.cellWidth, /// required this.builder, /// }) : assert(rowCount > 0), /// assert(columnCount > 0); /// /// final int rowCount; /// final int columnCount; /// final double cellWidth; /// final _CellBuilder builder; /// /// @override /// Widget build(BuildContext context) { /// return Table( /// // ignore: prefer_const_literals_to_create_immutables /// columnWidths: <int, TableColumnWidth>{ /// for (int column = 0; column < columnCount; column++) /// column: FixedColumnWidth(cellWidth), /// }, /// // ignore: prefer_const_literals_to_create_immutables /// children: <TableRow>[ /// for (int row = 0; row < rowCount; row++) /// // ignore: prefer_const_constructors /// TableRow( /// // ignore: prefer_const_literals_to_create_immutables /// children: <Widget>[ /// for (int column = 0; column < columnCount; column++) /// builder(context, row, column), /// ], /// ), /// ], /// ); /// } /// } /// ``` /// {@end-tool} /// /// See also: /// /// * [ListView.builder], which follows a similar pattern. final InteractiveViewerWidgetBuilder? builder; /// The child [Widget] that is transformed by InteractiveViewer. /// /// If the [InteractiveViewer.builder] constructor is used, then this will be /// null, otherwise it is required. 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. /// /// For example, for a child which is bigger than the viewport but can be /// panned to reveal parts that were initially offscreen, [constrained] must /// be set to false to allow it to size itself properly. If [constrained] is /// true and the child can only size itself to the viewport, then areas /// initially outside of the viewport will not be able to receive user /// interaction events. If experiencing regions of the child that are not /// receptive to user gestures, make sure [constrained] is false and the child /// is sized properly. /// /// 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 = 48; /// const int _columnCount = 6; /// /// return InteractiveViewer( /// alignPanAxis: true, /// constrained: false, /// scaleEnabled: false, /// child: Table( /// columnWidths: <int, TableColumnWidth>{ /// for (int column = 0; column < _columnCount; column += 1) /// column: const FixedColumnWidth(200.0), /// }, /// children: <TableRow>[ /// for (int row = 0; row < _rowCount; row += 1) /// TableRow( /// children: <Widget>[ /// for (int column = 0; column < _columnCount; column += 1) /// Container( /// height: 26, /// color: row % 2 + column % 2 == 1 /// ? Colors.white /// : Colors.grey.withOpacity(0.1), /// child: Align( /// alignment: Alignment.centerLeft, /// child: Text('$row x $column'), /// ), /// ), /// ], /// ), /// ], /// ), /// ); /// } /// ``` /// {@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. /// /// Scale is also affected by [boundaryMargin]. If the scale would result in /// viewing beyond the boundary, then it will not be allowed. By default, /// boundaryMargin is EdgeInsets.zero, so scaling below 1.0 will not be /// allowed in most cases without first increasing the boundaryMargin. /// /// 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. /// /// At the time this is called, the [TransformationController] will have /// already been updated to reflect the change caused by the interaction, /// though a pan may cause an inertia animation after this is called as well. /// /// {@template flutter.widgets.InteractiveViewer.onInteractionEnd} /// Will be called even if the interaction is disabled with [panEnabled] or /// [scaleEnabled] for both touch gestures and mouse interactions. /// /// 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. /// {@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. /// /// At the time this is called, the [TransformationController] will not have /// changed due to this interaction. /// /// {@macro flutter.widgets.InteractiveViewer.onInteractionEnd} /// /// The coordinates provided in the details' `focalPoint` and /// `localFocalPoint` are normal Flutter event coordinates, not /// InteractiveViewer scene coordinates. See /// [TransformationController.toScene] for how to convert these coordinates to /// scene coordinates relative to the child. /// /// 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. /// /// At the time this is called, the [TransformationController] will have /// already been updated to reflect the change caused by the interaction, if /// the interaction caused the matrix to change. /// /// {@macro flutter.widgets.InteractiveViewer.onInteractionEnd} /// /// The coordinates provided in the details' `focalPoint` and /// `localFocalPoint` are normal Flutter event coordinates, not /// InteractiveViewer scene coordinates. See /// [TransformationController.toScene] for how to convert these coordinates to /// scene coordinates relative to the child. /// /// 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; /// late final 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: const EdgeInsets.all(double.infinity), /// transformationController: _transformationController, /// minScale: 0.1, /// maxScale: 1.0, /// onInteractionStart: _onInteractionStart, /// child: Container( /// decoration: const BoxDecoration( /// gradient: LinearGradient( /// begin: Alignment.topCenter, /// end: Alignment.bottomCenter, /// colors: <Color>[Colors.orange, Colors.red], /// stops: <double>[0.0, 1.0], /// ), /// ), /// ), /// ), /// ), /// persistentFooterButtons: <Widget>[ /// 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); 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; late Vector3 closestOverall; for (final Vector3 closePoint in closestPoints) { final double distance = math.sqrt( math.pow(point.x - closePoint.x, 2) + math.pow(point.y - closePoint.y, 2), ); if (distance < minDistance) { minDistance = distance; closestOverall = closePoint; } } return closestOverall; } @override State<InteractiveViewer> createState() => _InteractiveViewerState(); } class _InteractiveViewerState extends State<InteractiveViewer> with TickerProviderStateMixin { TransformationController? _transformationController; final GlobalKey _childKey = GlobalKey(); final GlobalKey _parentKey = GlobalKey(); Animation<Offset>? _animation; late AnimationController _controller; Axis? _panAxis; // Used with alignPanAxis. 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 get _boundaryRect { 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; final Rect boundaryRect = 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( boundaryRect.isFinite || (boundaryRect.left.isInfinite && boundaryRect.top.isInfinite && boundaryRect.right.isInfinite && boundaryRect.bottom.isInfinite), 'boundaryRect must either be infinite in all directions or finite in all directions.', ); return boundaryRect; } // 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 Offset alignedTranslation = widget.alignPanAxis && _panAxis != null ? _alignAxis(translation, _panAxis!) : translation; final Matrix4 nextMatrix = matrix.clone()..translate( alignedTranslation.dx, alignedTranslation.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 = math.max( currentScale * scale, // Ensure that the scale cannot make the child so big that it can't fit // inside the boundaries (in either direction). math.max( _viewport.width / _boundaryRect.width, _viewport.height / _boundaryRect.height, ), ); final double clampedTotalScale = totalScale.clamp( widget.minScale, widget.maxScale, ); final double clampedScale = clampedTotalScale / currentScale; return matrix.clone()..scale(clampedScale); } // 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) { switch (gestureType) { case _GestureType.rotate: return _rotateEnabled; case _GestureType.scale: return widget.scaleEnabled; case _GestureType.pan: case null: return widget.panEnabled; } } // 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 no scale and no rotation because it uses only one // finger. _GestureType _getGestureType(ScaleUpdateDetails details) { final double scale = !widget.scaleEnabled ? 1.0 : details.scale; final double rotation = !_rotateEnabled ? 0.0 : details.rotation; if ((scale - 1).abs() > rotation.abs()) { return _GestureType.scale; } else if (rotation != 0.0) { return _GestureType.rotate; } else { return _GestureType.pan; } } // Handle the start of a gesture. All of pan, scale, and rotate are handled // with GestureDetector's scale gesture. void _onScaleStart(ScaleStartDetails details) { widget.onInteractionStart?.call(details); if (_controller.isAnimating) { _controller.stop(); _controller.reset(); _animation?.removeListener(_onAnimate); _animation = null; } _gestureType = null; _panAxis = null; _scaleStart = _transformationController!.value.getMaxScaleOnAxis(); _referenceFocalPoint = _transformationController!.toScene( details.localFocalPoint, ); _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(); final Offset focalPointScene = _transformationController!.toScene( details.localFocalPoint, ); if (_gestureType == _GestureType.pan) { // When a gesture first starts, it sometimes has no change in scale and // rotation despite being a two-finger gesture. Here the gesture is // allowed to be reinterpreted as its correct type after originally // being marked as a pan. _gestureType = _getGestureType(details); } else { _gestureType ??= _getGestureType(details); } if (!_gestureIsSupported(_gestureType)) { widget.onInteractionUpdate?.call(details); return; } switch (_gestureType!) { case _GestureType.scale: assert(_scaleStart != null); // 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; } break; case _GestureType.rotate: if (details.rotation == 0.0) { widget.onInteractionUpdate?.call(details); return; } final double desiredRotation = _rotationStart! + details.rotation; _transformationController!.value = _matrixRotate( _transformationController!.value, _currentRotation - desiredRotation, details.localFocalPoint, ); _currentRotation = desiredRotation; break; case _GestureType.pan: assert(_referenceFocalPoint != null); // details may have a change in scale here when scaleEnabled is false. // In an effort to keep the behavior similar whether or not scaleEnabled // is true, these gestures are thrown away. if (details.scale != 1.0) { widget.onInteractionUpdate?.call(details); return; } _panAxis ??= _getPanAxis(_referenceFocalPoint!, focalPointScene); // 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, ); break; } widget.onInteractionUpdate?.call(details); } // 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) { widget.onInteractionEnd?.call(details); _scaleStart = null; _rotationStart = null; _referenceFocalPoint = null; _animation?.removeListener(_onAnimate); _controller.reset(); if (!_gestureIsSupported(_gestureType)) { _panAxis = null; return; } // If the scale ended with enough velocity, animate inertial movement. if (_gestureType != _GestureType.pan || details.velocity.pixelsPerSecond.distance < kMinFlingVelocity) { _panAxis = null; return; } final Vector3 translationVector = _transformationController!.value.getTranslation(); final Offset translation = Offset(translationVector.x, translationVector.y); final FrictionSimulation frictionSimulationX = FrictionSimulation( _kDrag, translation.dx, details.velocity.pixelsPerSecond.dx, ); final FrictionSimulation frictionSimulationY = FrictionSimulation( _kDrag, translation.dy, details.velocity.pixelsPerSecond.dy, ); final double tFinal = _getFinalTime( details.velocity.pixelsPerSecond.distance, _kDrag, ); _animation = Tween<Offset>( begin: translation, end: Offset(frictionSimulationX.finalX, frictionSimulationY.finalX), ).animate(CurvedAnimation( parent: _controller, curve: Curves.decelerate, )); _controller.duration = Duration(milliseconds: (tFinal * 1000).round()); _animation!.addListener(_onAnimate); _controller.forward(); } // Handle mousewheel scroll events. void _receivedPointerSignal(PointerSignalEvent event) { if (event is PointerScrollEvent) { // Ignore left and right scroll. if (event.scrollDelta.dy == 0.0) { return; } widget.onInteractionStart?.call( ScaleStartDetails( focalPoint: event.position, localFocalPoint: event.localPosition, ), ); // In the Flutter engine, the mousewheel scrollDelta is hardcoded to 20 // per scroll, while a trackpad scroll can be any amount. The calculation // for scaleChange here was arbitrarily chosen to feel natural for both // trackpads and mousewheels on all platforms. final double scaleChange = math.exp(-event.scrollDelta.dy / 200); if (!_gestureIsSupported(_GestureType.scale)) { widget.onInteractionUpdate?.call(ScaleUpdateDetails( focalPoint: event.position, localFocalPoint: event.localPosition, rotation: 0.0, scale: scaleChange, horizontalScale: 1.0, verticalScale: 1.0, )); widget.onInteractionEnd?.call(ScaleEndDetails()); 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, ); widget.onInteractionUpdate?.call(ScaleUpdateDetails( focalPoint: event.position, localFocalPoint: event.localPosition, rotation: 0.0, scale: scaleChange, horizontalScale: 1.0, verticalScale: 1.0, )); widget.onInteractionEnd?.call(ScaleEndDetails()); } } // Handle inertia drag animation. void _onAnimate() { if (!_controller.isAnimating) { _panAxis = null; _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); // 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; if (widget.child != null) { child = _InteractiveViewerBuilt( childKey: _childKey, clipBehavior: widget.clipBehavior, constrained: widget.constrained, matrix: _transformationController!.value, child: widget.child!, ); } else { // When using InteractiveViewer.builder, then constrained is false and the // viewport is the size of the constraints. assert(widget.builder != null); assert(!widget.constrained); child = LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { final Matrix4 matrix = _transformationController!.value; return _InteractiveViewerBuilt( childKey: _childKey, clipBehavior: widget.clipBehavior, constrained: widget.constrained, matrix: matrix, child: widget.builder!( context, _transformViewport(matrix, Offset.zero & constraints.biggest), ), ); }, ); } return Listener( key: _parentKey, onPointerSignal: _receivedPointerSignal, child: GestureDetector( behavior: HitTestBehavior.opaque, // Necessary when panning off screen. dragStartBehavior: DragStartBehavior.start, onScaleEnd: _onScaleEnd, onScaleStart: _onScaleStart, onScaleUpdate: _onScaleUpdate, child: child, ), ); } } // This widget simply allows us to easily swap in and out the LayoutBuilder in // InteractiveViewer's depending on if it's using a builder or a child. class _InteractiveViewerBuilt extends StatelessWidget { const _InteractiveViewerBuilt({ Key? key, required this.child, required this.childKey, required this.clipBehavior, required this.constrained, required this.matrix, }) : super(key: key); final Widget child; final GlobalKey childKey; final Clip clipBehavior; final bool constrained; final Matrix4 matrix; @override Widget build(BuildContext context) { Widget child = Transform( transform: matrix, child: KeyedSubtree( key: childKey, child: this.child, ), ); if (!constrained) { child = OverflowBox( alignment: Alignment.topLeft, minWidth: 0.0, minHeight: 0.0, maxWidth: double.infinity, maxHeight: double.infinity, child: child, ); } if (clipBehavior != Clip.none) { child = ClipRect( clipBehavior: clipBehavior, child: child, ); } return 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); } // 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)), ); } // Align the given offset to the given axis by allowing movement only in the // axis direction. Offset _alignAxis(Offset offset, Axis axis) { switch (axis) { case Axis.horizontal: return Offset(offset.dx, 0.0); case Axis.vertical: return Offset(0.0, offset.dy); } } // Given two points, return the axis where the distance between the points is // greatest. If they are equal, return null. Axis? _getPanAxis(Offset point1, Offset point2) { if (point1 == point2) { return null; } final double x = point2.dx - point1.dx; final double y = point2.dy - point1.dy; return x.abs() > y.abs() ? Axis.horizontal : Axis.vertical; }