-
Notifications
You must be signed in to change notification settings - Fork 249
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
[SuperEditor][SuperReader][Android] Fix scroll physics (Resolves #1539) #1541
Conversation
@Jethro87 Could you please try this PR to see if it solves the issue for your app? |
@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 { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Updated in #1541 (comment)
@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 |
This comment was marked as outdated.
This comment was marked as outdated.
53b0967
to
8b11fee
Compare
@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.movOn iOS, we are also using I tried using I changed this PR to use Gravacao.de.Tela.2023-10-23.as.22.09.24.movUsing this method on Android, we can see the stretch animation on scroll: android_dragging.webmI 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 |
@@ -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) { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Updated.
super_editor/lib/src/default_editor/document_gestures_touch_ios.dart
Outdated
Show resolved
Hide resolved
@@ -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) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
super_editor/lib/src/default_editor/document_gestures_touch_ios.dart
Outdated
Show resolved
Hide resolved
super_editor/lib/src/default_editor/document_gestures_touch_android.dart
Outdated
Show resolved
Hide resolved
6b0094f
to
97cd440
Compare
super_editor/lib/src/default_editor/document_gestures_touch_android.dart
Outdated
Show resolved
Hide resolved
super_editor/lib/src/default_editor/document_gestures_touch_android.dart
Outdated
Show resolved
Hide resolved
@@ -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) { |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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
?
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Updated.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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)); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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
.
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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)) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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)), |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
.
) | ||
.withEditorSize(const Size(200, 200)) | ||
.insideCustomScrollView() | ||
.withCustomScrollViewScrollController(scrollController) |
There was a problem hiding this comment.
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()
?
There was a problem hiding this comment.
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.
@@ -474,6 +507,9 @@ class SuperEditorTestConfiguration { | |||
final plugins = <SuperEditorPlugin>{}; | |||
|
|||
WidgetTreeBuilder? widgetTreeBuilder; | |||
|
|||
bool insideCustomScrollView = false; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM
[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 topointerScroll
and the issue seems to be solved:android.webm
The difference between
jumpTo
andpointerScroll
is thatpointerScroll
callsforcePixels
clamping the jump offset betweenminScrollExtent
andmaxScrollExtent
, whereasjumpTo
calls it with the given jump offset.jumpTo
also updates theuserScrollDirection
.I'm not sure that using
pointerScroll
is the right way for us to handle scrolling. I also tried usingScrollPosition.drag
atonPanStart
and thenDrag.update
atonPanUpdate
, but doing so caused the following exception:I'm open to other ideas to fix this issue.