Using nested scrollable areas, as implemented in Gio (and many other
frameworks), leads to the following two bad user experiences when using
the scroll wheel:
1. The user starts scrolling the outer area. The inner scrollable area
happens to scroll into view and is positioned under the cursor. The
inner area now hijacks the scrolling action, and the user can no
longer scroll the outer area without moving their cursor to an area
unoccupied by the inner area, assuming there is one.
2. The user scrolls the inner area but overshoots one of the ends. This
causes the outer area to scroll, which is at best jarring, and at
worst pushes the inner area out of view, a "mistake" the user has to
correct.
I propose two changes to fix these issues. These changes are inspired by
the behaviors implemented by Firefox and Chrome.
1. Support a "scroll grab". Similar to pointer grabs, freeze the set of
handlers when scrolling begins. Scrolling isn't inherently a
continuous action — each scroll tick is an independent event — but
this can be worked around by using a timeout. If scroll events happen
within a defined duration, consider them part of a single continuous
scroll.
The duration shouldn't be too short to accommodate less dextrous
users. It also shouldn't be too long, or the user's implicitly
expressed desire to change which area is being scrolled will be
ignored.
It's also conceivable to break scroll grabs when the pointer moves.
2. Implement a delay before distributing scroll events based on scroll
ranges. Currently, the router distributes scroll events to multiple
handlers based on how much scrollable area they have left. This
directly causes the second bad user experience.
The simplest fix would be to disable this feature entirely,
respecting the scroll grab and only delivering scroll events to the
grabbed area. However, a more useful interaction is to allow users to
"forcefully" move the scroll grab, by continuously trying to scroll
past the end of the area. This can be implemented as a special case
of the scroll grab timeout: only extend the grab's duration if the
scroll event actually led to a non-zero amount of scrolling.
Both of these changes likely need to be opt-in. Scroll grabs make sense
for traditional UI, but might be disastrous for others. Similarly,
scroll ranges are an explicitly implemented feature and they probably
serve a purpose. The two changes can probably be coupled and enabled
together.
It is not clear to me whether the new behavior should be configured on a
pointer.InputOp basis, or globally. If configured per input op, the
configuration would have to be exposed to the user through various
widget, layout, and theme wrappers — for example, material.ListStyle ->
widget.List -> layout.List. If configured globally we can't support
mixed use cases.
What do you think?
On Sun, Sep 3, 2023 at 9:26 AM Dominik Honnef <dominik@honnef.co> wrote:
>> Using nested scrollable areas, as implemented in Gio (and many other> frameworks), leads to the following two bad user experiences when using> the scroll wheel:>> 1. The user starts scrolling the outer area. The inner scrollable area> happens to scroll into view and is positioned under the cursor. The> inner area now hijacks the scrolling action, and the user can no> longer scroll the outer area without moving their cursor to an area> unoccupied by the inner area, assuming there is one.>> 2. The user scrolls the inner area but overshoots one of the ends. This> causes the outer area to scroll, which is at best jarring, and at> worst pushes the inner area out of view, a "mistake" the user has to> correct.>> I propose two changes to fix these issues. These changes are inspired by> the behaviors implemented by Firefox and Chrome.>> 1. Support a "scroll grab". Similar to pointer grabs, freeze the set of> handlers when scrolling begins. Scrolling isn't inherently a> continuous action — each scroll tick is an independent event — but> this can be worked around by using a timeout. If scroll events happen> within a defined duration, consider them part of a single continuous> scroll.>> The duration shouldn't be too short to accommodate less dextrous> users. It also shouldn't be too long, or the user's implicitly> expressed desire to change which area is being scrolled will be> ignored.>> It's also conceivable to break scroll grabs when the pointer moves.>> 2. Implement a delay before distributing scroll events based on scroll> ranges. Currently, the router distributes scroll events to multiple> handlers based on how much scrollable area they have left. This> directly causes the second bad user experience.>> The simplest fix would be to disable this feature entirely,> respecting the scroll grab and only delivering scroll events to the> grabbed area. However, a more useful interaction is to allow users to> "forcefully" move the scroll grab, by continuously trying to scroll> past the end of the area. This can be implemented as a special case> of the scroll grab timeout: only extend the grab's duration if the> scroll event actually led to a non-zero amount of scrolling.>> Both of these changes likely need to be opt-in. Scroll grabs make sense> for traditional UI, but might be disastrous for others. Similarly,> scroll ranges are an explicitly implemented feature and they probably> serve a purpose. The two changes can probably be coupled and enabled> together.>> It is not clear to me whether the new behavior should be configured on a> pointer.InputOp basis, or globally. If configured per input op, the> configuration would have to be exposed to the user through various> widget, layout, and theme wrappers — for example, material.ListStyle ->> widget.List -> layout.List. If configured globally we can't support> mixed use cases.>> What do you think?
I agree that the current behavior leads to bad UX, and would like to
see it improved. I dislike the idea of globally configuring these
behaviors, as it makes it much harder to consume widgets and layouts
written by others (which global config does the layout expect?) and
right now there are no global input settings. It could be a
per-input-op thing, or a mode set and unset similarly to pointer
passthrough (not a pattern I love, but still better than a global).
Your concrete proposals seem reasonable to me, but I'd like to
opinions from Elias and others who have worked extensively on Gio's
router.
Cheers,
Chris
Hi Dominik,
Thank you very much for thinking about these issues.
On Sun, 3 Sept 2023 at 08:26, Dominik Honnef <dominik@honnef.co> wrote:
>> Using nested scrollable areas, as implemented in Gio (and many other> frameworks), leads to the following two bad user experiences when using> the scroll wheel:>> 1. The user starts scrolling the outer area. The inner scrollable area> happens to scroll into view and is positioned under the cursor. The> inner area now hijacks the scrolling action, and the user can no> longer scroll the outer area without moving their cursor to an area> unoccupied by the inner area, assuming there is one.>> 2. The user scrolls the inner area but overshoots one of the ends. This> causes the outer area to scroll, which is at best jarring, and at> worst pushes the inner area out of view, a "mistake" the user has to> correct.>
Got it. It seems to me nested scrollable areas are usually a sign of a bad
UI pattern, but I also agree that we should make them work as well as
possible in Gio.
> I propose two changes to fix these issues. These changes are inspired by> the behaviors implemented by Firefox and Chrome.>> 1. Support a "scroll grab". Similar to pointer grabs, freeze the set of> handlers when scrolling begins. Scrolling isn't inherently a> continuous action — each scroll tick is an independent event — but> this can be worked around by using a timeout. If scroll events happen> within a defined duration, consider them part of a single continuous> scroll.>> The duration shouldn't be too short to accommodate less dextrous> users. It also shouldn't be too long, or the user's implicitly> expressed desire to change which area is being scrolled will be> ignored.>> It's also conceivable to break scroll grabs when the pointer moves.>
Your change #1 sounds good to me.
> 2. Implement a delay before distributing scroll events based on scroll> ranges. Currently, the router distributes scroll events to multiple> handlers based on how much scrollable area they have left. This> directly causes the second bad user experience.>> The simplest fix would be to disable this feature entirely,> respecting the scroll grab and only delivering scroll events to the> grabbed area. However, a more useful interaction is to allow users to> "forcefully" move the scroll grab, by continuously trying to scroll> past the end of the area. This can be implemented as a special case> of the scroll grab timeout: only extend the grab's duration if the> scroll event actually led to a non-zero amount of scrolling.>
I think a version of #2 that never splits a scroll gesture sounds good. The
user may "force" a scroll of a parent scrollable, but only by starting a new
scroll gesture. What constitutes a scroll gesture is not entirely clear, but
a timeout from #1 sounds like a good starting point.
In fact, with your changes the scroll bounds would only be used to determine
which nested scrollable can accept the scroll event. If so, the scroll bounds
may be simplified to just indicate which directions a scrollable supports. This
is a significant improvement for widgets.
> Both of these changes likely need to be opt-in. Scroll grabs make sense> for traditional UI, but might be disastrous for others. Similarly,> scroll ranges are an explicitly implemented feature and they probably> serve a purpose. The two changes can probably be coupled and enabled> together.>> It is not clear to me whether the new behavior should be configured on a> pointer.InputOp basis, or globally. If configured per input op, the> configuration would have to be exposed to the user through various> widget, layout, and theme wrappers — for example, material.ListStyle ->> widget.List -> layout.List. If configured globally we can't support> mixed use cases.>
I agree with Chris here: we should avoid configurable scroll handling as
much as possible. Whatever new behaviour should always be enabled.
Elias