mym0404 / flutter-pencilkit

Flutter plugin for iOS PencilKit
MIT License
46 stars 12 forks source link

Cannot draw when PencilKit is inside an InteractiveViewer or GestureDectector #13

Open jack-szeto opened 8 months ago

jack-szeto commented 8 months ago

Description

When you put the PencilKit inside a InteractiveViewer, it can only draw a short line. It's likely it triggered the pan threshold. I tried to set panEnabled to false, still experience the same.

Edit

After some testing, you cannot draw when you wrapped the PencilKit with GestureDetector also.

Steps to reproduce

  1. Create a new Flutter app
  2. Add a InteractiveViewer
  3. Set the panEnabled to false
  4. Add a PencilKit to the child of InteractiveViewer
  5. Run the app

Snack or a link to a repository

https://github.com/jack-szeto/pencilkit_minimal.git

package version

1.0.6

Flutter Doctor Output

Doctor summary (to see all details, run flutter doctor -v): [✓] Flutter (Channel stable, 3.16.7, on macOS 13.6 22G120 darwin-arm64, locale zh-Hant-HK) [✓] Android toolchain - develop for Android devices (Android SDK version 34.0.0) [✓] Xcode - develop for iOS and macOS (Xcode 15.2) [✓] Chrome - develop for the web [✓] Android Studio (version 2023.1) [✓] VS Code (version 1.85.1) [✓] Connected device (3 available) [✓] Network resources

• No issues found!

Build type

Debug mode

Device

iOS Real device

Device model

iPad (9)

Acknowledgements

Yes

jack-szeto commented 8 months ago

I found out that's a problem with pan event in GestureDetector. The InteractiveViewer has a GuestureDetector in the source code. My workaround is to make my custom InteractiveViewer that copy everything from the native one. Then, comment out the pan events in it. This only works for me because I have another custom RawGuestureDetecter that handle the panning events. So, I would love to hear any other way to handle this.

yamashin0168 commented 7 months ago

I am facing the same problem as you. How did you activate pencil_kit and interact viewer?

Also, I had the same problem when I wrap pencil_kit with singlechildscrollview.

flexboni commented 7 months ago

@mym0404 please fix this error

flexboni commented 7 months ago

@jack-szeto Can you share custom interactiveviewer??

jack-szeto commented 6 months ago

@flexboni sorry for the late reply. I just copied the source code and commented the GestureDetector's event. You'll need to handle the events manually using the TransformationController.

Dart Code ```dart import 'dart:math' as math; import 'package:flutter/foundation.dart' show clampDouble; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/physics.dart'; import 'package:vector_math/vector_math_64.dart' show Matrix4, Quad, Vector3; class CustomInteractiveViewer extends StatefulWidget { /// Create an InteractiveViewer. CustomInteractiveViewer({ super.key, this.clipBehavior = Clip.hardEdge, @Deprecated( 'Use panAxis instead. ' 'This feature was deprecated after v3.3.0-0.5.pre.', ) this.alignPanAxis = false, this.panAxis = PanAxis.free, 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.interactionEndFrictionCoefficient = _kDrag, this.onInteractionEnd, this.onInteractionStart, this.onInteractionUpdate, this.panEnabled = true, this.scaleEnabled = true, this.scaleFactor = kDefaultMouseScrollToScaleFactor, this.transformationController, this.alignment, this.trackpadScrollCausesScale = false, required Widget this.child, }) : assert(minScale > 0), assert(interactionEndFrictionCoefficient > 0), assert(minScale.isFinite), assert(maxScale > 0), assert(!maxScale.isNaN), assert(maxScale >= minScale), // 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; /// 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. /// /// See the [builder] attribute docs for an example of using it to optimize a /// large child. CustomInteractiveViewer.builder({ super.key, this.clipBehavior = Clip.hardEdge, @Deprecated( 'Use panAxis instead. ' 'This feature was deprecated after v3.3.0-0.5.pre.', ) this.alignPanAxis = false, this.panAxis = PanAxis.free, 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.interactionEndFrictionCoefficient = _kDrag, this.onInteractionEnd, this.onInteractionStart, this.onInteractionUpdate, this.panEnabled = true, this.scaleEnabled = true, this.scaleFactor = 200.0, this.transformationController, this.alignment, this.trackpadScrollCausesScale = false, required InteractiveViewerWidgetBuilder this.builder, }) : assert(minScale > 0), assert(interactionEndFrictionCoefficient > 0), assert(minScale.isFinite), assert(maxScale > 0), assert(!maxScale.isNaN), assert(maxScale >= minScale), // 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; /// The alignment of the child's origin, relative to the size of the box. final Alignment? alignment; /// 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; /// This property is deprecated, please use [panAxis] instead. /// /// 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. @Deprecated( 'Use panAxis instead. ' 'This feature was deprecated after v3.3.0-0.5.pre.', ) final bool alignPanAxis; /// When set to [PanAxis.aligned], panning is only allowed in the horizontal /// axis or the vertical axis, diagonal panning is not allowed. /// /// When set to [PanAxis.vertical] or [PanAxis.horizontal] panning is only /// allowed in the specified axis. For example, if set to [PanAxis.vertical], /// panning will only be allowed in the vertical axis. And if set to [PanAxis.horizontal], /// panning will only be allowed in the horizontal axis. /// /// When set to [PanAxis.free] panning is allowed in all directions. /// /// Defaults to [PanAxis.free]. final PanAxis panAxis; /// 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 [CustomInteractiveViewer.builder] constructor. Otherwise, the /// [child] parameter must be passed directly, and this is null. /// /// {@tool dartpad} /// 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. /// /// ** See code in examples/api/lib/widgets/interactive_viewer/interactive_viewer.builder.0.dart ** /// {@end-tool} /// /// See also: /// /// * [ListView.builder], which follows a similar pattern. final InteractiveViewerWidgetBuilder? builder; /// The child [Widget] that is transformed by InteractiveViewer. /// /// If the [CustomInteractiveViewer.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} /// 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. /// /// ** See code in examples/api/lib/widgets/interactive_viewer/interactive_viewer.constrained.0.dart ** /// {@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; /// {@macro flutter.gestures.scale.trackpadScrollCausesScale} final bool trackpadScrollCausesScale; /// Determines the amount of scale to be performed per pointer scroll. /// /// Defaults to [kDefaultMouseScrollToScaleFactor]. /// /// Increasing this value above the default causes scaling to feel slower, /// while decreasing it causes scaling to feel faster. /// /// The amount of scale is calculated as the exponential function of the /// [PointerScrollEvent.scrollDelta] to [scaleFactor] ratio. In the Flutter /// engine, the mousewheel [PointerScrollEvent.scrollDelta] is hardcoded to 20 /// per scroll, while a trackpad scroll can be any amount. /// /// Affects only pointer device scrolling, not pinch to zoom. final double scaleFactor; /// The maximum allowed scale. /// /// The scale will be clamped between this and [minScale] inclusively. /// /// Defaults to 2.5. /// /// 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. /// /// Must be a finite number greater than zero and less than [maxScale]. final double minScale; /// Changes the deceleration behavior after a gesture. /// /// Defaults to 0.0000135. /// /// Must be a finite number greater than zero. final double interactionEndFrictionCoefficient; /// 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} /// This example shows how transformationController can be used to animate the /// transformation back to its starting position. /// /// ** See code in examples/api/lib/widgets/interactive_viewer/interactive_viewer.transformation_controller.0.dart ** /// {@end-tool} /// /// See also: /// /// * [ValueNotifier], the parent class of TransformationController. /// * [TextEditingController] for an example of another similar pattern. final TransformationController? transformationController; // 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; /// 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 = clampDouble(l1P.dot(l1L2) / lengthSquared, 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 closestPoints = [ CustomInteractiveViewer.getNearestPointOnLine( point, quad.point0, quad.point1), CustomInteractiveViewer.getNearestPointOnLine( point, quad.point1, quad.point2), CustomInteractiveViewer.getNearestPointOnLine( point, quad.point2, quad.point3), CustomInteractiveViewer.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 createState() => _CustomInteractiveViewerState(); } class _CustomInteractiveViewerState extends State with TickerProviderStateMixin { TransformationController? _transformationController; final GlobalKey _childKey = GlobalKey(); final GlobalKey _parentKey = GlobalKey(); Animation? _animation; Animation? _scaleAnimation; late Offset _scaleAnimationFocalPoint; late AnimationController _controller; late AnimationController _scaleController; Axis? _currentAxis; // Used with panAxis. 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; // 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); assert( !boundaryRect.isEmpty, "InteractiveViewer's child must have nonzero dimensions.", ); // 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(); } late final Offset alignedTranslation; if (_currentAxis != null) { switch (widget.panAxis) { case PanAxis.horizontal: alignedTranslation = _alignAxis(translation, Axis.horizontal); case PanAxis.vertical: alignedTranslation = _alignAxis(translation, Axis.vertical); case PanAxis.aligned: alignedTranslation = _alignAxis(translation, _currentAxis!); case PanAxis.free: alignedTranslation = translation; } } else { alignedTranslation = 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 = clampDouble( totalScale, 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; } if (_scaleController.isAnimating) { _scaleController.stop(); _scaleController.reset(); _scaleAnimation?.removeListener(_onScaleAnimate); _scaleAnimation = null; } _gestureType = null; _currentAxis = 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(); _scaleAnimationFocalPoint = details.localFocalPoint; 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; } 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; 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; } _currentAxis ??= _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, ); } 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); _scaleAnimation?.removeListener(_onScaleAnimate); _controller.reset(); _scaleController.reset(); if (!_gestureIsSupported(_gestureType)) { _currentAxis = null; return; } if (_gestureType == _GestureType.pan) { if (details.velocity.pixelsPerSecond.distance < kMinFlingVelocity) { _currentAxis = null; return; } final Vector3 translationVector = _transformationController!.value.getTranslation(); final Offset translation = Offset(translationVector.x, translationVector.y); final FrictionSimulation frictionSimulationX = FrictionSimulation( widget.interactionEndFrictionCoefficient, translation.dx, details.velocity.pixelsPerSecond.dx, ); final FrictionSimulation frictionSimulationY = FrictionSimulation( widget.interactionEndFrictionCoefficient, translation.dy, details.velocity.pixelsPerSecond.dy, ); final double tFinal = _getFinalTime( details.velocity.pixelsPerSecond.distance, widget.interactionEndFrictionCoefficient, ); _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(); } else if (_gestureType == _GestureType.scale) { if (details.scaleVelocity.abs() < 0.1) { _currentAxis = null; return; } final double scale = _transformationController!.value.getMaxScaleOnAxis(); final FrictionSimulation frictionSimulation = FrictionSimulation( widget.interactionEndFrictionCoefficient * widget.scaleFactor, scale, details.scaleVelocity / 10); final double tFinal = _getFinalTime( details.scaleVelocity.abs(), widget.interactionEndFrictionCoefficient, effectivelyMotionless: 0.1); _scaleAnimation = Tween(begin: scale, end: frictionSimulation.x(tFinal)) .animate(CurvedAnimation( parent: _scaleController, curve: Curves.decelerate)); _scaleController.duration = Duration(milliseconds: (tFinal * 1000).round()); _scaleAnimation!.addListener(_onScaleAnimate); _scaleController.forward(); } } // Handle mousewheel and web trackpad scroll events. void _receivedPointerSignal(PointerSignalEvent event) { final double scaleChange; if (event is PointerScrollEvent) { if (event.kind == PointerDeviceKind.trackpad && !widget.trackpadScrollCausesScale) { // Trackpad scroll, so treat it as a pan. widget.onInteractionStart?.call( ScaleStartDetails( focalPoint: event.position, localFocalPoint: event.localPosition, ), ); final Offset localDelta = PointerEvent.transformDeltaViaPositions( untransformedEndPosition: event.position + event.scrollDelta, untransformedDelta: event.scrollDelta, transform: event.transform, ); if (!_gestureIsSupported(_GestureType.pan)) { widget.onInteractionUpdate?.call(ScaleUpdateDetails( focalPoint: event.position - event.scrollDelta, localFocalPoint: event.localPosition - event.scrollDelta, focalPointDelta: -localDelta, )); widget.onInteractionEnd?.call(ScaleEndDetails()); return; } final Offset focalPointScene = _transformationController!.toScene( event.localPosition, ); final Offset newFocalPointScene = _transformationController!.toScene( event.localPosition - localDelta, ); _transformationController!.value = _matrixTranslate( _transformationController!.value, newFocalPointScene - focalPointScene); widget.onInteractionUpdate?.call(ScaleUpdateDetails( focalPoint: event.position - event.scrollDelta, localFocalPoint: event.localPosition - localDelta, focalPointDelta: -localDelta)); widget.onInteractionEnd?.call(ScaleEndDetails()); return; } // Ignore left and right mouse wheel scroll. if (event.scrollDelta.dy == 0.0) { return; } scaleChange = math.exp(-event.scrollDelta.dy / widget.scaleFactor); } else if (event is PointerScaleEvent) { scaleChange = event.scale; } else { return; } widget.onInteractionStart?.call( ScaleStartDetails( focalPoint: event.position, localFocalPoint: event.localPosition, ), ); if (!_gestureIsSupported(_GestureType.scale)) { widget.onInteractionUpdate?.call(ScaleUpdateDetails( focalPoint: event.position, localFocalPoint: event.localPosition, scale: scaleChange, )); 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, scale: scaleChange, )); widget.onInteractionEnd?.call(ScaleEndDetails()); } // Handle inertia drag animation. void _onAnimate() { if (!_controller.isAnimating) { _currentAxis = 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, ); } // Handle inertia scale animation. void _onScaleAnimate() { if (!_scaleController.isAnimating) { _currentAxis = null; _scaleAnimation?.removeListener(_onScaleAnimate); _scaleAnimation = null; _scaleController.reset(); return; } final double desiredScale = _scaleAnimation!.value; final double scaleChange = desiredScale / _transformationController!.value.getMaxScaleOnAxis(); final Offset referenceFocalPoint = _transformationController!.toScene( _scaleAnimationFocalPoint, ); _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( _scaleAnimationFocalPoint, ); _transformationController!.value = _matrixTranslate( _transformationController!.value, focalPointSceneScaled - referenceFocalPoint, ); } 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, ); _scaleController = AnimationController(vsync: this); } @override void didUpdateWidget(CustomInteractiveViewer 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(); _scaleController.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, alignment: widget.alignment, 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, alignment: widget.alignment, 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. // onScaleEnd: _onScaleEnd, // onScaleStart: _onScaleStart, // onScaleUpdate: _onScaleUpdate, trackpadScrollCausesScale: widget.trackpadScrollCausesScale, trackpadScrollToScaleFactor: Offset(0, -1 / widget.scaleFactor), child: child, ), ); } } // This widget 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({ required this.child, required this.childKey, required this.clipBehavior, required this.constrained, required this.matrix, required this.alignment, }); final Widget child; final GlobalKey childKey; final Clip clipBehavior; final bool constrained; final Matrix4 matrix; final Alignment? alignment; @override Widget build(BuildContext context) { Widget child = Transform( transform: matrix, alignment: alignment, 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, ); } return ClipRect( clipBehavior: clipBehavior, 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: /// /// * [CustomInteractiveViewer.transformationController] for detailed documentation /// on how to use TransformationController with [CustomInteractiveViewer]. 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 /// Widget 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, {double effectivelyMotionless = 10}) { 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 CustomInteractiveViewer.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 = CustomInteractiveViewer.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; } /// This enum is used to specify the behavior of the [CustomInteractiveViewer] when /// the user drags the viewport. enum PanAxis { /// The user can only pan the viewport along the horizontal axis. horizontal, /// The user can only pan the viewport along the vertical axis. vertical, /// The user can pan the viewport along the horizontal and vertical axes /// but not diagonally. aligned, /// The user can pan the viewport freely in any direction. free, } ```
frybitsinc commented 6 months ago

oh wow this works as a charm. thank you for sharing this!!! @jack-szeto

flexboni commented 6 months ago

This is added some my code

Check 'HERE' keyword

import 'dart:math' as math;

import 'package:flutter/foundation.dart' show clampDouble;
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
import 'package:mid_cave/core/utils/utils.dart';
import 'package:vector_math/vector_math_64.dart' show Matrix4, Quad, Vector3;

class CustomInteractiveViewer extends StatefulWidget {
  /// Create an InteractiveViewer.
  CustomInteractiveViewer({
    super.key,
    this.clipBehavior = Clip.hardEdge,
    @Deprecated(
      'Use panAxis instead. '
      'This feature was deprecated after v3.3.0-0.5.pre.',
    )
    this.alignPanAxis = false,
    this.panAxis = PanAxis.free,
    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.interactionEndFrictionCoefficient = _kDrag,
    this.onInteractionEnd,
    this.onInteractionStart,
    this.onInteractionUpdate,
    this.panEnabled = true,
    this.scaleEnabled = true,
    this.scaleFactor = kDefaultMouseScrollToScaleFactor,
    this.transformationController,
    this.alignment,
    this.trackpadScrollCausesScale = false,
    required Widget this.child,
  })  : assert(minScale > 0),
        assert(interactionEndFrictionCoefficient > 0),
        assert(minScale.isFinite),
        assert(maxScale > 0),
        assert(!maxScale.isNaN),
        assert(maxScale >= minScale),
        // 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;

  /// 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.
  ///
  /// See the [builder] attribute docs for an example of using it to optimize a
  /// large child.
  CustomInteractiveViewer.builder({
    super.key,
    this.clipBehavior = Clip.hardEdge,
    @Deprecated(
      'Use panAxis instead. '
      'This feature was deprecated after v3.3.0-0.5.pre.',
    )
    this.alignPanAxis = false,
    this.panAxis = PanAxis.free,
    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.interactionEndFrictionCoefficient = _kDrag,
    this.onInteractionEnd,
    this.onInteractionStart,
    this.onInteractionUpdate,
    this.panEnabled = true,
    this.scaleEnabled = true,
    this.scaleFactor = 200.0,
    this.transformationController,
    this.alignment,
    this.trackpadScrollCausesScale = false,
    required InteractiveViewerWidgetBuilder this.builder,
  })  : assert(minScale > 0),
        assert(interactionEndFrictionCoefficient > 0),
        assert(minScale.isFinite),
        assert(maxScale > 0),
        assert(!maxScale.isNaN),
        assert(maxScale >= minScale),
        // 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;

  /// The alignment of the child's origin, relative to the size of the box.
  final Alignment? alignment;

  /// 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;

  /// This property is deprecated, please use [panAxis] instead.
  ///
  /// 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.
  @Deprecated(
    'Use panAxis instead. '
    'This feature was deprecated after v3.3.0-0.5.pre.',
  )
  final bool alignPanAxis;

  /// When set to [PanAxis.aligned], panning is only allowed in the horizontal
  /// axis or the vertical axis, diagonal panning is not allowed.
  ///
  /// When set to [PanAxis.vertical] or [PanAxis.horizontal] panning is only
  /// allowed in the specified axis. For example, if set to [PanAxis.vertical],
  /// panning will only be allowed in the vertical axis. And if set to [PanAxis.horizontal],
  /// panning will only be allowed in the horizontal axis.
  ///
  /// When set to [PanAxis.free] panning is allowed in all directions.
  ///
  /// Defaults to [PanAxis.free].
  final PanAxis panAxis;

  /// 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 [CustomInteractiveViewer.builder] constructor. Otherwise, the
  /// [child] parameter must be passed directly, and this is null.
  ///
  /// {@tool dartpad}
  /// 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.
  ///
  /// ** See code in examples/api/lib/widgets/interactive_viewer/interactive_viewer.builder.0.dart **
  /// {@end-tool}
  ///
  /// See also:
  ///
  ///   * [ListView.builder], which follows a similar pattern.
  final InteractiveViewerWidgetBuilder? builder;

  /// The child [Widget] that is transformed by InteractiveViewer.
  ///
  /// If the [CustomInteractiveViewer.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}
  /// 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.
  ///
  /// ** See code in examples/api/lib/widgets/interactive_viewer/interactive_viewer.constrained.0.dart **
  /// {@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;

  /// {@macro flutter.gestures.scale.trackpadScrollCausesScale}
  final bool trackpadScrollCausesScale;

  /// Determines the amount of scale to be performed per pointer scroll.
  ///
  /// Defaults to [kDefaultMouseScrollToScaleFactor].
  ///
  /// Increasing this value above the default causes scaling to feel slower,
  /// while decreasing it causes scaling to feel faster.
  ///
  /// The amount of scale is calculated as the exponential function of the
  /// [PointerScrollEvent.scrollDelta] to [scaleFactor] ratio. In the Flutter
  /// engine, the mousewheel [PointerScrollEvent.scrollDelta] is hardcoded to 20
  /// per scroll, while a trackpad scroll can be any amount.
  ///
  /// Affects only pointer device scrolling, not pinch to zoom.
  final double scaleFactor;

  /// The maximum allowed scale.
  ///
  /// The scale will be clamped between this and [minScale] inclusively.
  ///
  /// Defaults to 2.5.
  ///
  /// 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.
  ///
  /// Must be a finite number greater than zero and less than [maxScale].
  final double minScale;

  /// Changes the deceleration behavior after a gesture.
  ///
  /// Defaults to 0.0000135.
  ///
  /// Must be a finite number greater than zero.
  final double interactionEndFrictionCoefficient;

  /// 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}
  /// This example shows how transformationController can be used to animate the
  /// transformation back to its starting position.
  ///
  /// ** See code in examples/api/lib/widgets/interactive_viewer/interactive_viewer.transformation_controller.0.dart **
  /// {@end-tool}
  ///
  /// See also:
  ///
  ///  * [ValueNotifier], the parent class of TransformationController.
  ///  * [TextEditingController] for an example of another similar pattern.
  final TransformationController? transformationController;

  // 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;

  /// 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 = clampDouble(l1P.dot(l1L2) / lengthSquared, 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>[
      CustomInteractiveViewer.getNearestPointOnLine(point, quad.point0, quad.point1),
      CustomInteractiveViewer.getNearestPointOnLine(point, quad.point1, quad.point2),
      CustomInteractiveViewer.getNearestPointOnLine(point, quad.point2, quad.point3),
      CustomInteractiveViewer.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<CustomInteractiveViewer> createState() => _CustomInteractiveViewerState();
}

class _CustomInteractiveViewerState extends State<CustomInteractiveViewer>
    with TickerProviderStateMixin {
  TransformationController? _transformationController;

  final GlobalKey _childKey = GlobalKey();
  final GlobalKey _parentKey = GlobalKey();
  Animation<Offset>? _animation;
  Animation<double>? _scaleAnimation;
  late Offset _scaleAnimationFocalPoint;
  late AnimationController _controller;
  late AnimationController _scaleController;
  Axis? _currentAxis; // Used with panAxis.
  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;

  bool _isDrawing = false; // Here!

  // 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;

  // 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);
    assert(
      !boundaryRect.isEmpty,
      "InteractiveViewer's child must have nonzero dimensions.",
    );
    // 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();
    }

    late final Offset alignedTranslation;

    if (_currentAxis != null) {
      switch (widget.panAxis) {
        case PanAxis.horizontal:
          alignedTranslation = _alignAxis(translation, Axis.horizontal);
        case PanAxis.vertical:
          alignedTranslation = _alignAxis(translation, Axis.vertical);
        case PanAxis.aligned:
          alignedTranslation = _alignAxis(translation, _currentAxis!);
        case PanAxis.free:
          alignedTranslation = translation;
      }
    } else {
      alignedTranslation = 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 = clampDouble(
      totalScale,
      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;
    }
    if (_scaleController.isAnimating) {
      _scaleController.stop();
      _scaleController.reset();
      _scaleAnimation?.removeListener(_onScaleAnimate);
      _scaleAnimation = null;
    }

    _gestureType = null;
    _currentAxis = 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();
    _scaleAnimationFocalPoint = details.localFocalPoint;
    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;
        }

      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;

      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;
        }
        _currentAxis ??= _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,
        );
    }
    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);
    _scaleAnimation?.removeListener(_onScaleAnimate);
    _controller.reset();
    _scaleController.reset();

    if (!_gestureIsSupported(_gestureType)) {
      _currentAxis = null;
      return;
    }

    if (_gestureType == _GestureType.pan) {
      if (details.velocity.pixelsPerSecond.distance < kMinFlingVelocity) {
        _currentAxis = null;
        return;
      }
      final Vector3 translationVector = _transformationController!.value.getTranslation();
      final Offset translation = Offset(translationVector.x, translationVector.y);
      final FrictionSimulation frictionSimulationX = FrictionSimulation(
        widget.interactionEndFrictionCoefficient,
        translation.dx,
        details.velocity.pixelsPerSecond.dx,
      );
      final FrictionSimulation frictionSimulationY = FrictionSimulation(
        widget.interactionEndFrictionCoefficient,
        translation.dy,
        details.velocity.pixelsPerSecond.dy,
      );
      final double tFinal = _getFinalTime(
        details.velocity.pixelsPerSecond.distance,
        widget.interactionEndFrictionCoefficient,
      );
      _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();
    } else if (_gestureType == _GestureType.scale) {
      if (details.scaleVelocity.abs() < 0.1) {
        _currentAxis = null;
        return;
      }
      final double scale = _transformationController!.value.getMaxScaleOnAxis();
      final FrictionSimulation frictionSimulation = FrictionSimulation(
          widget.interactionEndFrictionCoefficient * widget.scaleFactor,
          scale,
          details.scaleVelocity / 10);
      final double tFinal = _getFinalTime(
          details.scaleVelocity.abs(), widget.interactionEndFrictionCoefficient,
          effectivelyMotionless: 0.1);
      _scaleAnimation = Tween<double>(begin: scale, end: frictionSimulation.x(tFinal))
          .animate(CurvedAnimation(parent: _scaleController, curve: Curves.decelerate));
      _scaleController.duration = Duration(milliseconds: (tFinal * 1000).round());
      _scaleAnimation!.addListener(_onScaleAnimate);
      _scaleController.forward();
    }
  }

  // Handle mousewheel and web trackpad scroll events.
  void _receivedPointerSignal(PointerSignalEvent event) {
    final double scaleChange;
    logUtil.d(event);
    if (event is PointerScrollEvent) {
      if (event.kind == PointerDeviceKind.trackpad && !widget.trackpadScrollCausesScale) {
        // Trackpad scroll, so treat it as a pan.
        widget.onInteractionStart?.call(
          ScaleStartDetails(
            focalPoint: event.position,
            localFocalPoint: event.localPosition,
          ),
        );

        final Offset localDelta = PointerEvent.transformDeltaViaPositions(
          untransformedEndPosition: event.position + event.scrollDelta,
          untransformedDelta: event.scrollDelta,
          transform: event.transform,
        );

        if (!_gestureIsSupported(_GestureType.pan)) {
          widget.onInteractionUpdate?.call(ScaleUpdateDetails(
            focalPoint: event.position - event.scrollDelta,
            localFocalPoint: event.localPosition - event.scrollDelta,
            focalPointDelta: -localDelta,
          ));
          widget.onInteractionEnd?.call(ScaleEndDetails());
          return;
        }

        final Offset focalPointScene = _transformationController!.toScene(
          event.localPosition,
        );

        final Offset newFocalPointScene = _transformationController!.toScene(
          event.localPosition - localDelta,
        );

        _transformationController!.value = _matrixTranslate(
            _transformationController!.value, newFocalPointScene - focalPointScene);

        widget.onInteractionUpdate?.call(ScaleUpdateDetails(
            focalPoint: event.position - event.scrollDelta,
            localFocalPoint: event.localPosition - localDelta,
            focalPointDelta: -localDelta));
        widget.onInteractionEnd?.call(ScaleEndDetails());
        return;
      }
      // Ignore left and right mouse wheel scroll.
      if (event.scrollDelta.dy == 0.0) {
        return;
      }
      scaleChange = math.exp(-event.scrollDelta.dy / widget.scaleFactor);
    } else if (event is PointerScaleEvent) {
      scaleChange = event.scale;
    } else {
      return;
    }
    widget.onInteractionStart?.call(
      ScaleStartDetails(
        focalPoint: event.position,
        localFocalPoint: event.localPosition,
      ),
    );

    if (!_gestureIsSupported(_GestureType.scale)) {
      widget.onInteractionUpdate?.call(ScaleUpdateDetails(
        focalPoint: event.position,
        localFocalPoint: event.localPosition,
        scale: scaleChange,
      ));
      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,
      scale: scaleChange,
    ));
    widget.onInteractionEnd?.call(ScaleEndDetails());
  }

  // Handle inertia drag animation.
  void _onAnimate() {
    if (!_controller.isAnimating) {
      _currentAxis = 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,
    );
  }

  // Handle inertia scale animation.
  void _onScaleAnimate() {
    if (!_scaleController.isAnimating) {
      _currentAxis = null;
      _scaleAnimation?.removeListener(_onScaleAnimate);
      _scaleAnimation = null;
      _scaleController.reset();
      return;
    }
    final double desiredScale = _scaleAnimation!.value;
    final double scaleChange = desiredScale / _transformationController!.value.getMaxScaleOnAxis();
    final Offset referenceFocalPoint = _transformationController!.toScene(
      _scaleAnimationFocalPoint,
    );
    _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(
      _scaleAnimationFocalPoint,
    );
    _transformationController!.value = _matrixTranslate(
      _transformationController!.value,
      focalPointSceneScaled - referenceFocalPoint,
    );
  }

  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,
    );
    _scaleController = AnimationController(vsync: this);
  }

  @override
  void didUpdateWidget(CustomInteractiveViewer 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();
    _scaleController.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,
        alignment: widget.alignment,
        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,
            alignment: widget.alignment,
            matrix: matrix,
            child: widget.builder!(
              context,
              _transformViewport(matrix, Offset.zero & constraints.biggest),
            ),
          );
        },
      );
    }

    return Listener(
      key: _parentKey,
      onPointerSignal: _receivedPointerSignal,
      // Here!
      onPointerDown: (event) {
        if (event.kind == PointerDeviceKind.stylus) {
          setState(() {
            _isDrawing = true;
          });
        }
      },
      // Here!
      onPointerUp: (event) {
        if (event.kind == PointerDeviceKind.stylus) {
          setState(() {
            _isDrawing = false;
          });
        }
      },
      child: GestureDetector(
        behavior: HitTestBehavior.opaque, // Necessary when panning off screen.
        onScaleStart: _isDrawing ? null : _onScaleStart,
        onScaleEnd: _isDrawing ? null : _onScaleEnd,
        onScaleUpdate: _isDrawing ? null : _onScaleUpdate,
        trackpadScrollCausesScale: widget.trackpadScrollCausesScale,
        trackpadScrollToScaleFactor: Offset(0, -1 / widget.scaleFactor),
        child: child,
      ),
    );
  }
}

// This widget 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({
    required this.child,
    required this.childKey,
    required this.clipBehavior,
    required this.constrained,
    required this.matrix,
    required this.alignment,
  });

  final Widget child;
  final GlobalKey childKey;
  final Clip clipBehavior;
  final bool constrained;
  final Matrix4 matrix;
  final Alignment? alignment;

  @override
  Widget build(BuildContext context) {
    Widget child = Transform(
      transform: matrix,
      alignment: alignment,
      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,
      );
    }

    return ClipRect(
      clipBehavior: clipBehavior,
      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:
///
///  * [CustomInteractiveViewer.transformationController] for detailed documentation
///    on how to use TransformationController with [CustomInteractiveViewer].
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
  /// Widget 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, {double effectivelyMotionless = 10}) {
  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 CustomInteractiveViewer.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 = CustomInteractiveViewer.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;
}

/// This enum is used to specify the behavior of the [CustomInteractiveViewer] when
/// the user drags the viewport.
enum PanAxis {
  /// The user can only pan the viewport along the horizontal axis.
  horizontal,

  /// The user can only pan the viewport along the vertical axis.
  vertical,

  /// The user can pan the viewport along the horizontal and vertical axes
  /// but not diagonally.
  aligned,

  /// The user can pan the viewport freely in any direction.
  free,
}
EricPHassey commented 4 months ago

@flexboni this works brilliantly, however occasionally (and very often) it causes it to crash and not sure why. Works for a few minutes then get . Have you seen issues like that? I get a Pencil Kit in red at the top screen that says "Pencil Kit 9000+ ms"

EricPHassey commented 4 months ago

Found my issue. Was due to the Graphics HUD in the iPad menu. The solutions mentioned here are brilliant. Thanks!

frybitsinc commented 1 month ago

I just found out 1 issue case when using PencilKit inside interactiveviewer. It causes app to freeze when finger pan/scaling and using pencil drawing at the same time. and this pencil input was not preventable by using flutter widgets such as AbsorbPointer or IgnorePointer. I thought it would be neccesary to add native side input intercepting feature, so I added that and opened pr for it.