Skip to main content

cranpose_ui/
round_scaling_list.rs

1//! Placing list rows the way a round watch scales them.
2//!
3//! A scaling list shrinks and fades its rows towards the top and bottom of the
4//! display so the content follows the bezel. The surprising part, and the part
5//! that is wrong in every from-scratch implementation, is that **the list does
6//! not re-measure a scaled row**: the column underneath stacks rows at their
7//! FULL height, and each is then drawn through a graphics layer whose
8//! `transformOrigin` sits on the edge facing the centre line. A row's position
9//! therefore depends only on the rows above it, never on how much any of them
10//! shrank. Scale first and stack the scaled heights and the list drifts further
11//! out of place with every row.
12//!
13//! Derived from `androidx.wear.compose.foundation.lazy`
14//! (`ScalingLazyColumnItemWrapper`, `calculateScaleAndAlpha`, and
15//! `convertToCenterOffset`), then checked against where Compose puts rows on
16//! 454x454 and 384x384 displays.
17//!
18//! This is pure geometry: it answers where a row goes and takes no view of how
19//! it is drawn.
20
21/// How far a row at the very edge is shrunk and faded.
22pub const EDGE_SCALE: f32 = 0.7;
23pub const EDGE_ALPHA: f32 = 0.5;
24/// The row-height range, as a share of the viewport, over which the transition
25/// band grows from [`MIN_TRANSITION_AREA`] to [`MAX_TRANSITION_AREA`]. A taller
26/// row starts shrinking further from the edge than a short one.
27pub const MIN_ELEMENT_HEIGHT: f32 = 0.2;
28pub const MAX_ELEMENT_HEIGHT: f32 = 0.6;
29pub const MIN_TRANSITION_AREA: f32 = 0.35;
30pub const MAX_TRANSITION_AREA: f32 = 0.55;
31
32/// How much a row is shrunk and faded at a given position.
33#[derive(Clone, Copy, Debug, PartialEq)]
34pub struct ScaleAlpha {
35    pub scale: f32,
36    pub alpha: f32,
37}
38
39impl ScaleAlpha {
40    /// A row sitting fully inside the untransformed middle of the list.
41    pub const UNCHANGED: Self = Self {
42        scale: 1.0,
43        alpha: 1.0,
44    };
45}
46
47/// Wear's `calculateScaleAndAlpha`, for a row spanning `top..bottom` in a
48/// viewport of `viewport`.
49///
50/// All three are in one unit — device pixels if you want to match Compose
51/// exactly, since it does this arithmetic on integers.
52/// Returns `None` when the geometry is non-finite or the row has negative
53/// height.
54pub fn scale_and_alpha(viewport: f32, top: f32, bottom: f32) -> Option<ScaleAlpha> {
55    if !viewport.is_finite() || !top.is_finite() || !bottom.is_finite() || bottom < top {
56        return None;
57    }
58    if viewport <= 0.0 {
59        return Some(ScaleAlpha::UNCHANGED);
60    }
61    // Distance to whichever edge this row is nearer, as a share of the viewport.
62    let edge = (viewport - top).min(bottom) / viewport;
63    let size_ratio = inverse_lerp(
64        MIN_ELEMENT_HEIGHT,
65        MAX_ELEMENT_HEIGHT,
66        (bottom - top) / viewport,
67    );
68    let line = MIN_TRANSITION_AREA + (MAX_TRANSITION_AREA - MIN_TRANSITION_AREA) * size_ratio;
69    if edge >= line || line <= 0.0 {
70        return Some(ScaleAlpha::UNCHANGED);
71    }
72    let progress = ease(1.0 - edge / line);
73    Some(ScaleAlpha {
74        scale: 1.0 + (EDGE_SCALE - 1.0) * progress,
75        alpha: 1.0 + (EDGE_ALPHA - 1.0) * progress,
76    })
77}
78
79/// Where a row ends up once the list has scaled it.
80#[derive(Clone, Copy, Debug, PartialEq)]
81pub struct PlacedRow {
82    /// Top edge after the transform, in the unit `top` was given in.
83    pub top: f32,
84    /// Height after the transform.
85    pub height: f32,
86    pub scale: f32,
87    pub alpha: f32,
88}
89
90/// Places a row the way a scaling list places one.
91///
92/// `top` is where the row would sit with nothing scaled — the running total of
93/// the FULL heights of the rows above it — and `height` is its full height.
94/// `density` is device pixels per unit; pass `0.0` to skip the pixel rounding
95/// and work in continuous coordinates.
96///
97/// Compose does this on integers, and two details of that survive into the
98/// result. The scaled height is rounded to a whole pixel before the row is
99/// pinned, and `convertToCenterOffset` halves a size with integer division
100/// while the offset it is compared against halves in floating point — so an odd
101/// pixel height carries exactly half a pixel that a float-only implementation
102/// loses.
103///
104/// Returns `None` for non-finite geometry or a negative height.
105pub fn place_row(viewport: f32, top: f32, height: f32, density: f32) -> Option<PlacedRow> {
106    if !height.is_finite() || height < 0.0 || !density.is_finite() {
107        return None;
108    }
109    if density <= 0.0 {
110        let transform = scale_and_alpha(viewport, top, top + height)?;
111        return Some(PlacedRow {
112            top,
113            height: height * transform.scale,
114            scale: transform.scale,
115            alpha: transform.alpha,
116        });
117    }
118    let viewport_px = (viewport * density).round();
119    let top_px = (top * density).round();
120    let height_px = (height * density).round();
121    let transform = scale_and_alpha(viewport_px, top_px, top_px + height_px)?;
122    let scaled_px = (height_px * transform.scale).round();
123    // Wear's `isAboveLine`, on the same integers it uses: a row above the
124    // centre line keeps its BOTTOM edge, one below keeps its top.
125    let above = top_px + top_px + height_px < viewport_px;
126    let pinned = if above {
127        top_px + height_px - scaled_px
128    } else {
129        top_px
130    };
131    Some(PlacedRow {
132        top: (pinned + odd_pixel(height_px) - odd_pixel(scaled_px)) / density,
133        height: height_px * transform.scale / density,
134        scale: transform.scale,
135        alpha: transform.alpha,
136    })
137}
138
139/// Half a pixel when a pixel height is odd, nothing when it is even — what
140/// Compose's integer halving leaves behind beside its floating-point one.
141fn odd_pixel(pixels: f32) -> f32 {
142    let half = pixels * 0.5;
143    half - half.floor()
144}
145
146fn inverse_lerp(start: f32, stop: f32, value: f32) -> f32 {
147    ((value - start) / (stop - start)).clamp(0.0, 1.0)
148}
149
150/// Wear's transition easing, `CubicBezierEasing(0.3, 0.0, 0.7, 1.0)`.
151///
152/// A Compose easing curve is parametric, so the answer is the curve's y at the
153/// parameter whose x is `fraction`. Twelve bisections put the result inside
154/// 1/4096, which is far below a pixel at any watch size.
155fn ease(x: f32) -> f32 {
156    let x = x.clamp(0.0, 1.0);
157    let mut low = 0.0f32;
158    let mut high = 1.0f32;
159    let mut t = x;
160    for _ in 0..12 {
161        let value = bezier(t, 0.3, 0.7);
162        if value < x {
163            low = t;
164        } else {
165            high = t;
166        }
167        t = (low + high) * 0.5;
168    }
169    bezier(t, 0.0, 1.0)
170}
171
172fn bezier(t: f32, first: f32, second: f32) -> f32 {
173    let inverse = 1.0 - t;
174    3.0 * inverse * inverse * t * first + 3.0 * inverse * t * t * second + t * t * t
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    const VIEWPORT: f32 = 227.0;
182
183    #[test]
184    fn a_row_in_the_middle_is_left_alone() {
185        let middle = scale_and_alpha(VIEWPORT, VIEWPORT * 0.45, VIEWPORT * 0.55).unwrap();
186        assert_eq!(middle, ScaleAlpha::UNCHANGED);
187    }
188
189    #[test]
190    fn a_row_at_the_edge_is_shrunk_and_faded_together() {
191        let edge = scale_and_alpha(VIEWPORT, 0.0, 20.0).unwrap();
192        assert!(edge.scale < 1.0 && edge.scale >= EDGE_SCALE, "{edge:?}");
193        assert!(edge.alpha < 1.0 && edge.alpha >= EDGE_ALPHA, "{edge:?}");
194        // Both run to their limits together, so a row never fades without
195        // shrinking or the reverse.
196        let top = scale_and_alpha(VIEWPORT, 0.0, 0.0).unwrap();
197        assert!((top.scale - EDGE_SCALE).abs() < 1e-3, "{top:?}");
198        assert!((top.alpha - EDGE_ALPHA).abs() < 1e-3, "{top:?}");
199    }
200
201    #[test]
202    fn the_two_edges_treat_a_row_the_same() {
203        let height = 40.0;
204        let near_top = scale_and_alpha(VIEWPORT, 8.0, 8.0 + height).unwrap();
205        let near_bottom =
206            scale_and_alpha(VIEWPORT, VIEWPORT - 8.0 - height, VIEWPORT - 8.0).unwrap();
207        assert!((near_top.scale - near_bottom.scale).abs() < 1e-5);
208        assert!((near_top.alpha - near_bottom.alpha).abs() < 1e-5);
209    }
210
211    #[test]
212    fn a_taller_row_starts_shrinking_further_from_the_edge() {
213        // The transition band grows with row height. Isolating that needs two
214        // rows at the SAME distance from an edge: anchor both near the bottom,
215        // where `edge` is measured from the top edge and so does not move when
216        // the height does. The taller row has the wider band, so the same
217        // distance is a larger fraction of it and it shrinks more.
218        let top = VIEWPORT - 10.0;
219        let short = scale_and_alpha(VIEWPORT, top, top + VIEWPORT * 0.2).unwrap();
220        let tall = scale_and_alpha(VIEWPORT, top, top + VIEWPORT * 0.62).unwrap();
221        assert!(tall.scale < short.scale, "short {short:?} tall {tall:?}");
222    }
223
224    #[test]
225    fn a_row_is_placed_from_the_full_heights_above_it_not_the_scaled_ones() {
226        // Two rows of the same full height at the same unscaled offsets must
227        // land where those offsets say, however much the first one shrank.
228        let first = place_row(VIEWPORT, 0.0, 50.0, 2.0).unwrap();
229        let second = place_row(VIEWPORT, 50.0, 50.0, 2.0).unwrap();
230        assert!(first.scale < 1.0, "the first row is at the edge: {first:?}");
231        // The second row's position is not pushed up by the first row shrinking.
232        assert!(second.top >= 49.0, "{second:?}");
233    }
234
235    #[test]
236    fn a_row_above_the_centre_line_keeps_its_bottom_edge() {
237        // Above the line the transform origin is the bottom, so shrinking pulls
238        // the top down; below the line the top is pinned and the bottom rises.
239        let above = place_row(VIEWPORT, 4.0, 50.0, 2.0).unwrap();
240        assert!(above.scale < 1.0, "{above:?}");
241        assert!(
242            above.top > 4.0,
243            "shrinking should pull the top down: {above:?}"
244        );
245
246        let below = place_row(VIEWPORT, VIEWPORT - 54.0, 50.0, 2.0).unwrap();
247        assert!(below.scale < 1.0, "{below:?}");
248        assert!(
249            (below.top - (VIEWPORT - 54.0)).abs() < 0.6,
250            "the top is pinned below the line: {below:?}"
251        );
252    }
253
254    #[test]
255    fn an_odd_pixel_height_carries_the_half_pixel_composes_integer_halving_leaves() {
256        // 25 units at density 2 is a 50px row — even. 25.5 is 51px — odd, and
257        // that half pixel is exactly what a float-only implementation drops.
258        assert_eq!(odd_pixel(50.0), 0.0);
259        assert_eq!(odd_pixel(51.0), 0.5);
260        let odd = place_row(VIEWPORT, 3.0, 25.5, 2.0).unwrap();
261        assert!(odd.scale < 1.0, "needs to be in the scaled band: {odd:?}");
262    }
263
264    #[test]
265    fn a_density_of_zero_falls_back_to_continuous_placement_instead_of_dividing_by_it() {
266        let placed = place_row(VIEWPORT, 10.0, 50.0, 0.0).unwrap();
267        assert!(
268            placed.top.is_finite() && placed.height.is_finite(),
269            "{placed:?}"
270        );
271        assert_eq!(placed.top, 10.0);
272        let negative = place_row(VIEWPORT, 10.0, 50.0, -2.0).unwrap();
273        assert_eq!(negative, placed, "a nonsense density is not a crash");
274    }
275
276    #[test]
277    fn an_empty_viewport_leaves_everything_alone_rather_than_dividing_by_it() {
278        assert_eq!(scale_and_alpha(0.0, 0.0, 10.0), Some(ScaleAlpha::UNCHANGED));
279        assert_eq!(
280            scale_and_alpha(-5.0, 0.0, 10.0),
281            Some(ScaleAlpha::UNCHANGED)
282        );
283    }
284
285    #[test]
286    fn invalid_geometry_is_rejected_instead_of_producing_nan() {
287        assert_eq!(scale_and_alpha(f32::NAN, 0.0, 10.0), None);
288        assert_eq!(scale_and_alpha(VIEWPORT, 10.0, 9.0), None);
289        assert_eq!(place_row(VIEWPORT, 0.0, -1.0, 2.0), None);
290        assert_eq!(place_row(VIEWPORT, 0.0, 10.0, f32::INFINITY), None);
291    }
292
293    #[test]
294    fn the_easing_is_monotonic_and_spans_the_whole_range() {
295        assert!((ease(0.0) - 0.0).abs() < 1e-3, "{}", ease(0.0));
296        assert!((ease(1.0) - 1.0).abs() < 1e-3, "{}", ease(1.0));
297        let mut previous = -1.0;
298        for step in 0..=20 {
299            let value = ease(step as f32 / 20.0);
300            assert!(value >= previous - 1e-4, "not monotonic at {step}");
301            previous = value;
302        }
303    }
304
305    #[test]
306    fn the_easing_matches_the_current_wear_compose_curve() {
307        assert!((ease(0.25) - 0.166_779).abs() < 1e-3, "{}", ease(0.25));
308    }
309}