| 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'; |
| 8 | library; |
| 9 | |
| 10 | import 'dart:math' as math; |
| 11 | |
| 12 | import 'package:flutter/foundation.dart' show clampDouble; |
| 13 | import 'package:flutter/gestures.dart'; |
| 14 | import 'package:flutter/physics.dart'; |
| 15 | import 'package:flutter/rendering.dart'; |
| 16 | import 'package:vector_math/vector_math_64.dart' show Quad, Vector3; |
| 17 | |
| 18 | import 'basic.dart'; |
| 19 | import 'framework.dart'; |
| 20 | import 'gesture_detector.dart'; |
| 21 | import 'layout_builder.dart'; |
| 22 | import '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. |
| 36 | typedef 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 |
| 67 | class 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 | |
| 492 | class _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. |
| 1105 | class _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]. |
| 1155 | class 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. |
| 1205 | enum _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. |
| 1209 | double _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. |
| 1214 | Offset _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). |
| 1223 | Quad _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. |
| 1235 | Quad _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. |
| 1252 | Offset _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. |
| 1276 | Offset _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. |
| 1285 | Offset _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. |
| 1294 | Axis? _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. |
| 1305 | enum 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 | |