diff --git a/.vscode/settings.json b/.vscode/settings.json index 6ab2822..71cbbf1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -49,6 +49,7 @@ "sublist", "Subrect", "TEXTPAGE", + "trackpad", "unawaited", "uncollapsed", "unregister", diff --git a/example/lib/main.dart b/example/lib/main.dart index 807a2fe..7d1f21c 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:pdfrx/pdfrx.dart'; @@ -26,6 +28,9 @@ class _MyAppState extends State { super.dispose(); } + final _isDesktop = + kIsWeb || Platform.isWindows || Platform.isLinux || Platform.isMacOS; + @override Widget build(BuildContext context) { return MaterialApp( @@ -40,8 +45,11 @@ class _MyAppState extends State { ? 'assets/hello.pdf' : 'https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf'), controller: controller, - displayParams: const PdfViewerParams( + displayParams: PdfViewerParams( maxScale: 8, + // FIXME: if it's desktop, text selection feature is not correctly working now. + // Even on mobile platforms, it is still very experimental. Please take extreme care when using it. + enableTextSelection: !_isDesktop, ), ), AnimatedPositioned( diff --git a/lib/src/interactive_viewer.dart b/lib/src/interactive_viewer.dart new file mode 100644 index 0000000..87851be --- /dev/null +++ b/lib/src/interactive_viewer.dart @@ -0,0 +1,1404 @@ +// ------------------------------------------------------------ +// FORKED FROM Flutter's original InteractiveViewer.dart +// ------------------------------------------------------------ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:math' as math; + +import 'package:flutter/foundation.dart' show clampDouble; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/physics.dart'; +import 'package:vector_math/vector_math_64.dart' show Matrix4, Quad, Vector3; + +/// [**FORKED VERSION**] A widget that enables pan and zoom interactions with its child. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=zrn7V3bMJvg} +/// +/// The user can transform the child by dragging to pan or pinching to zoom. +/// +/// By default, InteractiveViewer clips its child using [Clip.hardEdge]. +/// To prevent this behavior, consider setting [clipBehavior] to [Clip.none]. +/// When [clipBehavior] is [Clip.none], InteractiveViewer may draw outside of +/// its original area of the screen, such as when a child is zoomed in and +/// To prevent dead areas where InteractiveViewer does not receive gestures, +/// don't set [clipBehavior] or be sure that the InteractiveViewer widget is the +/// size of the area that should be interactive. +/// +/// See also: +/// * The [Flutter Gallery's transformations demo](https://github.com/flutter/gallery/blob/master/lib/demos/reference/transformations_demo.dart), +/// which includes the use of InteractiveViewer. +/// * The [flutter-go demo](https://github.com/justinmc/flutter-go), which includes robust positioning of an InteractiveViewer child +/// that works for all screen sizes and child sizes. +/// * The [Lazy Flutter Performance Session](https://www.youtube.com/watch?v=qax_nOpgz7E), which includes the use of an InteractiveViewer to +/// performantly view subsets of a large set of widgets using the builder constructor. +/// +/// {@tool dartpad} +/// This example shows a simple Container that can be panned and zoomed. +/// +/// ** See code in examples/api/lib/widgets/interactive_viewer/interactive_viewer.0.dart ** +/// {@end-tool} +@immutable +class InteractiveViewer extends StatefulWidget { + /// Create an InteractiveViewer. + InteractiveViewer({ + required this.child, + super.key, + this.clipBehavior = Clip.hardEdge, + this.panAxis = PanAxis.free, + this.boundaryMargin = EdgeInsets.zero, + this.constrained = true, + // These default scale values were eyeballed as reasonable limits for common + // use cases. + this.maxScale = 2.5, + this.minScale = 0.8, + this.interactionEndFrictionCoefficient = _kDrag, + this.onInteractionEnd, + this.onInteractionStart, + this.onInteractionUpdate, + this.panEnabled = true, + this.scaleEnabled = true, + this.scaleFactor = kDefaultMouseScrollToScaleFactor, + this.transformationController, + this.alignment, + this.trackpadScrollCausesScale = false, + this.onWheelDelta, + }) : assert(minScale > 0), + assert(interactionEndFrictionCoefficient > 0), + assert(minScale.isFinite), + assert(maxScale > 0), + assert(!maxScale.isNaN), + assert(maxScale >= minScale), + // boundaryMargin must be either fully infinite or fully finite, but not + // a mix of both. + assert( + (boundaryMargin.horizontal.isInfinite && + boundaryMargin.vertical.isInfinite) || + (boundaryMargin.top.isFinite && + boundaryMargin.right.isFinite && + boundaryMargin.bottom.isFinite && + boundaryMargin.left.isFinite), + ), + builder = null; + + /// Creates an InteractiveViewer for a child that is created on demand. + /// + /// Can be used to render a child that changes in response to the current + /// transformation. + /// + /// See the [builder] attribute docs for an example of using it to optimize a + /// large child. + InteractiveViewer.builder({ + required InteractiveViewerWidgetBuilder this.builder, + super.key, + this.clipBehavior = Clip.hardEdge, + this.panAxis = PanAxis.free, + this.boundaryMargin = EdgeInsets.zero, + // These default scale values were eyeballed as reasonable limits for common + // use cases. + this.maxScale = 2.5, + this.minScale = 0.8, + this.interactionEndFrictionCoefficient = _kDrag, + this.onInteractionEnd, + this.onInteractionStart, + this.onInteractionUpdate, + this.panEnabled = true, + this.scaleEnabled = true, + this.scaleFactor = 200.0, + this.transformationController, + this.alignment, + this.trackpadScrollCausesScale = false, + this.onWheelDelta, + }) : assert(minScale > 0), + assert(interactionEndFrictionCoefficient > 0), + assert(minScale.isFinite), + assert(maxScale > 0), + assert(!maxScale.isNaN), + assert(maxScale >= minScale), + // boundaryMargin must be either fully infinite or fully finite, but not + // a mix of both. + assert( + (boundaryMargin.horizontal.isInfinite && + boundaryMargin.vertical.isInfinite) || + (boundaryMargin.top.isFinite && + boundaryMargin.right.isFinite && + boundaryMargin.bottom.isFinite && + boundaryMargin.left.isFinite), + ), + constrained = false, + child = null; + + /// The alignment of the child's origin, relative to the size of the box. + final Alignment? alignment; + + /// If set to [Clip.none], the child may extend beyond the size of the InteractiveViewer, + /// but it will not receive gestures in these areas. + /// Be sure that the InteractiveViewer is the desired size when using [Clip.none]. + /// + /// Defaults to [Clip.hardEdge]. + final Clip clipBehavior; + + /// When set to [PanAxis.aligned], panning is only allowed in the horizontal + /// axis or the vertical axis, diagonal panning is not allowed. + /// + /// When set to [PanAxis.vertical] or [PanAxis.horizontal] panning is only + /// allowed in the specified axis. For example, if set to [PanAxis.vertical], + /// panning will only be allowed in the vertical axis. And if set to [PanAxis.horizontal], + /// panning will only be allowed in the horizontal axis. + /// + /// When set to [PanAxis.free] panning is allowed in all directions. + /// + /// Defaults to [PanAxis.free]. + final PanAxis panAxis; + + /// A margin for the visible boundaries of the child. + /// + /// Any transformation that results in the viewport being able to view outside + /// of the boundaries will be stopped at the boundary. The boundaries do not + /// rotate with the rest of the scene, so they are always aligned with the + /// viewport. + /// + /// To produce no boundaries at all, pass infinite [EdgeInsets], such as + /// `EdgeInsets.all(double.infinity)`. + /// + /// No edge can be NaN. + /// + /// Defaults to [EdgeInsets.zero], which results in boundaries that are the + /// exact same size and position as the [child]. + final EdgeInsets boundaryMargin; + + /// Builds the child of this widget. + /// + /// Passed with the [InteractiveViewer.builder] constructor. Otherwise, the + /// [child] parameter must be passed directly, and this is null. + /// + /// {@tool dartpad} + /// This example shows how to use builder to create a [Table] whose cell + /// contents are only built when they are visible. Built and remove cells are + /// logged in the console for illustration. + /// + /// ** See code in examples/api/lib/widgets/interactive_viewer/interactive_viewer.builder.0.dart ** + /// {@end-tool} + /// + /// See also: + /// + /// * [ListView.builder], which follows a similar pattern. + final InteractiveViewerWidgetBuilder? builder; + + /// The child [Widget] that is transformed by InteractiveViewer. + /// + /// If the [InteractiveViewer.builder] constructor is used, then this will be + /// null, otherwise it is required. + final Widget? child; + + /// Whether the normal size constraints at this point in the widget tree are + /// applied to the child. + /// + /// If set to false, then the child will be given infinite constraints. This + /// is often useful when a child should be bigger than the InteractiveViewer. + /// + /// For example, for a child which is bigger than the viewport but can be + /// panned to reveal parts that were initially offscreen, [constrained] must + /// be set to false to allow it to size itself properly. If [constrained] is + /// true and the child can only size itself to the viewport, then areas + /// initially outside of the viewport will not be able to receive user + /// interaction events. If experiencing regions of the child that are not + /// receptive to user gestures, make sure [constrained] is false and the child + /// is sized properly. + /// + /// Defaults to true. + /// + /// {@tool dartpad} + /// This example shows how to create a pannable table. Because the table is + /// larger than the entire screen, setting [constrained] to false is necessary + /// to allow it to be drawn to its full size. The parts of the table that + /// exceed the screen size can then be panned into view. + /// + /// ** See code in examples/api/lib/widgets/interactive_viewer/interactive_viewer.constrained.0.dart ** + /// {@end-tool} + final bool constrained; + + /// If false, the user will be prevented from panning. + /// + /// Defaults to true. + /// + /// See also: + /// + /// * [scaleEnabled], which is similar but for scale. + final bool panEnabled; + + /// If false, the user will be prevented from scaling. + /// + /// Defaults to true. + /// + /// See also: + /// + /// * [panEnabled], which is similar but for panning. + final bool scaleEnabled; + + /// {@macro flutter.gestures.scale.trackpadScrollCausesScale} + final bool trackpadScrollCausesScale; + + /// Determines the amount of scale to be performed per pointer scroll. + /// + /// Defaults to [kDefaultMouseScrollToScaleFactor]. + /// + /// Increasing this value above the default causes scaling to feel slower, + /// while decreasing it causes scaling to feel faster. + /// + /// The amount of scale is calculated as the exponential function of the + /// [PointerScrollEvent.scrollDelta] to [scaleFactor] ratio. In the Flutter + /// engine, the mousewheel [PointerScrollEvent.scrollDelta] is hardcoded to 20 + /// per scroll, while a trackpad scroll can be any amount. + /// + /// Affects only pointer device scrolling, not pinch to zoom. + final double scaleFactor; + + /// The maximum allowed scale. + /// + /// The scale will be clamped between this and [minScale] inclusively. + /// + /// Defaults to 2.5. + /// + /// Must be greater than zero and greater than [minScale]. + final double maxScale; + + /// The minimum allowed scale. + /// + /// The scale will be clamped between this and [maxScale] inclusively. + /// + /// Scale is also affected by [boundaryMargin]. If the scale would result in + /// viewing beyond the boundary, then it will not be allowed. By default, + /// boundaryMargin is EdgeInsets.zero, so scaling below 1.0 will not be + /// allowed in most cases without first increasing the boundaryMargin. + /// + /// Defaults to 0.8. + /// + /// Must be a finite number greater than zero and less than [maxScale]. + final double minScale; + + /// Changes the deceleration behavior after a gesture. + /// + /// Defaults to 0.0000135. + /// + /// Must be a finite number greater than zero. + final double interactionEndFrictionCoefficient; + + /// Called when the user ends a pan or scale gesture on the widget. + /// + /// At the time this is called, the [TransformationController] will have + /// already been updated to reflect the change caused by the interaction, + /// though a pan may cause an inertia animation after this is called as well. + /// + /// {@template flutter.widgets.InteractiveViewer.onInteractionEnd} + /// Will be called even if the interaction is disabled with [panEnabled] or + /// [scaleEnabled] for both touch gestures and mouse interactions. + /// + /// A [GestureDetector] wrapping the InteractiveViewer will not respond to + /// [GestureDetector.onScaleStart], [GestureDetector.onScaleUpdate], and + /// [GestureDetector.onScaleEnd]. Use [onInteractionStart], + /// [onInteractionUpdate], and [onInteractionEnd] to respond to those + /// gestures. + /// {@endtemplate} + /// + /// See also: + /// + /// * [onInteractionStart], which handles the start of the same interaction. + /// * [onInteractionUpdate], which handles an update to the same interaction. + final GestureScaleEndCallback? onInteractionEnd; + + /// Called when the user begins a pan or scale gesture on the widget. + /// + /// At the time this is called, the [TransformationController] will not have + /// changed due to this interaction. + /// + /// {@macro flutter.widgets.InteractiveViewer.onInteractionEnd} + /// + /// The coordinates provided in the details' `focalPoint` and + /// `localFocalPoint` are normal Flutter event coordinates, not + /// InteractiveViewer scene coordinates. See + /// [TransformationController.toScene] for how to convert these coordinates to + /// scene coordinates relative to the child. + /// + /// See also: + /// + /// * [onInteractionUpdate], which handles an update to the same interaction. + /// * [onInteractionEnd], which handles the end of the same interaction. + final GestureScaleStartCallback? onInteractionStart; + + /// Called when the user updates a pan or scale gesture on the widget. + /// + /// At the time this is called, the [TransformationController] will have + /// already been updated to reflect the change caused by the interaction, if + /// the interaction caused the matrix to change. + /// + /// {@macro flutter.widgets.InteractiveViewer.onInteractionEnd} + /// + /// The coordinates provided in the details' `focalPoint` and + /// `localFocalPoint` are normal Flutter event coordinates, not + /// InteractiveViewer scene coordinates. See + /// [TransformationController.toScene] for how to convert these coordinates to + /// scene coordinates relative to the child. + /// + /// See also: + /// + /// * [onInteractionStart], which handles the start of the same interaction. + /// * [onInteractionEnd], which handles the end of the same interaction. + final GestureScaleUpdateCallback? onInteractionUpdate; + + /// A [TransformationController] for the transformation performed on the + /// child. + /// + /// Whenever the child is transformed, the [Matrix4] value is updated and all + /// listeners are notified. If the value is set, InteractiveViewer will update + /// to respect the new value. + /// + /// {@tool dartpad} + /// This example shows how transformationController can be used to animate the + /// transformation back to its starting position. + /// + /// ** See code in examples/api/lib/widgets/interactive_viewer/interactive_viewer.transformation_controller.0.dart ** + /// {@end-tool} + /// + /// See also: + /// + /// * [ValueNotifier], the parent class of TransformationController. + /// * [TextEditingController] for an example of another similar pattern. + final TransformationController? transformationController; + + /// To override the default mouse wheel behavior. + /// + final void Function(Offset scrollDelta)? onWheelDelta; + + // Used as the coefficient of friction in the inertial translation animation. + // This value was eyeballed to give a feel similar to Google Photos. + static const double _kDrag = 0.0000135; + + /// Returns the closest point to the given point on the given line segment. + @visibleForTesting + static Vector3 getNearestPointOnLine(Vector3 point, Vector3 l1, Vector3 l2) { + final double lengthSquared = math.pow(l2.x - l1.x, 2.0).toDouble() + + math.pow(l2.y - l1.y, 2.0).toDouble(); + + // In this case, l1 == l2. + if (lengthSquared == 0) { + return l1; + } + + // Calculate how far down the line segment the closest point is and return + // the point. + final Vector3 l1P = point - l1; + final Vector3 l1L2 = l2 - l1; + final double fraction = + clampDouble(l1P.dot(l1L2) / lengthSquared, 0.0, 1.0); + return l1 + l1L2 * fraction; + } + + /// Given a quad, return its axis aligned bounding box. + @visibleForTesting + static Quad getAxisAlignedBoundingBox(Quad quad) { + final double minX = math.min( + quad.point0.x, + math.min( + quad.point1.x, + math.min( + quad.point2.x, + quad.point3.x, + ), + ), + ); + final double minY = math.min( + quad.point0.y, + math.min( + quad.point1.y, + math.min( + quad.point2.y, + quad.point3.y, + ), + ), + ); + final double maxX = math.max( + quad.point0.x, + math.max( + quad.point1.x, + math.max( + quad.point2.x, + quad.point3.x, + ), + ), + ); + final double maxY = math.max( + quad.point0.y, + math.max( + quad.point1.y, + math.max( + quad.point2.y, + quad.point3.y, + ), + ), + ); + return Quad.points( + Vector3(minX, minY, 0), + Vector3(maxX, minY, 0), + Vector3(maxX, maxY, 0), + Vector3(minX, maxY, 0), + ); + } + + /// Returns true iff the point is inside the rectangle given by the Quad, + /// inclusively. + /// Algorithm from https://math.stackexchange.com/a/190373. + @visibleForTesting + static bool pointIsInside(Vector3 point, Quad quad) { + final Vector3 aM = point - quad.point0; + final Vector3 aB = quad.point1 - quad.point0; + final Vector3 aD = quad.point3 - quad.point0; + + final double aMAB = aM.dot(aB); + final double aBAB = aB.dot(aB); + final double aMAD = aM.dot(aD); + final double aDAD = aD.dot(aD); + + return 0 <= aMAB && aMAB <= aBAB && 0 <= aMAD && aMAD <= aDAD; + } + + /// Get the point inside (inclusively) the given Quad that is nearest to the + /// given Vector3. + @visibleForTesting + static Vector3 getNearestPointInside(Vector3 point, Quad quad) { + // If the point is inside the axis aligned bounding box, then it's ok where + // it is. + if (pointIsInside(point, quad)) { + return point; + } + + // Otherwise, return the nearest point on the quad. + final List closestPoints = [ + InteractiveViewer.getNearestPointOnLine(point, quad.point0, quad.point1), + InteractiveViewer.getNearestPointOnLine(point, quad.point1, quad.point2), + InteractiveViewer.getNearestPointOnLine(point, quad.point2, quad.point3), + InteractiveViewer.getNearestPointOnLine(point, quad.point3, quad.point0), + ]; + double minDistance = double.infinity; + late Vector3 closestOverall; + for (final Vector3 closePoint in closestPoints) { + final double distance = math.sqrt( + math.pow(point.x - closePoint.x, 2) + + math.pow(point.y - closePoint.y, 2), + ); + if (distance < minDistance) { + minDistance = distance; + closestOverall = closePoint; + } + } + return closestOverall; + } + + @override + State createState() => _InteractiveViewerState(); +} + +class _InteractiveViewerState extends State + with TickerProviderStateMixin { + TransformationController? _transformationController; + + final GlobalKey _childKey = GlobalKey(); + final GlobalKey _parentKey = GlobalKey(); + Animation? _animation; + Animation? _scaleAnimation; + late Offset _scaleAnimationFocalPoint; + late AnimationController _controller; + late AnimationController _scaleController; + Axis? _currentAxis; // Used with panAxis. + Offset? _referenceFocalPoint; // Point where the current gesture began. + double? _scaleStart; // Scale value at start of scaling gesture. + double? _rotationStart = 0.0; // Rotation at start of rotation gesture. + double _currentRotation = 0.0; // Rotation of _transformationController.value. + _GestureType? _gestureType; + + // TODO(justinmc): Add rotateEnabled parameter to the widget and remove this + // hardcoded value when the rotation feature is implemented. + // https://github.com/flutter/flutter/issues/57698 + final bool _rotateEnabled = false; + + // The _boundaryRect is calculated by adding the boundaryMargin to the size of + // the child. + Rect get _boundaryRect { + assert(_childKey.currentContext != null); + assert(!widget.boundaryMargin.left.isNaN); + assert(!widget.boundaryMargin.right.isNaN); + assert(!widget.boundaryMargin.top.isNaN); + assert(!widget.boundaryMargin.bottom.isNaN); + + final RenderBox childRenderBox = + _childKey.currentContext!.findRenderObject()! as RenderBox; + final Size childSize = childRenderBox.size; + final Rect boundaryRect = + widget.boundaryMargin.inflateRect(Offset.zero & childSize); + assert( + !boundaryRect.isEmpty, + "InteractiveViewer's child must have nonzero dimensions.", + ); + // Boundaries that are partially infinite are not allowed because Matrix4's + // rotation and translation methods don't handle infinites well. + assert( + boundaryRect.isFinite || + (boundaryRect.left.isInfinite && + boundaryRect.top.isInfinite && + boundaryRect.right.isInfinite && + boundaryRect.bottom.isInfinite), + 'boundaryRect must either be infinite in all directions or finite in all directions.', + ); + return boundaryRect; + } + + // The Rect representing the child's parent. + Rect get _viewport { + assert(_parentKey.currentContext != null); + final RenderBox parentRenderBox = + _parentKey.currentContext!.findRenderObject()! as RenderBox; + return Offset.zero & parentRenderBox.size; + } + + // Return a new matrix representing the given matrix after applying the given + // translation. + Matrix4 _matrixTranslate(Matrix4 matrix, Offset translation) { + if (translation == Offset.zero) { + return matrix.clone(); + } + + late final Offset alignedTranslation; + + if (_currentAxis != null) { + switch (widget.panAxis) { + case PanAxis.horizontal: + alignedTranslation = _alignAxis(translation, Axis.horizontal); + case PanAxis.vertical: + alignedTranslation = _alignAxis(translation, Axis.vertical); + case PanAxis.aligned: + alignedTranslation = _alignAxis(translation, _currentAxis!); + case PanAxis.free: + alignedTranslation = translation; + } + } else { + alignedTranslation = translation; + } + + final Matrix4 nextMatrix = matrix.clone() + ..translate( + alignedTranslation.dx, + alignedTranslation.dy, + ); + + // Transform the viewport to determine where its four corners will be after + // the child has been transformed. + final Quad nextViewport = _transformViewport(nextMatrix, _viewport); + + // If the boundaries are infinite, then no need to check if the translation + // fits within them. + if (_boundaryRect.isInfinite) { + return nextMatrix; + } + + // Expand the boundaries with rotation. This prevents the problem where a + // mismatch in orientation between the viewport and boundaries effectively + // limits translation. With this approach, all points that are visible with + // no rotation are visible after rotation. + final Quad boundariesAabbQuad = _getAxisAlignedBoundingBoxWithRotation( + _boundaryRect, + _currentRotation, + ); + + // If the given translation fits completely within the boundaries, allow it. + final Offset offendingDistance = + _exceedsBy(boundariesAabbQuad, nextViewport); + if (offendingDistance == Offset.zero) { + return nextMatrix; + } + + // Desired translation goes out of bounds, so translate to the nearest + // in-bounds point instead. + final Offset nextTotalTranslation = _getMatrixTranslation(nextMatrix); + final double currentScale = matrix.getMaxScaleOnAxis(); + final Offset correctedTotalTranslation = Offset( + nextTotalTranslation.dx - offendingDistance.dx * currentScale, + nextTotalTranslation.dy - offendingDistance.dy * currentScale, + ); + // TODO(justinmc): This needs some work to handle rotation properly. The + // idea is that the boundaries are axis aligned (boundariesAabbQuad), but + // calculating the translation to put the viewport inside that Quad is more + // complicated than this when rotated. + // https://github.com/flutter/flutter/issues/57698 + final Matrix4 correctedMatrix = matrix.clone() + ..setTranslation(Vector3( + correctedTotalTranslation.dx, + correctedTotalTranslation.dy, + 0.0, + )); + + // Double check that the corrected translation fits. + final Quad correctedViewport = + _transformViewport(correctedMatrix, _viewport); + final Offset offendingCorrectedDistance = + _exceedsBy(boundariesAabbQuad, correctedViewport); + if (offendingCorrectedDistance == Offset.zero) { + return correctedMatrix; + } + + // If the corrected translation doesn't fit in either direction, don't allow + // any translation at all. This happens when the viewport is larger than the + // entire boundary. + if (offendingCorrectedDistance.dx != 0.0 && + offendingCorrectedDistance.dy != 0.0) { + return matrix.clone(); + } + + // Otherwise, allow translation in only the direction that fits. This + // happens when the viewport is larger than the boundary in one direction. + final Offset unidirectionalCorrectedTotalTranslation = Offset( + offendingCorrectedDistance.dx == 0.0 ? correctedTotalTranslation.dx : 0.0, + offendingCorrectedDistance.dy == 0.0 ? correctedTotalTranslation.dy : 0.0, + ); + return matrix.clone() + ..setTranslation(Vector3( + unidirectionalCorrectedTotalTranslation.dx, + unidirectionalCorrectedTotalTranslation.dy, + 0.0, + )); + } + + // Return a new matrix representing the given matrix after applying the given + // scale. + Matrix4 _matrixScale(Matrix4 matrix, double scale) { + if (scale == 1.0) { + return matrix.clone(); + } + assert(scale != 0.0); + + // Don't allow a scale that results in an overall scale beyond min/max + // scale. + final double currentScale = + _transformationController!.value.getMaxScaleOnAxis(); + final double totalScale = math.max( + currentScale * scale, + // Ensure that the scale cannot make the child so big that it can't fit + // inside the boundaries (in either direction). + math.max( + _viewport.width / _boundaryRect.width, + _viewport.height / _boundaryRect.height, + ), + ); + final double clampedTotalScale = clampDouble( + totalScale, + widget.minScale, + widget.maxScale, + ); + final double clampedScale = clampedTotalScale / currentScale; + return matrix.clone()..scale(clampedScale); + } + + // Return a new matrix representing the given matrix after applying the given + // rotation. + Matrix4 _matrixRotate(Matrix4 matrix, double rotation, Offset focalPoint) { + if (rotation == 0) { + return matrix.clone(); + } + final Offset focalPointScene = _transformationController!.toScene( + focalPoint, + ); + return matrix.clone() + ..translate(focalPointScene.dx, focalPointScene.dy) + ..rotateZ(-rotation) + ..translate(-focalPointScene.dx, -focalPointScene.dy); + } + + // Returns true iff the given _GestureType is enabled. + bool _gestureIsSupported(_GestureType? gestureType) { + switch (gestureType) { + case _GestureType.rotate: + return _rotateEnabled; + + case _GestureType.scale: + return widget.scaleEnabled; + + case _GestureType.pan: + case null: + return widget.panEnabled; + } + } + + // Decide which type of gesture this is by comparing the amount of scale + // and rotation in the gesture, if any. Scale starts at 1 and rotation + // starts at 0. Pan will have no scale and no rotation because it uses only one + // finger. + _GestureType _getGestureType(ScaleUpdateDetails details) { + final double scale = !widget.scaleEnabled ? 1.0 : details.scale; + final double rotation = !_rotateEnabled ? 0.0 : details.rotation; + if ((scale - 1).abs() > rotation.abs()) { + return _GestureType.scale; + } else if (rotation != 0.0) { + return _GestureType.rotate; + } else { + return _GestureType.pan; + } + } + + // Handle the start of a gesture. All of pan, scale, and rotate are handled + // with GestureDetector's scale gesture. + void _onScaleStart(ScaleStartDetails details) { + widget.onInteractionStart?.call(details); + + if (_controller.isAnimating) { + _controller.stop(); + _controller.reset(); + _animation?.removeListener(_onAnimate); + _animation = null; + } + if (_scaleController.isAnimating) { + _scaleController.stop(); + _scaleController.reset(); + _scaleAnimation?.removeListener(_onScaleAnimate); + _scaleAnimation = null; + } + + _gestureType = null; + _currentAxis = null; + _scaleStart = _transformationController!.value.getMaxScaleOnAxis(); + _referenceFocalPoint = _transformationController!.toScene( + details.localFocalPoint, + ); + _rotationStart = _currentRotation; + } + + // Handle an update to an ongoing gesture. All of pan, scale, and rotate are + // handled with GestureDetector's scale gesture. + void _onScaleUpdate(ScaleUpdateDetails details) { + final double scale = _transformationController!.value.getMaxScaleOnAxis(); + _scaleAnimationFocalPoint = details.localFocalPoint; + final Offset focalPointScene = _transformationController!.toScene( + details.localFocalPoint, + ); + + if (_gestureType == _GestureType.pan) { + // When a gesture first starts, it sometimes has no change in scale and + // rotation despite being a two-finger gesture. Here the gesture is + // allowed to be reinterpreted as its correct type after originally + // being marked as a pan. + _gestureType = _getGestureType(details); + } else { + _gestureType ??= _getGestureType(details); + } + if (!_gestureIsSupported(_gestureType)) { + widget.onInteractionUpdate?.call(details); + return; + } + + switch (_gestureType!) { + case _GestureType.scale: + assert(_scaleStart != null); + // details.scale gives us the amount to change the scale as of the + // start of this gesture, so calculate the amount to scale as of the + // previous call to _onScaleUpdate. + final double desiredScale = _scaleStart! * details.scale; + final double scaleChange = desiredScale / scale; + _transformationController!.value = _matrixScale( + _transformationController!.value, + scaleChange, + ); + + // While scaling, translate such that the user's two fingers stay on + // the same places in the scene. That means that the focal point of + // the scale should be on the same place in the scene before and after + // the scale. + final Offset focalPointSceneScaled = _transformationController!.toScene( + details.localFocalPoint, + ); + _transformationController!.value = _matrixTranslate( + _transformationController!.value, + focalPointSceneScaled - _referenceFocalPoint!, + ); + + // details.localFocalPoint should now be at the same location as the + // original _referenceFocalPoint point. If it's not, that's because + // the translate came in contact with a boundary. In that case, update + // _referenceFocalPoint so subsequent updates happen in relation to + // the new effective focal point. + final Offset focalPointSceneCheck = _transformationController!.toScene( + details.localFocalPoint, + ); + if (_round(_referenceFocalPoint!) != _round(focalPointSceneCheck)) { + _referenceFocalPoint = focalPointSceneCheck; + } + + case _GestureType.rotate: + if (details.rotation == 0.0) { + widget.onInteractionUpdate?.call(details); + return; + } + final double desiredRotation = _rotationStart! + details.rotation; + _transformationController!.value = _matrixRotate( + _transformationController!.value, + _currentRotation - desiredRotation, + details.localFocalPoint, + ); + _currentRotation = desiredRotation; + + case _GestureType.pan: + assert(_referenceFocalPoint != null); + // details may have a change in scale here when scaleEnabled is false. + // In an effort to keep the behavior similar whether or not scaleEnabled + // is true, these gestures are thrown away. + if (details.scale != 1.0) { + widget.onInteractionUpdate?.call(details); + return; + } + _currentAxis ??= _getPanAxis(_referenceFocalPoint!, focalPointScene); + // Translate so that the same point in the scene is underneath the + // focal point before and after the movement. + final Offset translationChange = + focalPointScene - _referenceFocalPoint!; + _transformationController!.value = _matrixTranslate( + _transformationController!.value, + translationChange, + ); + _referenceFocalPoint = _transformationController!.toScene( + details.localFocalPoint, + ); + } + widget.onInteractionUpdate?.call(details); + } + + // Handle the end of a gesture of _GestureType. All of pan, scale, and rotate + // are handled with GestureDetector's scale gesture. + void _onScaleEnd(ScaleEndDetails details) { + widget.onInteractionEnd?.call(details); + _scaleStart = null; + _rotationStart = null; + _referenceFocalPoint = null; + + _animation?.removeListener(_onAnimate); + _scaleAnimation?.removeListener(_onScaleAnimate); + _controller.reset(); + _scaleController.reset(); + + if (!_gestureIsSupported(_gestureType)) { + _currentAxis = null; + return; + } + + if (_gestureType == _GestureType.pan) { + if (details.velocity.pixelsPerSecond.distance < kMinFlingVelocity) { + _currentAxis = null; + return; + } + final Vector3 translationVector = + _transformationController!.value.getTranslation(); + final Offset translation = + Offset(translationVector.x, translationVector.y); + final FrictionSimulation frictionSimulationX = FrictionSimulation( + widget.interactionEndFrictionCoefficient, + translation.dx, + details.velocity.pixelsPerSecond.dx, + ); + final FrictionSimulation frictionSimulationY = FrictionSimulation( + widget.interactionEndFrictionCoefficient, + translation.dy, + details.velocity.pixelsPerSecond.dy, + ); + final double tFinal = _getFinalTime( + details.velocity.pixelsPerSecond.distance, + widget.interactionEndFrictionCoefficient, + ); + _animation = Tween( + begin: translation, + end: Offset(frictionSimulationX.finalX, frictionSimulationY.finalX), + ).animate(CurvedAnimation( + parent: _controller, + curve: Curves.decelerate, + )); + _controller.duration = Duration(milliseconds: (tFinal * 1000).round()); + _animation!.addListener(_onAnimate); + _controller.forward(); + } else if (_gestureType == _GestureType.scale) { + if (details.scaleVelocity.abs() < 0.1) { + _currentAxis = null; + return; + } + final double scale = _transformationController!.value.getMaxScaleOnAxis(); + final FrictionSimulation frictionSimulation = FrictionSimulation( + widget.interactionEndFrictionCoefficient * widget.scaleFactor, + scale, + details.scaleVelocity / 10); + final double tFinal = _getFinalTime( + details.scaleVelocity.abs(), widget.interactionEndFrictionCoefficient, + effectivelyMotionless: 0.1); + _scaleAnimation = + Tween(begin: scale, end: frictionSimulation.x(tFinal)) + .animate(CurvedAnimation( + parent: _scaleController, curve: Curves.decelerate)); + _scaleController.duration = + Duration(milliseconds: (tFinal * 1000).round()); + _scaleAnimation!.addListener(_onScaleAnimate); + _scaleController.forward(); + } + } + + // Handle mousewheel and web trackpad scroll events. + void _receivedPointerSignal(PointerSignalEvent event) { + final double scaleChange; + if (event is PointerScrollEvent) { + if (event.kind == PointerDeviceKind.trackpad && + !widget.trackpadScrollCausesScale) { + // Trackpad scroll, so treat it as a pan. + widget.onInteractionStart?.call( + ScaleStartDetails( + focalPoint: event.position, + localFocalPoint: event.localPosition, + ), + ); + + final Offset localDelta = PointerEvent.transformDeltaViaPositions( + untransformedEndPosition: event.position + event.scrollDelta, + untransformedDelta: event.scrollDelta, + transform: event.transform, + ); + + if (!_gestureIsSupported(_GestureType.pan)) { + widget.onInteractionUpdate?.call(ScaleUpdateDetails( + focalPoint: event.position - event.scrollDelta, + localFocalPoint: event.localPosition - event.scrollDelta, + focalPointDelta: -localDelta, + )); + widget.onInteractionEnd?.call(ScaleEndDetails()); + return; + } + + final Offset focalPointScene = _transformationController!.toScene( + event.localPosition, + ); + + final Offset newFocalPointScene = _transformationController!.toScene( + event.localPosition - localDelta, + ); + + _transformationController!.value = _matrixTranslate( + _transformationController!.value, + newFocalPointScene - focalPointScene); + + widget.onInteractionUpdate?.call(ScaleUpdateDetails( + focalPoint: event.position - event.scrollDelta, + localFocalPoint: event.localPosition - localDelta, + focalPointDelta: -localDelta)); + widget.onInteractionEnd?.call(ScaleEndDetails()); + return; + } + + // We can handle mouse-wheel event here for our own purposes + if (widget.onWheelDelta != null) { + widget.onWheelDelta!(event.scrollDelta); + return; + } + + // Ignore left and right mouse wheel scroll. + if (event.scrollDelta.dy == 0.0) { + return; + } + scaleChange = math.exp(-event.scrollDelta.dy / widget.scaleFactor); + } else if (event is PointerScaleEvent) { + scaleChange = event.scale; + } else { + return; + } + widget.onInteractionStart?.call( + ScaleStartDetails( + focalPoint: event.position, + localFocalPoint: event.localPosition, + ), + ); + + if (!_gestureIsSupported(_GestureType.scale)) { + widget.onInteractionUpdate?.call(ScaleUpdateDetails( + focalPoint: event.position, + localFocalPoint: event.localPosition, + scale: scaleChange, + )); + widget.onInteractionEnd?.call(ScaleEndDetails()); + return; + } + + final Offset focalPointScene = _transformationController!.toScene( + event.localPosition, + ); + + _transformationController!.value = _matrixScale( + _transformationController!.value, + scaleChange, + ); + + // After scaling, translate such that the event's position is at the + // same scene point before and after the scale. + final Offset focalPointSceneScaled = _transformationController!.toScene( + event.localPosition, + ); + _transformationController!.value = _matrixTranslate( + _transformationController!.value, + focalPointSceneScaled - focalPointScene, + ); + + widget.onInteractionUpdate?.call(ScaleUpdateDetails( + focalPoint: event.position, + localFocalPoint: event.localPosition, + scale: scaleChange, + )); + widget.onInteractionEnd?.call(ScaleEndDetails()); + } + + // Handle inertia drag animation. + void _onAnimate() { + if (!_controller.isAnimating) { + _currentAxis = null; + _animation?.removeListener(_onAnimate); + _animation = null; + _controller.reset(); + return; + } + // Translate such that the resulting translation is _animation.value. + final Vector3 translationVector = + _transformationController!.value.getTranslation(); + final Offset translation = Offset(translationVector.x, translationVector.y); + final Offset translationScene = _transformationController!.toScene( + translation, + ); + final Offset animationScene = _transformationController!.toScene( + _animation!.value, + ); + final Offset translationChangeScene = animationScene - translationScene; + _transformationController!.value = _matrixTranslate( + _transformationController!.value, + translationChangeScene, + ); + } + + // Handle inertia scale animation. + void _onScaleAnimate() { + if (!_scaleController.isAnimating) { + _currentAxis = null; + _scaleAnimation?.removeListener(_onScaleAnimate); + _scaleAnimation = null; + _scaleController.reset(); + return; + } + final double desiredScale = _scaleAnimation!.value; + final double scaleChange = + desiredScale / _transformationController!.value.getMaxScaleOnAxis(); + final Offset referenceFocalPoint = _transformationController!.toScene( + _scaleAnimationFocalPoint, + ); + _transformationController!.value = _matrixScale( + _transformationController!.value, + scaleChange, + ); + + // While scaling, translate such that the user's two fingers stay on + // the same places in the scene. That means that the focal point of + // the scale should be on the same place in the scene before and after + // the scale. + final Offset focalPointSceneScaled = _transformationController!.toScene( + _scaleAnimationFocalPoint, + ); + _transformationController!.value = _matrixTranslate( + _transformationController!.value, + focalPointSceneScaled - referenceFocalPoint, + ); + } + + void _onTransformationControllerChange() { + // A change to the TransformationController's value is a change to the + // state. + setState(() {}); + } + + @override + void initState() { + super.initState(); + + _transformationController = + widget.transformationController ?? TransformationController(); + _transformationController!.addListener(_onTransformationControllerChange); + _controller = AnimationController( + vsync: this, + ); + _scaleController = AnimationController(vsync: this); + } + + @override + void didUpdateWidget(InteractiveViewer oldWidget) { + super.didUpdateWidget(oldWidget); + // Handle all cases of needing to dispose and initialize + // transformationControllers. + if (oldWidget.transformationController == null) { + if (widget.transformationController != null) { + _transformationController! + .removeListener(_onTransformationControllerChange); + _transformationController!.dispose(); + _transformationController = widget.transformationController; + _transformationController! + .addListener(_onTransformationControllerChange); + } + } else { + if (widget.transformationController == null) { + _transformationController! + .removeListener(_onTransformationControllerChange); + _transformationController = TransformationController(); + _transformationController! + .addListener(_onTransformationControllerChange); + } else if (widget.transformationController != + oldWidget.transformationController) { + _transformationController! + .removeListener(_onTransformationControllerChange); + _transformationController = widget.transformationController; + _transformationController! + .addListener(_onTransformationControllerChange); + } + } + } + + @override + void dispose() { + _controller.dispose(); + _scaleController.dispose(); + _transformationController! + .removeListener(_onTransformationControllerChange); + if (widget.transformationController == null) { + _transformationController!.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + Widget child; + if (widget.child != null) { + child = _InteractiveViewerBuilt( + childKey: _childKey, + clipBehavior: widget.clipBehavior, + constrained: widget.constrained, + matrix: _transformationController!.value, + alignment: widget.alignment, + child: widget.child!, + ); + } else { + // When using InteractiveViewer.builder, then constrained is false and the + // viewport is the size of the constraints. + assert(widget.builder != null); + assert(!widget.constrained); + child = LayoutBuilder( + builder: (context, constraints) { + final Matrix4 matrix = _transformationController!.value; + return _InteractiveViewerBuilt( + childKey: _childKey, + clipBehavior: widget.clipBehavior, + constrained: widget.constrained, + alignment: widget.alignment, + matrix: matrix, + child: widget.builder!( + context, + _transformViewport(matrix, Offset.zero & constraints.biggest), + ), + ); + }, + ); + } + + return Listener( + key: _parentKey, + onPointerSignal: _receivedPointerSignal, + child: GestureDetector( + behavior: HitTestBehavior.opaque, // Necessary when panning off screen. + onScaleEnd: _onScaleEnd, + onScaleStart: _onScaleStart, + onScaleUpdate: _onScaleUpdate, + trackpadScrollCausesScale: widget.trackpadScrollCausesScale, + trackpadScrollToScaleFactor: Offset(0, -1 / widget.scaleFactor), + child: child, + ), + ); + } +} + +// This widget allows us to easily swap in and out the LayoutBuilder in +// InteractiveViewer's depending on if it's using a builder or a child. +class _InteractiveViewerBuilt extends StatelessWidget { + const _InteractiveViewerBuilt({ + required this.child, + required this.childKey, + required this.clipBehavior, + required this.constrained, + required this.matrix, + required this.alignment, + }); + + final Widget child; + final GlobalKey childKey; + final Clip clipBehavior; + final bool constrained; + final Matrix4 matrix; + final Alignment? alignment; + + @override + Widget build(BuildContext context) { + Widget child = Transform( + transform: matrix, + alignment: alignment, + child: KeyedSubtree( + key: childKey, + child: this.child, + ), + ); + + if (!constrained) { + child = OverflowBox( + alignment: Alignment.topLeft, + minWidth: 0.0, + minHeight: 0.0, + maxWidth: double.infinity, + maxHeight: double.infinity, + child: child, + ); + } + + return ClipRect( + clipBehavior: clipBehavior, + child: child, + ); + } +} + +// A classification of relevant user gestures. Each contiguous user gesture is +// represented by exactly one _GestureType. +enum _GestureType { + pan, + scale, + rotate, +} + +// Given a velocity and drag, calculate the time at which motion will come to +// a stop, within the margin of effectivelyMotionless. +double _getFinalTime(double velocity, double drag, + {double effectivelyMotionless = 10}) { + return math.log(effectivelyMotionless / velocity) / math.log(drag / 100); +} + +// Return the translation from the given Matrix4 as an Offset. +Offset _getMatrixTranslation(Matrix4 matrix) { + final Vector3 nextTranslation = matrix.getTranslation(); + return Offset(nextTranslation.x, nextTranslation.y); +} + +// Transform the four corners of the viewport by the inverse of the given +// matrix. This gives the viewport after the child has been transformed by the +// given matrix. The viewport transforms as the inverse of the child (i.e. +// moving the child left is equivalent to moving the viewport right). +Quad _transformViewport(Matrix4 matrix, Rect viewport) { + final Matrix4 inverseMatrix = matrix.clone()..invert(); + return Quad.points( + inverseMatrix.transform3(Vector3( + viewport.topLeft.dx, + viewport.topLeft.dy, + 0.0, + )), + inverseMatrix.transform3(Vector3( + viewport.topRight.dx, + viewport.topRight.dy, + 0.0, + )), + inverseMatrix.transform3(Vector3( + viewport.bottomRight.dx, + viewport.bottomRight.dy, + 0.0, + )), + inverseMatrix.transform3(Vector3( + viewport.bottomLeft.dx, + viewport.bottomLeft.dy, + 0.0, + )), + ); +} + +// Find the axis aligned bounding box for the rect rotated about its center by +// the given amount. +Quad _getAxisAlignedBoundingBoxWithRotation(Rect rect, double rotation) { + final Matrix4 rotationMatrix = Matrix4.identity() + ..translate(rect.size.width / 2, rect.size.height / 2) + ..rotateZ(rotation) + ..translate(-rect.size.width / 2, -rect.size.height / 2); + final Quad boundariesRotated = Quad.points( + rotationMatrix.transform3(Vector3(rect.left, rect.top, 0.0)), + rotationMatrix.transform3(Vector3(rect.right, rect.top, 0.0)), + rotationMatrix.transform3(Vector3(rect.right, rect.bottom, 0.0)), + rotationMatrix.transform3(Vector3(rect.left, rect.bottom, 0.0)), + ); + return InteractiveViewer.getAxisAlignedBoundingBox(boundariesRotated); +} + +// Return the amount that viewport lies outside of boundary. If the viewport +// is completely contained within the boundary (inclusively), then returns +// Offset.zero. +Offset _exceedsBy(Quad boundary, Quad viewport) { + final List viewportPoints = [ + viewport.point0, + viewport.point1, + viewport.point2, + viewport.point3, + ]; + Offset largestExcess = Offset.zero; + for (final Vector3 point in viewportPoints) { + final Vector3 pointInside = + InteractiveViewer.getNearestPointInside(point, boundary); + final Offset excess = Offset( + pointInside.x - point.x, + pointInside.y - point.y, + ); + if (excess.dx.abs() > largestExcess.dx.abs()) { + largestExcess = Offset(excess.dx, largestExcess.dy); + } + if (excess.dy.abs() > largestExcess.dy.abs()) { + largestExcess = Offset(largestExcess.dx, excess.dy); + } + } + + return _round(largestExcess); +} + +// Round the output values. This works around a precision problem where +// values that should have been zero were given as within 10^-10 of zero. +Offset _round(Offset offset) { + return Offset( + double.parse(offset.dx.toStringAsFixed(9)), + double.parse(offset.dy.toStringAsFixed(9)), + ); +} + +// Align the given offset to the given axis by allowing movement only in the +// axis direction. +Offset _alignAxis(Offset offset, Axis axis) { + switch (axis) { + case Axis.horizontal: + return Offset(offset.dx, 0.0); + case Axis.vertical: + return Offset(0.0, offset.dy); + } +} + +// Given two points, return the axis where the distance between the points is +// greatest. If they are equal, return null. +Axis? _getPanAxis(Offset point1, Offset point2) { + if (point1 == point2) { + return null; + } + final double x = point2.dx - point1.dx; + final double y = point2.dy - point1.dy; + return x.abs() > y.abs() ? Axis.horizontal : Axis.vertical; +} diff --git a/lib/src/pdfrx_widgets.dart b/lib/src/pdfrx_widgets.dart index 64e104c..c7d96af 100644 --- a/lib/src/pdfrx_widgets.dart +++ b/lib/src/pdfrx_widgets.dart @@ -11,6 +11,7 @@ import 'package:rxdart/rxdart.dart'; import 'package:synchronized/extension.dart'; import 'package:vector_math/vector_math_64.dart' as vec; +import 'interactive_viewer.dart' as iv; import 'pdfrx_api.dart'; final _isDesktop = @@ -429,7 +430,7 @@ class _PdfViewerState extends State color: widget.params.backgroundColor, child: Stack( children: [ - InteractiveViewer( + iv.InteractiveViewer( transformationController: _controller, constrained: false, maxScale: widget.params.maxScale, @@ -443,6 +444,9 @@ class _PdfViewerState extends State onInteractionEnd: widget.params.onInteractionEnd, onInteractionStart: widget.params.onInteractionStart, onInteractionUpdate: widget.params.onInteractionUpdate, + onWheelDelta: widget.params.scrollByMouseWheel != null + ? _onWheelDelta + : null, child: StreamBuilder( stream: _stream.throttleTime( const Duration(milliseconds: 500), @@ -813,6 +817,15 @@ class _PdfViewerState extends State }); return null; } + + void _onWheelDelta(Offset delta) { + final m = _controller!.value.clone(); + m.translate( + -delta.dx * widget.params.scrollByMouseWheel!, + -delta.dy * widget.params.scrollByMouseWheel!, + ); + _controller!.value = m; + } } class PageLayout { @@ -1278,6 +1291,12 @@ class PdfViewerParams { /// ``` final PdfViewerParamGetPageRenderingScale? getPageRenderingScale; + /// Set the scroll amount ratio by mouse wheel. The default is 0.1. + /// + /// Negative value to scroll opposite direction. + /// null to disable scroll-by-mouse-wheel. + final double? scrollByMouseWheel = 0.1; + /// Check equality of parameters other than functions. bool isParamsDifferenceFrom(PdfViewerParams? other) { return other != null && @@ -1293,7 +1312,8 @@ class PdfViewerParams { other.panEnabled == panEnabled && other.scaleEnabled == scaleEnabled && // ignore: deprecated_member_use_from_same_package - other.devicePixelRatioOverride == devicePixelRatioOverride; + other.devicePixelRatioOverride == devicePixelRatioOverride && + other.scrollByMouseWheel == scrollByMouseWheel; } @override @@ -1316,7 +1336,8 @@ class PdfViewerParams { other.onInteractionUpdate == onInteractionUpdate && // ignore: deprecated_member_use_from_same_package other.devicePixelRatioOverride == devicePixelRatioOverride && - other.getPageRenderingScale == getPageRenderingScale; + other.getPageRenderingScale == getPageRenderingScale && + other.scrollByMouseWheel == scrollByMouseWheel; } @override @@ -1337,13 +1358,14 @@ class PdfViewerParams { onInteractionUpdate.hashCode ^ // ignore: deprecated_member_use_from_same_package devicePixelRatioOverride.hashCode ^ - getPageRenderingScale.hashCode; + getPageRenderingScale.hashCode ^ + scrollByMouseWheel.hashCode; } @override String toString() { // ignore: deprecated_member_use_from_same_package - return 'PdfViewerParams(margin: $margin, backgroundColor: $backgroundColor, pageDecoration: $pageDecoration, pageOverlaysBuilder: $pageOverlaysBuilder, maxScale: $maxScale, minScale: $minScale, panAxis: $panAxis, boundaryMargin: $boundaryMargin, enableTextSelection:$enableTextSelection, panEnabled: $panEnabled, scaleEnabled: $scaleEnabled, onInteractionEnd: $onInteractionEnd, onInteractionStart: $onInteractionStart, onInteractionUpdate: $onInteractionUpdate, devicePixelRatioOverride: $devicePixelRatioOverride, getPageRenderingScale: $getPageRenderingScale)'; + return 'PdfViewerParams(margin: $margin, backgroundColor: $backgroundColor, pageDecoration: $pageDecoration, pageOverlaysBuilder: $pageOverlaysBuilder, maxScale: $maxScale, minScale: $minScale, panAxis: $panAxis, boundaryMargin: $boundaryMargin, enableTextSelection:$enableTextSelection, panEnabled: $panEnabled, scaleEnabled: $scaleEnabled, onInteractionEnd: $onInteractionEnd, onInteractionStart: $onInteractionStart, onInteractionUpdate: $onInteractionUpdate, devicePixelRatioOverride: $devicePixelRatioOverride, getPageRenderingScale: $getPageRenderingScale, scrollByMouseWheel: $scrollByMouseWheel)'; } }