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