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 `LazyColumn` underneath stacks rows at
7//! their FULL height, measures and scrolls in that space, and each row is then
8//! moved and shrunk by a graphics layer over the top of it.
9//!
10//! # What that layer is told, and what it is not
11//!
12//! It is tempting to conclude that a row's drawn position therefore depends
13//! only on the rows above it and never on how much any of them shrank. That is
14//! true of the `LazyColumn`, and false of what you see.
15//! `ScalingLazyColumnItemWrapper` sets
16//!
17//! ```text
18//! translationY = startOffset(item, anchorType) - unadjustedStartOffset(item, anchorType)
19//! ```
20//!
21//! and both halves come out of `ScalingLazyListState.layoutInfo`, which builds
22//! its window by walking **outward from the centre item** with a cursor that
23//! advances by each row's `ScalingLazyListItemInfo.size` — the scaled size,
24//! `roundToInt(size * scale)` — plus the gap. Downward the next row starts at
25//! that cursor; upward the cursor is the next row's bottom. So the drawn boxes
26//! are stacked edge to edge **at their scaled sizes**, and the Nth row out does
27//! depend on how much the N-1 rows between it and the centre shrank.
28//!
29//! The two accounts agree exactly for the centre row (scale 1, so its scaled
30//! size is its full one) and for its immediate neighbours, and separate from
31//! the second row out. How far they separate is the whole of what the rows in
32//! between shrank, so it depends on the list: on six 52pt rows down a 454pt
33//! watch the third row out is 8.5pt higher under this rule and comes fully on
34//! screen where the full-height stack ran it off the bottom, while on the real
35//! Settings list it is a device pixel of the bottom row's sliver at 192dp and
36//! nothing at all at 227dp. The **shape** of the error is the part worth
37//! keeping in mind: under the full-height stack the drawn boxes drift apart as
38//! they shrink, and under this one they stay exactly one gap apart however
39//! small they get.
40//!
41//! [`place_row`] therefore takes the **cursor**, not a slot in the unscaled
42//! stack; [`PlacedRow::reported_height`] is what advances it; and
43//! [`place_rows`] is the walk, because a per-row call cannot state a rule about
44//! the row after it.
45//!
46//! The ramp itself is still stated on the row's FULL height at that cursor —
47//! `calculateItemInfo` passes `itemStart .. itemStart + item.size` — so a row
48//! is scaled by where its unshrunk box would fall and then pinned by whichever
49//! edge faces the centre line.
50//!
51//! Derived from `androidx.wear.compose.foundation.lazy`
52//! (`ScalingLazyListState.layoutInfo`, `ScalingLazyColumnItemWrapper`,
53//! `calculateItemInfo`, `calculateScaleAndAlpha` and `convertToCenterOffset`,
54//! disassembled out of compose-foundation 1.6.2), then checked against where
55//! Compose puts rows on 454x454 and 384x384 displays.
56//!
57//! This is pure geometry: it answers where a row goes and takes no view of how
58//! it is drawn.
59
60use cranpose_ui_layout::round_to_px;
61
62/// How far a row at the very edge is shrunk and faded.
63pub const EDGE_SCALE: f32 = 0.7;
64pub const EDGE_ALPHA: f32 = 0.5;
65/// The row-height range, as a share of the viewport, over which the transition
66/// band grows from [`MIN_TRANSITION_AREA`] to [`MAX_TRANSITION_AREA`]. A taller
67/// row starts shrinking further from the edge than a short one.
68pub const MIN_ELEMENT_HEIGHT: f32 = 0.2;
69pub const MAX_ELEMENT_HEIGHT: f32 = 0.6;
70pub const MIN_TRANSITION_AREA: f32 = 0.35;
71pub const MAX_TRANSITION_AREA: f32 = 0.55;
72
73/// AOSP's `ScalingParams`, as a value rather than six constants.
74///
75/// `ScalingLazyColumn` takes these as a parameter; the module-level constants
76/// above are the defaults it supplies. Holding them in a struct is what lets a
77/// caller turn scaling off (see [`ScalingParams::reduced_motion`]) without a
78/// second code path.
79#[derive(Clone, Copy, Debug, PartialEq)]
80pub struct ScalingParams {
81    pub edge_scale: f32,
82    pub edge_alpha: f32,
83    pub min_element_height: f32,
84    pub max_element_height: f32,
85    pub min_transition_area: f32,
86    pub max_transition_area: f32,
87}
88
89impl Default for ScalingParams {
90    fn default() -> Self {
91        Self::WEAR
92    }
93}
94
95impl ScalingParams {
96    /// `ScalingLazyColumnDefaults.scalingParams()`.
97    pub const WEAR: Self = Self {
98        edge_scale: EDGE_SCALE,
99        edge_alpha: EDGE_ALPHA,
100        min_element_height: MIN_ELEMENT_HEIGHT,
101        max_element_height: MAX_ELEMENT_HEIGHT,
102        min_transition_area: MIN_TRANSITION_AREA,
103        max_transition_area: MAX_TRANSITION_AREA,
104    };
105
106    /// What Wear uses under `LocalReduceMotion`: both edge values forced to
107    /// `1.0`, which disables scaling and fading entirely rather than damping
108    /// them.
109    pub const fn reduced_motion(self) -> Self {
110        Self {
111            edge_scale: 1.0,
112            edge_alpha: 1.0,
113            min_element_height: self.min_element_height,
114            max_element_height: self.max_element_height,
115            min_transition_area: self.min_transition_area,
116            max_transition_area: self.max_transition_area,
117        }
118    }
119}
120
121/// How much a row is shrunk and faded at a given position.
122#[derive(Clone, Copy, Debug, PartialEq)]
123pub struct ScaleAlpha {
124    pub scale: f32,
125    pub alpha: f32,
126}
127
128impl ScaleAlpha {
129    /// A row sitting fully inside the untransformed middle of the list.
130    pub const UNCHANGED: Self = Self {
131        scale: 1.0,
132        alpha: 1.0,
133    };
134}
135
136/// Wear's `calculateScaleAndAlpha`, for a row spanning `top..bottom` in a
137/// viewport of `viewport`.
138///
139/// All three are in one unit — device pixels if you want to match Compose
140/// exactly, since it does this arithmetic on integers.
141/// Returns `None` when the geometry is non-finite or the row has negative
142/// height.
143pub fn scale_and_alpha(viewport: f32, top: f32, bottom: f32) -> Option<ScaleAlpha> {
144    scale_and_alpha_with(ScalingParams::WEAR, viewport, top, bottom)
145}
146
147/// [`scale_and_alpha`] with the ramp's six knobs supplied.
148pub fn scale_and_alpha_with(
149    params: ScalingParams,
150    viewport: f32,
151    top: f32,
152    bottom: f32,
153) -> Option<ScaleAlpha> {
154    if !viewport.is_finite() || !top.is_finite() || !bottom.is_finite() || bottom < top {
155        return None;
156    }
157    if viewport <= 0.0 {
158        return Some(ScaleAlpha::UNCHANGED);
159    }
160    let edge = (viewport - top).min(bottom) / viewport;
161    let size_ratio = inverse_lerp(
162        params.min_element_height,
163        params.max_element_height,
164        (bottom - top) / viewport,
165    );
166    let line = params.min_transition_area
167        + (params.max_transition_area - params.min_transition_area) * size_ratio;
168    if edge >= line || line <= 0.0 {
169        return Some(ScaleAlpha::UNCHANGED);
170    }
171    let progress = ease(1.0 - edge / line);
172    Some(ScaleAlpha {
173        scale: 1.0 + (params.edge_scale - 1.0) * progress,
174        alpha: 1.0 + (params.edge_alpha - 1.0) * progress,
175    })
176}
177
178/// Where a row ends up once the list has scaled it.
179#[derive(Clone, Copy, Debug, PartialEq)]
180pub struct PlacedRow {
181    /// Top edge after the transform, in the unit `top` was given in.
182    pub top: f32,
183    /// Height after the transform.
184    pub height: f32,
185    /// The height the layout **reports** for this row:
186    /// `ScalingLazyListItemInfo.size`, which is `roundToInt(size * scale)`.
187    ///
188    /// It is not [`Self::height`]. The graphics layer scales by the unrounded
189    /// factor, so what is drawn is a fraction of a pixel different from what is
190    /// reported — and it is the reported one that Wear stacks the next row
191    /// against and that the scroll indicator divides by. Advance an outward
192    /// walk by this plus the gap; see the module docs for why the walk stacks
193    /// scaled sizes at all.
194    pub reported_height: f32,
195    pub scale: f32,
196    pub alpha: f32,
197}
198
199/// Places a row the way a scaling list places one.
200///
201/// `top` is the outward walk's cursor for this row — the drawn bottom edge of
202/// the row between it and the centre, plus the gap — and `height` is its full,
203/// unscaled height. It is **not** the row's slot in a stack of full heights;
204/// the two agree only out to the centre row's immediate neighbours. See the
205/// module docs.
206///
207/// `density` is device pixels per unit; pass `0.0` to skip the pixel rounding
208/// and work in continuous coordinates.
209///
210/// Compose does this on integers, and two details of that survive into the
211/// result. The scaled height is rounded to a whole pixel before the row is
212/// pinned, and `convertToCenterOffset` halves a size with integer division
213/// while the offset it is compared against halves in floating point — so an odd
214/// pixel height carries exactly half a pixel that a float-only implementation
215/// loses.
216///
217/// Returns `None` for non-finite geometry or a negative height.
218pub fn place_row(viewport: f32, top: f32, height: f32, density: f32) -> Option<PlacedRow> {
219    place_row_with(ScalingParams::WEAR, viewport, top, height, density)
220}
221
222/// [`place_row`] with the ramp's six knobs supplied.
223pub fn place_row_with(
224    params: ScalingParams,
225    viewport: f32,
226    top: f32,
227    height: f32,
228    density: f32,
229) -> Option<PlacedRow> {
230    if !height.is_finite() || height < 0.0 || !density.is_finite() {
231        return None;
232    }
233    if density <= 0.0 {
234        let transform = scale_and_alpha_with(params, viewport, top, top + height)?;
235        let scaled = height * transform.scale;
236        return Some(PlacedRow {
237            top,
238            height: scaled,
239            reported_height: scaled,
240            scale: transform.scale,
241            alpha: transform.alpha,
242        });
243    }
244    let viewport_px = (viewport * density).round();
245    let top_px = (top * density).round();
246    let height_px = (height * density).round();
247    let transform = scale_and_alpha_with(params, viewport_px, top_px, top_px + height_px)?;
248    let scaled_px = (height_px * transform.scale).round();
249    let above = top_px + top_px + height_px < viewport_px;
250    let pinned = if above {
251        top_px + height_px - scaled_px
252    } else {
253        top_px
254    };
255    Some(PlacedRow {
256        top: (pinned + odd_pixel(height_px) - odd_pixel(scaled_px)) / density,
257        height: height_px * transform.scale / density,
258        reported_height: scaled_px / density,
259        scale: transform.scale,
260        alpha: transform.alpha,
261    })
262}
263
264/// Everything about a scaling list that is the same for all of its rows.
265///
266/// Held together rather than passed one by one because [`place_rows_with`]
267/// walks a run and every one of these is a property of the run, not of a row.
268#[derive(Clone, Copy, Debug, PartialEq)]
269pub struct RowRun {
270    /// The list's full height, which is what the ramp is stated against.
271    pub viewport: f32,
272    /// Which row the walk starts from — `ScalingLazyListState.centerItemIndex`.
273    pub anchor: usize,
274    /// Where the anchored row's own box starts. This is the one position the
275    /// unscaled stack and the walk always agree on: the anchored row is never
276    /// scaled, so its cursor and its slot are the same number.
277    pub anchor_top: f32,
278    /// `Arrangement.spacedBy`, between every pair of drawn boxes.
279    pub gap: f32,
280    /// Device pixels per unit; `0.0` works in continuous coordinates.
281    pub density: f32,
282}
283
284/// Places a whole run of rows the way a scaling list places one, walking
285/// **outward from the anchored row**.
286///
287/// This is the shape the rule actually has. [`place_row`] answers for one row
288/// given its cursor, and the cursor for the row after it is
289/// `PlacedRow::reported_height + gap` further out — never the full height — so
290/// a per-row call cannot state the rule on its own and a caller that stacks
291/// full heights gets a list that drifts. See the module docs.
292///
293/// `out` is cleared first and comes back one entry per height, in list order.
294pub fn place_rows_with(
295    params: ScalingParams,
296    run: RowRun,
297    heights: &[f32],
298    out: &mut Vec<PlacedRow>,
299) {
300    out.clear();
301    if heights.is_empty() {
302        return;
303    }
304    let anchor = run.anchor.min(heights.len() - 1);
305    let unscaled = |top: f32, height: f32| PlacedRow {
306        top,
307        height,
308        reported_height: height,
309        scale: 1.0,
310        alpha: 1.0,
311    };
312    out.resize(heights.len(), unscaled(0.0, 0.0));
313    let place = |top: f32, height: f32| {
314        place_row_with(params, run.viewport, top, height, run.density)
315            .unwrap_or_else(|| unscaled(top, height))
316    };
317
318    let mut cursor = run.anchor_top;
319    for (index, &height) in heights.iter().enumerate().skip(anchor) {
320        let row = place(cursor, height);
321        cursor += height + run.gap;
322        out[index] = row;
323    }
324    let mut bottom = run.anchor_top;
325    for index in (0..anchor).rev() {
326        let height = heights[index];
327        bottom -= run.gap;
328        let row = place(bottom - height, height);
329        bottom -= height;
330        out[index] = row;
331    }
332}
333
334/// [`place_rows_with`] under Wear's own ramp.
335pub fn place_rows(run: RowRun, heights: &[f32], out: &mut Vec<PlacedRow>) {
336    place_rows_with(ScalingParams::WEAR, run, heights, out);
337}
338
339/// A row's unscaled place in the column: where it would sit and how tall it is
340/// with nothing scaled.
341///
342/// This is the coordinate space the whole module works in. [`place_row`] turns
343/// a slot into the transformed rectangle that is actually drawn; the slot
344/// itself never moves because a row shrank.
345#[derive(Clone, Copy, Debug, PartialEq)]
346pub struct Slot {
347    pub top: f32,
348    pub height: f32,
349}
350
351impl Slot {
352    pub fn centre(self) -> f32 {
353        self.top + self.height * 0.5
354    }
355
356    pub fn bottom(self) -> f32 {
357        self.top + self.height
358    }
359}
360
361/// Stacks row heights into slots, `gap` apart, starting at zero.
362///
363/// The stack is of FULL heights — that is the invariant the whole scaling model
364/// rests on, and stacking scaled heights instead is the mistake this module
365/// exists to prevent.
366pub fn stack_into(heights: impl IntoIterator<Item = f32>, gap: f32, out: &mut Vec<Slot>) {
367    out.clear();
368    let mut cursor = 0.0;
369    for height in heights {
370        out.push(Slot {
371            top: cursor,
372            height,
373        });
374        cursor += height + gap;
375    }
376}
377
378/// Which item the list holds on its centre line, and by how much it is offset.
379///
380/// This is `ScalingLazyListState`'s coordinate pair — `centerItemIndex` plus
381/// `centerItemScrollOffset` — under the default `ScalingLazyListAnchorType.ItemCenter`,
382/// where the anchored point is the item's centre rather than its top edge.
383/// A positive `offset` scrolls the content up, the same sign as a scroll
384/// position.
385#[derive(Clone, Copy, Debug, PartialEq)]
386pub struct CentreAnchor {
387    pub index: usize,
388    pub offset: f32,
389}
390
391impl Default for CentreAnchor {
392    fn default() -> Self {
393        Self {
394            index: 1,
395            offset: 0.0,
396        }
397    }
398}
399
400/// How far the whole column must move so the anchored item sits on the centre
401/// line — Wear's `autoCentering`, as one shift rather than two spacers.
402///
403/// Wear expresses this by injecting a `Spacer` before and after the content
404/// (see [`auto_centring_spacers`]), which is the same arithmetic seen from the
405/// other side: with the leading spacer un-clamped, the content offset it
406/// produces is exactly this shift. Returning the shift lets a caller place rows
407/// directly instead of measuring two phantom items.
408///
409/// The result is rounded to a whole device pixel, because the `LazyColumn`
410/// underneath holds its scroll position as a whole number of pixels: a float
411/// delta is rounded before it is applied and the remainder carried, so every
412/// item top stays integral. Rounding once here does that for the whole column.
413/// Pass `density <= 0.0` to work in continuous coordinates.
414pub fn centre_offset(slots: &[Slot], viewport: f32, anchor: CentreAnchor, density: f32) -> f32 {
415    let Some(slot) = slots.get(anchor.index).or_else(|| slots.last()) else {
416        return 0.0;
417    };
418    round_to_px(viewport * 0.5 - slot.centre() - anchor.offset, density)
419}
420
421/// [`centre_offset`] for a caller that holds its scroll position as a
422/// fractional item index rather than an index and a pixel offset.
423///
424/// `scroll` of `2.5` centres the point halfway between the third and fourth
425/// items' centres. This is the shape an app that scrolls by whole rows wants,
426/// and it interpolates between item *centres* rather than tops so a tall row
427/// next to a short one does not accelerate through the middle.
428pub fn centre_offset_at(slots: &[Slot], viewport: f32, scroll: f32, density: f32) -> f32 {
429    if slots.is_empty() {
430        return 0.0;
431    }
432    let scroll = if scroll.is_finite() { scroll } else { 0.0 };
433    let whole = (scroll.floor().max(0.0) as usize).min(slots.len() - 1);
434    let fraction = (scroll - whole as f32).clamp(0.0, 1.0);
435    let mut anchor = slots[whole].centre();
436    if let Some(next) = slots.get(whole + 1) {
437        anchor += (next.centre() - anchor) * fraction;
438    }
439    round_to_px(viewport * 0.5 - anchor, density)
440}
441
442/// Moves every slot by `offset`.
443pub fn shift(slots: &mut [Slot], offset: f32) {
444    for slot in slots.iter_mut() {
445        slot.top += offset;
446    }
447}
448
449/// The two spacer heights Wear's `autoCentering` injects around the content.
450///
451/// Wear does not shift the column; it inserts a `Spacer` item before all
452/// content and another after it, which is why `totalItemsCount` is two less
453/// than the `LazyColumn`'s and every public index is one higher. Both are
454/// reproduced here because the numbers differ from the plain shift in two
455/// places that show on screen:
456///
457/// - the leading spacer is clamped at zero, so the anchored item cannot be
458///   pushed *below* the centre line by a short list;
459/// - the centre line is `floor(viewport / 2)` on an integer pixel grid, so an
460///   odd viewport gives its spare pixel to the trailing spacer.
461///
462/// `viewport` and the slot geometry are in device pixels here, not points —
463/// that is the space Wear does this arithmetic in.
464pub fn auto_centring_spacers(slots: &[Slot], viewport_px: f32, anchor: CentreAnchor) -> (f32, f32) {
465    let leading = slots
466        .get(anchor.index)
467        .or_else(|| slots.last())
468        .map_or(0.0, |slot| {
469            leading_auto_centring_spacer(viewport_px, slot.centre(), anchor.offset)
470        });
471    let trailing = slots.last().map_or(0.0, |slot| {
472        trailing_auto_centring_spacer(viewport_px, slot.height)
473    });
474    (leading, trailing)
475}
476
477/// The leading `autoCentering` spacer, for a caller holding the anchored row
478/// rather than the stack it came from.
479///
480/// `anchor_centre_px` is that row's centre measured from the top of the
481/// content, which is where [`stack_into`] puts it. See
482/// [`auto_centring_spacers`] for what the two spacers are and why the clamp
483/// and the floored centre line matter.
484pub fn leading_auto_centring_spacer(
485    viewport_px: f32,
486    anchor_centre_px: f32,
487    anchor_offset: f32,
488) -> f32 {
489    ((viewport_px * 0.5).floor() - anchor_offset - anchor_centre_px).max(0.0)
490}
491
492/// The trailing `autoCentering` spacer, which depends only on the last row's
493/// height: `unadjustedSizeBelowOffsetPoint` under `ItemCenter` is half of it.
494pub fn trailing_auto_centring_spacer(viewport_px: f32, last_height_px: f32) -> f32 {
495    (viewport_px - (viewport_px * 0.5).floor() - last_height_px * 0.5).max(0.0)
496}
497
498/// The stretch of content a scaling list can hold on its centre line.
499///
500/// Both ends are content coordinates — the same space [`stack_into`] stacks
501/// slots in — and the pair is what a scroll position has to be kept inside.
502///
503/// **It is not "the anchored row centred" to "the last row centred".** The
504/// `LazyColumn` underneath takes its `contentPadding` OUTSIDE both auto-centring
505/// spacers, so the padding is scroll the list can spend at each end. At the
506/// bottom that means the last row settles `after_padding` **above** the centre
507/// line rather than on it; at the top the anchored row can be pulled
508/// `before_padding` **below** it. Wear says the same thing from the other side
509/// in `ScalingLazyListState.scrollToItem`, which scrolls the `LazyColumn` to
510/// `beforeContentPaddingPx - viewportCenterLinePx` to put a row on the line —
511/// so the list is already `before_padding` in from its own top when it opens,
512/// and a port that stops at the two centred rows cannot reach either end.
513///
514/// Clamping at the two centred rows costs behaviour and not only pixels: it is
515/// the difference between a user reaching the last row of a settings list and
516/// not reaching it.
517///
518/// Both spacers are clamped at zero in Wear (see [`auto_centring_spacers`]), and
519/// this states the travel for a list where neither clamp bit — a list long
520/// enough to scroll with a leading spacer left. On one clamped at either end the
521/// true travel is shorter at that end.
522pub fn anchor_travel(
523    anchor_centre: f32,
524    last_centre: f32,
525    before_padding: f32,
526    after_padding: f32,
527) -> (f32, f32) {
528    let end = last_centre + after_padding;
529    let start = anchor_centre - before_padding;
530    (start.min(end), end)
531}
532
533fn odd_pixel(pixels: f32) -> f32 {
534    let half = pixels * 0.5;
535    half - half.floor()
536}
537
538fn inverse_lerp(start: f32, stop: f32, value: f32) -> f32 {
539    ((value - start) / (stop - start)).clamp(0.0, 1.0)
540}
541
542fn ease(x: f32) -> f32 {
543    let x = x.clamp(0.0, 1.0);
544    let mut low = 0.0f32;
545    let mut high = 1.0f32;
546    let mut t = x;
547    for _ in 0..12 {
548        let value = bezier(t, 0.3, 0.7);
549        if value < x {
550            low = t;
551        } else {
552            high = t;
553        }
554        t = (low + high) * 0.5;
555    }
556    bezier(t, 0.0, 1.0)
557}
558
559fn bezier(t: f32, first: f32, second: f32) -> f32 {
560    let inverse = 1.0 - t;
561    3.0 * inverse * inverse * t * first + 3.0 * inverse * t * t * second + t * t * t
562}
563
564#[cfg(test)]
565#[path = "tests/round_scaling_list_tests.rs"]
566mod tests;