Skip to main content

cranpose_ui/
round_scroll_indicator.rs

1//! The curved scroll indicator a round watch puts at 3 o'clock.
2//!
3//! Every round-screen app needs this and none of it is guessable: the track is
4//! described by a height in dp rather than an angle, the thumb is a separate
5//! segment with a gap at each end rather than paint over a continuous rail, and
6//! a segment shorter than its own stroke turns into a shrinking, fading dot
7//! instead of a stubby arc.
8//!
9//! The numbers and the arithmetic here were read out of
10//! `androidx.wear.compose.material3` 1.6.2 with `javap -c` and then checked
11//! against where the shipping Compose build actually puts pixels on 454x454 and
12//! 384x384 displays. The sources are named per item so the next person can
13//! re-derive them rather than trust this comment.
14//!
15//! This module is deliberately pure geometry. It returns the segments to draw
16//! and takes no view of how they are drawn, so it costs nothing to a platform
17//! that never shows it and can be tested without a GPU.
18
19use std::f32::consts::FRAC_PI_2;
20
21/// `ScrollIndicatorDefaults.indicatorHeight` — how far the track reaches up and
22/// down from 3 o'clock, as a straight-line height rather than an arc length.
23pub const INDICATOR_HEIGHT_DP: f32 = 50.0;
24/// `ScrollIndicatorDefaults.indicatorWidth`, whose two values are chosen by
25/// screen size.
26pub const INDICATOR_WIDTH_DP: f32 = 6.0;
27pub const INDICATOR_NARROW_WIDTH_DP: f32 = 5.0;
28/// Wear's own breakpoint: a display at least this wide gets the wider stroke.
29pub const INDICATOR_LARGE_SCREEN_DP: f32 = 225.0;
30/// `PaddingDefaults.edgePadding` — how far the track's outer edge stays off the
31/// display edge.
32pub const INDICATOR_EDGE_PADDING_DP: f32 = 2.0;
33/// `ScrollIndicatorDefaults.gapHeight` — the blank left between the thumb and
34/// each end of the track.
35pub const INDICATOR_GAP_DP: f32 = 3.0;
36/// `ScrollIndicatorDefaults.minSizeFraction` / `maxSizeFraction` — the thumb's
37/// share of the track is clamped to this range however long the list is.
38pub const INDICATOR_MIN_THUMB: f32 = 0.3;
39pub const INDICATOR_MAX_THUMB: f32 = 0.7;
40
41/// The stroke width Wear would use on a display this wide.
42pub fn indicator_width_dp(display_dp: f32) -> f32 {
43    if display_dp.is_finite() && display_dp >= INDICATOR_LARGE_SCREEN_DP {
44        INDICATOR_WIDTH_DP
45    } else {
46        INDICATOR_NARROW_WIDTH_DP
47    }
48}
49
50/// Where the track sits on a display of the given radius.
51#[derive(Clone, Copy, Debug, PartialEq)]
52pub struct IndicatorArc {
53    /// Radius of the stroke's centreline, in the same unit as the radius given.
54    centreline: f32,
55    /// Stroke width, same unit.
56    width: f32,
57    /// Half the angle the whole track covers, in radians.
58    half_sweep: f32,
59    /// Angular amount removed from every segment before round caps are drawn.
60    /// Wear derives this from the stroke width plus the visible gap.
61    segment_inset: f32,
62}
63
64impl IndicatorArc {
65    /// Radius of the stroke's centreline.
66    pub fn centreline(self) -> f32 {
67        self.centreline
68    }
69
70    /// Stroke width.
71    pub fn width(self) -> f32 {
72        self.width
73    }
74
75    /// Angular amount removed from each segment before its round caps draw.
76    pub fn segment_inset(self) -> f32 {
77        self.segment_inset
78    }
79
80    /// The angle at which the track starts, measured the way a canvas measures
81    /// it: `0` at 3 o'clock, increasing clockwise.
82    pub fn start_angle(self) -> f32 {
83        -self.half_sweep
84    }
85
86    /// The whole track's sweep in radians.
87    pub fn sweep(self) -> f32 {
88        self.half_sweep * 2.0
89    }
90
91    /// How much angle a round cap adds beyond the nominal arc at each end.
92    ///
93    /// Wear draws each segment inset by half a cap at the start and a whole cap
94    /// shorter, so the round caps put the ink back exactly on the nominal
95    /// bounds. A caller that draws with a butt cap wants this to be zero.
96    pub fn cap_sweep(self) -> f32 {
97        if self.centreline > 0.0 {
98            self.width / self.centreline
99        } else {
100            0.0
101        }
102    }
103}
104
105fn height_to_sweep(height: f32, radius: f32) -> f32 {
106    if radius <= 0.0 || !radius.is_finite() {
107        return 0.0;
108    }
109    (height * 0.5 / radius).clamp(-1.0, 1.0).asin() * 2.0
110}
111
112/// Where the track's centreline sits and how far it sweeps.
113///
114/// Wear describes the track by a height in dp, so the angle it covers depends
115/// on the radius it is drawn at — deriving it here rather than storing an angle
116/// keeps the indicator the same size in millimetres on every watch.
117///
118/// The centreline is `radius - edgePadding - strokeWidth / 2`. Wear converts
119/// both the track height and `(strokeWidth + gapHeight)` to angles using the
120/// padded radius, then adds the latter inset to the total sweep before each
121/// segment removes it again. The round caps restore the stroke-width share,
122/// leaving the requested visible gap.
123pub fn indicator_arc(radius: f32) -> IndicatorArc {
124    let width = indicator_width_dp(radius * 2.0);
125    let usable_radius = radius - INDICATOR_EDGE_PADDING_DP;
126    let centreline = usable_radius - width * 0.5;
127    if centreline <= 0.0 || !centreline.is_finite() {
128        return IndicatorArc {
129            centreline: 0.0,
130            width,
131            half_sweep: 0.0,
132            segment_inset: 0.0,
133        };
134    }
135    let segment_inset = height_to_sweep(width + INDICATOR_GAP_DP, usable_radius);
136    let half_sweep = ((height_to_sweep(INDICATOR_HEIGHT_DP, usable_radius) + segment_inset) * 0.5)
137        .min(FRAC_PI_2);
138    IndicatorArc {
139        centreline,
140        width,
141        half_sweep,
142        segment_inset,
143    }
144}
145
146/// Where the thumb sits inside the track and how long it is, both as fractions
147/// of the whole track.
148#[derive(Clone, Copy, Debug, PartialEq)]
149pub struct IndicatorGeometry {
150    /// Thumb length as a share of the track, clamped to Wear's range.
151    pub thumb: f32,
152    /// The thumb's leading edge: `0.0` at the top, `1.0 - thumb` at the bottom.
153    pub offset: f32,
154}
155
156/// Works out the thumb for a list, or `None` when everything fits on screen and
157/// Wear shows nothing at all.
158///
159/// `content` and `viewport` are lengths in any one unit; `scrolled` is how far
160/// the content has travelled, in the same unit.
161pub fn indicator_geometry(content: f32, viewport: f32, scrolled: f32) -> Option<IndicatorGeometry> {
162    if !(content.is_finite() && viewport.is_finite() && scrolled.is_finite()) {
163        return None;
164    }
165    if viewport <= 0.0 || content <= viewport {
166        return None;
167    }
168    let thumb = (viewport / content).clamp(INDICATOR_MIN_THUMB, INDICATOR_MAX_THUMB);
169    let travel = content - viewport;
170    let progress = (scrolled / travel).clamp(0.0, 1.0);
171    Some(IndicatorGeometry {
172        thumb,
173        offset: progress * (1.0 - thumb),
174    })
175}
176
177/// One piece of the indicator, ready to draw.
178///
179/// A segment shorter than its own stroke cannot be drawn as an arc without
180/// looking like a blob, so Wear swaps it for a circle that shrinks and fades
181/// out together. Callers draw whichever variant they are handed.
182#[derive(Clone, Copy, Debug, PartialEq)]
183pub enum IndicatorSegment {
184    /// A stroked arc with a round cap, already inset so the caps land on the
185    /// nominal bounds. `start` and `sweep` are radians, `0` at 3 o'clock.
186    Arc { start: f32, sweep: f32, alpha: f32 },
187    /// A filled circle standing in for an arc too short to draw.
188    Dot {
189        /// Angle of the dot's centre, radians.
190        angle: f32,
191        /// Radius, in the same unit as the arc's stroke width.
192        radius: f32,
193        alpha: f32,
194    },
195}
196
197/// Which part of the indicator a segment belongs to, so a caller can colour the
198/// thumb and the track differently without re-deriving the order.
199#[derive(Clone, Copy, Debug, PartialEq, Eq)]
200pub enum IndicatorPart {
201    Track,
202    Thumb,
203}
204
205/// The whole indicator as a list of drawable pieces: track, thumb, track.
206///
207/// It is three separate segments with a gap at each end of the thumb, not a
208/// thumb painted over a continuous rail — drawing a full-length track under a
209/// thumb gives a visibly different picture where the gaps should be.
210///
211/// `alpha` scales every piece, which is how the indicator fades out after the
212/// list has been still.
213pub fn indicator_segments(
214    arc: IndicatorArc,
215    geometry: IndicatorGeometry,
216    alpha: f32,
217) -> [(IndicatorPart, IndicatorSegment); 3] {
218    let alpha = if alpha.is_finite() {
219        alpha.clamp(0.0, 1.0)
220    } else {
221        0.0
222    };
223    let thumb = if geometry.thumb.is_finite() {
224        geometry.thumb.clamp(0.0, 1.0)
225    } else {
226        0.0
227    };
228    let offset = if geometry.offset.is_finite() {
229        geometry.offset.clamp(0.0, 1.0 - thumb)
230    } else {
231        0.0
232    };
233    let sweep = arc.sweep();
234    let top = arc.start_angle();
235    let thumb_start = top + sweep * offset;
236    let thumb_sweep = sweep * thumb;
237    let below_start = thumb_start + thumb_sweep;
238    [
239        (
240            IndicatorPart::Track,
241            segment(top, thumb_start - top, arc.width, arc.segment_inset, alpha),
242        ),
243        (
244            IndicatorPart::Thumb,
245            segment(
246                thumb_start,
247                thumb_sweep,
248                arc.width,
249                arc.segment_inset,
250                alpha,
251            ),
252        ),
253        (
254            IndicatorPart::Track,
255            segment(
256                below_start,
257                top + sweep - below_start,
258                arc.width,
259                arc.segment_inset,
260                alpha,
261            ),
262        ),
263    ]
264}
265
266/// One segment, with Wear's cap inset applied and its too-short case handled.
267fn segment(start: f32, sweep: f32, width: f32, inset: f32, alpha: f32) -> IndicatorSegment {
268    if sweep <= 0.0 || inset <= 0.0 {
269        return IndicatorSegment::Arc {
270            start,
271            sweep: 0.0,
272            alpha: 0.0,
273        };
274    }
275    if sweep < inset {
276        // Below one stroke width Wear stops drawing an arc and draws a circle
277        // that shrinks and fades on the same fraction, so a segment leaves the
278        // screen smoothly instead of collapsing into a dash.
279        let fill = sweep / inset;
280        return IndicatorSegment::Dot {
281            angle: start + sweep * 0.5,
282            radius: width * 0.5 * fill,
283            alpha: alpha * fill,
284        };
285    }
286    // `drawCurvedIndicatorSegment` starts half an inset in and runs a whole
287    // inset shorter; round caps restore the stroke share and leave the gap.
288    IndicatorSegment::Arc {
289        start: start + inset * 0.5,
290        sweep: sweep - inset,
291        alpha,
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    /// The two displays Google Play requires a Wear app to support, in dp.
300    const LARGE_RADIUS_DP: f32 = 113.5; // 454px at density 2
301    const SMALL_RADIUS_DP: f32 = 96.0; // 384px at density 2
302
303    #[test]
304    fn stroke_width_switches_at_the_wear_large_screen_breakpoint() {
305        assert_eq!(indicator_width_dp(224.99), INDICATOR_NARROW_WIDTH_DP);
306        assert_eq!(indicator_width_dp(225.0), INDICATOR_WIDTH_DP);
307        assert_eq!(indicator_width_dp(f32::NAN), INDICATOR_NARROW_WIDTH_DP);
308    }
309
310    #[test]
311    fn the_track_lands_where_the_shipping_compose_build_draws_it() {
312        // Measured off the Compose build itself: the stroke's centreline sits
313        // at 108.5dp on a 454px display and 91.5dp on a 384px one, and the
314        // stroke is 6dp on the first and 5dp on the second.
315        let large = indicator_arc(LARGE_RADIUS_DP);
316        assert!((large.centreline() - 108.5).abs() < 0.01, "{large:?}");
317        assert!((large.width() - 6.0).abs() < 0.01, "{large:?}");
318
319        let small = indicator_arc(SMALL_RADIUS_DP);
320        assert!((small.centreline() - 91.5).abs() < 0.01, "{small:?}");
321        assert!((small.width() - 5.0).abs() < 0.01, "{small:?}");
322    }
323
324    #[test]
325    fn the_sweep_is_a_height_in_dp_not_a_fixed_angle() {
326        // The same 50dp track covers a wider angle on a smaller watch, which is
327        // the whole point of storing a height rather than an angle.
328        let large = indicator_arc(LARGE_RADIUS_DP).sweep().to_degrees();
329        let small = indicator_arc(SMALL_RADIUS_DP).sweep().to_degrees();
330        assert!((large - 30.54).abs() < 0.05, "{large}");
331        assert!((small - 35.73).abs() < 0.05, "{small}");
332        assert!(small > large);
333    }
334
335    #[test]
336    fn a_list_that_fits_on_screen_shows_no_indicator_at_all() {
337        assert_eq!(indicator_geometry(100.0, 100.0, 0.0), None);
338        assert_eq!(indicator_geometry(80.0, 100.0, 0.0), None);
339        assert_eq!(indicator_geometry(f32::NAN, 100.0, 0.0), None);
340        assert_eq!(indicator_geometry(200.0, 0.0, 0.0), None);
341    }
342
343    #[test]
344    fn the_thumb_is_the_viewport_share_clamped_at_both_ends() {
345        // Half the content visible is half the track...
346        let half = indicator_geometry(200.0, 100.0, 0.0).unwrap();
347        assert!((half.thumb - 0.5).abs() < 1e-6, "{half:?}");
348        // ...but a very long list never shrinks it past the floor, and a barely
349        // scrolling one never grows it past the ceiling.
350        let long = indicator_geometry(10_000.0, 100.0, 0.0).unwrap();
351        assert!((long.thumb - INDICATOR_MIN_THUMB).abs() < 1e-6, "{long:?}");
352        let short = indicator_geometry(105.0, 100.0, 0.0).unwrap();
353        assert!(
354            (short.thumb - INDICATOR_MAX_THUMB).abs() < 1e-6,
355            "{short:?}"
356        );
357    }
358
359    #[test]
360    fn the_thumb_reaches_the_bottom_of_the_track_and_no_further() {
361        let bottom = indicator_geometry(200.0, 100.0, 100.0).unwrap();
362        assert!(
363            (bottom.offset + bottom.thumb - 1.0).abs() < 1e-6,
364            "{bottom:?}"
365        );
366        // Overscrolling past the end must not push it off the track.
367        let past = indicator_geometry(200.0, 100.0, 500.0).unwrap();
368        assert_eq!(past, bottom);
369    }
370
371    #[test]
372    fn the_indicator_is_three_segments_with_a_gap_either_side_of_the_thumb() {
373        let arc = indicator_arc(LARGE_RADIUS_DP);
374        let geometry = IndicatorGeometry {
375            thumb: 0.4,
376            offset: 0.3,
377        };
378        let parts = indicator_segments(arc, geometry, 1.0);
379        assert_eq!(parts[0].0, IndicatorPart::Track);
380        assert_eq!(parts[1].0, IndicatorPart::Thumb);
381        assert_eq!(parts[2].0, IndicatorPart::Track);
382
383        // Every piece is an arc at this size, and the ink they cover — the
384        // nominal bounds, once the round caps undo the inset — must stay inside
385        // the track with the gaps left blank.
386        let ink_bounds = |segment: IndicatorSegment| match segment {
387            IndicatorSegment::Arc { start, sweep, .. } => {
388                (start - arc.cap_sweep() * 0.5, sweep + arc.cap_sweep())
389            }
390            other => panic!("expected an arc, got {other:?}"),
391        };
392        let (above_start, above_sweep) = ink_bounds(parts[0].1);
393        let (thumb_start, thumb_sweep) = ink_bounds(parts[1].1);
394        let (below_start, below_sweep) = ink_bounds(parts[2].1);
395        let gap = arc.segment_inset() - arc.cap_sweep();
396
397        assert!((above_start - arc.start_angle() - gap * 0.5).abs() < 1e-4);
398        assert!((thumb_start - (above_start + above_sweep) - gap).abs() < 1e-4);
399        assert!((below_start - (thumb_start + thumb_sweep) - gap).abs() < 1e-4);
400        assert!(
401            (below_start + below_sweep + gap * 0.5 - (arc.start_angle() + arc.sweep())).abs()
402                < 1e-4,
403            "the track has to end where it should"
404        );
405    }
406
407    #[test]
408    fn a_segment_shorter_than_its_stroke_becomes_a_shrinking_dot() {
409        let arc = indicator_arc(LARGE_RADIUS_DP);
410        // Thumb hard against the top: the track above it has almost no room.
411        let parts = indicator_segments(
412            arc,
413            IndicatorGeometry {
414                thumb: 0.7,
415                offset: 0.0,
416            },
417            1.0,
418        );
419        match parts[0].1 {
420            IndicatorSegment::Dot { radius, alpha, .. } => {
421                assert!(
422                    radius <= arc.width() * 0.5,
423                    "a dot never exceeds the stroke"
424                );
425                assert!(alpha < 1.0, "it fades on the same fraction as it shrinks");
426            }
427            IndicatorSegment::Arc { sweep, .. } => {
428                assert!(sweep <= 0.0, "an arc this short should have been a dot");
429            }
430        }
431    }
432
433    #[test]
434    fn fading_the_indicator_fades_every_piece_of_it() {
435        let arc = indicator_arc(LARGE_RADIUS_DP);
436        let geometry = IndicatorGeometry {
437            thumb: 0.4,
438            offset: 0.3,
439        };
440        for (_, segment) in indicator_segments(arc, geometry, 0.25) {
441            let alpha = match segment {
442                IndicatorSegment::Arc { alpha, .. } => alpha,
443                IndicatorSegment::Dot { alpha, .. } => alpha,
444            };
445            assert!(alpha <= 0.25 + 1e-6, "{segment:?}");
446        }
447    }
448
449    #[test]
450    fn a_display_too_small_to_hold_the_track_degrades_instead_of_panicking() {
451        let tiny = indicator_arc(1.0);
452        assert_eq!(tiny.centreline(), 0.0);
453        assert_eq!(tiny.sweep(), 0.0);
454        assert_eq!(tiny.cap_sweep(), 0.0);
455        // And asking for its segments must not divide by that zero.
456        let parts = indicator_segments(
457            tiny,
458            IndicatorGeometry {
459                thumb: 0.4,
460                offset: 0.3,
461            },
462            1.0,
463        );
464        for (_, segment) in parts {
465            assert!(matches!(segment, IndicatorSegment::Arc { sweep: 0.0, .. }));
466        }
467    }
468
469    #[test]
470    fn invalid_public_inputs_never_emit_non_finite_draw_values() {
471        for radius in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -1.0] {
472            let arc = indicator_arc(radius);
473            assert_eq!(arc.sweep(), 0.0);
474            assert_eq!(arc.segment_inset(), 0.0);
475        }
476
477        let parts = indicator_segments(
478            indicator_arc(LARGE_RADIUS_DP),
479            IndicatorGeometry {
480                thumb: f32::NAN,
481                offset: f32::INFINITY,
482            },
483            f32::NAN,
484        );
485        for (_, part) in parts {
486            match part {
487                IndicatorSegment::Arc {
488                    start,
489                    sweep,
490                    alpha,
491                } => assert!(start.is_finite() && sweep.is_finite() && alpha == 0.0),
492                IndicatorSegment::Dot {
493                    angle,
494                    radius,
495                    alpha,
496                } => assert!(angle.is_finite() && radius.is_finite() && alpha == 0.0),
497            }
498        }
499    }
500}