Show a thin scroll cue on Android instead of no scrollbar at all

On Android the desktop scrollbar is off, so every scroller that has one
on a desktop (the develop column, the grid, the collections sidebar,
Settings, the help sheet and the film list) gave no sign of how long it
was or where the view was in it. That is how the black-and-white film
stocks came to look deleted when the list could not scroll.

ScrollBar now has a second mode, chosen in the one Scrolling global:
where bars are off and `cue` is on, it draws the thumb alone, 3 px wide
against the edge, while the viewport moves, and fades it 500 ms after
the last move. It has no TouchArea, so a flick that starts on it
scrolls the content. Rust sets cue on touch-first builds; a desktop
build shows it when DR_SCROLL_CUE is set, to look at it without a
device. Desktop is otherwise unchanged.

Test: on the testing backend's help sheet, a press on the cue moves
nothing, a drag starting on it carries the list with the finger, the
cue is drawn while the list moves (drag, fling, wheel) and not once it
is idle; with bars on there is no cue and the thumb still takes a drag.
This commit is contained in:
2026-09-27 07:22:09 -04:00
parent adbb9ac9e6
commit 502023c0f4
5 changed files with 338 additions and 26 deletions
+62 -5
View File
@@ -1114,8 +1114,8 @@ export component ListView {
}
}
// TRACES: FR-UI-1
// Whether this build draws scrollbars.
// TRACES: FR-UI-1 | FR-UI-2
// How this build shows where a scroller is: a bar, a cue, or nothing.
//
// Set once by Rust from `dr_plat::is_touch_first()` — the same answer that
// puts the adjustment groups in the tool rail, and for the same reason: it
@@ -1124,8 +1124,15 @@ export component ListView {
// far, and where it is in it, and to get there without a wheel. A finger
// does not: it flicks, and a thin bar along the edge of a tablet is a target
// no finger hits on purpose and one a scrolling thumb hits by accident.
//
// A finger still needs to know the list goes on, which is the half of the
// bar that is not a control. `cue` is that half alone: a line a few pixels
// wide, drawn while the view moves and gone soon after, taking no input —
// what Android's own lists show. It applies only where `bars` is off; the
// two are one decision, made in one place, and a scroller never picks.
export global Scrolling {
in property <bool> bars: true;
in property <bool> cue: false;
}
// GESTURE: Scroll by the scrollbar
@@ -1138,7 +1145,11 @@ export global Scrolling {
// is the one way to scroll that needs neither a wheel nor a drag
// on the content, which may be a row that would take the click.
//
// A vertical scrollbar for a `Flickable`, drawn over its right-hand edge.
// A vertical scrollbar for a `Flickable`, drawn over its right-hand edge —
// or, where `Scrolling.cue` is set instead of `Scrolling.bars`, the cue: the
// thumb alone, thin, while the viewport moves. The cue is not a gesture and
// has no entry in the help sheet: it is a picture of where the view is, and
// a flick that starts on it scrolls the list under it.
//
// **A sibling of the Flickable, not a wrapper around it.** Every scroller in
// this application has a note on why its viewport is spelled the way it is,
@@ -1169,6 +1180,10 @@ export component ScrollBar {
/// How far the content can move.
property <length> range: max(0px, root.viewport-height - root.visible-height);
property <bool> live: Scrolling.bars && root.range > 0.5px;
/// The cue instead of the bar: the same thumb, drawn and never touched.
property <bool> cued: !Scrolling.bars && Scrolling.cue && root.range > 0.5px;
/// Whether the view has moved in the last `idle.interval`.
property <bool> moving: false;
/// In proportion to the share on screen, and never too small to grab.
property <length> thumb-height:
min(root.height, max(24px, root.height * root.visible-height / max(1px, root.viewport-height)));
@@ -1179,20 +1194,62 @@ export component ScrollBar {
width: 10px;
height: root.visible-height;
visible: root.live;
visible: root.live || root.cued;
function scroll-to(top: length) {
root.viewport-y = -clamp(top, 0px, root.range);
}
// Any move — a drag, the fling after it, a jump from code — shows the
// cue and puts off its fading. Watched here rather than in each scroller
// because the viewport is the one thing all of them share with the bar.
changed viewport-y => {
if (root.cued) {
if (root.moving) {
idle.restart();
} else {
root.moving = true;
}
}
}
// Android waits about this long after a list stops before its indicator
// fades; long enough to read where the fling ended, short enough that
// the line is not part of the page.
idle := Timer {
interval: 500ms;
running: root.moving;
triggered => { root.moving = false; }
}
// The cue: the thumb's size and place, three pixels wide against the
// edge, fading in fast and out slowly. No TouchArea anywhere under it,
// so a press on it lands on the Flickable beneath.
cue := Rectangle {
visible: root.cued;
opacity: root.moving ? 1 : 0;
animate opacity { duration: root.moving ? 80ms : 250ms; }
line := Rectangle {
x: parent.width - self.width - 1px;
y: root.thumb-y;
width: 3px;
height: root.thumb-height;
border-radius: self.width / 2;
background: Theme.ink-dim;
}
}
// The track, drawn only while the pointer is on it: at rest a scroller
// shows only its thumb, which is all the "there is more" it needs to say.
Rectangle {
visible: root.live;
background: root.lit ? Theme.hover : transparent;
border-radius: 3px;
}
Rectangle {
thumb := Rectangle {
visible: root.live;
x: root.lit ? 2px : 4px;
y: root.thumb-y;
width: parent.width - self.x - 2px;