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;