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;