Skip to main content

cranpose_ui/widgets/wear/
scroll_indicator.rs

1//! The curved indicator a round watch puts at 3 o'clock.
2//!
3//! The geometry is [`crate::round_scroll_indicator`], which is golden-tested
4//! against where the shipping Compose build puts pixels on 454x454 and 384x384
5//! displays. This module is the widget around it: it reads a list's layout,
6//! turns it into three segments, and draws them.
7//!
8//! Two things about this indicator are not guessable and are worth restating
9//! where the drawing is. It is **three segments with a gap at each end of the
10//! thumb**, not a thumb painted over a continuous rail — drawing a full-length
11//! track underneath gives a visibly different picture. And a segment shorter
12//! than its own stroke is drawn as a **circle that shrinks and fades on the
13//! same fraction**, so a segment leaving the screen dwindles to a dot instead
14//! of stopping at one round cap's width.
15
16use cranpose_core::NodeId;
17use cranpose_ui_graphics::{DrawScope, Stroke, StrokeCap};
18
19use crate::{
20    composable,
21    modifier::{Brush, Color, Modifier, Point},
22    round_scroll_indicator::{
23        IndicatorGeometry, IndicatorPart, IndicatorSegment, indicator_arc, indicator_segments,
24        scaling_list_geometry,
25    },
26    widgets::{
27        Canvas,
28        wear::{scaling_list::WearScalingListState, theme::WearColors},
29    },
30};
31
32/// How a [`ScrollIndicator`] is drawn.
33#[derive(Clone, Copy, Debug, PartialEq)]
34pub struct ScrollIndicatorSpec {
35    pub colors: WearColors,
36    /// How visible the whole indicator is.
37    ///
38    /// Wear fades this to zero two seconds after the last scroll, so a
39    /// screenshot of a settled screen shows no indicator at all. That is a
40    /// policy, not geometry, and an app with an always-on indicator sets this
41    /// to `1.0` and never animates it — which is why the widget takes an alpha
42    /// rather than owning a timer.
43    pub alpha: f32,
44}
45
46impl Default for ScrollIndicatorSpec {
47    fn default() -> Self {
48        Self {
49            colors: WearColors::default(),
50            alpha: 1.0,
51        }
52    }
53}
54
55impl ScrollIndicatorSpec {
56    pub fn colors(mut self, colors: WearColors) -> Self {
57        self.colors = colors;
58        self
59    }
60
61    pub fn alpha(mut self, alpha: f32) -> Self {
62        self.alpha = alpha;
63        self
64    }
65}
66
67/// The thumb for a scaling list.
68///
69/// The thumb is measured in **fractional item indices**, which is what
70/// `ScalingLazyColumnStateAdapter` does and what
71/// [`scaling_list_geometry`] ports: its length is the share of the *items* on
72/// screen and its position is how many items are left, so a list whose rows
73/// differ in height puts it somewhere a pixel model cannot. The two answers
74/// agree only on a list of uniform rows that is as tall as its content, and a
75/// Wear list has a header. Measured against the shipping Compose build on a
76/// settled Settings screen, by integrating the indicator's ink across the
77/// stroke band row by row: the thumb's centroid was 5.97 device pixels below
78/// Compose's at 192dp and 7.73 at 227dp under the flat model, and is 0.61 and
79/// 0.83 under this one.
80///
81/// Whether a list is scrollable at all is a separate question, and Wear leaves
82/// it to `ScreenScaffold` rather than to the adapter — so it is asked here,
83/// against the list's own travel, and not inside the geometry.
84pub fn indicator_for_scaling_list(state: &WearScalingListState) -> Option<IndicatorGeometry> {
85    let info = state.layout_info();
86    let travel = info.travel();
87    if travel <= 0.0 || info.viewport <= 0.0 || info.content <= info.viewport {
88        return None;
89    }
90    state.with_indicator_list(scaling_list_geometry)
91}
92
93/// Draws the indicator into a scope whose bounds are the whole display.
94///
95/// Split out from the composable so it can be tested against a bare
96/// `DrawScopeDefault` and reused by an app that still draws its own screens.
97pub fn draw_scroll_indicator(
98    scope: &mut dyn DrawScope,
99    geometry: IndicatorGeometry,
100    spec: ScrollIndicatorSpec,
101) {
102    let size = scope.size();
103    let radius = size.width.min(size.height) * 0.5;
104    if radius <= 0.0 || !radius.is_finite() || spec.alpha <= 0.0 {
105        return;
106    }
107    let centre = Point {
108        x: size.width * 0.5,
109        y: size.height * 0.5,
110    };
111    let arc = indicator_arc(radius);
112    if arc.centreline() <= 0.0 {
113        return;
114    }
115    let stroke = Stroke::new(arc.width()).with_cap(StrokeCap::Round);
116    for (part, segment) in indicator_segments(arc, geometry, spec.alpha) {
117        let color = match part {
118            IndicatorPart::Track => spec.colors.indicator_track,
119            IndicatorPart::Thumb => spec.colors.indicator_thumb,
120        };
121        match segment {
122            IndicatorSegment::Arc {
123                start,
124                sweep,
125                alpha,
126            } => {
127                if sweep <= 0.0 || alpha <= 0.0 {
128                    continue;
129                }
130                scope.draw_arc(
131                    Brush::Solid(with_alpha(color, alpha)),
132                    centre,
133                    arc.centreline(),
134                    start,
135                    sweep,
136                    stroke,
137                );
138            }
139            IndicatorSegment::Dot {
140                angle,
141                radius: dot,
142                alpha,
143            } => {
144                if dot <= 0.0 || alpha <= 0.0 {
145                    continue;
146                }
147                scope.draw_circle(
148                    Brush::Solid(with_alpha(color, alpha)),
149                    Point {
150                        x: centre.x + arc.centreline() * angle.cos(),
151                        y: centre.y + arc.centreline() * angle.sin(),
152                    },
153                    dot,
154                );
155            }
156        }
157    }
158}
159
160fn with_alpha(color: Color, alpha: f32) -> Color {
161    Color::rgba(color.0, color.1, color.2, color.3 * alpha)
162}
163
164/// The curved scroll indicator, sized to the display it sits on.
165///
166/// It draws nothing when the list fits on screen, which is what Wear does.
167#[composable]
168pub fn ScrollIndicator(
169    modifier: Modifier,
170    state: WearScalingListState,
171    spec: ScrollIndicatorSpec,
172) -> NodeId {
173    let draw_state = state;
174    Canvas(
175        modifier.fill_max_size(),
176        move |scope: &mut dyn DrawScope| {
177            if let Some(geometry) = indicator_for_scaling_list(&draw_state) {
178                draw_scroll_indicator(scope, geometry, spec);
179            }
180        },
181    )
182}
183
184#[cfg(test)]
185#[path = "tests/scroll_indicator_tests.rs"]
186mod tests;