Skip to content

InteractiveViewer Rotation - #73358

Closed
justinmc wants to merge 29 commits into
flutter:masterfrom
justinmc:iv-rotation
Closed

justinmc wants to merge 29 commits into
flutter:masterfrom
justinmc:iv-rotation

Conversation

@justinmc

@justinmc justinmc commented Jan 5, 2021 •

Copy link
Copy Markdown
Contributor

Description

This PR adds support for rotation to InteractiveViewer:

Screen.Recording.2021-01-05.at.11.29.35.AM.mov

I decided that the most intuitive behavior was to prevent any full side of the viewport from viewing beyond the boundary. This creates an intuitive experience in most cases by allowing the user to see beyond the boundary (the pink in the video above), but not to fully move beyond it.

Related Issues

Closes #57698

Tests

I added the following tests:

  • Rotating with a two-finger rotation gesture works and the angle follows the pointers.
  • After rotating, a fling gesture still allows inertial animated movement, and colliding with the boundary allows sliding in one direction.
  • Many unit tests were added for new geometry-related functions I added.
  • Some unit tests were removed for functions that I replaced.

Breaking Change

Shouldn't be a breaking change, though rotation will be enabled by default. Existing usages of InteractiveViewer that don't want rotation should explicitly set rotateEnabled to false. If anyone thinks that this will negatively affect many users, I'm open to defaulting rotateEnabled to true, though it seems more logical to me that all of the enabled parameters default to true.

justinmc added 23 commits July 18, 2020 11:09
… approach, with axis-aligned boundary, depends on size of viewport, but I didn't realize that before
…dge when rotated, but gets weird numbers in corners and parallel...
…, though there is probably a solution that corrects cases with two invalid sides
@justinmc justinmc self-assigned this Jan 5, 2021
@flutter-dashboard flutter-dashboard Bot added the framework flutter/packages/flutter repository. See also f: labels. label Jan 5, 2021
@google-cla google-cla Bot added the cla: yes label Jan 5, 2021

/// Returns the closest point to the given point on the given line segment.
@visibleForTesting
static Vector3 getNearestPointOnLine(Vector3 point, Vector3 l1, Vector3 l2) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These methods that I removed were involved in my previous iteration of correcting an invalid matrix, which in this PR I have generalized to work with a rotated matrix as well.


return _round(largestExcess);
}
const double _kTolerance = 100000000000.0;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are a bunch of places where I had to use rounding in order to get numbers to match. Do we have a standardized way of handling stuff like this somewhere?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get numbers to match

You mean checking for floating-point equality/comparison? As far as I can tell we just check if 2 floating numbers are within an epsilon range, eg:

/// Whether two doubles are within a given distance of each other.
///
/// The `epsilon` argument must be positive and not null.
/// The `a` and `b` arguments may be null. A null value is only considered
/// near-equal to another null value.
bool nearEqual(double? a, double? b, double epsilon) {
assert(epsilon != null);
assert(epsilon >= 0.0);
if (a == null || b == null)
return a == b;
return (a > (b - epsilon)) && (a < (b + epsilon)) || a == b;
}
/// Whether a double is within a given distance of zero.
///
/// The epsilon argument must be positive.
bool nearZero(double a, double epsilon) => nearEqual(a, 0.0, epsilon);

@justinmc
justinmc marked this pull request as ready for review February 4, 2021 19:35
@justinmc

justinmc commented Feb 4, 2021

Copy link
Copy Markdown
Contributor Author

I think the biggest open questions for this PR are:

  • How can we better handle the rounding errors that crop up in this PR?
  • Should rotation be enabled by default?

@justinmc

justinmc commented Feb 4, 2021

Copy link
Copy Markdown
Contributor Author

CC @RomanJos in case you're interested in reviewing this.

@RomanJos

RomanJos commented Feb 6, 2021

Copy link
Copy Markdown
Contributor

Hi, thanks for calling me I appreciate it.
I first want to say that I tried my best to understand the logic of what this pr do but all the math you implemented are kinda hard to me so I did not understood everything !

My understanding of how this widget handle gestures is as follow :

  • User press with two fingers
  • _onScaleStart() set _gestureType to null
  • User move his finger
  • _onScaleUpdate() call _getGestureType() to set _gestureType to pan, scale or rotate if it is null. Because _gestureType is global it will lock on his value until _onScaleStart() is called again.
  • depending on the value of _gestureType, this function pan, scale or rotate the matrix with a focalpoint given by the GestureDetector Widget. In case of a rotation or a pan, _validateMatrix() is called to ensure the corner of the child don't goes inside the viewport.
  • _onScaleEnd() do the inertia.

I have two problems with this logic :

  • First, locking _gestureType isn't a good things, sometimes I want to rotate but the widget pan instead and vice versa so I need to restart my move. Or if I change my mind while panning and want to rotate instead I can't.
  • The focal point isn't move with the fingers for a rotation, this feels weird when I move my finger while gestureType is locked in rotation, I expect the center of the rotation to be where I started my gesture but it follow my fingers.

I believe it would be nicer to transform _gestureType into a list of movements and rotate, pan or scale only if the value is present inside. Similarly to how chrome do for panning, if I start a vertical drage I have X px to drag horizontaly to unlock it and move how I want.

chrome horizontal and vertical gesture

This would solve both problem as the focal point will move with the child.

During testing I also found some issue involving _validateMatrix() :

  • when I rotate really fast, sometimes the assert assert(_lineContains(pointOnLine)) inside findClosestOffsetOnLineSegmentToOffsetOnLine() fail
  • I have a child with an aspect ration of let's say 1:4 that is rotated a little bit, if I try to move to left or right and I hit the border, the child pan up or down
doc_2021-02-06_15-41-38.mp4

I hope you understood everything, I borrowed a phone from a friend as I don't have one currently so I couldn't test more lol

const LineSegment(
this.p0,
this.p1,
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assert p0 != p1 maybe?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually this seems to make LineSegment not be able to be const anymore. Is there a way around that?

/// A special case of the linesIntersectAt method.
static Offset _isVerticalAndInterceptsNonVerticalLineAt(LineSegment vertical, LineSegment nonVertical) {
// Assert that vertical is indeed vertical.
assert(vertical.p0.dx == vertical.p1.dx);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: asserts nonVertical is not vertical too maybe?

}
final double d0 = _distanceBetweenPoints(pointOnLine, p0);
final double d1 = _distanceBetweenPoints(pointOnLine, p1);
return d0.abs() <= d1.abs() ? p0 : p1;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isn't the distance always non-negative?

);
}

// Otherwise, the closest points on the line segments are the closest points

@LongCatIsLooong LongCatIsLooong Feb 6, 2021 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this true? If the 2 lines are not perpendicular, and the intersection is only on one of the line segments, then the closet point on that line segment is not necessarily the intersection right?
e.g.:

_________

  \
    \

the closest point on the horizontal line is not the (projected) intersection.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, thanks for catching this! I feel like the behavior near the boundary is a bit smoother after fixing this even.
I had a bunch of tests that were slightly off because of this too.

// A valid matrix is defined as a matrix where all sides of the viewport
// intersect the boundary. This allows the viewport to partially see beyond
// the boundary after rotation, but keeps the entire boundary visible.
Matrix4 _validateMatrix(Matrix4 matrix, Matrix4 defaultMatrix) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it also achieve the same goal if we compute the bounding box of the viewport and compare it to _boundaryRect to determine the amount of translation we need? That sounds a lot easier than dealing with line segments.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried that way first, but I ended up preferring the user experience this way when near the boundary. If I force the axis aligned bounding box of the viewport to fit inside of the boundary, then I won't be able to see the corners of the child. Also, I assume that many uses of InteractiveViewer will have a similarly sized boundary and viewport initially, like a photo viewer, and in that case it's nice to be able to rotate at the default size without being stopped by the boundary.

Maybe this is something that could be configured in the long term since the behavior is different for the two approaches.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry I probably don't fully understand how this works, but from

A valid matrix is defined as a matrix where all sides of the viewport intersect the boundary.

It sounds like a necessary and sufficient condition for _boundaryRect to intersect all 4 edges of nextViewport is:

  1. _boundaryRect.top is in [nextViewport.smallestY, nextViewport.2ndSmallestY]
  2. _boundaryRect.bottm is in [nextViewport.2ndLargestY, nextViewport.largestY]
  3. 4, similarly we can get the horizontal constraints for _boundaryRect.left and _boundaryRect.right.

_boundaryRect.bottm - _boundaryRect.top is a constant.

So the problem turns into finding the dx and dy that minimize dx^2 + dy^2 and satisfy all 4 constaints, or one of them in each direction if they can't be satisfied at the same time, i.e.:

_boundaryRect.top + dy in [nextViewport.smallestY, nextViewport.2ndSmallestY] and/or [_boundaryRect.height - nextViewport.largestY, _boundaryRect.height - nextViewport.2ndLargestY]
&&
_boundaryRect.left + dx in [nextViewport.smallestX, nextViewport.2ndSmallestX] and/or [_boundaryRect.width - nextViewport.largestX, _boundaryRect.width - nextViewport.2ndLargestX]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you might be on to something, but there is a case where this doesn't work if I understand correctly.

Sorry for the bad drawing but imagine the pen rectangle is the boundary and the pencil rectangle is the viewport:

I would consider this to be invalid because one edge of the viewport does not intersect the boundary. However, _boundaryRect.top is in [nextViewport.smallestY, nextViewport.2ndSmallestY].

@LongCatIsLooong LongCatIsLooong Feb 24, 2021 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There're 4 constraints (2 horizontal and 2 vertical) and to make a viewport valid it has to satisfy all 4. In the drawing the viewport only satisfies 2 (one on each axis): _boundaryRect.top is in [nextViewport.smallestY, nextViewport.2ndSmallestY] but _boundaryRect.bottm is not in [nextViewport.2ndLargestY, nextViewport.largestY] so this is indeed invalid.

@LongCatIsLooong LongCatIsLooong Feb 24, 2021 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ohhh it's neither sufficient or necessary (counter example: https://jamboard.google.com/d/1l4Gg2AAs8d_8fGEAK_diDle-9qSQjYYFPkxpzUXAS84/viewer?f=0). Let me see if it can be improved. I think we can make more use of the fact that the boundary is a Rect to simplify the calculation.

@LongCatIsLooong LongCatIsLooong Feb 24, 2021 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@justinmc

Copy link
Copy Markdown
Contributor Author

@RomanJos Thanks for the thorough review! Your summary of the logic is correct. I've been able to fix some of your points:

  • _gestureType should be allowed to change. I agree with this. It seems to be how Google Maps works as well. I will try to make this work nicely when I get a chance.
  • Movable focal point. I was able to fix this pretty easily, thanks for the suggestion! I noticed that Google Maps also allows a moving focal point.
  • Assertion when rotating fast. I think I have fixed this, could you try again if you get a chance? I think it had to do with InteractiveViewer Rotation #73358 (comment).
  • Panning up/down when hitting the border. I couldn't reproduce this after making a few code changes, but I'm also not sure it's fixed. Let me know if you reproduce it again if you have time.

I'll make another attempt at fixing the _gestureType problem soon.

@RomanJos

Copy link
Copy Markdown
Contributor

No problem, I tested with the new commits and it looks great! I can't pan up/down by hitting the border anymore, the movable focal point feel a lot more natural. When gestureType will change dynamically it will be even better, sometimes I unconsciously try to zoom when rotating.

The same assert still throw but now on each quarter turns and I think it cause the following behavior, I moved my finger to the top of the screen because it seems to do more crazy things:
interactiveviewer new bug3
(the gif is compressed)

without boundaries margins, my finger doesn't seems to be taken into account only for rotating if it is not on the child area, this is weird since ScaleUpdateDetails.pointerCount is 2 but somehow rotation is 0 inside _getGestureType() :
interactiveviewer new bug

final ClosestPoints closestPoints = invalidSide.findClosestPointsRect(_boundaryRect);
final Offset difference = closestPoints.a - closestPoints.b;
if (magnitudesAligned) {
final bool xMisaligned = difference.dx != 0.0 && value.dx != 0.0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: equivalent to difference.dx * value.dx < 0 (i.e. having different signs)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, slick 👍

);

// If this matrix is invalid and uncorrectable, then return defaultMatrix.
if (!correctedViewportSide01.intersectsRect(_boundaryRect)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so for cases like this: https://jamboard.google.com/d/1l4Gg2AAs8d_8fGEAK_diDle-9qSQjYYFPkxpzUXAS84/viewer?f=1, this will try to correct the matrix by moving the child to the right edge of the viewport because it's farther, then return the default matrix because the top left corner of the child is inside the viewport?

@Piinks Piinks added the c: new feature Nothing broken; request for a new capability label Jun 10, 2021
@goderbauer

Copy link
Copy Markdown
Member

@justinmc Do you still have plans for this one?

@justinmc

justinmc commented Jul 9, 2021

Copy link
Copy Markdown
Contributor Author

Yes, but I'll close it for now until I have time to look at it again.

@JaffaKetchup

Copy link
Copy Markdown
Contributor

Any chance of this being looked at again? I understand if you don't have the time!

@UdaraWanasinghe

Copy link
Copy Markdown

I have completed a similar project at https://github.com/UdaraWanasinghe/android-transform-layout. The issue with your implementation is that it handles translation, scale, and rotation separately based on the GestureType. But it's possible to do it simultaneously.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c: new feature Nothing broken; request for a new capability framework flutter/packages/flutter repository. See also f: labels.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

InteractiveViewer should support rotation

7 participants