diff --git a/lib/core/interactive_viewer.dart b/lib/core/interactive_viewer.dart new file mode 100644 index 00000000..4caacf8d --- /dev/null +++ b/lib/core/interactive_viewer.dart @@ -0,0 +1,1263 @@ +// 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. + +// @dart = 2.8 + +import 'dart:math' as math; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/physics.dart'; + +import 'package:vector_math/vector_math_64.dart' show Quad, Vector3, Matrix4; + +/// 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. +/// +/// By default, InteractiveViewerCopy 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 +/// InteractiveViewerCopy from drawing outside of its original size, wrap it in a +/// [ClipRect]. Or, to prevent dead areas where InteractiveViewerCopy does not +/// receive gestures, be sure that the InteractiveViewerCopy 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 InteractiveViewerCopy 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 InteractiveViewerCopy. +/// +/// {@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: InteractiveViewerCopy( +/// boundaryMargin: EdgeInsets.all(20.0), +/// minScale: 0.1, +/// maxScale: 1.6, +/// child: Container( +/// decoration: BoxDecoration( +/// gradient: LinearGradient( +/// begin: Alignment.topCenter, +/// end: Alignment.bottomCenter, +/// colors: [Colors.orange, Colors.red], +/// stops: [0.0, 1.0], +/// ), +/// ), +/// ), +/// ), +/// ); +/// } +/// ``` +/// {@end-tool} +@immutable +class InteractiveViewerCopy extends StatefulWidget { + /// Create an InteractiveViewerCopy. + /// + /// The [child] parameter must not be null. + InteractiveViewerCopy({ + Key key, + 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 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)), + super(key: key); + + /// 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. + 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; + + /// 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 InteractiveViewerCopy. + /// + /// 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: InteractiveViewerCopy( + /// constrained: false, + /// scaleEnabled: false, + /// child: Table( + /// columnWidths: { + /// for (int column = 0; column < _columnCount; column += 1) + /// column: const FixedColumnWidth(300.0), + /// }, + /// children: [ + /// for (int row = 0; row < _rowCount; row += 1) + /// TableRow( + /// children: [ + /// 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.interactiveViewerCopy.onInteraction} + /// Will be called even if the interaction is disabled with + /// [panEnabled] or [scaleEnabled]. + /// + /// A [GestureDetector] wrapping the InteractiveViewerCopy 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.interactiveViewerCopy.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.interactiveViewerCopy.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, InteractiveViewerCopy 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 _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: InteractiveViewerCopy( + /// 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: [Colors.orange, Colors.red], + /// stops: [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 closestPoints = [ + InteractiveViewerCopy.getNearestPointOnLine( + point, quad.point0, quad.point1), + InteractiveViewerCopy.getNearestPointOnLine( + point, quad.point1, quad.point2), + InteractiveViewerCopy.getNearestPointOnLine( + point, quad.point2, quad.point3), + InteractiveViewerCopy.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 + _InteractiveViewerCopyState createState() => _InteractiveViewerCopyState(); +} + +class _InteractiveViewerCopyState extends State + with TickerProviderStateMixin { + TransformationController _transformationController; + + final GlobalKey _childKey = GlobalKey(); + final GlobalKey _parentKey = GlobalKey(); + Animation _animation; + 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 = 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) { + switch (gestureType) { + case _GestureType.rotate: + return _rotateEnabled; + + case _GestureType.scale: + return widget.scaleEnabled; + + case _GestureType.pan: + default: + 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) { + if (widget.onInteractionStart != null) { + widget.onInteractionStart(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(); + 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, + ); + + 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)) { + 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; + } + 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: + 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) { + 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, + ); + 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)) { + _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( + 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 (!_gestureIsSupported(_GestureType.scale)) { + return; + } + 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) { + _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(InteractiveViewerCopy 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 = 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: +/// +/// * [InteractiveViewerCopy.transformationController] for detailed documentation +/// on how to use TransformationController with [InteractiveViewerCopy]. +class TransformationController extends ValueNotifier { + /// 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: InteractiveViewerCopy( + /// 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 InteractiveViewerCopy.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 viewportPoints = [ + viewport.point0, + viewport.point1, + viewport.point2, + viewport.point3, + ]; + Offset largestExcess = Offset.zero; + for (final Vector3 point in viewportPoints) { + final Vector3 pointInside = + InteractiveViewerCopy.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: + default: + 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; +} diff --git a/lib/screens/graph_view.dart b/lib/screens/graph_view.dart index 158217dd..dd8d464b 100644 --- a/lib/screens/graph_view.dart +++ b/lib/screens/graph_view.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:gitjournal/core/graph.dart'; +import 'package:gitjournal/core/interactive_viewer.dart'; import 'package:gitjournal/core/notes_folder_fs.dart'; class GraphViewScreen extends StatefulWidget { @@ -121,23 +122,11 @@ class _GraphViewState extends State { ), ); - return InteractiveViewer( + return InteractiveViewerCopy( child: view, + panEnabled: true, + constrained: false, ); - - /* - return Scrollbar( - child: SingleChildScrollView( - child: Scrollbar( - child: SingleChildScrollView( - child: view, - scrollDirection: Axis.horizontal, - ), - ), - scrollDirection: Axis.vertical, - ), - ); - */ } }