-
Notifications
You must be signed in to change notification settings - Fork 29.7k
Description
Scrolling in a Flutter list on Android and other platforms often feels much slower and less responsive than it should be. This issue is a major reason why: it can make a fling go about 15% less far than it should. This issue has also gotten in the way of previous attempts to fix some other reasons why.
Background
The framework's approach to scrolling assumes that each ScrollPhysics implementation is restartable, or ballistic. (*) The simulation is frequently restarted, and the ScrollPhysics is responsible for not letting that cause problems.
This is a lot like how the framework may frequently rebuild widgets, and if that affects behavior then that's a bug in the widget.
In the case of scrolling, this assumption is very natural because it will automatically be satisfied by any scroll physics so long as it obeys a reasonable ("ballistic") physical metaphor:
- The thing that's scrolling is a physical object in motion.
- Its velocity changes due to physical forces on it, according to Newton's second law.
- Those forces depend only on (a) its current velocity and (b) where it is in its environment.
This metaphor works great for a wide variety of springs, frictional forces, clamping barriers, and other forms of scrolling physics one could dream up. Modulo small bugs (#109675, and another which I'll file shortly #120340 which affects macOS scrolling), it works great for all of the framework's scroll physics…
((*) This assumption doesn't seem to be clearly documented; I'll file a separate issue for that (→#120341), and see about writing a docs PR for it. Fundamentally it stems from the fact that ScrollPhysics.createBallisticAnimation takes only (a) a velocity and (b) a ScrollMetrics, where the latter encodes the current position plus some facts like ScrollMetrics.maxScrollExtent that describe the physical environment.)
Problem
… all, that is, except one. The trouble is that one scroll physics, ClampingScrollPhysics, breaks this assumption.
(That's the scroll physics we use by default on Android and all other non-Apple platforms.)
If you restart it — specifically if you restart it in the middle of a fling, where it uses ClampingScrollSimulation — then it slows down more.
The effect is that scrolling is often much slower and less responsive-feeling than it should be. In the repro below, a fling goes about 20% less far than it does for a native Android list. Part of that is due to #119875, but most of it (a 15% reduction) is due to this issue.
Related issues
- Symptom caused by this issue and several others: Android scroll speed deceleration rate doesn't match native #16371
- Different issue causing overlapping symptoms: ClampingScrollSimulation decays sooner and goes less far than native Android scrolling #119875
- Thread about the fact that we do frequently restart the simulations: Ballistic simulations may be created too frequently #11599
(Frequently restarting isn't a bug in itself, unless it's so often as to become a performance issue. The bug is that we do restart, even sometimes, and that the physics doesn't handle this.) - One visible quirk of ClampingScrollPhysics and ClampingScrollSimulation is that near the end, it speeds up slightly and then abruptly stops:
ClampingScrollSimulationhas large nonzero velocity when near the end, and velocity even increases slightly there #113424
This quirk would be impossible in a restartable/ballistic scroll simulation: it has to slow down at every speed, or it would never stop.
Steps to Reproduce
-
Run the
platform_tests/scroll_overlayapp on an Android device. -
Scroll with a fling gesture: swipe rapidly upward and release. Wait a couple of seconds for the scroll to come to a stop.
- Swipe just once, and swipe as precisely straight upward as possible, just like with ClampingScrollSimulation decays sooner and goes less far than native Android scrolling #119875.
-
Observe how far the scroll went, as shown by the numbered item "Flutter n" in the Flutter-driven scrolling list.
-
Edit
lib/main.dartto delete the lineitemCount: 1000,. Reload, and scroll back to the top. -
Repeat steps 2 and 3.
Expected results:
The list should go the same distance, reaching the same numbered item, regardless of whether the list is infinite or merely practically infinite. (With 1000 items, we never get close to the end of the list.)
Actual results:
The list goes much less far with itemCount: 1000 present than it does without it.
Specifically, when I try this on the current version of scroll_overlay (at flutter/platform_tests@1d797b8), I see "Flutter 81" at the top of the screen when the fling is complete. With itemCount: 1000, removed, I see "Flutter 90".
(Ideally the list should also go the same distance that the Android list does: I see "Android 94" at the top of the screen. With itemCount removed, the remaining discrepancy is #119875.)
This means the total distance travelled with itemCount present is about 15% less than without it, and about 20% less than the Android list. (Just dividing (90-81)/90 would give too low an estimate, because the later items are bigger: item N is 40+N logical pixels tall. The 15% and 20% are my calculations using those actual heights.)
How the repro works:
This repro uses the fact that passing a non-null itemCount to the ListView constructor causes frequent restarts of the scroll's ballistic simulation, #11599, and an itemCount of null suppresses them. That isolates the effect of this issue from #119875 and others.
Details of why `itemCount` affects the repro
When itemCount is non-null, the underlying SliverList will estimate a max scroll offset based on the children it's laid out, so this estimate constantly changes and each ScrollPosition.applyContentDimensions call causes an applyNewDimensions call, which leads to goBallistic and restarting the simulation. With a null itemCount, the estimate is always infinite (from SliverMultiBoxAdaptorElement.estimateMaxScrollOffset), so it never changes and applyNewDimensions isn't called.
Logs
$ flutter analyze
Analyzing scroll_overlay...
No issues found! (ran in 0.8s)
$ flutter doctor -v
[✓] Flutter (Channel main, 3.8.0-8.0.pre.13, on Debian GNU/Linux 10 (buster) 4.19.0-23-amd64, locale en_US.UTF-8)
• Flutter version 3.8.0-8.0.pre.13 on channel main at /home/greg/n/flutter/flutter
• Upstream repository https://github.com/flutter/flutter.git
• Framework revision 2303f42250 (3 hours ago), 2023-02-08 14:24:12 -0500
• Engine revision cc4ca6a06a
• Dart version 3.0.0 (build 3.0.0-215.0.dev)
• DevTools version 2.21.1
[✓] Android toolchain - develop for Android devices (Android SDK version 33.0.1)
• Android SDK at /home/greg/Android/Sdk
• Platform android-33, build-tools 33.0.1
• Java binary at: /home/greg/lib/android-studio-2020.3.1.24/jbr/bin/java
• Java version OpenJDK Runtime Environment (build 11.0.15+0-b2043.56-8887301)
• All Android licenses accepted.
[✓] Chrome - develop for the web
• Chrome at google-chrome
[✓] Linux toolchain - develop for Linux desktop
• clang version 7.0.1-8+deb10u2 (tags/RELEASE_701/final)
• cmake version 3.13.4
• ninja version 1.8.2
• pkg-config version 0.29
[✓] Android Studio (version 2022.1)
• Android Studio at /home/greg/lib/android-studio-2020.3.1.24
• Flutter plugin version 71.2.4
• Dart plugin version 221.6096
• Java version OpenJDK Runtime Environment (build 11.0.15+0-b2043.56-8887301)
[✓] Android Studio (version 2021.2)
• Android Studio at /opt/android-studio
• Flutter plugin version 71.1.2
• Dart plugin version 212.5744
• Java version OpenJDK Runtime Environment (build 11.0.15+0-b2043.56-8887301)
[✓] VS Code (version 1.75.0)
• VS Code at /usr/share/code
• Flutter extension can be installed from:
🔨 https://marketplace.visualstudio.com/items?itemName=Dart-Code.flutter
[✓] Connected device (3 available)
• Pixel 5 (mobile) • 08171FDD40025F • android-arm64 • Android 13 (API 33)
• Linux (desktop) • linux • linux-x64 • Debian GNU/Linux 10 (buster) 4.19.0-23-amd64
• Chrome (web) • chrome • web-javascript • Google Chrome 109.0.5414.119
[✓] HTTP Host Availability
• All required HTTP hosts are available
• No issues found!
Android's scroll physics isn't ballistic either
When PR #77497 was merged in order to switch ClampingScrollSimulation to use the native Android physics (which would fix #113424 and #119875 and thereby some of the causes of #16371), it had to be reverted because of a variation on this issue. That was:
- Scroll view regains velocity as it scrolls #83632
and its duplicate [2.2.1 Update] Scrolling is way too fast on Android #84197
The root cause of those is that it turns out that the native Android scroll physics is also not restartable/ballistic, but in the opposite direction. Restarting the current ClampingScrollSimulation makes it slow down more, but restarting the Android scroll physics makes it keep going more, i.e. makes it slow down less.
Specifically, the Android physics starts at a deceleration of zero, and gradually decelerates more later. (See the curve at #119875 (comment) , the blue Android curve in the lower-left graph showing velocity, and note that the velocity at the beginning is flat.) So if you restart it, then it returns to not decelerating — which, when it happens again and again, produces the extremely long gliding behavior seen in #83632.
(That issue title says the scroll "regains velocity", but I think that is an illusion. My eyes see that same illusion too; but when I repro the issue and log the velocity, it always gets slower, not faster. It's just that it gets slower very slowly.)