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
60/// How far a row at the very edge is shrunk and faded.
61pub const EDGE_SCALE: f32 = 0.7;
62pub const EDGE_ALPHA: f32 = 0.5;
63/// The row-height range, as a share of the viewport, over which the transition
64/// band grows from [`MIN_TRANSITION_AREA`] to [`MAX_TRANSITION_AREA`]. A taller
65/// row starts shrinking further from the edge than a short one.
66pub const MIN_ELEMENT_HEIGHT: f32 = 0.2;
67pub const MAX_ELEMENT_HEIGHT: f32 = 0.6;
68pub const MIN_TRANSITION_AREA: f32 = 0.35;
69pub const MAX_TRANSITION_AREA: f32 = 0.55;
70
71/// AOSP's `ScalingParams`, as a value rather than six constants.
72///
73/// `ScalingLazyColumn` takes these as a parameter; the module-level constants
74/// above are the defaults it supplies. Holding them in a struct is what lets a
75/// caller turn scaling off (see [`ScalingParams::reduced_motion`]) without a
76/// second code path.
77#[derive(Clone, Copy, Debug, PartialEq)]
78pub struct ScalingParams {
79 pub edge_scale: f32,
80 pub edge_alpha: f32,
81 pub min_element_height: f32,
82 pub max_element_height: f32,
83 pub min_transition_area: f32,
84 pub max_transition_area: f32,
85}
86
87impl Default for ScalingParams {
88 fn default() -> Self {
89 Self::WEAR
90 }
91}
92
93impl ScalingParams {
94 /// `ScalingLazyColumnDefaults.scalingParams()`.
95 pub const WEAR: Self = Self {
96 edge_scale: EDGE_SCALE,
97 edge_alpha: EDGE_ALPHA,
98 min_element_height: MIN_ELEMENT_HEIGHT,
99 max_element_height: MAX_ELEMENT_HEIGHT,
100 min_transition_area: MIN_TRANSITION_AREA,
101 max_transition_area: MAX_TRANSITION_AREA,
102 };
103
104 /// What Wear uses under `LocalReduceMotion`: both edge values forced to
105 /// `1.0`, which disables scaling and fading entirely rather than damping
106 /// them.
107 pub const fn reduced_motion(self) -> Self {
108 Self {
109 edge_scale: 1.0,
110 edge_alpha: 1.0,
111 min_element_height: self.min_element_height,
112 max_element_height: self.max_element_height,
113 min_transition_area: self.min_transition_area,
114 max_transition_area: self.max_transition_area,
115 }
116 }
117}
118
119/// How much a row is shrunk and faded at a given position.
120#[derive(Clone, Copy, Debug, PartialEq)]
121pub struct ScaleAlpha {
122 pub scale: f32,
123 pub alpha: f32,
124}
125
126impl ScaleAlpha {
127 /// A row sitting fully inside the untransformed middle of the list.
128 pub const UNCHANGED: Self = Self {
129 scale: 1.0,
130 alpha: 1.0,
131 };
132}
133
134/// Wear's `calculateScaleAndAlpha`, for a row spanning `top..bottom` in a
135/// viewport of `viewport`.
136///
137/// All three are in one unit — device pixels if you want to match Compose
138/// exactly, since it does this arithmetic on integers.
139/// Returns `None` when the geometry is non-finite or the row has negative
140/// height.
141pub fn scale_and_alpha(viewport: f32, top: f32, bottom: f32) -> Option<ScaleAlpha> {
142 scale_and_alpha_with(ScalingParams::WEAR, viewport, top, bottom)
143}
144
145/// [`scale_and_alpha`] with the ramp's six knobs supplied.
146pub fn scale_and_alpha_with(
147 params: ScalingParams,
148 viewport: f32,
149 top: f32,
150 bottom: f32,
151) -> Option<ScaleAlpha> {
152 if !viewport.is_finite() || !top.is_finite() || !bottom.is_finite() || bottom < top {
153 return None;
154 }
155 if viewport <= 0.0 {
156 return Some(ScaleAlpha::UNCHANGED);
157 }
158 // Distance to whichever edge this row is nearer, as a share of the viewport.
159 let edge = (viewport - top).min(bottom) / viewport;
160 let size_ratio = inverse_lerp(
161 params.min_element_height,
162 params.max_element_height,
163 (bottom - top) / viewport,
164 );
165 let line = params.min_transition_area
166 + (params.max_transition_area - params.min_transition_area) * size_ratio;
167 if edge >= line || line <= 0.0 {
168 return Some(ScaleAlpha::UNCHANGED);
169 }
170 // Wear does not clamp this before easing, so an item scrolled past the edge
171 // reads `edge < 0` and comes out below `edge_scale`. `ease` clamps, which
172 // is the behaviour a port wants and the one the spec recommends.
173 let progress = ease(1.0 - edge / line);
174 Some(ScaleAlpha {
175 scale: 1.0 + (params.edge_scale - 1.0) * progress,
176 alpha: 1.0 + (params.edge_alpha - 1.0) * progress,
177 })
178}
179
180/// Where a row ends up once the list has scaled it.
181#[derive(Clone, Copy, Debug, PartialEq)]
182pub struct PlacedRow {
183 /// Top edge after the transform, in the unit `top` was given in.
184 pub top: f32,
185 /// Height after the transform.
186 pub height: f32,
187 /// The height the layout **reports** for this row:
188 /// `ScalingLazyListItemInfo.size`, which is `roundToInt(size * scale)`.
189 ///
190 /// It is not [`Self::height`]. The graphics layer scales by the unrounded
191 /// factor, so what is drawn is a fraction of a pixel different from what is
192 /// reported — and it is the reported one that Wear stacks the next row
193 /// against and that the scroll indicator divides by. Advance an outward
194 /// walk by this plus the gap; see the module docs for why the walk stacks
195 /// scaled sizes at all.
196 pub reported_height: f32,
197 pub scale: f32,
198 pub alpha: f32,
199}
200
201/// Places a row the way a scaling list places one.
202///
203/// `top` is the outward walk's cursor for this row — the drawn bottom edge of
204/// the row between it and the centre, plus the gap — and `height` is its full,
205/// unscaled height. It is **not** the row's slot in a stack of full heights;
206/// the two agree only out to the centre row's immediate neighbours. See the
207/// module docs.
208///
209/// `density` is device pixels per unit; pass `0.0` to skip the pixel rounding
210/// and work in continuous coordinates.
211///
212/// Compose does this on integers, and two details of that survive into the
213/// result. The scaled height is rounded to a whole pixel before the row is
214/// pinned, and `convertToCenterOffset` halves a size with integer division
215/// while the offset it is compared against halves in floating point — so an odd
216/// pixel height carries exactly half a pixel that a float-only implementation
217/// loses.
218///
219/// Returns `None` for non-finite geometry or a negative height.
220pub fn place_row(viewport: f32, top: f32, height: f32, density: f32) -> Option<PlacedRow> {
221 place_row_with(ScalingParams::WEAR, viewport, top, height, density)
222}
223
224/// [`place_row`] with the ramp's six knobs supplied.
225pub fn place_row_with(
226 params: ScalingParams,
227 viewport: f32,
228 top: f32,
229 height: f32,
230 density: f32,
231) -> Option<PlacedRow> {
232 if !height.is_finite() || height < 0.0 || !density.is_finite() {
233 return None;
234 }
235 if density <= 0.0 {
236 let transform = scale_and_alpha_with(params, viewport, top, top + height)?;
237 let scaled = height * transform.scale;
238 return Some(PlacedRow {
239 top,
240 height: scaled,
241 // `roundToInt` has no meaning without a pixel grid to round onto.
242 reported_height: scaled,
243 scale: transform.scale,
244 alpha: transform.alpha,
245 });
246 }
247 let viewport_px = (viewport * density).round();
248 let top_px = (top * density).round();
249 let height_px = (height * density).round();
250 let transform = scale_and_alpha_with(params, viewport_px, top_px, top_px + height_px)?;
251 let scaled_px = (height_px * transform.scale).round();
252 // Wear's `isAboveLine`, on the same integers it uses: a row above the
253 // centre line keeps its BOTTOM edge, one below keeps its top.
254 let above = top_px + top_px + height_px < viewport_px;
255 let pinned = if above {
256 top_px + height_px - scaled_px
257 } else {
258 top_px
259 };
260 Some(PlacedRow {
261 top: (pinned + odd_pixel(height_px) - odd_pixel(scaled_px)) / density,
262 height: height_px * transform.scale / density,
263 reported_height: scaled_px / density,
264 scale: transform.scale,
265 alpha: transform.alpha,
266 })
267}
268
269/// Everything about a scaling list that is the same for all of its rows.
270///
271/// Held together rather than passed one by one because [`place_rows_with`]
272/// walks a run and every one of these is a property of the run, not of a row.
273#[derive(Clone, Copy, Debug, PartialEq)]
274pub struct RowRun {
275 /// The list's full height, which is what the ramp is stated against.
276 pub viewport: f32,
277 /// Which row the walk starts from — `ScalingLazyListState.centerItemIndex`.
278 pub anchor: usize,
279 /// Where the anchored row's own box starts. This is the one position the
280 /// unscaled stack and the walk always agree on: the anchored row is never
281 /// scaled, so its cursor and its slot are the same number.
282 pub anchor_top: f32,
283 /// `Arrangement.spacedBy`, between every pair of drawn boxes.
284 pub gap: f32,
285 /// Device pixels per unit; `0.0` works in continuous coordinates.
286 pub density: f32,
287}
288
289/// Places a whole run of rows the way a scaling list places one, walking
290/// **outward from the anchored row**.
291///
292/// This is the shape the rule actually has. [`place_row`] answers for one row
293/// given its cursor, and the cursor for the row after it is
294/// `PlacedRow::reported_height + gap` further out — never the full height — so
295/// a per-row call cannot state the rule on its own and a caller that stacks
296/// full heights gets a list that drifts. See the module docs.
297///
298/// `out` is cleared first and comes back one entry per height, in list order.
299pub fn place_rows_with(
300 params: ScalingParams,
301 run: RowRun,
302 heights: &[f32],
303 out: &mut Vec<PlacedRow>,
304) {
305 out.clear();
306 if heights.is_empty() {
307 return;
308 }
309 let anchor = run.anchor.min(heights.len() - 1);
310 let unscaled = |top: f32, height: f32| PlacedRow {
311 top,
312 height,
313 reported_height: height,
314 scale: 1.0,
315 alpha: 1.0,
316 };
317 out.resize(heights.len(), unscaled(0.0, 0.0));
318 let place = |top: f32, height: f32| {
319 place_row_with(params, run.viewport, top, height, run.density)
320 .unwrap_or_else(|| unscaled(top, height))
321 };
322
323 let mut cursor = run.anchor_top;
324 for (index, &height) in heights.iter().enumerate().skip(anchor) {
325 let row = place(cursor, height);
326 cursor += row.reported_height + run.gap;
327 out[index] = row;
328 }
329 // Upward the cursor is the next row's BOTTOM, and the ramp is still read
330 // off the row's full box hanging from it.
331 let mut bottom = run.anchor_top;
332 for index in (0..anchor).rev() {
333 let height = heights[index];
334 bottom -= run.gap;
335 let row = place(bottom - height, height);
336 bottom -= row.reported_height;
337 out[index] = row;
338 }
339}
340
341/// [`place_rows_with`] under Wear's own ramp.
342pub fn place_rows(run: RowRun, heights: &[f32], out: &mut Vec<PlacedRow>) {
343 place_rows_with(ScalingParams::WEAR, run, heights, out)
344}
345
346/// A row's unscaled place in the column: where it would sit and how tall it is
347/// with nothing scaled.
348///
349/// This is the coordinate space the whole module works in. [`place_row`] turns
350/// a slot into the transformed rectangle that is actually drawn; the slot
351/// itself never moves because a row shrank.
352#[derive(Clone, Copy, Debug, PartialEq)]
353pub struct Slot {
354 pub top: f32,
355 pub height: f32,
356}
357
358impl Slot {
359 pub fn centre(self) -> f32 {
360 self.top + self.height * 0.5
361 }
362
363 pub fn bottom(self) -> f32 {
364 self.top + self.height
365 }
366}
367
368/// Stacks row heights into slots, `gap` apart, starting at zero.
369///
370/// The stack is of FULL heights — that is the invariant the whole scaling model
371/// rests on, and stacking scaled heights instead is the mistake this module
372/// exists to prevent.
373pub fn stack_into(heights: impl IntoIterator<Item = f32>, gap: f32, out: &mut Vec<Slot>) {
374 out.clear();
375 let mut cursor = 0.0;
376 for height in heights {
377 out.push(Slot {
378 top: cursor,
379 height,
380 });
381 cursor += height + gap;
382 }
383}
384
385/// Which item the list holds on its centre line, and by how much it is offset.
386///
387/// This is `ScalingLazyListState`'s coordinate pair — `centerItemIndex` plus
388/// `centerItemScrollOffset` — under the default `ScalingLazyListAnchorType.ItemCenter`,
389/// where the anchored point is the item's centre rather than its top edge.
390/// A positive `offset` scrolls the content up, the same sign as a scroll
391/// position.
392#[derive(Clone, Copy, Debug, PartialEq)]
393pub struct CentreAnchor {
394 pub index: usize,
395 pub offset: f32,
396}
397
398impl Default for CentreAnchor {
399 /// `rememberScalingLazyListState()`'s own default: the second item, centred.
400 fn default() -> Self {
401 Self {
402 index: 1,
403 offset: 0.0,
404 }
405 }
406}
407
408/// A length moved onto the whole device pixel Compose would give it.
409///
410/// Compose's layout is integral — `Dp.roundToPx()` runs before anything is
411/// measured and children are placed at an `IntOffset` — and Kotlin's
412/// `roundToInt` sends an exact half **up**, not away from zero. Rust's
413/// `f32::round` disagrees on exactly the negative halves, which is the case a
414/// scroll offset reaches.
415pub fn round_to_px(value: f32, density: f32) -> f32 {
416 if density <= 0.0 || !density.is_finite() || !value.is_finite() {
417 return value;
418 }
419 (value * density + 0.5).floor() / density
420}
421
422/// How far the whole column must move so the anchored item sits on the centre
423/// line — Wear's `autoCentering`, as one shift rather than two spacers.
424///
425/// Wear expresses this by injecting a `Spacer` before and after the content
426/// (see [`auto_centring_spacers`]), which is the same arithmetic seen from the
427/// other side: with the leading spacer un-clamped, the content offset it
428/// produces is exactly this shift. Returning the shift lets a caller place rows
429/// directly instead of measuring two phantom items.
430///
431/// The result is rounded to a whole device pixel, because the `LazyColumn`
432/// underneath holds its scroll position as a whole number of pixels: a float
433/// delta is rounded before it is applied and the remainder carried, so every
434/// item top stays integral. Rounding once here does that for the whole column.
435/// Pass `density <= 0.0` to work in continuous coordinates.
436pub fn centre_offset(slots: &[Slot], viewport: f32, anchor: CentreAnchor, density: f32) -> f32 {
437 let Some(slot) = slots.get(anchor.index).or_else(|| slots.last()) else {
438 return 0.0;
439 };
440 round_to_px(viewport * 0.5 - slot.centre() - anchor.offset, density)
441}
442
443/// [`centre_offset`] for a caller that holds its scroll position as a
444/// fractional item index rather than an index and a pixel offset.
445///
446/// `scroll` of `2.5` centres the point halfway between the third and fourth
447/// items' centres. This is the shape an app that scrolls by whole rows wants,
448/// and it interpolates between item *centres* rather than tops so a tall row
449/// next to a short one does not accelerate through the middle.
450pub fn centre_offset_at(slots: &[Slot], viewport: f32, scroll: f32, density: f32) -> f32 {
451 if slots.is_empty() {
452 return 0.0;
453 }
454 let scroll = if scroll.is_finite() { scroll } else { 0.0 };
455 let whole = (scroll.floor().max(0.0) as usize).min(slots.len() - 1);
456 let fraction = (scroll - whole as f32).clamp(0.0, 1.0);
457 let mut anchor = slots[whole].centre();
458 if let Some(next) = slots.get(whole + 1) {
459 anchor += (next.centre() - anchor) * fraction;
460 }
461 round_to_px(viewport * 0.5 - anchor, density)
462}
463
464/// Moves every slot by `offset`.
465pub fn shift(slots: &mut [Slot], offset: f32) {
466 for slot in slots.iter_mut() {
467 slot.top += offset;
468 }
469}
470
471/// The two spacer heights Wear's `autoCentering` injects around the content.
472///
473/// Wear does not shift the column; it inserts a `Spacer` item before all
474/// content and another after it, which is why `totalItemsCount` is two less
475/// than the `LazyColumn`'s and every public index is one higher. Both are
476/// reproduced here because the numbers differ from the plain shift in two
477/// places that show on screen:
478///
479/// - the leading spacer is clamped at zero, so the anchored item cannot be
480/// pushed *below* the centre line by a short list;
481/// - the centre line is `floor(viewport / 2)` on an integer pixel grid, so an
482/// odd viewport gives its spare pixel to the trailing spacer.
483///
484/// `viewport` and the slot geometry are in device pixels here, not points —
485/// that is the space Wear does this arithmetic in.
486pub fn auto_centring_spacers(slots: &[Slot], viewport_px: f32, anchor: CentreAnchor) -> (f32, f32) {
487 let leading = slots
488 .get(anchor.index)
489 .or_else(|| slots.last())
490 .map(|slot| leading_auto_centring_spacer(viewport_px, slot.centre(), anchor.offset))
491 .unwrap_or(0.0);
492 let trailing = slots
493 .last()
494 .map(|slot| trailing_auto_centring_spacer(viewport_px, slot.height))
495 .unwrap_or(0.0);
496 (leading, trailing)
497}
498
499/// The leading `autoCentering` spacer, for a caller holding the anchored row
500/// rather than the stack it came from.
501///
502/// `anchor_centre_px` is that row's centre measured from the top of the
503/// content, which is where [`stack_into`] puts it. See
504/// [`auto_centring_spacers`] for what the two spacers are and why the clamp
505/// and the floored centre line matter.
506pub fn leading_auto_centring_spacer(
507 viewport_px: f32,
508 anchor_centre_px: f32,
509 anchor_offset: f32,
510) -> f32 {
511 ((viewport_px * 0.5).floor() - anchor_offset - anchor_centre_px).max(0.0)
512}
513
514/// The trailing `autoCentering` spacer, which depends only on the last row's
515/// height: `unadjustedSizeBelowOffsetPoint` under `ItemCenter` is half of it.
516pub fn trailing_auto_centring_spacer(viewport_px: f32, last_height_px: f32) -> f32 {
517 (viewport_px - (viewport_px * 0.5).floor() - last_height_px * 0.5).max(0.0)
518}
519
520/// The stretch of content a scaling list can hold on its centre line.
521///
522/// Both ends are content coordinates — the same space [`stack_into`] stacks
523/// slots in — and the pair is what a scroll position has to be kept inside.
524///
525/// **It is not "the anchored row centred" to "the last row centred".** The
526/// `LazyColumn` underneath takes its `contentPadding` OUTSIDE both auto-centring
527/// spacers, so the padding is scroll the list can spend at each end. At the
528/// bottom that means the last row settles `after_padding` **above** the centre
529/// line rather than on it; at the top the anchored row can be pulled
530/// `before_padding` **below** it. Wear says the same thing from the other side
531/// in `ScalingLazyListState.scrollToItem`, which scrolls the `LazyColumn` to
532/// `beforeContentPaddingPx - viewportCenterLinePx` to put a row on the line —
533/// so the list is already `before_padding` in from its own top when it opens,
534/// and a port that stops at the two centred rows cannot reach either end.
535///
536/// Clamping at the two centred rows costs behaviour and not only pixels: it is
537/// the difference between a user reaching the last row of a settings list and
538/// not reaching it.
539///
540/// Both spacers are clamped at zero in Wear (see [`auto_centring_spacers`]), and
541/// this states the travel for a list where neither clamp bit — a list long
542/// enough to scroll with a leading spacer left. On one clamped at either end the
543/// true travel is shorter at that end.
544pub fn anchor_travel(
545 anchor_centre: f32,
546 last_centre: f32,
547 before_padding: f32,
548 after_padding: f32,
549) -> (f32, f32) {
550 let end = last_centre + after_padding;
551 let start = anchor_centre - before_padding;
552 (start.min(end), end)
553}
554
555/// Half a pixel when a pixel height is odd, nothing when it is even — what
556/// Compose's integer halving leaves behind beside its floating-point one.
557fn odd_pixel(pixels: f32) -> f32 {
558 let half = pixels * 0.5;
559 half - half.floor()
560}
561
562fn inverse_lerp(start: f32, stop: f32, value: f32) -> f32 {
563 ((value - start) / (stop - start)).clamp(0.0, 1.0)
564}
565
566/// Wear's transition easing, `CubicBezierEasing(0.3, 0.0, 0.7, 1.0)`.
567///
568/// A Compose easing curve is parametric, so the answer is the curve's y at the
569/// parameter whose x is `fraction`. Twelve bisections put the result inside
570/// 1/4096, which is far below a pixel at any watch size.
571fn ease(x: f32) -> f32 {
572 let x = x.clamp(0.0, 1.0);
573 let mut low = 0.0f32;
574 let mut high = 1.0f32;
575 let mut t = x;
576 for _ in 0..12 {
577 let value = bezier(t, 0.3, 0.7);
578 if value < x {
579 low = t;
580 } else {
581 high = t;
582 }
583 t = (low + high) * 0.5;
584 }
585 bezier(t, 0.0, 1.0)
586}
587
588fn bezier(t: f32, first: f32, second: f32) -> f32 {
589 let inverse = 1.0 - t;
590 3.0 * inverse * inverse * t * first + 3.0 * inverse * t * t * second + t * t * t
591}
592
593#[cfg(test)]
594mod tests {
595 use super::*;
596
597 const VIEWPORT: f32 = 227.0;
598
599 #[test]
600 fn a_row_in_the_middle_is_left_alone() {
601 let middle = scale_and_alpha(VIEWPORT, VIEWPORT * 0.45, VIEWPORT * 0.55).unwrap();
602 assert_eq!(middle, ScaleAlpha::UNCHANGED);
603 }
604
605 #[test]
606 fn a_row_at_the_edge_is_shrunk_and_faded_together() {
607 let edge = scale_and_alpha(VIEWPORT, 0.0, 20.0).unwrap();
608 assert!(edge.scale < 1.0 && edge.scale >= EDGE_SCALE, "{edge:?}");
609 assert!(edge.alpha < 1.0 && edge.alpha >= EDGE_ALPHA, "{edge:?}");
610 // Both run to their limits together, so a row never fades without
611 // shrinking or the reverse.
612 let top = scale_and_alpha(VIEWPORT, 0.0, 0.0).unwrap();
613 assert!((top.scale - EDGE_SCALE).abs() < 1e-3, "{top:?}");
614 assert!((top.alpha - EDGE_ALPHA).abs() < 1e-3, "{top:?}");
615 }
616
617 #[test]
618 fn the_two_edges_treat_a_row_the_same() {
619 let height = 40.0;
620 let near_top = scale_and_alpha(VIEWPORT, 8.0, 8.0 + height).unwrap();
621 let near_bottom =
622 scale_and_alpha(VIEWPORT, VIEWPORT - 8.0 - height, VIEWPORT - 8.0).unwrap();
623 assert!((near_top.scale - near_bottom.scale).abs() < 1e-5);
624 assert!((near_top.alpha - near_bottom.alpha).abs() < 1e-5);
625 }
626
627 #[test]
628 fn a_taller_row_starts_shrinking_further_from_the_edge() {
629 // The transition band grows with row height. Isolating that needs two
630 // rows at the SAME distance from an edge: anchor both near the bottom,
631 // where `edge` is measured from the top edge and so does not move when
632 // the height does. The taller row has the wider band, so the same
633 // distance is a larger fraction of it and it shrinks more.
634 let top = VIEWPORT - 10.0;
635 let short = scale_and_alpha(VIEWPORT, top, top + VIEWPORT * 0.2).unwrap();
636 let tall = scale_and_alpha(VIEWPORT, top, top + VIEWPORT * 0.62).unwrap();
637 assert!(tall.scale < short.scale, "short {short:?} tall {tall:?}");
638 }
639
640 #[test]
641 fn a_row_is_placed_from_the_full_heights_above_it_not_the_scaled_ones() {
642 // Two rows of the same full height at the same unscaled offsets must
643 // land where those offsets say, however much the first one shrank.
644 let first = place_row(VIEWPORT, 0.0, 50.0, 2.0).unwrap();
645 let second = place_row(VIEWPORT, 50.0, 50.0, 2.0).unwrap();
646 assert!(first.scale < 1.0, "the first row is at the edge: {first:?}");
647 // The second row's position is not pushed up by the first row shrinking.
648 assert!(second.top >= 49.0, "{second:?}");
649 }
650
651 #[test]
652 fn a_row_above_the_centre_line_keeps_its_bottom_edge() {
653 // Above the line the transform origin is the bottom, so shrinking pulls
654 // the top down; below the line the top is pinned and the bottom rises.
655 let above = place_row(VIEWPORT, 4.0, 50.0, 2.0).unwrap();
656 assert!(above.scale < 1.0, "{above:?}");
657 assert!(
658 above.top > 4.0,
659 "shrinking should pull the top down: {above:?}"
660 );
661
662 let below = place_row(VIEWPORT, VIEWPORT - 54.0, 50.0, 2.0).unwrap();
663 assert!(below.scale < 1.0, "{below:?}");
664 assert!(
665 (below.top - (VIEWPORT - 54.0)).abs() < 0.6,
666 "the top is pinned below the line: {below:?}"
667 );
668 }
669
670 #[test]
671 fn an_odd_pixel_height_carries_the_half_pixel_composes_integer_halving_leaves() {
672 // 25 units at density 2 is a 50px row — even. 25.5 is 51px — odd, and
673 // that half pixel is exactly what a float-only implementation drops.
674 assert_eq!(odd_pixel(50.0), 0.0);
675 assert_eq!(odd_pixel(51.0), 0.5);
676 let odd = place_row(VIEWPORT, 3.0, 25.5, 2.0).unwrap();
677 assert!(odd.scale < 1.0, "needs to be in the scaled band: {odd:?}");
678 }
679
680 #[test]
681 fn a_density_of_zero_falls_back_to_continuous_placement_instead_of_dividing_by_it() {
682 let placed = place_row(VIEWPORT, 10.0, 50.0, 0.0).unwrap();
683 assert!(
684 placed.top.is_finite() && placed.height.is_finite(),
685 "{placed:?}"
686 );
687 assert_eq!(placed.top, 10.0);
688 let negative = place_row(VIEWPORT, 10.0, 50.0, -2.0).unwrap();
689 assert_eq!(negative, placed, "a nonsense density is not a crash");
690 }
691
692 #[test]
693 fn an_empty_viewport_leaves_everything_alone_rather_than_dividing_by_it() {
694 assert_eq!(scale_and_alpha(0.0, 0.0, 10.0), Some(ScaleAlpha::UNCHANGED));
695 assert_eq!(
696 scale_and_alpha(-5.0, 0.0, 10.0),
697 Some(ScaleAlpha::UNCHANGED)
698 );
699 }
700
701 #[test]
702 fn invalid_geometry_is_rejected_instead_of_producing_nan() {
703 assert_eq!(scale_and_alpha(f32::NAN, 0.0, 10.0), None);
704 assert_eq!(scale_and_alpha(VIEWPORT, 10.0, 9.0), None);
705 assert_eq!(place_row(VIEWPORT, 0.0, -1.0, 2.0), None);
706 assert_eq!(place_row(VIEWPORT, 0.0, 10.0, f32::INFINITY), None);
707 }
708
709 #[test]
710 fn the_easing_is_monotonic_and_spans_the_whole_range() {
711 assert!((ease(0.0) - 0.0).abs() < 1e-3, "{}", ease(0.0));
712 assert!((ease(1.0) - 1.0).abs() < 1e-3, "{}", ease(1.0));
713 let mut previous = -1.0;
714 for step in 0..=20 {
715 let value = ease(step as f32 / 20.0);
716 assert!(value >= previous - 1e-4, "not monotonic at {step}");
717 previous = value;
718 }
719 }
720
721 #[test]
722 fn the_easing_matches_the_current_wear_compose_curve() {
723 assert!((ease(0.25) - 0.166_779).abs() < 1e-3, "{}", ease(0.25));
724 }
725
726 #[test]
727 fn the_default_scaling_params_are_the_constants_the_module_documents() {
728 let params = ScalingParams::default();
729 assert_eq!(params.edge_scale, EDGE_SCALE);
730 assert_eq!(params.edge_alpha, EDGE_ALPHA);
731 assert_eq!(params.min_element_height, MIN_ELEMENT_HEIGHT);
732 assert_eq!(params.max_element_height, MAX_ELEMENT_HEIGHT);
733 assert_eq!(params.min_transition_area, MIN_TRANSITION_AREA);
734 assert_eq!(params.max_transition_area, MAX_TRANSITION_AREA);
735 // And the parameterised entry points agree with the fixed ones.
736 assert_eq!(
737 scale_and_alpha_with(params, VIEWPORT, 0.0, 20.0),
738 scale_and_alpha(VIEWPORT, 0.0, 20.0)
739 );
740 assert_eq!(
741 place_row_with(params, VIEWPORT, 4.0, 50.0, 2.0),
742 place_row(VIEWPORT, 4.0, 50.0, 2.0)
743 );
744 }
745
746 #[test]
747 fn reduced_motion_turns_the_ramp_off_rather_than_damping_it() {
748 let params = ScalingParams::default().reduced_motion();
749 let edge = scale_and_alpha_with(params, VIEWPORT, 0.0, 0.0).unwrap();
750 assert_eq!(edge, ScaleAlpha::UNCHANGED);
751 }
752
753 #[test]
754 fn the_supplied_params_reach_the_pixel_path_and_not_only_the_continuous_one() {
755 // `place_row_with` used to hand the pixel branch the Wear defaults and
756 // ignore its own argument, so a `reduced_motion` list was identical to
757 // a scaling one on every real display and only differed at density 0 —
758 // which is the one case no device is in.
759 let params = ScalingParams::default().reduced_motion();
760 let still = place_row_with(params, VIEWPORT, 4.0, 50.0, 2.0).unwrap();
761 assert_eq!(still.scale, 1.0, "{still:?}");
762 assert_eq!(still.alpha, 1.0, "{still:?}");
763 assert_eq!(still.top, 4.0, "an unscaled row is not pinned anywhere");
764 // And the Wear defaults still scale the same row, so the test is not
765 // passing because the row was out of the band.
766 assert!(place_row(VIEWPORT, 4.0, 50.0, 2.0).unwrap().scale < 1.0);
767 }
768
769 #[test]
770 fn a_row_is_placed_against_the_scaled_size_of_the_row_between_it_and_the_centre() {
771 // `ScalingLazyListState.layoutInfo` walks outward from the centre item
772 // with a cursor that advances by `ScalingLazyListItemInfo.size`, which
773 // is the SCALED size. Two rows below the anchor, that cursor has
774 // already lost whatever the first one shrank by.
775 let viewport = 192.0;
776 let density = 2.0;
777 let (gap, height) = (4.0, 52.0);
778 let anchor_top = 70.0;
779
780 let anchor = place_row(viewport, anchor_top, height, density).unwrap();
781 assert_eq!(anchor.scale, 1.0, "the anchored row is not scaled");
782 assert_eq!(
783 anchor.reported_height, height,
784 "so it reports its full height"
785 );
786
787 // First row below: both accounts agree, because the anchor is unscaled.
788 let first_top = anchor_top + anchor.reported_height + gap;
789 assert_eq!(first_top, anchor_top + height + gap);
790 let first = place_row(viewport, first_top, height, density).unwrap();
791 assert!(first.scale < 1.0, "{first:?}");
792 assert!(
793 first.reported_height < height,
794 "and it reports less than its full height: {first:?}"
795 );
796
797 // Second row below: the cursor and the full-height stack part company.
798 let second_top = first_top + first.reported_height + gap;
799 let stacked_top = first_top + height + gap;
800 assert!(
801 second_top < stacked_top,
802 "cursor {second_top} vs full stack {stacked_top}"
803 );
804 let by_cursor = place_row(viewport, second_top, height, density).unwrap();
805 let by_stack = place_row(viewport, stacked_top, height, density).unwrap();
806 assert_ne!(by_cursor.top, by_stack.top);
807 }
808
809 #[test]
810 fn the_reported_height_is_the_rounded_one_and_the_drawn_height_is_not() {
811 // 51 device pixels at some scale under one: the layout reports a whole
812 // pixel and the graphics layer draws the fraction.
813 let placed = place_row(VIEWPORT, 3.0, 25.5, 2.0).unwrap();
814 assert!(
815 placed.scale < 1.0,
816 "needs to be in the scaled band: {placed:?}"
817 );
818 assert_eq!(
819 placed.reported_height * 2.0,
820 (placed.reported_height * 2.0).round(),
821 "the reported height is a whole pixel: {placed:?}"
822 );
823 assert_ne!(placed.reported_height, placed.height);
824 }
825
826 #[test]
827 fn a_stack_puts_full_heights_a_gap_apart() {
828 let mut slots = Vec::new();
829 stack_into([10.0, 20.0, 30.0], 4.0, &mut slots);
830 assert_eq!(
831 slots,
832 vec![
833 Slot {
834 top: 0.0,
835 height: 10.0
836 },
837 Slot {
838 top: 14.0,
839 height: 20.0
840 },
841 Slot {
842 top: 38.0,
843 height: 30.0
844 },
845 ]
846 );
847 assert_eq!(slots[1].centre(), 24.0);
848 assert_eq!(slots[2].bottom(), 68.0);
849 }
850
851 #[test]
852 fn the_centre_anchor_puts_the_anchored_items_centre_on_the_centre_line() {
853 let mut slots = Vec::new();
854 stack_into([40.0, 60.0, 40.0], 4.0, &mut slots);
855 // Item 1 spans 44..104, centre 74. The viewport centre is 113.5, which
856 // at density 2 is a whole pixel, so the shift is exact.
857 let offset = centre_offset(&slots, VIEWPORT, CentreAnchor::default(), 2.0);
858 shift(&mut slots, offset);
859 assert!(
860 (slots[1].centre() - VIEWPORT * 0.5).abs() < 1e-4,
861 "{slots:?}"
862 );
863 }
864
865 #[test]
866 fn a_scroll_offset_moves_the_content_up() {
867 let mut slots = Vec::new();
868 stack_into([40.0, 60.0, 40.0], 4.0, &mut slots);
869 let still = centre_offset(&slots, VIEWPORT, CentreAnchor::default(), 0.0);
870 let scrolled = centre_offset(
871 &slots,
872 VIEWPORT,
873 CentreAnchor {
874 index: 1,
875 offset: 10.0,
876 },
877 0.0,
878 );
879 assert!((still - scrolled - 10.0).abs() < 1e-4, "{still} {scrolled}");
880 }
881
882 #[test]
883 fn a_fractional_scroll_travels_between_item_centres_not_item_tops() {
884 let mut slots = Vec::new();
885 // A short row beside a tall one: interpolating tops would move the
886 // anchor by the first row's height, centres by the mean of the two.
887 stack_into([20.0, 100.0], 0.0, &mut slots);
888 let start = centre_offset_at(&slots, VIEWPORT, 0.0, 0.0);
889 let end = centre_offset_at(&slots, VIEWPORT, 1.0, 0.0);
890 let middle = centre_offset_at(&slots, VIEWPORT, 0.5, 0.0);
891 assert!((middle - (start + end) * 0.5).abs() < 1e-4);
892 // And the endpoints agree with the index-and-offset form.
893 assert_eq!(
894 start,
895 centre_offset(
896 &slots,
897 VIEWPORT,
898 CentreAnchor {
899 index: 0,
900 offset: 0.0
901 },
902 0.0
903 )
904 );
905 }
906
907 #[test]
908 fn a_scroll_past_either_end_clamps_instead_of_running_off() {
909 let mut slots = Vec::new();
910 stack_into([20.0, 20.0], 4.0, &mut slots);
911 assert_eq!(
912 centre_offset_at(&slots, VIEWPORT, -5.0, 0.0),
913 centre_offset_at(&slots, VIEWPORT, 0.0, 0.0)
914 );
915 assert_eq!(
916 centre_offset_at(&slots, VIEWPORT, 9.0, 0.0),
917 centre_offset_at(&slots, VIEWPORT, 1.0, 0.0)
918 );
919 assert_eq!(centre_offset_at(&[], VIEWPORT, 0.0, 2.0), 0.0);
920 assert_eq!(
921 centre_offset(&[], VIEWPORT, CentreAnchor::default(), 2.0),
922 0.0
923 );
924 }
925
926 #[test]
927 fn rounding_a_length_to_a_pixel_sends_an_exact_half_up_the_way_kotlin_does() {
928 // 0.25 at density 2 is exactly half a pixel.
929 assert_eq!(round_to_px(0.25, 2.0), 0.5);
930 assert_eq!(round_to_px(-0.25, 2.0), 0.0);
931 // Rust's own rounding sends the negative half the other way, which is
932 // the disagreement this helper exists to settle.
933 assert_eq!((-0.5f32).round(), -1.0);
934 assert_eq!(round_to_px(0.3, 0.0), 0.3);
935 assert!(round_to_px(f32::NAN, 2.0).is_nan());
936 }
937
938 #[test]
939 fn the_shift_and_the_two_spacers_are_the_same_arithmetic_seen_from_two_sides() {
940 // In pixels, on an even viewport, with the anchored item far enough
941 // down that the leading spacer is not clamped.
942 let viewport_px = 454.0;
943 let mut slots = Vec::new();
944 stack_into([96.0, 104.0, 104.0], 8.0, &mut slots);
945 let anchor = CentreAnchor::default();
946 let (leading, _) = auto_centring_spacers(&slots, viewport_px, anchor);
947 let offset = centre_offset(&slots, viewport_px, anchor, 1.0);
948 assert!((leading - offset).abs() < 1e-4, "{leading} vs {offset}");
949 }
950
951 #[test]
952 fn the_leading_spacer_never_pushes_the_anchor_below_the_centre_line() {
953 // One short item: the plain shift would be positive and large, and the
954 // clamp is what stops the list from starting scrolled.
955 let mut slots = Vec::new();
956 stack_into([600.0], 8.0, &mut slots);
957 let anchor = CentreAnchor::default();
958 let (leading, trailing) = auto_centring_spacers(&slots, 454.0, anchor);
959 assert_eq!(leading, 0.0, "a tall first item needs no leading spacer");
960 assert!(trailing >= 0.0, "{trailing}");
961 }
962
963 #[test]
964 fn the_content_padding_is_travel_at_both_ends_and_not_blank_beyond_them() {
965 let mut slots = Vec::new();
966 stack_into([96.0, 104.0, 104.0, 104.0], 8.0, &mut slots);
967 let anchor = slots[1].centre();
968 let last = slots[3].centre();
969 let (start, end) = anchor_travel(anchor, last, 68.0, 68.0);
970 // The anchored row can be pulled below the centre line by the padding,
971 // and the last row can be pushed above it by the same.
972 assert_eq!(start, anchor - 68.0);
973 assert_eq!(end, last + 68.0);
974 // Which is 136 px more travel than stopping at the two centred rows.
975 assert_eq!((end - start) - (last - anchor), 136.0);
976 }
977
978 #[test]
979 fn a_list_with_nowhere_to_go_does_not_travel_backwards() {
980 // One row, so both ends are the same row's centre. The padding must not
981 // hand back a range whose start is past its end.
982 let mut slots = Vec::new();
983 stack_into([104.0], 8.0, &mut slots);
984 let centre = slots[0].centre();
985 let (start, end) = anchor_travel(centre, centre, 68.0, 0.0);
986 assert!(start <= end, "{start} {end}");
987 assert_eq!(end, centre);
988 }
989
990 #[test]
991 fn an_odd_viewport_gives_its_spare_pixel_to_the_trailing_spacer() {
992 let mut slots = Vec::new();
993 stack_into([100.0, 100.0], 8.0, &mut slots);
994 let anchor = CentreAnchor::default();
995 let (_, odd) = auto_centring_spacers(&slots, 455.0, anchor);
996 let (_, even) = auto_centring_spacers(&slots, 454.0, anchor);
997 assert_eq!(odd - even, 1.0, "odd {odd} even {even}");
998 }
999}