1// Copyright 2014 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import 'dart:math' as math;
6
7import 'package:flutter/foundation.dart' show clampDouble;
8import 'package:flutter/gestures.dart';
9import 'package:flutter/physics.dart';
10import 'package:vector_math/vector_math_64.dart' show Matrix4, Quad, Vector3;
11
12import 'basic.dart';
13import 'framework.dart';
14import 'gesture_detector.dart';
15import 'layout_builder.dart';
16import 'ticker_provider.dart';
17
18// Examples can assume:
19// late BuildContext context;
20// late Offset? _childWasTappedAt;
21// late TransformationController _transformationController;
22// Widget child = const Placeholder();
23
24/// A signature for widget builders that take a [Quad] of the current viewport.
25///
26/// See also:
27///
28/// * [InteractiveViewer.builder], whose builder is of this type.
29/// * [WidgetBuilder], which is similar, but takes no viewport.
30typedef InteractiveViewerWidgetBuilder = Widget Function(BuildContext context, Quad viewport);
31
32/// A widget that enables pan and zoom interactions with its child.
33///
34/// {@youtube 560 315 https://www.youtube.com/watch?v=zrn7V3bMJvg}
35///
36/// The user can transform the child by dragging to pan or pinching to zoom.
37///
38/// By default, InteractiveViewer clips its child using [Clip.hardEdge].
39/// To prevent this behavior, consider setting [clipBehavior] to [Clip.none].
40/// When [clipBehavior] is [Clip.none], InteractiveViewer may draw outside of
41/// its original area of the screen, such as when a child is zoomed in and
42/// increases in size. However, it will not receive gestures outside of its original area.
43/// To prevent dead areas where InteractiveViewer does not receive gestures,
44/// don't set [clipBehavior] or be sure that the InteractiveViewer widget is the
45/// size of the area that should be interactive.
46///
47/// See also:
48/// * The [Flutter Gallery's transformations demo](https://github.com/flutter/gallery/blob/master/lib/demos/reference/transformations_demo.dart),
49/// which includes the use of InteractiveViewer.
50/// * The [flutter-go demo](https://github.com/justinmc/flutter-go), which includes robust positioning of an InteractiveViewer child
51/// that works for all screen sizes and child sizes.
52/// * The [Lazy Flutter Performance Session](https://www.youtube.com/watch?v=qax_nOpgz7E), which includes the use of an InteractiveViewer to
53/// performantly view subsets of a large set of widgets using the builder constructor.
54///
55/// {@tool dartpad}
56/// This example shows a simple Container that can be panned and zoomed.
57///
58/// ** See code in examples/api/lib/widgets/interactive_viewer/interactive_viewer.0.dart **
59/// {@end-tool}
60@immutable
61class InteractiveViewer extends StatefulWidget {
62 /// Create an InteractiveViewer.
63 InteractiveViewer({
64 super.key,
65 this.clipBehavior = Clip.hardEdge,
66 @Deprecated(
67 'Use panAxis instead. '
68 'This feature was deprecated after v3.3.0-0.5.pre.',
69 )
70 this.alignPanAxis = false,
71 this.panAxis = PanAxis.free,
72 this.boundaryMargin = EdgeInsets.zero,
73 this.constrained = true,
74 // These default scale values were eyeballed as reasonable limits for common
75 // use cases.
76 this.maxScale = 2.5,
77 this.minScale = 0.8,
78 this.interactionEndFrictionCoefficient = _kDrag,
79 this.onInteractionEnd,
80 this.onInteractionStart,
81 this.onInteractionUpdate,
82 this.panEnabled = true,
83 this.scaleEnabled = true,
84 this.scaleFactor = kDefaultMouseScrollToScaleFactor,
85 this.transformationController,
86 this.alignment,
87 this.trackpadScrollCausesScale = false,
88 required Widget this.child,
89 }) : assert(minScale > 0),
90 assert(interactionEndFrictionCoefficient > 0),
91 assert(minScale.isFinite),
92 assert(maxScale > 0),
93 assert(!maxScale.isNaN),
94 assert(maxScale >= minScale),
95 // boundaryMargin must be either fully infinite or fully finite, but not
96 // a mix of both.
97 assert(
98 (boundaryMargin.horizontal.isInfinite
99 && boundaryMargin.vertical.isInfinite) || (boundaryMargin.top.isFinite
100 && boundaryMargin.right.isFinite && boundaryMargin.bottom.isFinite
101 && boundaryMargin.left.isFinite),
102 ),
103 builder = null;
104
105 /// Creates an InteractiveViewer for a child that is created on demand.
106 ///
107 /// Can be used to render a child that changes in response to the current
108 /// transformation.
109 ///
110 /// See the [builder] attribute docs for an example of using it to optimize a
111 /// large child.
112 InteractiveViewer.builder({
113 super.key,
114 this.clipBehavior = Clip.hardEdge,
115 @Deprecated(
116 'Use panAxis instead. '
117 'This feature was deprecated after v3.3.0-0.5.pre.',
118 )
119 this.alignPanAxis = false,
120 this.panAxis = PanAxis.free,
121 this.boundaryMargin = EdgeInsets.zero,
122 // These default scale values were eyeballed as reasonable limits for common
123 // use cases.
124 this.maxScale = 2.5,
125 this.minScale = 0.8,
126 this.interactionEndFrictionCoefficient = _kDrag,
127 this.onInteractionEnd,
128 this.onInteractionStart,
129 this.onInteractionUpdate,
130 this.panEnabled = true,
131 this.scaleEnabled = true,
132 this.scaleFactor = 200.0,
133 this.transformationController,
134 this.alignment,
135 this.trackpadScrollCausesScale = false,
136 required InteractiveViewerWidgetBuilder this.builder,
137 }) : assert(minScale > 0),
138 assert(interactionEndFrictionCoefficient > 0),
139 assert(minScale.isFinite),
140 assert(maxScale > 0),
141 assert(!maxScale.isNaN),
142 assert(maxScale >= minScale),
143 // boundaryMargin must be either fully infinite or fully finite, but not
144 // a mix of both.
145 assert(
146 (boundaryMargin.horizontal.isInfinite && boundaryMargin.vertical.isInfinite) ||
147 (boundaryMargin.top.isFinite &&
148 boundaryMargin.right.isFinite &&
149 boundaryMargin.bottom.isFinite &&
150 boundaryMargin.left.isFinite),
151 ),
152 constrained = false,
153 child = null;
154
155 /// The alignment of the child's origin, relative to the size of the box.
156 final Alignment? alignment;
157
158 /// If set to [Clip.none], the child may extend beyond the size of the InteractiveViewer,
159 /// but it will not receive gestures in these areas.
160 /// Be sure that the InteractiveViewer is the desired size when using [Clip.none].
161 ///
162 /// Defaults to [Clip.hardEdge].
163 final Clip clipBehavior;
164
165 /// This property is deprecated, please use [panAxis] instead.
166 ///
167 /// If true, panning is only allowed in the direction of the horizontal axis
168 /// or the vertical axis.
169 ///
170 /// In other words, when this is true, diagonal panning is not allowed. A
171 /// single gesture begun along one axis cannot also cause panning along the
172 /// other axis without stopping and beginning a new gesture. This is a common
173 /// pattern in tables where data is displayed in columns and rows.
174 ///
175 /// See also:
176 /// * [constrained], which has an example of creating a table that uses
177 /// alignPanAxis.
178 @Deprecated(
179 'Use panAxis instead. '
180 'This feature was deprecated after v3.3.0-0.5.pre.',
181 )
182 final bool alignPanAxis;
183
184 /// When set to [PanAxis.aligned], panning is only allowed in the horizontal
185 /// axis or the vertical axis, diagonal panning is not allowed.
186 ///
187 /// When set to [PanAxis.vertical] or [PanAxis.horizontal] panning is only
188 /// allowed in the specified axis. For example, if set to [PanAxis.vertical],
189 /// panning will only be allowed in the vertical axis. And if set to [PanAxis.horizontal],
190 /// panning will only be allowed in the horizontal axis.
191 ///
192 /// When set to [PanAxis.free] panning is allowed in all directions.
193 ///
194 /// Defaults to [PanAxis.free].
195 final PanAxis panAxis;
196
197 /// A margin for the visible boundaries of the child.
198 ///
199 /// Any transformation that results in the viewport being able to view outside
200 /// of the boundaries will be stopped at the boundary. The boundaries do not
201 /// rotate with the rest of the scene, so they are always aligned with the
202 /// viewport.
203 ///
204 /// To produce no boundaries at all, pass infinite [EdgeInsets], such as
205 /// `EdgeInsets.all(double.infinity)`.
206 ///
207 /// No edge can be NaN.
208 ///
209 /// Defaults to [EdgeInsets.zero], which results in boundaries that are the
210 /// exact same size and position as the [child].
211 final EdgeInsets boundaryMargin;
212
213 /// Builds the child of this widget.
214 ///
215 /// Passed with the [InteractiveViewer.builder] constructor. Otherwise, the
216 /// [child] parameter must be passed directly, and this is null.
217 ///
218 /// {@tool dartpad}
219 /// This example shows how to use builder to create a [Table] whose cell
220 /// contents are only built when they are visible. Built and remove cells are
221 /// logged in the console for illustration.
222 ///
223 /// ** See code in examples/api/lib/widgets/interactive_viewer/interactive_viewer.builder.0.dart **
224 /// {@end-tool}
225 ///
226 /// See also:
227 ///
228 /// * [ListView.builder], which follows a similar pattern.
229 final InteractiveViewerWidgetBuilder? builder;
230
231 /// The child [Widget] that is transformed by InteractiveViewer.
232 ///
233 /// If the [InteractiveViewer.builder] constructor is used, then this will be
234 /// null, otherwise it is required.
235 final Widget? child;
236
237 /// Whether the normal size constraints at this point in the widget tree are
238 /// applied to the child.
239 ///
240 /// If set to false, then the child will be given infinite constraints. This
241 /// is often useful when a child should be bigger than the InteractiveViewer.
242 ///
243 /// For example, for a child which is bigger than the viewport but can be
244 /// panned to reveal parts that were initially offscreen, [constrained] must
245 /// be set to false to allow it to size itself properly. If [constrained] is
246 /// true and the child can only size itself to the viewport, then areas
247 /// initially outside of the viewport will not be able to receive user
248 /// interaction events. If experiencing regions of the child that are not
249 /// receptive to user gestures, make sure [constrained] is false and the child
250 /// is sized properly.
251 ///
252 /// Defaults to true.
253 ///
254 /// {@tool dartpad}
255 /// This example shows how to create a pannable table. Because the table is
256 /// larger than the entire screen, setting [constrained] to false is necessary
257 /// to allow it to be drawn to its full size. The parts of the table that
258 /// exceed the screen size can then be panned into view.
259 ///
260 /// ** See code in examples/api/lib/widgets/interactive_viewer/interactive_viewer.constrained.0.dart **
261 /// {@end-tool}
262 final bool constrained;
263
264 /// If false, the user will be prevented from panning.
265 ///
266 /// Defaults to true.
267 ///
268 /// See also:
269 ///
270 /// * [scaleEnabled], which is similar but for scale.
271 final bool panEnabled;
272
273 /// If false, the user will be prevented from scaling.
274 ///
275 /// Defaults to true.
276 ///
277 /// See also:
278 ///
279 /// * [panEnabled], which is similar but for panning.
280 final bool scaleEnabled;
281
282 /// {@macro flutter.gestures.scale.trackpadScrollCausesScale}
283 final bool trackpadScrollCausesScale;
284
285 /// Determines the amount of scale to be performed per pointer scroll.
286 ///
287 /// Defaults to [kDefaultMouseScrollToScaleFactor].
288 ///
289 /// Increasing this value above the default causes scaling to feel slower,
290 /// while decreasing it causes scaling to feel faster.
291 ///
292 /// The amount of scale is calculated as the exponential function of the
293 /// [PointerScrollEvent.scrollDelta] to [scaleFactor] ratio. In the Flutter
294 /// engine, the mousewheel [PointerScrollEvent.scrollDelta] is hardcoded to 20
295 /// per scroll, while a trackpad scroll can be any amount.
296 ///
297 /// Affects only pointer device scrolling, not pinch to zoom.
298 final double scaleFactor;
299
300 /// The maximum allowed scale.
301 ///
302 /// The scale will be clamped between this and [minScale] inclusively.
303 ///
304 /// Defaults to 2.5.
305 ///
306 /// Must be greater than zero and greater than [minScale].
307 final double maxScale;
308
309 /// The minimum allowed scale.
310 ///
311 /// The scale will be clamped between this and [maxScale] inclusively.
312 ///
313 /// Scale is also affected by [boundaryMargin]. If the scale would result in
314 /// viewing beyond the boundary, then it will not be allowed. By default,
315 /// boundaryMargin is EdgeInsets.zero, so scaling below 1.0 will not be
316 /// allowed in most cases without first increasing the boundaryMargin.
317 ///
318 /// Defaults to 0.8.
319 ///
320 /// Must be a finite number greater than zero and less than [maxScale].
321 final double minScale;
322
323 /// Changes the deceleration behavior after a gesture.
324 ///
325 /// Defaults to 0.0000135.
326 ///
327 /// Must be a finite number greater than zero.
328 final double interactionEndFrictionCoefficient;
329
330 /// Called when the user ends a pan or scale gesture on the widget.
331 ///
332 /// At the time this is called, the [TransformationController] will have
333 /// already been updated to reflect the change caused by the interaction,
334 /// though a pan may cause an inertia animation after this is called as well.
335 ///
336 /// {@template flutter.widgets.InteractiveViewer.onInteractionEnd}
337 /// Will be called even if the interaction is disabled with [panEnabled] or
338 /// [scaleEnabled] for both touch gestures and mouse interactions.
339 ///
340 /// A [GestureDetector] wrapping the InteractiveViewer will not respond to
341 /// [GestureDetector.onScaleStart], [GestureDetector.onScaleUpdate], and
342 /// [GestureDetector.onScaleEnd]. Use [onInteractionStart],
343 /// [onInteractionUpdate], and [onInteractionEnd] to respond to those
344 /// gestures.
345 /// {@endtemplate}
346 ///
347 /// See also:
348 ///
349 /// * [onInteractionStart], which handles the start of the same interaction.
350 /// * [onInteractionUpdate], which handles an update to the same interaction.
351 final GestureScaleEndCallback? onInteractionEnd;
352
353 /// Called when the user begins a pan or scale gesture on the widget.
354 ///
355 /// At the time this is called, the [TransformationController] will not have
356 /// changed due to this interaction.
357 ///
358 /// {@macro flutter.widgets.InteractiveViewer.onInteractionEnd}
359 ///
360 /// The coordinates provided in the details' `focalPoint` and
361 /// `localFocalPoint` are normal Flutter event coordinates, not
362 /// InteractiveViewer scene coordinates. See
363 /// [TransformationController.toScene] for how to convert these coordinates to
364 /// scene coordinates relative to the child.
365 ///
366 /// See also:
367 ///
368 /// * [onInteractionUpdate], which handles an update to the same interaction.
369 /// * [onInteractionEnd], which handles the end of the same interaction.
370 final GestureScaleStartCallback? onInteractionStart;
371
372 /// Called when the user updates a pan or scale gesture on the widget.
373 ///
374 /// At the time this is called, the [TransformationController] will have
375 /// already been updated to reflect the change caused by the interaction, if
376 /// the interaction caused the matrix to change.
377 ///
378 /// {@macro flutter.widgets.InteractiveViewer.onInteractionEnd}
379 ///
380 /// The coordinates provided in the details' `focalPoint` and
381 /// `localFocalPoint` are normal Flutter event coordinates, not
382 /// InteractiveViewer scene coordinates. See
383 /// [TransformationController.toScene] for how to convert these coordinates to
384 /// scene coordinates relative to the child.
385 ///
386 /// See also:
387 ///
388 /// * [onInteractionStart], which handles the start of the same interaction.
389 /// * [onInteractionEnd], which handles the end of the same interaction.
390 final GestureScaleUpdateCallback? onInteractionUpdate;
391
392 /// A [TransformationController] for the transformation performed on the
393 /// child.
394 ///
395 /// Whenever the child is transformed, the [Matrix4] value is updated and all
396 /// listeners are notified. If the value is set, InteractiveViewer will update
397 /// to respect the new value.
398 ///
399 /// {@tool dartpad}
400 /// This example shows how transformationController can be used to animate the
401 /// transformation back to its starting position.
402 ///
403 /// ** See code in examples/api/lib/widgets/interactive_viewer/interactive_viewer.transformation_controller.0.dart **
404 /// {@end-tool}
405 ///
406 /// See also:
407 ///
408 /// * [ValueNotifier], the parent class of TransformationController.
409 /// * [TextEditingController] for an example of another similar pattern.
410 final TransformationController? transformationController;
411
412 // Used as the coefficient of friction in the inertial translation animation.
413 // This value was eyeballed to give a feel similar to Google Photos.
414 static const double _kDrag = 0.0000135;
415
416 /// Returns the closest point to the given point on the given line segment.
417 @visibleForTesting
418 static Vector3 getNearestPointOnLine(Vector3 point, Vector3 l1, Vector3 l2) {
419 final double lengthSquared = math.pow(l2.x - l1.x, 2.0).toDouble()
420 + math.pow(l2.y - l1.y, 2.0).toDouble();
421
422 // In this case, l1 == l2.
423 if (lengthSquared == 0) {
424 return l1;
425 }
426
427 // Calculate how far down the line segment the closest point is and return
428 // the point.
429 final Vector3 l1P = point - l1;
430 final Vector3 l1L2 = l2 - l1;
431 final double fraction = clampDouble(l1P.dot(l1L2) / lengthSquared, 0.0, 1.0);
432 return l1 + l1L2 * fraction;
433 }
434
435 /// Given a quad, return its axis aligned bounding box.
436 @visibleForTesting
437 static Quad getAxisAlignedBoundingBox(Quad quad) {
438 final double minX = math.min(
439 quad.point0.x,
440 math.min(
441 quad.point1.x,
442 math.min(
443 quad.point2.x,
444 quad.point3.x,
445 ),
446 ),
447 );
448 final double minY = math.min(
449 quad.point0.y,
450 math.min(
451 quad.point1.y,
452 math.min(
453 quad.point2.y,
454 quad.point3.y,
455 ),
456 ),
457 );
458 final double maxX = math.max(
459 quad.point0.x,
460 math.max(
461 quad.point1.x,
462 math.max(
463 quad.point2.x,
464 quad.point3.x,
465 ),
466 ),
467 );
468 final double maxY = math.max(
469 quad.point0.y,
470 math.max(
471 quad.point1.y,
472 math.max(
473 quad.point2.y,
474 quad.point3.y,
475 ),
476 ),
477 );
478 return Quad.points(
479 Vector3(minX, minY, 0),
480 Vector3(maxX, minY, 0),
481 Vector3(maxX, maxY, 0),
482 Vector3(minX, maxY, 0),
483 );
484 }
485
486 /// Returns true iff the point is inside the rectangle given by the Quad,
487 /// inclusively.
488 /// Algorithm from https://math.stackexchange.com/a/190373.
489 @visibleForTesting
490 static bool pointIsInside(Vector3 point, Quad quad) {
491 final Vector3 aM = point - quad.point0;
492 final Vector3 aB = quad.point1 - quad.point0;
493 final Vector3 aD = quad.point3 - quad.point0;
494
495 final double aMAB = aM.dot(aB);
496 final double aBAB = aB.dot(aB);
497 final double aMAD = aM.dot(aD);
498 final double aDAD = aD.dot(aD);
499
500 return 0 <= aMAB && aMAB <= aBAB && 0 <= aMAD && aMAD <= aDAD;
501 }
502
503 /// Get the point inside (inclusively) the given Quad that is nearest to the
504 /// given Vector3.
505 @visibleForTesting
506 static Vector3 getNearestPointInside(Vector3 point, Quad quad) {
507 // If the point is inside the axis aligned bounding box, then it's ok where
508 // it is.
509 if (pointIsInside(point, quad)) {
510 return point;
511 }
512
513 // Otherwise, return the nearest point on the quad.
514 final List<Vector3> closestPoints = <Vector3>[
515 InteractiveViewer.getNearestPointOnLine(point, quad.point0, quad.point1),
516 InteractiveViewer.getNearestPointOnLine(point, quad.point1, quad.point2),
517 InteractiveViewer.getNearestPointOnLine(point, quad.point2, quad.point3),
518 InteractiveViewer.getNearestPointOnLine(point, quad.point3, quad.point0),
519 ];
520 double minDistance = double.infinity;
521 late Vector3 closestOverall;
522 for (final Vector3 closePoint in closestPoints) {
523 final double distance = math.sqrt(
524 math.pow(point.x - closePoint.x, 2) + math.pow(point.y - closePoint.y, 2),
525 );
526 if (distance < minDistance) {
527 minDistance = distance;
528 closestOverall = closePoint;
529 }
530 }
531 return closestOverall;
532 }
533
534 @override
535 State<InteractiveViewer> createState() => _InteractiveViewerState();
536}
537
538class _InteractiveViewerState extends State<InteractiveViewer> with TickerProviderStateMixin {
539 TransformationController? _transformationController;
540
541 final GlobalKey _childKey = GlobalKey();
542 final GlobalKey _parentKey = GlobalKey();
543 Animation<Offset>? _animation;
544 Animation<double>? _scaleAnimation;
545 late Offset _scaleAnimationFocalPoint;
546 late AnimationController _controller;
547 late AnimationController _scaleController;
548 Axis? _currentAxis; // Used with panAxis.
549 Offset? _referenceFocalPoint; // Point where the current gesture began.
550 double? _scaleStart; // Scale value at start of scaling gesture.
551 double? _rotationStart = 0.0; // Rotation at start of rotation gesture.
552 double _currentRotation = 0.0; // Rotation of _transformationController.value.
553 _GestureType? _gestureType;
554
555 // TODO(justinmc): Add rotateEnabled parameter to the widget and remove this
556 // hardcoded value when the rotation feature is implemented.
557 // https://github.com/flutter/flutter/issues/57698
558 final bool _rotateEnabled = false;
559
560 // The _boundaryRect is calculated by adding the boundaryMargin to the size of
561 // the child.
562 Rect get _boundaryRect {
563 assert(_childKey.currentContext != null);
564 assert(!widget.boundaryMargin.left.isNaN);
565 assert(!widget.boundaryMargin.right.isNaN);
566 assert(!widget.boundaryMargin.top.isNaN);
567 assert(!widget.boundaryMargin.bottom.isNaN);
568
569 final RenderBox childRenderBox = _childKey.currentContext!.findRenderObject()! as RenderBox;
570 final Size childSize = childRenderBox.size;
571 final Rect boundaryRect = widget.boundaryMargin.inflateRect(Offset.zero & childSize);
572 assert(
573 !boundaryRect.isEmpty,
574 "InteractiveViewer's child must have nonzero dimensions.",
575 );
576 // Boundaries that are partially infinite are not allowed because Matrix4's
577 // rotation and translation methods don't handle infinites well.
578 assert(
579 boundaryRect.isFinite ||
580 (boundaryRect.left.isInfinite
581 && boundaryRect.top.isInfinite
582 && boundaryRect.right.isInfinite
583 && boundaryRect.bottom.isInfinite),
584 'boundaryRect must either be infinite in all directions or finite in all directions.',
585 );
586 return boundaryRect;
587 }
588
589 // The Rect representing the child's parent.
590 Rect get _viewport {
591 assert(_parentKey.currentContext != null);
592 final RenderBox parentRenderBox = _parentKey.currentContext!.findRenderObject()! as RenderBox;
593 return Offset.zero & parentRenderBox.size;
594 }
595
596 // Return a new matrix representing the given matrix after applying the given
597 // translation.
598 Matrix4 _matrixTranslate(Matrix4 matrix, Offset translation) {
599 if (translation == Offset.zero) {
600 return matrix.clone();
601 }
602
603 late final Offset alignedTranslation;
604
605 if (_currentAxis != null) {
606 switch (widget.panAxis){
607 case PanAxis.horizontal:
608 alignedTranslation = _alignAxis(translation, Axis.horizontal);
609 case PanAxis.vertical:
610 alignedTranslation = _alignAxis(translation, Axis.vertical);
611 case PanAxis.aligned:
612 alignedTranslation = _alignAxis(translation, _currentAxis!);
613 case PanAxis.free:
614 alignedTranslation = translation;
615 }
616 } else {
617 alignedTranslation = translation;
618 }
619
620 final Matrix4 nextMatrix = matrix.clone()..translate(
621 alignedTranslation.dx,
622 alignedTranslation.dy,
623 );
624
625 // Transform the viewport to determine where its four corners will be after
626 // the child has been transformed.
627 final Quad nextViewport = _transformViewport(nextMatrix, _viewport);
628
629 // If the boundaries are infinite, then no need to check if the translation
630 // fits within them.
631 if (_boundaryRect.isInfinite) {
632 return nextMatrix;
633 }
634
635 // Expand the boundaries with rotation. This prevents the problem where a
636 // mismatch in orientation between the viewport and boundaries effectively
637 // limits translation. With this approach, all points that are visible with
638 // no rotation are visible after rotation.
639 final Quad boundariesAabbQuad = _getAxisAlignedBoundingBoxWithRotation(
640 _boundaryRect,
641 _currentRotation,
642 );
643
644 // If the given translation fits completely within the boundaries, allow it.
645 final Offset offendingDistance = _exceedsBy(boundariesAabbQuad, nextViewport);
646 if (offendingDistance == Offset.zero) {
647 return nextMatrix;
648 }
649
650 // Desired translation goes out of bounds, so translate to the nearest
651 // in-bounds point instead.
652 final Offset nextTotalTranslation = _getMatrixTranslation(nextMatrix);
653 final double currentScale = matrix.getMaxScaleOnAxis();
654 final Offset correctedTotalTranslation = Offset(
655 nextTotalTranslation.dx - offendingDistance.dx * currentScale,
656 nextTotalTranslation.dy - offendingDistance.dy * currentScale,
657 );
658 // TODO(justinmc): This needs some work to handle rotation properly. The
659 // idea is that the boundaries are axis aligned (boundariesAabbQuad), but
660 // calculating the translation to put the viewport inside that Quad is more
661 // complicated than this when rotated.
662 // https://github.com/flutter/flutter/issues/57698
663 final Matrix4 correctedMatrix = matrix.clone()..setTranslation(Vector3(
664 correctedTotalTranslation.dx,
665 correctedTotalTranslation.dy,
666 0.0,
667 ));
668
669 // Double check that the corrected translation fits.
670 final Quad correctedViewport = _transformViewport(correctedMatrix, _viewport);
671 final Offset offendingCorrectedDistance = _exceedsBy(boundariesAabbQuad, correctedViewport);
672 if (offendingCorrectedDistance == Offset.zero) {
673 return correctedMatrix;
674 }
675
676 // If the corrected translation doesn't fit in either direction, don't allow
677 // any translation at all. This happens when the viewport is larger than the
678 // entire boundary.
679 if (offendingCorrectedDistance.dx != 0.0 && offendingCorrectedDistance.dy != 0.0) {
680 return matrix.clone();
681 }
682
683 // Otherwise, allow translation in only the direction that fits. This
684 // happens when the viewport is larger than the boundary in one direction.
685 final Offset unidirectionalCorrectedTotalTranslation = Offset(
686 offendingCorrectedDistance.dx == 0.0 ? correctedTotalTranslation.dx : 0.0,
687 offendingCorrectedDistance.dy == 0.0 ? correctedTotalTranslation.dy : 0.0,
688 );
689 return matrix.clone()..setTranslation(Vector3(
690 unidirectionalCorrectedTotalTranslation.dx,
691 unidirectionalCorrectedTotalTranslation.dy,
692 0.0,
693 ));
694 }
695
696 // Return a new matrix representing the given matrix after applying the given
697 // scale.
698 Matrix4 _matrixScale(Matrix4 matrix, double scale) {
699 if (scale == 1.0) {
700 return matrix.clone();
701 }
702 assert(scale != 0.0);
703
704 // Don't allow a scale that results in an overall scale beyond min/max
705 // scale.
706 final double currentScale = _transformationController!.value.getMaxScaleOnAxis();
707 final double totalScale = math.max(
708 currentScale * scale,
709 // Ensure that the scale cannot make the child so big that it can't fit
710 // inside the boundaries (in either direction).
711 math.max(
712 _viewport.width / _boundaryRect.width,
713 _viewport.height / _boundaryRect.height,
714 ),
715 );
716 final double clampedTotalScale = clampDouble(totalScale,
717 widget.minScale,
718 widget.maxScale,
719 );
720 final double clampedScale = clampedTotalScale / currentScale;
721 return matrix.clone()..scale(clampedScale);
722 }
723
724 // Return a new matrix representing the given matrix after applying the given
725 // rotation.
726 Matrix4 _matrixRotate(Matrix4 matrix, double rotation, Offset focalPoint) {
727 if (rotation == 0) {
728 return matrix.clone();
729 }
730 final Offset focalPointScene = _transformationController!.toScene(
731 focalPoint,
732 );
733 return matrix
734 .clone()
735 ..translate(focalPointScene.dx, focalPointScene.dy)
736 ..rotateZ(-rotation)
737 ..translate(-focalPointScene.dx, -focalPointScene.dy);
738 }
739
740 // Returns true iff the given _GestureType is enabled.
741 bool _gestureIsSupported(_GestureType? gestureType) {
742 switch (gestureType) {
743 case _GestureType.rotate:
744 return _rotateEnabled;
745
746 case _GestureType.scale:
747 return widget.scaleEnabled;
748
749 case _GestureType.pan:
750 case null:
751 return widget.panEnabled;
752 }
753 }
754
755 // Decide which type of gesture this is by comparing the amount of scale
756 // and rotation in the gesture, if any. Scale starts at 1 and rotation
757 // starts at 0. Pan will have no scale and no rotation because it uses only one
758 // finger.
759 _GestureType _getGestureType(ScaleUpdateDetails details) {
760 final double scale = !widget.scaleEnabled ? 1.0 : details.scale;
761 final double rotation = !_rotateEnabled ? 0.0 : details.rotation;
762 if ((scale - 1).abs() > rotation.abs()) {
763 return _GestureType.scale;
764 } else if (rotation != 0.0) {
765 return _GestureType.rotate;
766 } else {
767 return _GestureType.pan;
768 }
769 }
770
771 // Handle the start of a gesture. All of pan, scale, and rotate are handled
772 // with GestureDetector's scale gesture.
773 void _onScaleStart(ScaleStartDetails details) {
774 widget.onInteractionStart?.call(details);
775
776 if (_controller.isAnimating) {
777 _controller.stop();
778 _controller.reset();
779 _animation?.removeListener(_onAnimate);
780 _animation = null;
781 }
782 if (_scaleController.isAnimating) {
783 _scaleController.stop();
784 _scaleController.reset();
785 _scaleAnimation?.removeListener(_onScaleAnimate);
786 _scaleAnimation = null;
787 }
788
789 _gestureType = null;
790 _currentAxis = null;
791 _scaleStart = _transformationController!.value.getMaxScaleOnAxis();
792 _referenceFocalPoint = _transformationController!.toScene(
793 details.localFocalPoint,
794 );
795 _rotationStart = _currentRotation;
796 }
797
798 // Handle an update to an ongoing gesture. All of pan, scale, and rotate are
799 // handled with GestureDetector's scale gesture.
800 void _onScaleUpdate(ScaleUpdateDetails details) {
801 final double scale = _transformationController!.value.getMaxScaleOnAxis();
802 _scaleAnimationFocalPoint = details.localFocalPoint;
803 final Offset focalPointScene = _transformationController!.toScene(
804 details.localFocalPoint,
805 );
806
807 if (_gestureType == _GestureType.pan) {
808 // When a gesture first starts, it sometimes has no change in scale and
809 // rotation despite being a two-finger gesture. Here the gesture is
810 // allowed to be reinterpreted as its correct type after originally
811 // being marked as a pan.
812 _gestureType = _getGestureType(details);
813 } else {
814 _gestureType ??= _getGestureType(details);
815 }
816 if (!_gestureIsSupported(_gestureType)) {
817 widget.onInteractionUpdate?.call(details);
818 return;
819 }
820
821 switch (_gestureType!) {
822 case _GestureType.scale:
823 assert(_scaleStart != null);
824 // details.scale gives us the amount to change the scale as of the
825 // start of this gesture, so calculate the amount to scale as of the
826 // previous call to _onScaleUpdate.
827 final double desiredScale = _scaleStart! * details.scale;
828 final double scaleChange = desiredScale / scale;
829 _transformationController!.value = _matrixScale(
830 _transformationController!.value,
831 scaleChange,
832 );
833
834 // While scaling, translate such that the user's two fingers stay on
835 // the same places in the scene. That means that the focal point of
836 // the scale should be on the same place in the scene before and after
837 // the scale.
838 final Offset focalPointSceneScaled = _transformationController!.toScene(
839 details.localFocalPoint,
840 );
841 _transformationController!.value = _matrixTranslate(
842 _transformationController!.value,
843 focalPointSceneScaled - _referenceFocalPoint!,
844 );
845
846 // details.localFocalPoint should now be at the same location as the
847 // original _referenceFocalPoint point. If it's not, that's because
848 // the translate came in contact with a boundary. In that case, update
849 // _referenceFocalPoint so subsequent updates happen in relation to
850 // the new effective focal point.
851 final Offset focalPointSceneCheck = _transformationController!.toScene(
852 details.localFocalPoint,
853 );
854 if (_round(_referenceFocalPoint!) != _round(focalPointSceneCheck)) {
855 _referenceFocalPoint = focalPointSceneCheck;
856 }
857
858 case _GestureType.rotate:
859 if (details.rotation == 0.0) {
860 widget.onInteractionUpdate?.call(details);
861 return;
862 }
863 final double desiredRotation = _rotationStart! + details.rotation;
864 _transformationController!.value = _matrixRotate(
865 _transformationController!.value,
866 _currentRotation - desiredRotation,
867 details.localFocalPoint,
868 );
869 _currentRotation = desiredRotation;
870
871 case _GestureType.pan:
872 assert(_referenceFocalPoint != null);
873 // details may have a change in scale here when scaleEnabled is false.
874 // In an effort to keep the behavior similar whether or not scaleEnabled
875 // is true, these gestures are thrown away.
876 if (details.scale != 1.0) {
877 widget.onInteractionUpdate?.call(details);
878 return;
879 }
880 _currentAxis ??= _getPanAxis(_referenceFocalPoint!, focalPointScene);
881 // Translate so that the same point in the scene is underneath the
882 // focal point before and after the movement.
883 final Offset translationChange = focalPointScene - _referenceFocalPoint!;
884 _transformationController!.value = _matrixTranslate(
885 _transformationController!.value,
886 translationChange,
887 );
888 _referenceFocalPoint = _transformationController!.toScene(
889 details.localFocalPoint,
890 );
891 }
892 widget.onInteractionUpdate?.call(details);
893 }
894
895 // Handle the end of a gesture of _GestureType. All of pan, scale, and rotate
896 // are handled with GestureDetector's scale gesture.
897 void _onScaleEnd(ScaleEndDetails details) {
898 widget.onInteractionEnd?.call(details);
899 _scaleStart = null;
900 _rotationStart = null;
901 _referenceFocalPoint = null;
902
903 _animation?.removeListener(_onAnimate);
904 _scaleAnimation?.removeListener(_onScaleAnimate);
905 _controller.reset();
906 _scaleController.reset();
907
908 if (!_gestureIsSupported(_gestureType)) {
909 _currentAxis = null;
910 return;
911 }
912
913 if (_gestureType == _GestureType.pan) {
914 if (details.velocity.pixelsPerSecond.distance < kMinFlingVelocity) {
915 _currentAxis = null;
916 return;
917 }
918 final Vector3 translationVector = _transformationController!.value.getTranslation();
919 final Offset translation = Offset(translationVector.x, translationVector.y);
920 final FrictionSimulation frictionSimulationX = FrictionSimulation(
921 widget.interactionEndFrictionCoefficient,
922 translation.dx,
923 details.velocity.pixelsPerSecond.dx,
924 );
925 final FrictionSimulation frictionSimulationY = FrictionSimulation(
926 widget.interactionEndFrictionCoefficient,
927 translation.dy,
928 details.velocity.pixelsPerSecond.dy,
929 );
930 final double tFinal = _getFinalTime(
931 details.velocity.pixelsPerSecond.distance,
932 widget.interactionEndFrictionCoefficient,
933 );
934 _animation = Tween<Offset>(
935 begin: translation,
936 end: Offset(frictionSimulationX.finalX, frictionSimulationY.finalX),
937 ).animate(CurvedAnimation(
938 parent: _controller,
939 curve: Curves.decelerate,
940 ));
941 _controller.duration = Duration(milliseconds: (tFinal * 1000).round());
942 _animation!.addListener(_onAnimate);
943 _controller.forward();
944 } else if (_gestureType == _GestureType.scale) {
945 if (details.scaleVelocity.abs() < 0.1) {
946 _currentAxis = null;
947 return;
948 }
949 final double scale = _transformationController!.value.getMaxScaleOnAxis();
950 final FrictionSimulation frictionSimulation = FrictionSimulation(
951 widget.interactionEndFrictionCoefficient * widget.scaleFactor,
952 scale,
953 details.scaleVelocity / 10
954 );
955 final double tFinal = _getFinalTime(details.scaleVelocity.abs(), widget.interactionEndFrictionCoefficient, effectivelyMotionless: 0.1);
956 _scaleAnimation = Tween<double>(
957 begin: scale,
958 end: frictionSimulation.x(tFinal)
959 ).animate(CurvedAnimation(
960 parent: _scaleController,
961 curve: Curves.decelerate
962 ));
963 _scaleController.duration = Duration(milliseconds: (tFinal * 1000).round());
964 _scaleAnimation!.addListener(_onScaleAnimate);
965 _scaleController.forward();
966 }
967 }
968
969 // Handle mousewheel and web trackpad scroll events.
970 void _receivedPointerSignal(PointerSignalEvent event) {
971 final double scaleChange;
972 if (event is PointerScrollEvent) {
973 if (event.kind == PointerDeviceKind.trackpad && !widget.trackpadScrollCausesScale) {
974 // Trackpad scroll, so treat it as a pan.
975 widget.onInteractionStart?.call(
976 ScaleStartDetails(
977 focalPoint: event.position,
978 localFocalPoint: event.localPosition,
979 ),
980 );
981
982 final Offset localDelta = PointerEvent.transformDeltaViaPositions(
983 untransformedEndPosition: event.position + event.scrollDelta,
984 untransformedDelta: event.scrollDelta,
985 transform: event.transform,
986 );
987
988 if (!_gestureIsSupported(_GestureType.pan)) {
989 widget.onInteractionUpdate?.call(ScaleUpdateDetails(
990 focalPoint: event.position - event.scrollDelta,
991 localFocalPoint: event.localPosition - event.scrollDelta,
992 focalPointDelta: -localDelta,
993 ));
994 widget.onInteractionEnd?.call(ScaleEndDetails());
995 return;
996 }
997
998 final Offset focalPointScene = _transformationController!.toScene(
999 event.localPosition,
1000 );
1001
1002 final Offset newFocalPointScene = _transformationController!.toScene(
1003 event.localPosition - localDelta,
1004 );
1005
1006 _transformationController!.value = _matrixTranslate(
1007 _transformationController!.value,
1008 newFocalPointScene - focalPointScene
1009 );
1010
1011 widget.onInteractionUpdate?.call(ScaleUpdateDetails(
1012 focalPoint: event.position - event.scrollDelta,
1013 localFocalPoint: event.localPosition - localDelta,
1014 focalPointDelta: -localDelta
1015 ));
1016 widget.onInteractionEnd?.call(ScaleEndDetails());
1017 return;
1018 }
1019 // Ignore left and right mouse wheel scroll.
1020 if (event.scrollDelta.dy == 0.0) {
1021 return;
1022 }
1023 scaleChange = math.exp(-event.scrollDelta.dy / widget.scaleFactor);
1024 }
1025 else if (event is PointerScaleEvent) {
1026 scaleChange = event.scale;
1027 }
1028 else {
1029 return;
1030 }
1031 widget.onInteractionStart?.call(
1032 ScaleStartDetails(
1033 focalPoint: event.position,
1034 localFocalPoint: event.localPosition,
1035 ),
1036 );
1037
1038 if (!_gestureIsSupported(_GestureType.scale)) {
1039 widget.onInteractionUpdate?.call(ScaleUpdateDetails(
1040 focalPoint: event.position,
1041 localFocalPoint: event.localPosition,
1042 scale: scaleChange,
1043 ));
1044 widget.onInteractionEnd?.call(ScaleEndDetails());
1045 return;
1046 }
1047
1048 final Offset focalPointScene = _transformationController!.toScene(
1049 event.localPosition,
1050 );
1051
1052 _transformationController!.value = _matrixScale(
1053 _transformationController!.value,
1054 scaleChange,
1055 );
1056
1057 // After scaling, translate such that the event's position is at the
1058 // same scene point before and after the scale.
1059 final Offset focalPointSceneScaled = _transformationController!.toScene(
1060 event.localPosition,
1061 );
1062 _transformationController!.value = _matrixTranslate(
1063 _transformationController!.value,
1064 focalPointSceneScaled - focalPointScene,
1065 );
1066
1067 widget.onInteractionUpdate?.call(ScaleUpdateDetails(
1068 focalPoint: event.position,
1069 localFocalPoint: event.localPosition,
1070 scale: scaleChange,
1071 ));
1072 widget.onInteractionEnd?.call(ScaleEndDetails());
1073 }
1074
1075 // Handle inertia drag animation.
1076 void _onAnimate() {
1077 if (!_controller.isAnimating) {
1078 _currentAxis = null;
1079 _animation?.removeListener(_onAnimate);
1080 _animation = null;
1081 _controller.reset();
1082 return;
1083 }
1084 // Translate such that the resulting translation is _animation.value.
1085 final Vector3 translationVector = _transformationController!.value.getTranslation();
1086 final Offset translation = Offset(translationVector.x, translationVector.y);
1087 final Offset translationScene = _transformationController!.toScene(
1088 translation,
1089 );
1090 final Offset animationScene = _transformationController!.toScene(
1091 _animation!.value,
1092 );
1093 final Offset translationChangeScene = animationScene - translationScene;
1094 _transformationController!.value = _matrixTranslate(
1095 _transformationController!.value,
1096 translationChangeScene,
1097 );
1098 }
1099
1100 // Handle inertia scale animation.
1101 void _onScaleAnimate() {
1102 if (!_scaleController.isAnimating) {
1103 _currentAxis = null;
1104 _scaleAnimation?.removeListener(_onScaleAnimate);
1105 _scaleAnimation = null;
1106 _scaleController.reset();
1107 return;
1108 }
1109 final double desiredScale = _scaleAnimation!.value;
1110 final double scaleChange = desiredScale / _transformationController!.value.getMaxScaleOnAxis();
1111 final Offset referenceFocalPoint = _transformationController!.toScene(
1112 _scaleAnimationFocalPoint,
1113 );
1114 _transformationController!.value = _matrixScale(
1115 _transformationController!.value,
1116 scaleChange,
1117 );
1118
1119 // While scaling, translate such that the user's two fingers stay on
1120 // the same places in the scene. That means that the focal point of
1121 // the scale should be on the same place in the scene before and after
1122 // the scale.
1123 final Offset focalPointSceneScaled = _transformationController!.toScene(
1124 _scaleAnimationFocalPoint,
1125 );
1126 _transformationController!.value = _matrixTranslate(
1127 _transformationController!.value,
1128 focalPointSceneScaled - referenceFocalPoint,
1129 );
1130 }
1131
1132 void _onTransformationControllerChange() {
1133 // A change to the TransformationController's value is a change to the
1134 // state.
1135 setState(() {});
1136 }
1137
1138 @override
1139 void initState() {
1140 super.initState();
1141
1142 _transformationController = widget.transformationController
1143 ?? TransformationController();
1144 _transformationController!.addListener(_onTransformationControllerChange);
1145 _controller = AnimationController(
1146 vsync: this,
1147 );
1148 _scaleController = AnimationController(
1149 vsync: this
1150 );
1151 }
1152
1153 @override
1154 void didUpdateWidget(InteractiveViewer oldWidget) {
1155 super.didUpdateWidget(oldWidget);
1156 // Handle all cases of needing to dispose and initialize
1157 // transformationControllers.
1158 if (oldWidget.transformationController == null) {
1159 if (widget.transformationController != null) {
1160 _transformationController!.removeListener(_onTransformationControllerChange);
1161 _transformationController!.dispose();
1162 _transformationController = widget.transformationController;
1163 _transformationController!.addListener(_onTransformationControllerChange);
1164 }
1165 } else {
1166 if (widget.transformationController == null) {
1167 _transformationController!.removeListener(_onTransformationControllerChange);
1168 _transformationController = TransformationController();
1169 _transformationController!.addListener(_onTransformationControllerChange);
1170 } else if (widget.transformationController != oldWidget.transformationController) {
1171 _transformationController!.removeListener(_onTransformationControllerChange);
1172 _transformationController = widget.transformationController;
1173 _transformationController!.addListener(_onTransformationControllerChange);
1174 }
1175 }
1176 }
1177
1178 @override
1179 void dispose() {
1180 _controller.dispose();
1181 _scaleController.dispose();
1182 _transformationController!.removeListener(_onTransformationControllerChange);
1183 if (widget.transformationController == null) {
1184 _transformationController!.dispose();
1185 }
1186 super.dispose();
1187 }
1188
1189 @override
1190 Widget build(BuildContext context) {
1191 Widget child;
1192 if (widget.child != null) {
1193 child = _InteractiveViewerBuilt(
1194 childKey: _childKey,
1195 clipBehavior: widget.clipBehavior,
1196 constrained: widget.constrained,
1197 matrix: _transformationController!.value,
1198 alignment: widget.alignment,
1199 child: widget.child!,
1200 );
1201 } else {
1202 // When using InteractiveViewer.builder, then constrained is false and the
1203 // viewport is the size of the constraints.
1204 assert(widget.builder != null);
1205 assert(!widget.constrained);
1206 child = LayoutBuilder(
1207 builder: (BuildContext context, BoxConstraints constraints) {
1208 final Matrix4 matrix = _transformationController!.value;
1209 return _InteractiveViewerBuilt(
1210 childKey: _childKey,
1211 clipBehavior: widget.clipBehavior,
1212 constrained: widget.constrained,
1213 alignment: widget.alignment,
1214 matrix: matrix,
1215 child: widget.builder!(
1216 context,
1217 _transformViewport(matrix, Offset.zero & constraints.biggest),
1218 ),
1219 );
1220 },
1221 );
1222 }
1223
1224 return Listener(
1225 key: _parentKey,
1226 onPointerSignal: _receivedPointerSignal,
1227 child: GestureDetector(
1228 behavior: HitTestBehavior.opaque, // Necessary when panning off screen.
1229 onScaleEnd: _onScaleEnd,
1230 onScaleStart: _onScaleStart,
1231 onScaleUpdate: _onScaleUpdate,
1232 trackpadScrollCausesScale: widget.trackpadScrollCausesScale,
1233 trackpadScrollToScaleFactor: Offset(0, -1/widget.scaleFactor),
1234 child: child,
1235 ),
1236 );
1237 }
1238}
1239
1240// This widget allows us to easily swap in and out the LayoutBuilder in
1241// InteractiveViewer's depending on if it's using a builder or a child.
1242class _InteractiveViewerBuilt extends StatelessWidget {
1243 const _InteractiveViewerBuilt({
1244 required this.child,
1245 required this.childKey,
1246 required this.clipBehavior,
1247 required this.constrained,
1248 required this.matrix,
1249 required this.alignment,
1250 });
1251
1252 final Widget child;
1253 final GlobalKey childKey;
1254 final Clip clipBehavior;
1255 final bool constrained;
1256 final Matrix4 matrix;
1257 final Alignment? alignment;
1258
1259 @override
1260 Widget build(BuildContext context) {
1261 Widget child = Transform(
1262 transform: matrix,
1263 alignment: alignment,
1264 child: KeyedSubtree(
1265 key: childKey,
1266 child: this.child,
1267 ),
1268 );
1269
1270 if (!constrained) {
1271 child = OverflowBox(
1272 alignment: Alignment.topLeft,
1273 minWidth: 0.0,
1274 minHeight: 0.0,
1275 maxWidth: double.infinity,
1276 maxHeight: double.infinity,
1277 child: child,
1278 );
1279 }
1280
1281 return ClipRect(
1282 clipBehavior: clipBehavior,
1283 child: child,
1284 );
1285 }
1286}
1287
1288/// A thin wrapper on [ValueNotifier] whose value is a [Matrix4] representing a
1289/// transformation.
1290///
1291/// The [value] defaults to the identity matrix, which corresponds to no
1292/// transformation.
1293///
1294/// See also:
1295///
1296/// * [InteractiveViewer.transformationController] for detailed documentation
1297/// on how to use TransformationController with [InteractiveViewer].
1298class TransformationController extends ValueNotifier<Matrix4> {
1299 /// Create an instance of [TransformationController].
1300 ///
1301 /// The [value] defaults to the identity matrix, which corresponds to no
1302 /// transformation.
1303 TransformationController([Matrix4? value]) : super(value ?? Matrix4.identity());
1304
1305 /// Return the scene point at the given viewport point.
1306 ///
1307 /// A viewport point is relative to the parent while a scene point is relative
1308 /// to the child, regardless of transformation. Calling toScene with a
1309 /// viewport point essentially returns the scene coordinate that lies
1310 /// underneath the viewport point given the transform.
1311 ///
1312 /// The viewport transforms as the inverse of the child (i.e. moving the child
1313 /// left is equivalent to moving the viewport right).
1314 ///
1315 /// This method is often useful when determining where an event on the parent
1316 /// occurs on the child. This example shows how to determine where a tap on
1317 /// the parent occurred on the child.
1318 ///
1319 /// ```dart
1320 /// @override
1321 /// Widget build(BuildContext context) {
1322 /// return GestureDetector(
1323 /// onTapUp: (TapUpDetails details) {
1324 /// _childWasTappedAt = _transformationController.toScene(
1325 /// details.localPosition,
1326 /// );
1327 /// },
1328 /// child: InteractiveViewer(
1329 /// transformationController: _transformationController,
1330 /// child: child,
1331 /// ),
1332 /// );
1333 /// }
1334 /// ```
1335 Offset toScene(Offset viewportPoint) {
1336 // On viewportPoint, perform the inverse transformation of the scene to get
1337 // where the point would be in the scene before the transformation.
1338 final Matrix4 inverseMatrix = Matrix4.inverted(value);
1339 final Vector3 untransformed = inverseMatrix.transform3(Vector3(
1340 viewportPoint.dx,
1341 viewportPoint.dy,
1342 0,
1343 ));
1344 return Offset(untransformed.x, untransformed.y);
1345 }
1346}
1347
1348// A classification of relevant user gestures. Each contiguous user gesture is
1349// represented by exactly one _GestureType.
1350enum _GestureType {
1351 pan,
1352 scale,
1353 rotate,
1354}
1355
1356// Given a velocity and drag, calculate the time at which motion will come to
1357// a stop, within the margin of effectivelyMotionless.
1358double _getFinalTime(double velocity, double drag, {double effectivelyMotionless = 10}) {
1359 return math.log(effectivelyMotionless / velocity) / math.log(drag / 100);
1360}
1361
1362// Return the translation from the given Matrix4 as an Offset.
1363Offset _getMatrixTranslation(Matrix4 matrix) {
1364 final Vector3 nextTranslation = matrix.getTranslation();
1365 return Offset(nextTranslation.x, nextTranslation.y);
1366}
1367
1368// Transform the four corners of the viewport by the inverse of the given
1369// matrix. This gives the viewport after the child has been transformed by the
1370// given matrix. The viewport transforms as the inverse of the child (i.e.
1371// moving the child left is equivalent to moving the viewport right).
1372Quad _transformViewport(Matrix4 matrix, Rect viewport) {
1373 final Matrix4 inverseMatrix = matrix.clone()..invert();
1374 return Quad.points(
1375 inverseMatrix.transform3(Vector3(
1376 viewport.topLeft.dx,
1377 viewport.topLeft.dy,
1378 0.0,
1379 )),
1380 inverseMatrix.transform3(Vector3(
1381 viewport.topRight.dx,
1382 viewport.topRight.dy,
1383 0.0,
1384 )),
1385 inverseMatrix.transform3(Vector3(
1386 viewport.bottomRight.dx,
1387 viewport.bottomRight.dy,
1388 0.0,
1389 )),
1390 inverseMatrix.transform3(Vector3(
1391 viewport.bottomLeft.dx,
1392 viewport.bottomLeft.dy,
1393 0.0,
1394 )),
1395 );
1396}
1397
1398// Find the axis aligned bounding box for the rect rotated about its center by
1399// the given amount.
1400Quad _getAxisAlignedBoundingBoxWithRotation(Rect rect, double rotation) {
1401 final Matrix4 rotationMatrix = Matrix4.identity()
1402 ..translate(rect.size.width / 2, rect.size.height / 2)
1403 ..rotateZ(rotation)
1404 ..translate(-rect.size.width / 2, -rect.size.height / 2);
1405 final Quad boundariesRotated = Quad.points(
1406 rotationMatrix.transform3(Vector3(rect.left, rect.top, 0.0)),
1407 rotationMatrix.transform3(Vector3(rect.right, rect.top, 0.0)),
1408 rotationMatrix.transform3(Vector3(rect.right, rect.bottom, 0.0)),
1409 rotationMatrix.transform3(Vector3(rect.left, rect.bottom, 0.0)),
1410 );
1411 return InteractiveViewer.getAxisAlignedBoundingBox(boundariesRotated);
1412}
1413
1414// Return the amount that viewport lies outside of boundary. If the viewport
1415// is completely contained within the boundary (inclusively), then returns
1416// Offset.zero.
1417Offset _exceedsBy(Quad boundary, Quad viewport) {
1418 final List<Vector3> viewportPoints = <Vector3>[
1419 viewport.point0, viewport.point1, viewport.point2, viewport.point3,
1420 ];
1421 Offset largestExcess = Offset.zero;
1422 for (final Vector3 point in viewportPoints) {
1423 final Vector3 pointInside = InteractiveViewer.getNearestPointInside(point, boundary);
1424 final Offset excess = Offset(
1425 pointInside.x - point.x,
1426 pointInside.y - point.y,
1427 );
1428 if (excess.dx.abs() > largestExcess.dx.abs()) {
1429 largestExcess = Offset(excess.dx, largestExcess.dy);
1430 }
1431 if (excess.dy.abs() > largestExcess.dy.abs()) {
1432 largestExcess = Offset(largestExcess.dx, excess.dy);
1433 }
1434 }
1435
1436 return _round(largestExcess);
1437}
1438
1439// Round the output values. This works around a precision problem where
1440// values that should have been zero were given as within 10^-10 of zero.
1441Offset _round(Offset offset) {
1442 return Offset(
1443 double.parse(offset.dx.toStringAsFixed(9)),
1444 double.parse(offset.dy.toStringAsFixed(9)),
1445 );
1446}
1447
1448// Align the given offset to the given axis by allowing movement only in the
1449// axis direction.
1450Offset _alignAxis(Offset offset, Axis axis) {
1451 switch (axis) {
1452 case Axis.horizontal:
1453 return Offset(offset.dx, 0.0);
1454 case Axis.vertical:
1455 return Offset(0.0, offset.dy);
1456 }
1457}
1458
1459// Given two points, return the axis where the distance between the points is
1460// greatest. If they are equal, return null.
1461Axis? _getPanAxis(Offset point1, Offset point2) {
1462 if (point1 == point2) {
1463 return null;
1464 }
1465 final double x = point2.dx - point1.dx;
1466 final double y = point2.dy - point1.dy;
1467 return x.abs() > y.abs() ? Axis.horizontal : Axis.vertical;
1468}
1469
1470/// This enum is used to specify the behavior of the [InteractiveViewer] when
1471/// the user drags the viewport.
1472enum PanAxis{
1473 /// The user can only pan the viewport along the horizontal axis.
1474 horizontal,
1475
1476 /// The user can only pan the viewport along the vertical axis.
1477 vertical,
1478
1479 /// The user can pan the viewport along the horizontal and vertical axes
1480 /// but not diagonally.
1481 aligned,
1482
1483 /// The user can pan the viewport freely in any direction.
1484 free,
1485}
1486