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 | import 'dart:math' as math; |
6 | |
7 | import 'package:flutter/foundation.dart' show clampDouble; |
8 | import 'package:flutter/gestures.dart'; |
9 | import 'package:flutter/physics.dart'; |
10 | import 'package:vector_math/vector_math_64.dart' show Matrix4, Quad, Vector3; |
11 | |
12 | import 'basic.dart'; |
13 | import 'framework.dart'; |
14 | import 'gesture_detector.dart'; |
15 | import 'layout_builder.dart'; |
16 | import '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. |
30 | typedef 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 |
61 | class 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 | |
538 | class _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. |
1242 | class _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]. |
1298 | class 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. |
1350 | enum _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. |
1358 | double _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. |
1363 | Offset _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). |
1372 | Quad _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. |
1400 | Quad _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. |
1417 | Offset _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. |
1441 | Offset _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. |
1450 | Offset _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. |
1461 | Axis? _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. |
1472 | enum 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 | |