Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[SuperEditor][SuperReader][Android] Fix scroll physics (Resolves #1539) #1541

Merged
merged 13 commits into from
Nov 21, 2023

Conversation

angelosilvestre
Copy link
Collaborator

[SuperEditor][SuperReader][Android] Fix scroll physics. Resolves #1539

On Android, we are noticing underscroll/overscroll with the editor being pinned at the position where the drag ended:

screen-20231020-111047.2.mp4

Android uses ClampingScrollPhysics, so it shouldn't even allow for underscroll/overscroll. It seems that the way we handle scrolling doesn't honor the scroll physics correctly.

I replaced the call to jumpTo with a call to pointerScroll and the issue seems to be solved:

android.webm

The difference between jumpTo and pointerScroll is that pointerScroll calls forcePixels clamping the jump offset between minScrollExtent and maxScrollExtent, whereas jumpTo calls it with the given jump offset. jumpTo also updates the userScrollDirection.

I'm not sure that using pointerScroll is the right way for us to handle scrolling. I also tried using ScrollPosition.drag at onPanStart and then Drag.update at onPanUpdate, but doing so caused the following exception:

════════ Exception caught by gesture ═══════════════════════════════════════════
The following assertion was thrown while handling a gesture:
'package:flutter/src/widgets/scroll_activity.dart': Failed assertion: line 370 pos 12: 'details.primaryDelta != null': is not true.
scroll_activity.dart:370

When the exception was thrown, this was the stack
#2      ScrollDragController.update scroll_activity.dart:370
#3      _AndroidDocumentTouchInteractorState._onPanUpdate document_gestures_touch_android.dart:785
#4      DragGestureRecognizer._checkUpdate.<anonymous closure> monodrag.dart:548

I'm open to other ideas to fix this issue.

@angelosilvestre
Copy link
Collaborator Author

@Jethro87 Could you please try this PR to see if it solves the issue for your app?

@matthew-carroll
Copy link
Contributor

@angelosilvestre is there any reason you chose to switch the scrolling method rather than check the min/max pixels and do nothing if there's nothing more to scroll?

@@ -500,6 +500,76 @@ void main() {
expect(SuperEditorInspector.findDocumentSelection(), isNull);
});

testWidgetsOnAndroid("doesn't underscroll when dragging down", (tester) async {
Copy link
Contributor

Choose a reason for hiding this comment

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

Is "underscroll" the correct word? I've only heard of it as "overscroll" regardless of which direction you're scrolling.

Also, are you sure we don't already have tests that are supposed to be verifying this? I'm surprised if we don't, and if we do, something is wrong with those tests.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I saw "underscroll" in a comment in the ClampingScrollPhysics class while debugging, so I thought it was a thing. I changed to "overscroll".

I didn't find tests for this...

@@ -787,7 +787,7 @@ class _AndroidDocumentTouchInteractorState extends State<AndroidDocumentTouchInt
}

// The user is trying to scroll the document. Change the scroll offset.
scrollPosition.jumpTo(scrollPosition.pixels - details.delta.dy);
scrollPosition.pointerScroll(-details.delta.dy);
Copy link
Contributor

Choose a reason for hiding this comment

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

Did you happen to check how we're handling this on iOS?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Updated in #1541 (comment)

@Jethro87
Copy link
Contributor

@angelosilvestre I am not having this issue in my Android app after this update.

FYI on iOS, there is a similar over-scroll issue, though it does not "pin" the screen as it did on Android. Just in case you want to include iOS changes in this branch as well:

RPReplay_Final1698069813.mov

@angelosilvestre

This comment was marked as outdated.

@angelosilvestre
Copy link
Collaborator Author

@matthew-carroll After Jeff's last comment I took a deeper look into the iOS scrolling. Here's how it looks right now:

Gravacao.de.Tela.2023-10-23.as.21.58.35.mov

On iOS, we are also using jumpTo. We can see in the video that we seem to be "fighting" the scroll physics. I'm convinced we shouldn't be using jumpTo, as each call to jumpTo is like an entire drag gesture: tapping down/dragging/releasing the tap. Also, dragging a bit and then holding down for a moment causes the editor to scroll back to the start. When holding the pointer down, the scroll position shouldn't jump back to the start.

I tried using pointerScroll on iOS, but then we can't overscroll and trigger the bouncing effect.

I changed this PR to use ScrollPosition.drag, here's how it's looking:

Gravacao.de.Tela.2023-10-23.as.22.09.24.mov

Using this method on Android, we can see the stretch animation on scroll:

android_dragging.webm

I mentioned in the PR description that using this API caused an exception on Android. On iOS, it worked without issues, so I looked at what's different between Android on iOS. The difference is that on Android we are using PanGestureRecognizer and on iOS VerticalDragGestureRecognizer. Switching the Android implementation to user VerticalDragGestureRecognizer solved the issue.

@@ -809,15 +812,12 @@ class _IosDocumentTouchInteractorState extends State<IosDocumentTouchInteractor>
// placement during onTapDown, and then pick that up here. I think the little
// bit of slop might be the problem.
final selection = widget.selection.value;
if (selection == null) {
Copy link
Contributor

Choose a reason for hiding this comment

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

By removing this null check, you've allowed all following behaviors to execute with a null selection. Is that really what you intended?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Those behaviors won't be executed. _isOverBaseHandle and _isOverExtentHandle return false if there isn't a selection.

Also, I added a guard clause after all of the if statements to return early if there isn't a selection.

Copy link
Contributor

Choose a reason for hiding this comment

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

The change seems likely to create confusion when reading the code. We have if-statements that check if the user is pressing a handle, but moving this selection check creates the possibility that there is no handle, which probably isn't expected when you're reading code that checks for it.

Is there are more expectable order of code that won't lead to surprises?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Updated.

@@ -911,8 +919,8 @@ class _IosDocumentTouchInteractorState extends State<IosDocumentTouchInteractor>
void _onPanUpdate(DragUpdateDetails details) {
// If the user isn't dragging a handle, then the user is trying to
// scroll the document. Scroll it, accordingly.
if (_dragMode == null) {
scrollPosition.jumpTo(scrollPosition.pixels - details.delta.dy);
if (_dragMode == null && _drag != null) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Should the drag mode have a value for scrolling or something? It seems strange that we're checking for a null drag mode and then checking for a non-null drag. From a reader's standpoint, that looks we're checking for opposites at the same time.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It makes sense. I included a DragMode for scrolling.

@@ -809,15 +812,12 @@ class _IosDocumentTouchInteractorState extends State<IosDocumentTouchInteractor>
// placement during onTapDown, and then pick that up here. I think the little
// bit of slop might be the problem.
final selection = widget.selection.value;
if (selection == null) {
Copy link
Contributor

Choose a reason for hiding this comment

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

The change seems likely to create confusion when reading the code. We have if-statements that check if the user is pressing a handle, but moving this selection check creates the possibility that there is no handle, which probably isn't expected when you're reading code that checks for it.

Is there are more expectable order of code that won't lead to surprises?

// scroll the document. Scroll it, accordingly.
if (_dragMode == null) {
scrollPosition.jumpTo(scrollPosition.pixels - details.delta.dy);
if (_dragMode == DragMode.scroll && _dragToScroll != null) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Should it ever be possible that our drag mode is scroll and _dragToScroll is null?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It shouldn't be possible. Updated.

return;
}

if (_dragMode != null) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Between this if-statement and the previous one, we're creating an easy opportunity for future developers to create bugs here. Though it's not immediately obvious, this 2nd if-statement isn't merely looking for a non-null _dragMode, it's looking for a _dragMode that isn't scroll. The average reader won't make that connection.

Please rework these two if-statements so that it's clear that there's two related conditions, not two independent conditions. In general, if _dragToScroll is always non-null when _dragMode is scroll, then we shouldn't check for null _dragToScroll, we should just look for a _dragMode of scroll.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Updated.

Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think this latest version has resolved the concern. We still have two independent if-statements, and the 2nd one depends on the 1st one. This isn't obvious.

Please switch this to a switch statement and have it do nothing on null

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Updated.

return;
}

if (_dragMode != null) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think this latest version has resolved the concern. We still have two independent if-statements, and the 2nd one depends on the 1st one. This isn't obvious.

Please switch this to a switch statement and have it do nothing on null

// Ensure the scrollview didn't start scrolled.
expect(scrollController.offset, 0);

final editorRect = tester.getRect(find.byType(SuperEditor));
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think we need to get the editor rectangle do we? Don't we just need to attempt to scroll by some significant amount?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Updated.


final editorRect = tester.getRect(find.byType(SuperEditor));

const dragFrameCount = 10;
Copy link
Contributor

Choose a reason for hiding this comment

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

I assume this multi-frame drag gesture is repeated in these tests. I think I've done similar things elsewhere. Let's define an extension on WidgetTester that does this for an arbitrary dragPerFrame Offset.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I think that instead of passing a dragPerFrame, it makes more sense to pass the starting location and the total amount of dragging, letting the extension compute the dragPerFrame. What do you think?

await tester.pump();

// Ensure we don't scroll.
expect(scrollController.offset, 0);
Copy link
Contributor

Choose a reason for hiding this comment

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

Can't we check this one time after the drag is over?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Updated.

await tester
.createDocument()
.withSingleParagraph()
.withInputSource(TextInputSource.ime)
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we care that this is IME? Why not use the default input source?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

We don't. Removed.

.createDocument()
.withSingleParagraph()
.withInputSource(TextInputSource.ime)
.withEditorSize(const Size(300, 300))
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we care about the editor size in this test?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Nope, removed.

// Ensure the editor didn't start scrolled.
expect(scrollController.offset, 0);

// Drag an amount of pixels chosen experimentally from the top of the editor.
Copy link
Contributor

Choose a reason for hiding this comment

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

This says "pixels chosen experimentally" - what was the experiment? Isn't this amount arbitrary?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah, it's arbitrary.


// Drag an amount of pixels chosen experimentally from the top of the editor.
final dragGesture = await tester.dragInMultipleFrames(
startLocation: tester.getTopLeft(find.byType(SuperEditor)),
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's not start a drag from the top left corner of the editor. That's a very unnatural place for a drag to ever begin with a real user, and it creates opportunities for things like safe areas, drawer drag areas, or other edge-based effects to interfere. A more natural place to test the drag would be the horizontal center, and at least some distance down from the top of the editor.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Updated.

await tester
.createDocument()
.withSingleParagraph()
.withCustomWidgetTreeBuilder(
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we need any of this custom tree complexity for this test?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

We need to setup an ancestor CustomScrollView for this test.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ok. Why do we need to constrain the height to 200px?

Also, if we need the same tree in multiple tests, is there any reason not to extract it to either a private widget or a private method? Or, perhaps what would make even more sense is to either break it into a widget shared within the test suite, or even add it as a configuration option to the builder, because it looks like there are tests across multiple files that need this same setup.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Added methods to the test builder to configure the widget tree with a CustomScrollView.

super_editor/test/test_tools.dart Outdated Show resolved Hide resolved
super_editor/test/test_tools.dart Outdated Show resolved Hide resolved
super_editor/test/test_tools.dart Outdated Show resolved Hide resolved
super_editor/test/test_tools.dart Outdated Show resolved Hide resolved
)
.withEditorSize(const Size(200, 200))
.insideCustomScrollView()
.withCustomScrollViewScrollController(scrollController)
Copy link
Contributor

Choose a reason for hiding this comment

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

Should this be an optional argument to insideCustomScrollView() instead? Would it ever make sense to call withCustomScrollViewController() but without having previously called insideCustomScrollView()?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah, I also thought about that. I guess it makes sense to add the scrollController as an optional argument.

super_editor/test/super_editor/supereditor_test_tools.dart Outdated Show resolved Hide resolved
@@ -474,6 +507,9 @@ class SuperEditorTestConfiguration {
final plugins = <SuperEditorPlugin>{};

WidgetTreeBuilder? widgetTreeBuilder;

bool insideCustomScrollView = false;
Copy link
Contributor

Choose a reason for hiding this comment

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

These properties look out of place given the order and separation of everything else.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Moved up right below the scrollController.

Copy link
Contributor

@matthew-carroll matthew-carroll left a comment

Choose a reason for hiding this comment

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

LGTM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[SuperEditor] Overscrolling can pin the editor on Android
3 participants