1use std::{collections::BTreeMap, hash::Hash, ops::Range, sync::Arc};
7
8use tessera_ui::{
9 AxisConstraint, CallbackWith, ComputedData, Constraint, Dp, FocusDirection, FocusScopeNode,
10 LayoutResult, MeasurementError, Modifier, ParentConstraint, Px, PxPosition, RenderSlotWith,
11 State, key,
12 layout::{MeasureScope, PlacementResult, PlacementScope, layout},
13 modifier::FocusModifierExt as _,
14 remember, remember_with_key, tessera,
15};
16
17use crate::{
18 alignment::CrossAxisAlignment,
19 layout_flex::{cross_axis_child_constraint, px_mul},
20 lazy_shared::{
21 FenwickTree, LazyItemsSlot, MeasuredMain, ZeroLayout, clamp_reported_main, hash_key,
22 },
23 scrollable::{ScrollBarBehavior, ScrollableController, scrollable, synchronize_scroll_density},
24};
25
26const DEFAULT_VIEWPORT_ITEMS: usize = 8;
27
28pub struct LazyListController {
54 scroll: ScrollableController,
55 cache: LazyListCache,
56 pending_position: Option<PxPosition>,
57 pending_item: Option<ItemScrollRequest>,
58 viewport: Option<State<ScrollableController>>,
59 geometry: Option<ListScrollGeometry>,
60 composed: BTreeMap<usize, u64>,
61 focused_item: Option<usize>,
62 retains_composition: bool,
63}
64
65struct ListAttachment {
66 controller: State<LazyListController>,
67 viewport: State<ScrollableController>,
68}
69
70impl Drop for ListAttachment {
71 fn drop(&mut self) {
72 if self.controller.is_alive()
73 && self
74 .controller
75 .with(|controller| controller.viewport == Some(self.viewport))
76 {
77 self.controller.with_mut(|controller| {
78 controller.composed.clear();
79 controller.focused_item = None;
80 controller.retains_composition = false;
81 controller.viewport = None;
82 controller.geometry = None;
83 });
84 }
85 }
86}
87
88#[derive(Clone, Copy)]
89struct ItemScrollRequest {
90 index: usize,
91 offset: Dp,
92 position: Option<PxPosition>,
93}
94
95#[derive(Clone, Copy, PartialEq)]
96struct ListScrollGeometry {
97 axis: LazyListAxis,
98 estimated_main: Px,
99 spacing: Px,
100 padding: Px,
101}
102
103impl Default for LazyListController {
104 fn default() -> Self {
105 Self::new()
106 }
107}
108
109impl LazyListController {
110 pub fn new() -> Self {
113 Self {
114 scroll: ScrollableController::new(),
115 cache: LazyListCache::default(),
116 pending_position: None,
117 pending_item: None,
118 viewport: None,
119 geometry: None,
120 composed: BTreeMap::new(),
121 focused_item: None,
122 retains_composition: false,
123 }
124 }
125
126 pub fn scroll_controller(&self) -> &ScrollableController {
128 &self.scroll
129 }
130
131 pub fn scroll_controller_mut(&mut self) -> &mut ScrollableController {
133 &mut self.scroll
134 }
135
136 pub fn scroll_to(&mut self, x: Dp, y: Dp) {
142 let position = PxPosition::new(-Dp(x.0.max(0.0)).to_px(), -Dp(y.0.max(0.0)).to_px());
143 self.scroll.set_scroll_position(position);
144 self.pending_position = Some(position);
145 self.pending_item = None;
146 }
147
148 pub fn scroll_to_item(&mut self, index: usize, offset: Dp) {
159 assert!(offset.0.is_finite(), "item offset must be finite");
160 let mut request = ItemScrollRequest {
161 index,
162 offset,
163 position: None,
164 };
165 if let Some(geometry) = self.geometry {
166 if index >= self.cache.total_items {
167 return;
168 }
169 let main = (self
170 .cache
171 .offset_for(index, geometry.estimated_main, geometry.spacing)
172 + geometry.padding
173 - Px::from(offset))
174 .max(Px::ZERO);
175 let position = geometry.axis.scroll_position(main);
176 self.scroll.set_scroll_position(position);
177 if let Some(viewport) = self.viewport.filter(State::is_alive) {
178 viewport.with_mut(|viewport| viewport.set_scroll_position(position));
179 }
180 request.position = Some(position);
181 }
182 self.pending_position = None;
183 self.pending_item = Some(request);
184 }
185}
186
187fn restore_scroll_position(
188 controller: State<LazyListController>,
189 viewport: State<ScrollableController>,
190) {
191 let density = tessera_ui::dp::scale_factor();
192 if controller.with(|controller| controller.cache.density != density) {
193 controller.with_mut(|controller| {
194 let ratio = controller.scroll.update_density(density);
195 if let Some(position) = &mut controller.pending_position {
196 position.x = (f64::from(position.x) * ratio) as f32;
197 position.y = (f64::from(position.y) * ratio) as f32;
198 }
199 if let Some(position) = controller
200 .pending_item
201 .as_mut()
202 .and_then(|request| request.position.as_mut())
203 {
204 position.x = (f64::from(position.x) * ratio) as f32;
205 position.y = (f64::from(position.y) * ratio) as f32;
206 }
207 });
208 }
209 synchronize_scroll_density(viewport);
210 let request = controller.with(|controller| controller.pending_position);
211 if request.is_some() {
212 controller.with_mut(|controller| controller.pending_position = None);
213 }
214 let saved = controller.with(|controller| controller.scroll.child_position());
215 let restore = saved != PxPosition::ZERO && !viewport.with(|viewport| viewport.is_positioned());
216 if let Some(position) = request.or_else(|| restore.then_some(saved)) {
217 viewport.with_mut(|viewport| viewport.set_scroll_position(position));
218 }
219}
220
221#[derive(Clone, Default, PartialEq)]
223pub struct LazyListContent {
224 slots: Vec<LazySlot>,
225}
226
227impl LazyListContent {
228 fn push(&mut self, slot: LazySlot) {
229 self.slots.push(slot);
230 }
231}
232
233#[tessera]
283pub fn lazy_column(
284 modifier: Option<Modifier>,
285 cross_axis_alignment: Option<CrossAxisAlignment>,
286 item_spacing: Option<Dp>,
287 overscan: Option<usize>,
288 estimated_item_size: Option<Dp>,
289 content_padding: Option<Dp>,
290 max_viewport_main: Option<Px>,
291 scroll_smoothing: Option<f32>,
292 scrollbar_behavior: Option<ScrollBarBehavior>,
293 controller: Option<State<LazyListController>>,
294 viewport_controller: Option<State<ScrollableController>>,
295 keep_composed_items: Option<bool>,
296 #[prop(skip_setter)] content: Option<LazyListContent>,
297) {
298 let content = content.unwrap_or_default();
299 let controller = match controller {
300 Some(value) => value,
301 None => remember(LazyListController::new),
302 };
303 let scroll_controller = if let Some(controller) = viewport_controller {
304 controller
305 } else {
306 remember(ScrollableController::default)
307 };
308 let args = LazyListSlotsArgs {
309 modifier: modifier.unwrap_or_default(),
310 cross_axis_alignment: cross_axis_alignment.unwrap_or_default(),
311 item_spacing: item_spacing.unwrap_or(Dp(0.0)),
312 overscan: overscan.unwrap_or(0),
313 estimated_item_size: estimated_item_size.unwrap_or(Dp(0.0)),
314 content_padding: content_padding.unwrap_or(Dp(0.0)),
315 max_viewport_main,
316 scroll_smoothing: scroll_smoothing.unwrap_or(crate::scrollable::DEFAULT_SCROLL_SMOOTHING),
317 scrollbar_behavior: scrollbar_behavior.unwrap_or_default(),
318 controller,
319 scroll_controller,
320 keep_composed_items: keep_composed_items.unwrap_or(false),
321 slots: content.slots,
322 };
323 let parameters = remember(|| args.clone());
324 if parameters.with(|parameters| parameters != &args) {
325 parameters.set(args);
326 }
327 lazy_column_slots(parameters);
328}
329
330#[derive(Clone, PartialEq)]
331struct LazyListSlotsArgs {
332 modifier: Modifier,
333 cross_axis_alignment: CrossAxisAlignment,
334 item_spacing: Dp,
335 overscan: usize,
336 estimated_item_size: Dp,
337 content_padding: Dp,
338 max_viewport_main: Option<Px>,
339 scroll_smoothing: f32,
340 scrollbar_behavior: ScrollBarBehavior,
341 controller: State<LazyListController>,
342 scroll_controller: State<ScrollableController>,
343 keep_composed_items: bool,
344 slots: Vec<LazySlot>,
345}
346
347#[tessera]
348fn lazy_column_slots(parameters: State<LazyListSlotsArgs>) {
349 let args = parameters.get();
350 let scroll_controller = args.scroll_controller;
356 restore_scroll_position(args.controller, scroll_controller);
357
358 scrollable()
359 .modifier(args.modifier)
360 .vertical(true)
361 .horizontal(false)
362 .apply_child_offset(false)
363 .scroll_smoothing(args.scroll_smoothing)
364 .scrollbar_behavior(args.scrollbar_behavior)
365 .controller(scroll_controller)
366 .child(move || {
367 let args = parameters.get();
368 let scroll_controller = args.scroll_controller;
369 let item_spacing = sanitize_spacing(Px::from(args.item_spacing));
370 let estimated_item_main = ensure_positive_px(Px::from(args.estimated_item_size));
371 let padding_main = sanitize_spacing(Px::from(args.content_padding));
372 let padding_cross = sanitize_spacing(Px::from(args.content_padding));
373 let current_pos = scroll_controller.with(|sc| sc.child_position());
375 let should_sync_position = args
376 .controller
377 .with(|c| c.scroll.child_position() != current_pos);
378 if should_sync_position {
379 args.controller
380 .with_mut(|c| c.scroll.set_scroll_position(current_pos));
381 }
382 lazy_list_view()
383 .axis(LazyListAxis::Vertical)
384 .cross_axis_alignment(args.cross_axis_alignment)
385 .item_spacing(item_spacing)
386 .estimated_item_main(estimated_item_main)
387 .overscan(args.overscan)
388 .padding_main(padding_main)
389 .padding_cross(padding_cross)
390 .slots(args.slots.clone())
391 .controller(args.controller)
392 .scroll_controller(scroll_controller)
393 .keep_composed_items(args.keep_composed_items)
394 .max_viewport_main_optional(args.max_viewport_main);
395 });
396}
397
398#[tessera]
447pub fn lazy_row(
448 modifier: Option<Modifier>,
449 cross_axis_alignment: Option<CrossAxisAlignment>,
450 item_spacing: Option<Dp>,
451 overscan: Option<usize>,
452 estimated_item_size: Option<Dp>,
453 content_padding: Option<Dp>,
454 max_viewport_main: Option<Px>,
455 scroll_smoothing: Option<f32>,
456 scrollbar_behavior: Option<ScrollBarBehavior>,
457 controller: Option<State<LazyListController>>,
458 viewport_controller: Option<State<ScrollableController>>,
459 keep_composed_items: Option<bool>,
460 #[prop(skip_setter)] content: Option<LazyListContent>,
461) {
462 let content = content.unwrap_or_default();
463 let controller = match controller {
464 Some(value) => value,
465 None => remember(LazyListController::new),
466 };
467 let scroll_controller = if let Some(controller) = viewport_controller {
468 controller
469 } else {
470 remember(ScrollableController::default)
471 };
472 let args = LazyListSlotsArgs {
473 modifier: modifier.unwrap_or_default(),
474 cross_axis_alignment: cross_axis_alignment.unwrap_or_default(),
475 item_spacing: item_spacing.unwrap_or(Dp(0.0)),
476 overscan: overscan.unwrap_or(0),
477 estimated_item_size: estimated_item_size.unwrap_or(Dp(0.0)),
478 content_padding: content_padding.unwrap_or(Dp(0.0)),
479 max_viewport_main,
480 scroll_smoothing: scroll_smoothing.unwrap_or(crate::scrollable::DEFAULT_SCROLL_SMOOTHING),
481 scrollbar_behavior: scrollbar_behavior.unwrap_or_default(),
482 controller,
483 scroll_controller,
484 keep_composed_items: keep_composed_items.unwrap_or(false),
485 slots: content.slots,
486 };
487 let parameters = remember(|| args.clone());
488 if parameters.with(|parameters| parameters != &args) {
489 parameters.set(args);
490 }
491 lazy_row_slots(parameters);
492}
493
494macro_rules! impl_lazy_list_builder {
495 ($builder:ident) => {
496 impl $builder {
497 pub fn items_with_key_shared<K, KF>(
499 &self,
500 count: usize,
501 key_provider: KF,
502 builder: impl Into<RenderSlotWith<usize>> + Send + Sync + 'static,
503 ) -> Self
504 where
505 K: Hash,
506 KF: Fn(usize) -> K + Send + Sync + 'static,
507 {
508 self.props.append("content", move |props| {
509 let slot = {
510 let builder = builder.into();
511 LazySlot::Items(LazyItemsSlot {
512 count,
513 builder,
514 key_provider: Some(CallbackWith::new(move |index| {
515 hash_key(key_provider(index))
516 })),
517 })
518 };
519 props
520 .content
521 .get_or_insert_with(LazyListContent::default)
522 .push(slot);
523 });
524 Self { props: self.props }
525 }
526
527 pub fn sticky_header<F, Output>(&self, builder: F) -> Self
529 where
530 F: Fn() -> Output + Send + Sync + 'static,
531 {
532 self.props.append("content", move |props| {
533 let slot = LazySlot::sticky(
534 RenderSlotWith::new(move |_| {
535 builder();
536 }),
537 None,
538 );
539 props
540 .content
541 .get_or_insert_with(LazyListContent::default)
542 .push(slot);
543 });
544 Self { props: self.props }
545 }
546
547 pub fn sticky_header_with_key<K, F, Output>(&self, key: K, builder: F) -> Self
549 where
550 K: Hash,
551 F: Fn() -> Output + Send + Sync + 'static,
552 {
553 let key = hash_key(key);
554 self.props.append("content", move |props| {
555 let slot = LazySlot::sticky(
556 RenderSlotWith::new(move |_| {
557 builder();
558 }),
559 Some(key),
560 );
561 props
562 .content
563 .get_or_insert_with(LazyListContent::default)
564 .push(slot);
565 });
566 Self { props: self.props }
567 }
568
569 pub fn sticky_header_with_key_shared<K>(
571 &self,
572 key: K,
573 builder: impl Into<RenderSlotWith<usize>> + Send + Sync + 'static,
574 ) -> Self
575 where
576 K: Hash,
577 {
578 let key_hash = Some(hash_key(key));
579 self.props.append("content", move |props| {
580 let slot = {
581 let builder = builder.into();
582 LazySlot::Sticky(LazyStickySlot { builder, key_hash })
583 };
584 props
585 .content
586 .get_or_insert_with(LazyListContent::default)
587 .push(slot);
588 });
589 Self { props: self.props }
590 }
591 }
592 };
593}
594
595impl_lazy_list_builder!(LazyColumnBuilder);
596impl_lazy_list_builder!(LazyRowBuilder);
597crate::lazy_shared::impl_lazy_builder!(LazyColumnBuilder, LazyListContent);
598crate::lazy_shared::impl_lazy_builder!(LazyRowBuilder, LazyListContent);
599
600#[tessera]
601fn lazy_row_slots(parameters: State<LazyListSlotsArgs>) {
602 let args = parameters.get();
603 let scroll_controller = args.scroll_controller;
609 restore_scroll_position(args.controller, scroll_controller);
610
611 scrollable()
612 .modifier(args.modifier)
613 .vertical(false)
614 .horizontal(true)
615 .apply_child_offset(false)
616 .scroll_smoothing(args.scroll_smoothing)
617 .scrollbar_behavior(args.scrollbar_behavior)
618 .controller(scroll_controller)
619 .child(move || {
620 let args = parameters.get();
621 let scroll_controller = args.scroll_controller;
622 let item_spacing = sanitize_spacing(Px::from(args.item_spacing));
623 let estimated_item_main = ensure_positive_px(Px::from(args.estimated_item_size));
624 let padding_main = sanitize_spacing(Px::from(args.content_padding));
625 let padding_cross = sanitize_spacing(Px::from(args.content_padding));
626 let current_pos = scroll_controller.with(|sc| sc.child_position());
628 let should_sync_position = args
629 .controller
630 .with(|c| c.scroll.child_position() != current_pos);
631 if should_sync_position {
632 args.controller
633 .with_mut(|c| c.scroll.set_scroll_position(current_pos));
634 }
635 lazy_list_view()
636 .axis(LazyListAxis::Horizontal)
637 .cross_axis_alignment(args.cross_axis_alignment)
638 .item_spacing(item_spacing)
639 .estimated_item_main(estimated_item_main)
640 .overscan(args.overscan)
641 .padding_main(padding_main)
642 .padding_cross(padding_cross)
643 .slots(args.slots.clone())
644 .controller(args.controller)
645 .scroll_controller(scroll_controller)
646 .keep_composed_items(args.keep_composed_items)
647 .max_viewport_main_optional(args.max_viewport_main);
648 });
649}
650
651#[tessera]
652fn lazy_list_view(
653 axis: Option<LazyListAxis>,
654 cross_axis_alignment: Option<CrossAxisAlignment>,
655 item_spacing: Option<Px>,
656 estimated_item_main: Option<Px>,
657 overscan: Option<usize>,
658 max_viewport_main: Option<Px>,
659 padding_main: Option<Px>,
660 padding_cross: Option<Px>,
661 controller: Option<State<LazyListController>>,
662 slots: Option<Vec<LazySlot>>,
663 scroll_controller: Option<State<ScrollableController>>,
664 keep_composed_items: Option<bool>,
665) {
666 let keep_composed_items = keep_composed_items.unwrap_or(false);
667 let axis = axis.unwrap_or_default();
668 let cross_axis_alignment = cross_axis_alignment.unwrap_or_default();
669 let item_spacing = item_spacing.unwrap_or(Px::ZERO);
670 let estimated_item_main = estimated_item_main.unwrap_or(Px::ZERO);
671 let overscan = overscan.unwrap_or(0);
672 let padding_main = padding_main.unwrap_or(Px::ZERO);
673 let padding_cross = padding_cross.unwrap_or(Px::ZERO);
674 let slots = slots.unwrap_or_default();
675 let controller = controller.expect("lazy_list_view requires controller");
676 let scroll_controller = scroll_controller.expect("lazy_list_view requires scroll_controller");
677 let _attachment = remember_with_key((controller, scroll_controller), || ListAttachment {
678 controller,
679 viewport: scroll_controller,
680 });
681 let plan = LazySlotPlan::new(slots.clone());
682 let total_count = plan.total_count();
683
684 let cache_config_changed = controller.with(|c| {
685 c.cache.total_items != total_count
686 || c.cache.estimated_main != estimated_item_main
687 || c.cache.density != tessera_ui::dp::scale_factor()
688 });
689 if cache_config_changed {
690 controller.with_mut(|c| c.cache.configure(total_count, estimated_item_main));
691 }
692
693 let geometry = ListScrollGeometry {
694 axis,
695 estimated_main: estimated_item_main,
696 spacing: item_spacing,
697 padding: padding_main,
698 };
699 if controller.with(|controller| {
700 controller.viewport != Some(scroll_controller)
701 || controller.geometry != Some(geometry)
702 || controller.retains_composition != keep_composed_items
703 }) {
704 controller.with_mut(|controller| {
705 controller.viewport = Some(scroll_controller);
706 controller.geometry = Some(geometry);
707 controller.retains_composition = keep_composed_items;
708 });
709 }
710 if !keep_composed_items
711 && controller
712 .with(|controller| !controller.composed.is_empty() || controller.focused_item.is_some())
713 {
714 controller.with_mut(|controller| {
715 controller.composed.clear();
716 controller.focused_item = None;
717 });
718 }
719 let request = controller.with(|controller| controller.pending_item);
720 if let Some(mut request) = request {
721 if request.index >= total_count {
722 controller.with_mut(|controller| controller.pending_item = None);
723 } else {
724 let main = controller.with(|controller| {
725 controller
726 .cache
727 .offset_for(request.index, estimated_item_main, item_spacing)
728 }) + padding_main
729 - Px::from(request.offset);
730 let desired = axis.scroll_position(main.max(Px::ZERO));
731 if let Some(previous) = request.position {
732 let delta = desired - previous;
733 if delta != PxPosition::ZERO {
734 scroll_controller.with_mut(|scroll| scroll.translate_scroll_position(delta));
735 }
736 } else {
737 scroll_controller.with_mut(|scroll| scroll.set_scroll_position(desired));
738 }
739 request.position = Some(desired);
740 controller.with_mut(|controller| controller.pending_item = Some(request));
741 }
742 }
743 let scroll_offset = axis.scroll_offset(scroll_controller.with(|s| s.child_position()));
744 let viewport_span = resolve_viewport_span(
745 axis.visible_span(scroll_controller.with(|s| s.visible_size())),
746 estimated_item_main,
747 item_spacing,
748 );
749 let viewport_span = max_viewport_main
750 .map(|limit| viewport_span.min(limit.max(Px::ZERO)))
751 .unwrap_or(viewport_span);
752 let viewport_span = (viewport_span - (padding_main * 2)).max(Px::ZERO);
753 let total_main =
754 controller.with(|c| c.cache.total_main_size(estimated_item_main, item_spacing));
755 let total_main_with_padding = total_main + padding_main + padding_main;
756 let visible_cross = axis.cross(&scroll_controller.with(|s| s.visible_size()));
757 let current_child_cross = axis.cross(&scroll_controller.with(|s| s.child_size()));
761 let estimated_cross = if current_child_cross > Px::ZERO {
762 current_child_cross
763 } else {
764 visible_cross
765 };
766 let scroll_child_size = axis.pack_size(total_main_with_padding, estimated_cross);
767 let needs_scroll_child_size_update =
768 scroll_controller.with(|c| c.child_size() != scroll_child_size);
769 if needs_scroll_child_size_update {
770 scroll_controller.with_mut(|c| c.override_child_size(scroll_child_size));
771 }
772
773 let mut visible_children = controller.with(|c| {
774 compute_visible_children(
775 &plan,
776 &c.cache,
777 total_count,
778 scroll_offset,
779 viewport_span,
780 overscan,
781 estimated_item_main,
782 item_spacing,
783 )
784 });
785
786 let invalid_focus = controller.with(|controller| {
787 controller.focused_item.is_some_and(|index| {
788 plan.visible_child(index)
789 .is_none_or(|child| controller.composed.get(&index) != Some(&child.key_hash))
790 })
791 });
792 if invalid_focus {
793 controller.with_mut(|controller| controller.focused_item = None);
794 }
795 for index in controller
796 .with(|controller| {
797 [
798 controller.pending_item.map(|request| request.index),
799 controller.focused_item,
800 ]
801 })
802 .into_iter()
803 .flatten()
804 {
805 if !visible_children
806 .children
807 .iter()
808 .any(|child| child.item_index == index)
809 && let Some(child) = plan.visible_child(index)
810 {
811 visible_children.children.push(child);
812 }
813 }
814 visible_children
815 .children
816 .sort_unstable_by_key(|child| child.item_index);
817 if visible_children.children.is_empty() {
818 if controller
819 .with(|controller| !controller.composed.is_empty() || controller.focused_item.is_some())
820 {
821 controller.with_mut(|controller| {
822 controller.composed.clear();
823 controller.focused_item = None;
824 });
825 }
826 layout().policy(ZeroLayout);
827 return;
828 }
829
830 let next_focus = LazyListFocusArgs {
831 axis,
832 controller,
833 scroll_controller,
834 total_count,
835 total_main,
836 viewport_span,
837 scroll_offset,
838 visible_range: visible_children.range.clone(),
839 estimated_item_main,
840 item_spacing,
841 };
842 let focus_args = remember(|| next_focus.clone());
843 if focus_args.with(|args| args != &next_focus) {
844 focus_args.set(next_focus);
845 }
846 let focus_modifier = lazy_list_focus_beyond_bounds_modifier(focus_args);
847
848 let viewport_limit = viewport_span + padding_main + padding_main;
849 let visible_item_indices = visible_children
850 .children
851 .iter()
852 .map(|visible| visible.item_index)
853 .collect();
854
855 let mut children = if keep_composed_items {
856 let mut history = controller.with(|controller| controller.composed.clone());
857 history.retain(|index, key| {
858 plan.visible_child(*index)
859 .is_some_and(|child| child.key_hash == *key)
860 });
861 for child in &visible_children.children {
862 history.insert(child.item_index, child.key_hash);
863 }
864 if controller.with(|controller| controller.composed != history) {
865 controller.with_mut(|controller| controller.composed = history.clone());
866 }
867 history
868 .keys()
869 .filter_map(|index| plan.visible_child(*index))
870 .collect::<Vec<_>>()
871 } else {
872 visible_children.children
873 };
874 children.sort_by_key(|child| {
877 plan.sticky_indices()
878 .binary_search(&child.item_index)
879 .is_ok()
880 });
881 let composed_item_indices = children.iter().map(|child| child.item_index).collect();
882 layout()
883 .modifier(focus_modifier)
884 .policy(LazyListLayout {
885 axis,
886 cross_axis_alignment,
887 item_spacing,
888 estimated_item_main,
889 max_viewport_main,
890 padding_main,
891 padding_cross,
892 viewport_limit,
893 visible_item_indices,
894 composed_item_indices,
895 sticky_indices: plan.sticky_indices().to_vec(),
896 scroll_offset,
897 controller,
898 scroll_controller,
899 })
900 .child(move || {
901 for child in &children {
902 let child = child.clone();
903 key(child.key_hash, || {
904 retained_lazy_item(
905 child.item_index,
906 child.local_index,
907 child.builder,
908 controller,
909 keep_composed_items,
910 );
911 });
912 }
913 });
914}
915
916#[derive(Clone, PartialEq)]
917struct LazyListLayout {
918 axis: LazyListAxis,
919 cross_axis_alignment: CrossAxisAlignment,
920 item_spacing: Px,
921 estimated_item_main: Px,
922 max_viewport_main: Option<Px>,
923 padding_main: Px,
924 padding_cross: Px,
925 viewport_limit: Px,
926 visible_item_indices: Vec<usize>,
927 composed_item_indices: Vec<usize>,
928 sticky_indices: Vec<usize>,
929 scroll_offset: Px,
930 controller: State<LazyListController>,
931 scroll_controller: State<ScrollableController>,
932}
933
934impl tessera_ui::LayoutModifierNode for LazyListLayout {
935 fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
936 let mut result = LayoutResult::default();
937 let children = input.children();
938 if children.len() != self.composed_item_indices.len() {
939 return Err(MeasurementError::MeasureFnFailed(
940 "Lazy list measured child count mismatch".into(),
941 ));
942 }
943
944 let mut measured_children: Vec<_> = self
945 .composed_item_indices
946 .iter()
947 .copied()
948 .zip(children.iter().copied())
949 .filter(|(index, _)| self.visible_item_indices.binary_search(index).is_ok())
950 .collect();
951 measured_children.sort_unstable_by_key(|(item_index, _)| *item_index);
952
953 let mut child_constraint = self
954 .axis
955 .child_constraint(input.parent_constraint(), self.cross_axis_alignment);
956 apply_cross_padding(&mut child_constraint, self.axis, self.padding_cross);
957 let mut measured_entries = Vec::with_capacity(self.visible_item_indices.len());
958 let mut inner_cross = Px::ZERO;
959 for (item_index, child) in &measured_children {
960 let child_size = child.measure(input, &child_constraint)?;
961 let child_size = child_size.size();
962 let measured_main = self.axis.main(&child_size);
963 inner_cross = inner_cross.max(self.axis.cross(&child_size));
964 measured_entries.push((*item_index, *child, child_size, measured_main));
965 }
966
967 let cross_limit = match self.axis {
968 LazyListAxis::Vertical => child_constraint.width.resolve_max(),
969 LazyListAxis::Horizontal => child_constraint.height.resolve_max(),
970 };
971 let has_cache_updates = cross_limit.is_none_or(|size| size > Px::ZERO)
973 && self.controller.with(|c| {
974 measured_entries
975 .iter()
976 .any(|(item_index, _, _, measured_main)| {
977 c.cache.measurement(*item_index) != Some(*measured_main)
978 })
979 });
980 if has_cache_updates {
981 let scroll_offset = self.axis.scroll_offset(
982 self.scroll_controller
983 .with(|controller| controller.child_position()),
984 );
985 let anchor_delta = self.controller.with_mut(|c| {
986 let mut anchor_index =
987 c.pending_item
988 .map(|request| request.index)
989 .unwrap_or_else(|| {
990 c.cache.index_for_offset(
991 (scroll_offset - self.padding_main).max(Px::ZERO),
992 self.estimated_item_main,
993 self.item_spacing,
994 )
995 });
996 let first_start =
997 c.cache
998 .offset_for(anchor_index, self.estimated_item_main, self.item_spacing)
999 + self.padding_main;
1000 let next_start = c.cache.offset_for(
1001 anchor_index + 1,
1002 self.estimated_item_main,
1003 self.item_spacing,
1004 ) + self.padding_main;
1005 if c.pending_item.is_none()
1008 && first_start < scroll_offset
1009 && anchor_index + 1 < c.cache.total_items
1010 && next_start < scroll_offset + self.viewport_limit
1011 {
1012 anchor_index += 1;
1013 }
1014 let anchor_offset = (c.pending_item.is_some()
1015 || c.cache.measurement(anchor_index).is_some())
1016 .then(|| {
1017 c.cache
1018 .offset_for(anchor_index, self.estimated_item_main, self.item_spacing)
1019 });
1020 for (item_index, _, _, measured_main) in &measured_entries {
1021 c.cache.record_measurement(
1022 *item_index,
1023 *measured_main,
1024 self.estimated_item_main,
1025 );
1026 }
1027 anchor_offset.map_or(Px::ZERO, |previous| {
1028 c.cache
1029 .offset_for(anchor_index, self.estimated_item_main, self.item_spacing)
1030 - previous
1031 })
1032 });
1033 if anchor_delta != Px::ZERO {
1034 self.scroll_controller.with_mut(|controller| {
1035 controller.translate_scroll_position(self.axis.scroll_position(anchor_delta));
1036 });
1037 }
1038 }
1039
1040 if self.controller.with(|controller| {
1041 controller.pending_item.is_some_and(|request| {
1042 measured_entries
1043 .iter()
1044 .any(|(index, _, _, _)| *index == request.index)
1045 })
1046 }) {
1047 self.controller
1048 .with_mut(|controller| controller.pending_item = None);
1049 }
1050 let scroll_offset = self.axis.scroll_offset(
1051 self.scroll_controller
1052 .with(|controller| controller.child_position()),
1053 );
1054
1055 let (item_offsets, total_main) = self.controller.with(|c| {
1056 let item_offsets = measured_entries
1057 .iter()
1058 .map(|(item_index, _, _, _)| {
1059 c.cache
1060 .offset_for(*item_index, self.estimated_item_main, self.item_spacing)
1061 })
1062 .collect::<Vec<_>>();
1063 let total_main = c
1064 .cache
1065 .total_main_size(self.estimated_item_main, self.item_spacing);
1066 (item_offsets, total_main)
1067 });
1068 let mut placements = Vec::with_capacity(measured_entries.len());
1069 for ((item_index, child_id, child_size, _), offset_main) in
1070 measured_entries.into_iter().zip(item_offsets)
1071 {
1072 placements.push((item_index, child_id, offset_main, child_size));
1073 }
1074
1075 let total_main_with_padding = total_main + self.padding_main + self.padding_main;
1076 let cross_with_padding = inner_cross + self.padding_cross + self.padding_cross;
1077 let size = self
1078 .axis
1079 .pack_size(total_main_with_padding, cross_with_padding);
1080 let needs_scroll_child_size_update =
1081 self.scroll_controller.with(|c| c.child_size() != size);
1082 if needs_scroll_child_size_update {
1083 self.scroll_controller
1084 .with_mut(|c| c.override_child_size(size));
1085 }
1086
1087 let reported_main = clamp_reported_main(
1088 total_main_with_padding,
1089 self.viewport_limit,
1090 self.axis.constraint_max(input.parent_constraint()),
1091 self.max_viewport_main,
1092 );
1093
1094 for placement in &placements {
1095 let cross_offset = compute_cross_offset(
1096 inner_cross,
1097 self.axis.cross(&placement.3),
1098 self.cross_axis_alignment,
1099 );
1100 let mut main_offset = placement.2 + self.padding_main - scroll_offset;
1101 if self.is_sticky(placement.0) {
1102 let sticky_start = self.padding_main;
1103 main_offset = main_offset.max(sticky_start);
1104 if let Some(next_index) = self.next_sticky_after(placement.0) {
1105 let next_offset = self.controller.with(|c| {
1106 c.cache
1107 .offset_for(next_index, self.estimated_item_main, self.item_spacing)
1108 });
1109 let max_offset = next_offset + self.padding_main
1110 - self.axis.main(&placement.3)
1111 - scroll_offset;
1112 main_offset = main_offset.min(max_offset);
1113 }
1114 }
1115 let position = self
1116 .axis
1117 .position(main_offset, self.padding_cross + cross_offset);
1118 result.place_child(placement.1, position);
1119 }
1120
1121 Ok(result.with_size(self.axis.pack_size(reported_main, cross_with_padding)))
1122 }
1123
1124 fn measure_eq(&self, other: &Self) -> bool {
1125 self.axis == other.axis
1126 && self.cross_axis_alignment == other.cross_axis_alignment
1127 && self.item_spacing == other.item_spacing
1128 && self.estimated_item_main == other.estimated_item_main
1129 && self.max_viewport_main == other.max_viewport_main
1130 && self.padding_main == other.padding_main
1131 && self.padding_cross == other.padding_cross
1132 && self.viewport_limit == other.viewport_limit
1133 && self.visible_item_indices == other.visible_item_indices
1134 && self.composed_item_indices == other.composed_item_indices
1135 && self.sticky_indices == other.sticky_indices
1136 }
1137
1138 fn placement_eq(&self, other: &Self) -> bool {
1139 self.measure_eq(other) && self.scroll_offset == other.scroll_offset
1140 }
1141
1142 fn place_children(
1143 &self,
1144 input: &PlacementScope<'_>,
1145 _inner: &PlacementResult,
1146 ) -> Option<PlacementResult> {
1147 let mut result = LayoutResult::default();
1148 let children = input.children();
1149 if children.len() != self.composed_item_indices.len() {
1150 return None;
1151 }
1152
1153 let mut placements = Vec::with_capacity(self.visible_item_indices.len());
1154 let scroll_offset = self.axis.scroll_offset(
1155 self.scroll_controller
1156 .with(|controller| controller.child_position()),
1157 );
1158 let mut inner_cross = Px::ZERO;
1159 for (&item_index, &child) in self.composed_item_indices.iter().zip(children.iter()) {
1160 if self
1161 .visible_item_indices
1162 .binary_search(&item_index)
1163 .is_err()
1164 {
1165 continue;
1166 }
1167 let child_size = child.size();
1168 let measured_cross = self.axis.cross(&child_size);
1169 inner_cross = inner_cross.max(measured_cross);
1170 let offset_main = self.controller.with(|c| {
1171 c.cache
1172 .offset_for(item_index, self.estimated_item_main, self.item_spacing)
1173 });
1174 placements.push((item_index, child, offset_main, child_size));
1175 }
1176
1177 placements.sort_unstable_by_key(|placement| placement.0);
1178 for placement in &placements {
1179 let cross_offset = compute_cross_offset(
1180 inner_cross,
1181 self.axis.cross(&placement.3),
1182 self.cross_axis_alignment,
1183 );
1184 let mut main_offset = placement.2 + self.padding_main - scroll_offset;
1185 if self.is_sticky(placement.0) {
1186 let sticky_start = self.padding_main;
1187 main_offset = main_offset.max(sticky_start);
1188 if let Some(next_index) = self.next_sticky_after(placement.0) {
1189 let next_offset = self.controller.with(|c| {
1190 c.cache
1191 .offset_for(next_index, self.estimated_item_main, self.item_spacing)
1192 });
1193 let max_offset = next_offset + self.padding_main
1194 - self.axis.main(&placement.3)
1195 - scroll_offset;
1196 main_offset = main_offset.min(max_offset);
1197 }
1198 }
1199 let position = self
1200 .axis
1201 .position(main_offset, self.padding_cross + cross_offset);
1202 result.place_child(placement.1, position);
1203 }
1204
1205 Some(result.into_placement_result())
1206 }
1207}
1208
1209impl LazyListLayout {
1210 fn is_sticky(&self, index: usize) -> bool {
1211 self.sticky_indices.binary_search(&index).is_ok()
1212 }
1213
1214 fn next_sticky_after(&self, index: usize) -> Option<usize> {
1215 match self.sticky_indices.binary_search(&index) {
1216 Ok(pos) => self.sticky_indices.get(pos + 1).copied(),
1217 Err(pos) => self.sticky_indices.get(pos).copied(),
1218 }
1219 }
1220}
1221
1222fn resolve_viewport_span(current: Px, estimated: Px, spacing: Px) -> Px {
1223 if current > Px::ZERO {
1224 current
1225 } else {
1226 let per_item = estimated + spacing;
1227 px_mul(per_item, DEFAULT_VIEWPORT_ITEMS.max(1))
1228 }
1229}
1230
1231#[allow(clippy::too_many_arguments)]
1232fn compute_visible_children(
1233 plan: &LazySlotPlan,
1234 cache: &LazyListCache,
1235 total_count: usize,
1236 scroll_offset: Px,
1237 viewport_span: Px,
1238 overscan: usize,
1239 estimated_main: Px,
1240 spacing: Px,
1241) -> VisibleChildrenPlan {
1242 if total_count == 0 {
1243 return VisibleChildrenPlan::empty();
1244 }
1245
1246 let mut start_index = cache.index_for_offset(scroll_offset, estimated_main, spacing);
1247 let end_target = scroll_offset + viewport_span;
1248 let mut end_index = cache.index_for_offset(end_target, estimated_main, spacing) + 1;
1249
1250 start_index = start_index.saturating_sub(overscan);
1251 end_index = (end_index + overscan).min(total_count);
1252 if start_index >= end_index {
1253 end_index = (start_index + 1).min(total_count);
1254 start_index = start_index.saturating_sub(1);
1255 }
1256
1257 let visible_range = start_index..end_index;
1258 let mut result = plan.visible_children(visible_range.clone());
1259 if let Some(sticky_index) = plan.last_sticky_before(start_index) {
1260 let existing = result
1261 .iter()
1262 .position(|child| child.item_index == sticky_index);
1263 let sticky_child = existing
1264 .map(|pos| result.remove(pos))
1265 .or_else(|| plan.visible_child(sticky_index));
1266 if let Some(child) = sticky_child {
1267 result.push(child);
1268 }
1269 }
1270 VisibleChildrenPlan {
1271 children: result,
1272 range: visible_range,
1273 }
1274}
1275
1276fn compute_cross_offset(final_cross: Px, child_cross: Px, alignment: CrossAxisAlignment) -> Px {
1277 match alignment {
1278 CrossAxisAlignment::Start | CrossAxisAlignment::Stretch => Px::ZERO,
1279 CrossAxisAlignment::Center => (final_cross - child_cross).max(Px::ZERO) / 2,
1280 CrossAxisAlignment::End => (final_cross - child_cross).max(Px::ZERO),
1281 }
1282}
1283
1284#[tessera]
1285fn retained_lazy_item(
1286 index: usize,
1287 local_index: usize,
1288 builder: RenderSlotWith<usize>,
1289 controller: State<LazyListController>,
1290 retain_focus: bool,
1291) {
1292 let scope = remember(FocusScopeNode::new).get();
1293 let binding = remember(|| {
1294 (
1295 index,
1296 local_index,
1297 builder.clone(),
1298 controller,
1299 retain_focus,
1300 )
1301 });
1302 let binding_changed = binding.with(|binding| {
1303 binding.0 != index
1304 || binding.1 != local_index
1305 || binding.2 != builder
1306 || binding.3 != controller
1307 || binding.4 != retain_focus
1308 });
1309 if binding_changed {
1310 if retain_focus
1311 && scope.has_focus()
1312 && controller.with(|controller| controller.focused_item != Some(index))
1313 {
1314 controller.with_mut(|controller| controller.focused_item = Some(index));
1315 }
1316 binding.set((index, local_index, builder, controller, retain_focus));
1317 }
1318 let modifier = Modifier::new().focus_scope_with(scope);
1319 let modifier = if retain_focus {
1320 modifier.on_focus_changed(move |focus: tessera_ui::FocusState| {
1321 if !binding.is_alive() {
1322 return;
1323 }
1324 let (index, _, _, controller, _) = binding.get();
1325 if !controller.is_alive()
1326 || !controller.with(|controller| controller.retains_composition)
1327 {
1328 return;
1329 }
1330 let previous = controller.with(|controller| controller.focused_item);
1331 let next = if focus.has_focus() {
1332 Some(index)
1333 } else if previous == Some(index) {
1334 None
1335 } else {
1336 previous
1337 };
1338 if previous != next {
1339 controller.with_mut(|controller| controller.focused_item = next);
1340 }
1341 })
1342 } else {
1343 modifier
1344 };
1345 layout().modifier(modifier).child(move || {
1346 let (_, local_index, builder, _, _) = binding.get();
1347 builder.render(local_index);
1348 });
1349}
1350
1351#[derive(Clone, Copy, PartialEq, Eq, Default)]
1352enum LazyListAxis {
1353 #[default]
1354 Vertical,
1355 Horizontal,
1356}
1357
1358#[derive(Clone, Copy)]
1359enum FocusScrollDirection {
1360 Forward,
1361 Backward,
1362}
1363
1364impl LazyListAxis {
1365 fn main(&self, size: &ComputedData) -> Px {
1366 match self {
1367 Self::Vertical => size.height,
1368 Self::Horizontal => size.width,
1369 }
1370 }
1371
1372 fn cross(&self, size: &ComputedData) -> Px {
1373 match self {
1374 Self::Vertical => size.width,
1375 Self::Horizontal => size.height,
1376 }
1377 }
1378
1379 fn pack_size(&self, main: Px, cross: Px) -> ComputedData {
1380 match self {
1381 Self::Vertical => ComputedData {
1382 width: cross,
1383 height: main,
1384 },
1385 Self::Horizontal => ComputedData {
1386 width: main,
1387 height: cross,
1388 },
1389 }
1390 }
1391
1392 fn position(&self, main: Px, cross: Px) -> PxPosition {
1393 match self {
1394 Self::Vertical => PxPosition::new(cross, main),
1395 Self::Horizontal => PxPosition::new(main, cross),
1396 }
1397 }
1398
1399 fn visible_span(&self, size: ComputedData) -> Px {
1400 match self {
1401 Self::Vertical => size.height,
1402 Self::Horizontal => size.width,
1403 }
1404 }
1405
1406 fn scroll_position(&self, offset: Px) -> PxPosition {
1407 match self {
1408 Self::Vertical => PxPosition::new(Px::ZERO, -offset),
1409 Self::Horizontal => PxPosition::new(-offset, Px::ZERO),
1410 }
1411 }
1412
1413 fn scroll_offset(&self, position: PxPosition) -> Px {
1414 match self {
1415 Self::Vertical => Px((-position.y).max(0.0) as i32),
1416 Self::Horizontal => Px((-position.x).max(0.0) as i32),
1417 }
1418 }
1419
1420 fn focus_scroll_direction(&self, direction: FocusDirection) -> Option<FocusScrollDirection> {
1421 match (self, direction) {
1422 (_, FocusDirection::Next | FocusDirection::Enter) => {
1423 Some(FocusScrollDirection::Forward)
1424 }
1425 (_, FocusDirection::Previous | FocusDirection::Exit) => {
1426 Some(FocusScrollDirection::Backward)
1427 }
1428 (Self::Vertical, FocusDirection::Down) => Some(FocusScrollDirection::Forward),
1429 (Self::Vertical, FocusDirection::Up) => Some(FocusScrollDirection::Backward),
1430 (Self::Horizontal, FocusDirection::Right) => Some(FocusScrollDirection::Forward),
1431 (Self::Horizontal, FocusDirection::Left) => Some(FocusScrollDirection::Backward),
1432 _ => None,
1433 }
1434 }
1435
1436 fn child_constraint(
1437 &self,
1438 parent: ParentConstraint<'_>,
1439 alignment: CrossAxisAlignment,
1440 ) -> Constraint {
1441 let cross_constraint = match self {
1442 Self::Vertical => cross_axis_child_constraint(parent.width(), alignment),
1443 Self::Horizontal => cross_axis_child_constraint(parent.height(), alignment),
1444 };
1445
1446 match self {
1447 Self::Vertical => Constraint::new(cross_constraint, AxisConstraint::NONE),
1448 Self::Horizontal => Constraint::new(AxisConstraint::NONE, cross_constraint),
1449 }
1450 }
1451
1452 fn constraint_max(&self, constraint: ParentConstraint<'_>) -> Option<Px> {
1453 match self {
1454 Self::Vertical => constraint.height().resolve_max(),
1455 Self::Horizontal => constraint.width().resolve_max(),
1456 }
1457 }
1458}
1459
1460#[derive(Clone)]
1461struct VisibleChildrenPlan {
1462 children: Vec<VisibleChild>,
1463 range: Range<usize>,
1464}
1465
1466impl VisibleChildrenPlan {
1467 fn empty() -> Self {
1468 Self {
1469 children: Vec::new(),
1470 range: 0..0,
1471 }
1472 }
1473}
1474
1475#[derive(Clone, PartialEq)]
1476struct LazyListFocusArgs {
1477 axis: LazyListAxis,
1478 controller: State<LazyListController>,
1479 scroll_controller: State<ScrollableController>,
1480 total_count: usize,
1481 total_main: Px,
1482 viewport_span: Px,
1483 scroll_offset: Px,
1484 visible_range: Range<usize>,
1485 estimated_item_main: Px,
1486 item_spacing: Px,
1487}
1488
1489fn lazy_list_focus_beyond_bounds_modifier(args: State<LazyListFocusArgs>) -> Modifier {
1490 Modifier::new().focus_beyond_bounds_handler(CallbackWith::new(move |direction| {
1491 if !args.is_alive() {
1492 return false;
1493 }
1494 let args = args.get();
1495 let max_scroll = (args.total_main - args.viewport_span).max(Px::ZERO);
1496 let Some(scroll_direction) = args.axis.focus_scroll_direction(direction) else {
1497 return false;
1498 };
1499 if args.total_count == 0 || args.viewport_span <= Px::ZERO {
1500 return false;
1501 }
1502
1503 let target_index = match scroll_direction {
1504 FocusScrollDirection::Forward => {
1505 if args.visible_range.end >= args.total_count {
1506 return false;
1507 }
1508 args.visible_range.end
1509 }
1510 FocusScrollDirection::Backward => {
1511 let Some(index) = args.visible_range.start.checked_sub(1) else {
1512 return false;
1513 };
1514 index
1515 }
1516 };
1517
1518 let (target_offset, target_main) = args.controller.with(|c| {
1519 (
1520 c.cache
1521 .offset_for(target_index, args.estimated_item_main, args.item_spacing),
1522 c.cache
1523 .measurement(target_index)
1524 .unwrap_or(args.estimated_item_main),
1525 )
1526 });
1527
1528 let desired_scroll = match scroll_direction {
1529 FocusScrollDirection::Forward => {
1530 (target_offset + target_main - args.viewport_span).max(Px::ZERO)
1531 }
1532 FocusScrollDirection::Backward => target_offset,
1533 }
1534 .min(max_scroll);
1535
1536 if desired_scroll == args.scroll_offset {
1537 return false;
1538 }
1539
1540 let position = args.axis.scroll_position(desired_scroll);
1541 args.scroll_controller
1542 .with_mut(|c| c.set_scroll_position(position));
1543 args.controller
1544 .with_mut(|c| c.scroll.set_scroll_position(position));
1545 true
1546 }))
1547}
1548
1549#[derive(Clone, PartialEq)]
1550enum LazySlot {
1551 Items(LazyItemsSlot),
1552 Sticky(LazyStickySlot),
1553}
1554
1555impl LazySlot {
1556 fn items(
1557 count: usize,
1558 builder: RenderSlotWith<usize>,
1559 key_provider: Option<CallbackWith<usize, u64>>,
1560 ) -> Self {
1561 Self::Items(LazyItemsSlot {
1562 count,
1563 builder,
1564 key_provider,
1565 })
1566 }
1567
1568 fn sticky(builder: RenderSlotWith<usize>, key_hash: Option<u64>) -> Self {
1569 Self::Sticky(LazyStickySlot { builder, key_hash })
1570 }
1571
1572 fn len(&self) -> usize {
1573 match self {
1574 Self::Items(slot) => slot.count,
1575 Self::Sticky(_) => 1,
1576 }
1577 }
1578}
1579
1580#[derive(Clone, PartialEq)]
1581struct LazyStickySlot {
1582 builder: RenderSlotWith<usize>,
1583 key_hash: Option<u64>,
1584}
1585
1586#[derive(Clone, PartialEq)]
1587struct LazySlotPlan {
1588 entries: Vec<LazySlotEntry>,
1589 total_count: usize,
1590 sticky_indices: Vec<usize>,
1591}
1592
1593impl LazySlotPlan {
1594 fn new(slots: Vec<LazySlot>) -> Self {
1595 let mut entries = Vec::with_capacity(slots.len());
1596 let mut cursor = 0;
1597 let mut sticky_indices = Vec::new();
1598 for slot in slots {
1599 let len = slot.len();
1600 if matches!(slot, LazySlot::Sticky(_)) {
1601 sticky_indices.push(cursor);
1602 }
1603 entries.push(LazySlotEntry {
1604 start: cursor,
1605 len,
1606 slot,
1607 });
1608 cursor += len;
1609 }
1610 Self {
1611 entries,
1612 total_count: cursor,
1613 sticky_indices,
1614 }
1615 }
1616
1617 fn total_count(&self) -> usize {
1618 self.total_count
1619 }
1620
1621 fn sticky_indices(&self) -> &[usize] {
1622 &self.sticky_indices
1623 }
1624
1625 fn last_sticky_before(&self, index: usize) -> Option<usize> {
1626 if self.sticky_indices.is_empty() {
1627 return None;
1628 }
1629 match self.sticky_indices.binary_search(&index) {
1630 Ok(pos) => self.sticky_indices.get(pos).copied(),
1631 Err(0) => None,
1632 Err(pos) => self.sticky_indices.get(pos.saturating_sub(1)).copied(),
1633 }
1634 }
1635
1636 fn visible_child(&self, index: usize) -> Option<VisibleChild> {
1637 let resolved = self.resolve(index)?;
1638 let key_hash = match resolved {
1639 ResolvedSlot::Items(slot, local_index) => {
1640 if let Some(provider) = &slot.key_provider {
1641 provider.call(local_index)
1642 } else {
1643 hash_key(index)
1644 }
1645 }
1646 ResolvedSlot::Sticky(slot) => slot.key_hash.unwrap_or_else(|| hash_key(index)),
1647 };
1648 let (builder, local_index) = match resolved {
1649 ResolvedSlot::Items(slot, local_index) => (slot.builder, local_index),
1650 ResolvedSlot::Sticky(slot) => (slot.builder, 0),
1651 };
1652 Some(VisibleChild {
1653 item_index: index,
1654 local_index,
1655 builder,
1656 key_hash,
1657 })
1658 }
1659
1660 fn visible_children(&self, range: Range<usize>) -> Vec<VisibleChild> {
1661 let mut result = Vec::new();
1662 for index in range {
1663 if let Some(child) = self.visible_child(index) {
1664 result.push(child);
1665 }
1666 }
1667 result
1668 }
1669
1670 fn resolve(&self, index: usize) -> Option<ResolvedSlot<'_>> {
1671 self.entries.iter().find_map(|entry| {
1672 if index >= entry.start && index < entry.start + entry.len {
1673 let local_index = index - entry.start;
1674 match &entry.slot {
1675 LazySlot::Items(slot) => Some(ResolvedSlot::Items(slot, local_index)),
1676 LazySlot::Sticky(slot) => Some(ResolvedSlot::Sticky(slot)),
1677 }
1678 } else {
1679 None
1680 }
1681 })
1682 }
1683}
1684
1685enum ResolvedSlot<'a> {
1686 Items(&'a LazyItemsSlot, usize),
1687 Sticky(&'a LazyStickySlot),
1688}
1689
1690#[derive(Clone, PartialEq)]
1691struct LazySlotEntry {
1692 start: usize,
1693 len: usize,
1694 slot: LazySlot,
1695}
1696
1697#[derive(Clone, PartialEq)]
1698struct VisibleChild {
1699 item_index: usize,
1700 local_index: usize,
1701 builder: RenderSlotWith<usize>,
1702 key_hash: u64,
1703}
1704
1705#[derive(PartialEq, Default)]
1706struct LazyListCache {
1707 total_items: usize,
1708 estimated_main: Px,
1709 density: f64,
1710 measured_main: Vec<Option<MeasuredMain>>,
1711 fenwick: FenwickTree,
1712}
1713
1714impl LazyListCache {
1715 fn configure(&mut self, count: usize, estimated: Px) {
1716 let density = tessera_ui::dp::scale_factor();
1717 let density_changed = self.density != density;
1718 if self.total_items != count {
1719 self.total_items = count;
1720 self.measured_main = vec![None; count];
1721 self.fenwick.resize(count);
1722 }
1723 if self.estimated_main != estimated || density_changed {
1724 self.estimated_main = estimated;
1725 self.density = density;
1726 self.fenwick.resize(count);
1727 for (index, actual) in self.measured_main.iter_mut().enumerate() {
1728 if let Some(actual) = actual {
1729 if density_changed {
1730 actual.rebase(density);
1731 }
1732 self.fenwick.update(index, actual.pixels() - estimated);
1733 }
1734 }
1735 }
1736 }
1737
1738 fn measurement(&self, index: usize) -> Option<Px> {
1739 self.measured_main
1740 .get(index)
1741 .copied()
1742 .flatten()
1743 .map(MeasuredMain::pixels)
1744 }
1745
1746 fn record_measurement(&mut self, index: usize, actual: Px, estimated: Px) {
1747 if index >= self.total_items {
1748 return;
1749 }
1750 let previous = self.measurement(index);
1751 if previous == Some(actual) {
1752 return;
1753 }
1754
1755 let prev_delta = previous.map(|val| val - estimated).unwrap_or(Px::ZERO);
1756 let new_delta = actual - estimated;
1757 let delta_change = new_delta - prev_delta;
1758 self.measured_main[index] = Some(MeasuredMain::new(actual, self.density));
1759 self.fenwick.update(index, delta_change);
1760 }
1761
1762 fn offset_for(&self, index: usize, estimated: Px, spacing: Px) -> Px {
1763 if self.total_items == 0 {
1764 return Px::ZERO;
1765 }
1766 let clamped = index.min(self.total_items);
1767 let spacing_contrib = px_mul(spacing, clamped);
1768 let estimated_contrib = px_mul(estimated, clamped);
1769 spacing_contrib + estimated_contrib + self.fenwick.prefix_sum(clamped)
1770 }
1771
1772 fn total_main_size(&self, estimated: Px, spacing: Px) -> Px {
1773 if self.total_items == 0 {
1774 return Px::ZERO;
1775 }
1776 let spacing_contrib = px_mul(spacing, self.total_items.saturating_sub(1));
1777 let estimated_contrib = px_mul(estimated, self.total_items);
1778 spacing_contrib + estimated_contrib + self.fenwick.prefix_sum(self.total_items)
1779 }
1780
1781 fn index_for_offset(&self, offset: Px, estimated: Px, spacing: Px) -> usize {
1782 if self.total_items == 0 {
1783 return 0;
1784 }
1785
1786 let mut low = 0usize;
1787 let mut high = self.total_items;
1788 while low < high {
1789 let mid = (low + high) / 2;
1790 if self.offset_for(mid, estimated, spacing) <= offset {
1791 low = mid + 1;
1792 } else {
1793 high = mid;
1794 }
1795 }
1796 low.saturating_sub(1)
1797 .min(self.total_items.saturating_sub(1))
1798 }
1799}
1800
1801fn ensure_positive_px(px: Px) -> Px {
1802 if px <= Px::ZERO { Px(1) } else { px }
1803}
1804
1805fn sanitize_spacing(px: Px) -> Px {
1806 if px < Px::ZERO { Px::ZERO } else { px }
1807}
1808
1809fn apply_cross_padding(constraint: &mut Constraint, axis: LazyListAxis, padding: Px) {
1810 let total_padding = padding + padding;
1811 match axis {
1812 LazyListAxis::Vertical => {
1813 constraint.width = shrink_axis_max(constraint.width, total_padding);
1814 }
1815 LazyListAxis::Horizontal => {
1816 constraint.height = shrink_axis_max(constraint.height, total_padding);
1817 }
1818 }
1819}
1820
1821fn shrink_axis_max(axis: AxisConstraint, amount: Px) -> AxisConstraint {
1822 let min = axis.min;
1823 let max = axis.max.map(|m| saturating_sub_px(m, amount));
1824 if max == Some(min) {
1825 AxisConstraint::exact(saturating_sub_px(min, amount))
1826 } else {
1827 AxisConstraint::new(min, max)
1828 }
1829}
1830
1831fn saturating_sub_px(lhs: Px, rhs: Px) -> Px {
1832 Px(lhs.0.saturating_sub(rhs.0))
1833}
1834
1835#[cfg(test)]
1836mod tests {
1837
1838 use tessera_ui::{
1839 AxisConstraint, ComputedData, Dp, LayoutResult, MeasurementError, Modifier,
1840 NoopDrawModifier, Px, PxPosition, RenderSlotWith, State,
1841 layout::{MeasureScope, layout},
1842 receive_frame_nanos, remember, remember_with_key, tessera,
1843 };
1844
1845 use crate::{
1846 alignment::CrossAxisAlignment,
1847 modifier::{ModifierExt as _, SemanticsArgs},
1848 text::text,
1849 theme::{MaterialTheme, material_theme},
1850 };
1851
1852 use super::{LazyListCache, LazyListController, lazy_column, lazy_row};
1853
1854 #[tessera]
1855 fn capped_list_fixture(horizontal: bool) {
1856 material_theme().child(move || {
1857 if horizontal {
1858 lazy_row()
1859 .modifier(Modifier::new().size(Dp(360.0), Dp(40.0)))
1860 .max_viewport_main(Px(360))
1861 .estimated_item_size(Dp(336.0))
1862 .items(20, |index| {
1863 fixed_test_box()
1864 .tag(format!("capped_{index}"))
1865 .width(336)
1866 .height(40);
1867 });
1868 } else {
1869 lazy_column()
1870 .modifier(Modifier::new().size(Dp(40.0), Dp(360.0)))
1871 .max_viewport_main(Px(360))
1872 .estimated_item_size(Dp(336.0))
1873 .items(20, |index| {
1874 fixed_test_box()
1875 .tag(format!("capped_{index}"))
1876 .width(40)
1877 .height(336);
1878 });
1879 }
1880 });
1881 }
1882
1883 #[test]
1884 fn viewport_cap_limits_initial_item_construction_on_both_axes() {
1885 crate::test_utils::run_isolated_test(
1886 "lazy_list::tests::viewport_cap_limits_initial_item_construction_on_both_axes",
1887 || {
1888 for horizontal in [false, true] {
1889 let mut session = tessera_ui::testing::start_layout_test_session(
1890 tessera_ui::testing::layout_test(move || {
1891 capped_list_fixture(horizontal);
1892 })
1893 .viewport_px(360, 360),
1894 );
1895 let snapshot = tessera_ui::testing::pump_layout_test_session(&mut session, 0);
1896 snapshot.node("capped_0").exists();
1897 snapshot.node("capped_1").exists();
1898 assert_eq!(
1899 session
1900 .work()
1901 .count("fixed_test_box", tessera_ui::WorkPhase::Build),
1902 2,
1903 "the first layout must only construct the two intersecting items"
1904 );
1905 }
1906 },
1907 );
1908 }
1909
1910 #[derive(Clone, Copy, Default)]
1911 struct FixtureHandles {
1912 estimate: Option<State<Dp>>,
1913 collapsed_width: Option<State<Dp>>,
1914 collapsed_list: Option<State<LazyListController>>,
1915 programmatic_list: Option<State<LazyListController>>,
1916 responsive_cross: Option<State<Dp>>,
1917 responsive_main: Option<State<Dp>>,
1918 list_generation: Option<State<u64>>,
1919 replaced_list: Option<State<LazyListController>>,
1920 }
1921
1922 #[tessera]
1923 fn programmatic_list_fixture(
1924 horizontal: bool,
1925 observed: tessera_ui::testing::TestProbe<FixtureHandles>,
1926 ) {
1927 let controller = remember(LazyListController::new);
1928 observed.with_mut(|handles| handles.programmatic_list = Some(controller));
1929 let items = RenderSlotWith::new(|index| {
1930 fixed_test_box()
1931 .tag(format!("programmatic_{index}"))
1932 .width(20)
1933 .height(20);
1934 });
1935 material_theme().child(move || {
1936 if horizontal {
1937 lazy_row()
1938 .modifier(Modifier::new().size(Dp(100.0), Dp(40.0)))
1939 .controller(controller)
1940 .estimated_item_size(Dp(20.0))
1941 .items_with_key_shared(100, |index| index, items);
1942 } else {
1943 lazy_column()
1944 .modifier(Modifier::new().size(Dp(40.0), Dp(100.0)))
1945 .controller(controller)
1946 .estimated_item_size(Dp(20.0))
1947 .items_with_key_shared(100, |index| index, items);
1948 }
1949 });
1950 }
1951
1952 fn check_programmatic_position(horizontal: bool) {
1953 tessera_ui::dp::set_scale_factor(1.0);
1954 let observed = tessera_ui::testing::TestProbe::default();
1955 let fixture_observed = observed.clone();
1956 let mut session = tessera_ui::testing::start_layout_test_session(
1957 tessera_ui::testing::layout_test(move || {
1958 programmatic_list_fixture(horizontal, fixture_observed.clone());
1959 })
1960 .viewport_px(120, 120),
1961 );
1962 tessera_ui::testing::pump_layout_test_session(&mut session, 0);
1963 let controller = observed.get().programmatic_list.expect("controller");
1964 controller.with_mut(|controller| {
1965 controller.scroll_to(
1966 if horizontal { Dp(80.0) } else { Dp::ZERO },
1967 if horizontal { Dp::ZERO } else { Dp(80.0) },
1968 )
1969 });
1970 tessera_ui::testing::pump_layout_test_session(&mut session, 16_666_667);
1971 let snapshot = tessera_ui::testing::pump_layout_test_session(&mut session, 33_333_334);
1972 snapshot.node("programmatic_4").position(0, 0);
1973 controller.with_mut(|controller| controller.scroll_to(Dp::ZERO, Dp::ZERO));
1974 tessera_ui::testing::pump_layout_test_session(&mut session, 50_000_001);
1975 let snapshot = tessera_ui::testing::pump_layout_test_session(&mut session, 66_666_668);
1976 snapshot.node("programmatic_0").position(0, 0);
1977 }
1978
1979 #[test]
1980 fn lazy_column_applies_programmatic_positions_after_mounting() {
1981 let runtime = tessera_ui::RuntimeSession::new();
1982 let _scope = runtime.enter();
1983 check_programmatic_position(false);
1984 }
1985
1986 #[test]
1987 fn lazy_row_applies_programmatic_positions_after_mounting() {
1988 let runtime = tessera_ui::RuntimeSession::new();
1989 let _scope = runtime.enter();
1990 check_programmatic_position(true);
1991 }
1992
1993 #[tessera]
1994 fn replaceable_list_fixture(
1995 horizontal: bool,
1996 observed: tessera_ui::testing::TestProbe<FixtureHandles>,
1997 ) {
1998 let generation = remember(|| 0_u64);
1999 observed.with_mut(|handles| handles.list_generation = Some(generation));
2000 let controller = remember_with_key(generation.get(), LazyListController::new);
2001 observed.with_mut(|handles| handles.replaced_list = Some(controller));
2002 let items = RenderSlotWith::new(|index| {
2003 fixed_test_box()
2004 .tag(format!("replacement_{index}"))
2005 .width(20)
2006 .height(20);
2007 });
2008 material_theme().child(move || {
2009 if horizontal {
2010 lazy_row()
2011 .modifier(Modifier::new().size(Dp(100.0), Dp(40.0)))
2012 .controller(controller)
2013 .estimated_item_size(Dp(20.0))
2014 .items_with_key_shared(100, |index| index, items);
2015 } else {
2016 lazy_column()
2017 .modifier(Modifier::new().size(Dp(40.0), Dp(100.0)))
2018 .controller(controller)
2019 .estimated_item_size(Dp(20.0))
2020 .items_with_key_shared(100, |index| index, items);
2021 }
2022 });
2023 }
2024
2025 #[test]
2026 fn replacing_lazy_list_controllers_refreshes_retained_layout_and_scroll_ownership() {
2027 let runtime = tessera_ui::RuntimeSession::new();
2028 let _scope = runtime.enter();
2029 crate::test_utils::run_isolated_test(
2030 "lazy_list::tests::replacing_lazy_list_controllers_refreshes_retained_layout_and_scroll_ownership",
2031 || {
2032 tessera_ui::dp::set_scale_factor(1.0);
2033 for horizontal in [false, true] {
2034 let observed = tessera_ui::testing::TestProbe::default();
2035 let fixture_observed = observed.clone();
2036 let mut session = tessera_ui::testing::start_layout_test_session(
2037 tessera_ui::testing::layout_test(move || {
2038 replaceable_list_fixture(horizontal, fixture_observed.clone());
2039 })
2040 .viewport_px(120, 120),
2041 );
2042 tessera_ui::testing::pump_layout_test_session(&mut session, 0);
2043 let mut time = 0;
2044 for generation in 1..=4 {
2045 let previous = observed.get().replaced_list.unwrap();
2046 observed.get().list_generation.unwrap().set(generation);
2047 time += 16_666_667;
2048 tessera_ui::testing::pump_layout_test_session(&mut session, time);
2049 time += 16_666_667;
2050 let snapshot =
2051 tessera_ui::testing::pump_layout_test_session(&mut session, time);
2052 assert!(
2053 !previous.is_alive(),
2054 "the old keyed controller was actually released"
2055 );
2056 let current = observed.get().replaced_list.unwrap();
2057 assert!(current.is_alive());
2058 assert!(
2059 current != previous,
2060 "the controller identity must change with its key"
2061 );
2062 snapshot.node("replacement_0").position(0, 0);
2063 current.with_mut(|controller| {
2064 controller.scroll_to(
2065 if horizontal { Dp(80.0) } else { Dp::ZERO },
2066 if horizontal { Dp::ZERO } else { Dp(80.0) },
2067 )
2068 });
2069 time += 16_666_667;
2070 tessera_ui::testing::pump_layout_test_session(&mut session, time);
2071 time += 16_666_667;
2072 let snapshot =
2073 tessera_ui::testing::pump_layout_test_session(&mut session, time);
2074 snapshot.node("replacement_4").position(0, 0);
2075 current.with_mut(|controller| controller.scroll_to(Dp::ZERO, Dp::ZERO));
2076 time += 16_666_667;
2077 tessera_ui::testing::pump_layout_test_session(&mut session, time);
2078 time += 16_666_667;
2079 let snapshot =
2080 tessera_ui::testing::pump_layout_test_session(&mut session, time);
2081 snapshot.node("replacement_0").position(0, 0);
2082 }
2083 }
2084 },
2085 );
2086 }
2087
2088 #[derive(Clone, PartialEq)]
2089 struct ResponsiveItemLayout(bool, bool);
2090
2091 impl tessera_ui::LayoutModifierNode for ResponsiveItemLayout {
2092 fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
2093 let cross = if self.0 {
2094 input.parent_constraint().height().resolve_max()
2095 } else {
2096 input.parent_constraint().width().resolve_max()
2097 }
2098 .expect("bounded cross axis");
2099 let main = if cross < Dp(200.0).to_px() {
2100 Dp(80.0).to_px()
2101 } else {
2102 Dp(40.0).to_px()
2103 };
2104 let main = if self.1 { main * 10 } else { main };
2105 Ok(LayoutResult::new(if self.0 {
2106 ComputedData {
2107 width: main,
2108 height: cross,
2109 }
2110 } else {
2111 ComputedData {
2112 width: cross,
2113 height: main,
2114 }
2115 }))
2116 }
2117 }
2118
2119 #[tessera]
2120 fn responsive_anchor_fixture(
2121 horizontal: bool,
2122 tall: bool,
2123 observed: tessera_ui::testing::TestProbe<FixtureHandles>,
2124 ) {
2125 let cross = remember(|| Dp(300.0));
2126 let main = remember(|| Dp(120.0));
2127 let controller = remember(LazyListController::new);
2128 observed.with_mut(|handles| handles.responsive_cross = Some(cross));
2129 observed.with_mut(|handles| handles.responsive_main = Some(main));
2130 observed.with_mut(|handles| handles.programmatic_list = Some(controller));
2131 let items = RenderSlotWith::new(move |index| {
2132 layout()
2133 .modifier(Modifier::new().semantics(SemanticsArgs {
2134 test_tag: Some(format!("responsive_{index}")),
2135 ..Default::default()
2136 }))
2137 .policy(ResponsiveItemLayout(horizontal, tall));
2138 });
2139 material_theme().child(move || {
2140 crate::boxed::boxed().children(move || {
2141 if horizontal {
2142 lazy_row()
2143 .modifier(Modifier::new().size(main.get(), cross.get()))
2144 .scrollbar_behavior(crate::scrollable::ScrollBarBehavior::Hidden)
2145 .controller(controller)
2146 .overscan(2)
2147 .estimated_item_size(Dp(40.0))
2148 .items_with_key_shared(20, |index| index, items);
2149 } else {
2150 lazy_column()
2151 .modifier(Modifier::new().size(cross.get(), main.get()))
2152 .scrollbar_behavior(crate::scrollable::ScrollBarBehavior::Hidden)
2153 .controller(controller)
2154 .overscan(2)
2155 .estimated_item_size(Dp(40.0))
2156 .items_with_key_shared(20, |index| index, items);
2157 }
2158 });
2159 });
2160 }
2161
2162 #[test]
2163 fn responsive_measurements_preserve_the_first_visible_item_start_on_both_axes() {
2164 let runtime = tessera_ui::RuntimeSession::new();
2165 let _scope = runtime.enter();
2166 crate::test_utils::run_isolated_test(
2167 "lazy_list::tests::responsive_measurements_preserve_the_first_visible_item_start_on_both_axes",
2168 || {
2169 for density in [1.0, 1.25, 1.5, 2.0] {
2170 tessera_ui::dp::set_scale_factor(density);
2171 for (horizontal, tall) in
2172 [(false, false), (true, false), (false, true), (true, true)]
2173 {
2174 let observed = tessera_ui::testing::TestProbe::default();
2175 let fixture_observed = observed.clone();
2176 let mut session = tessera_ui::testing::start_layout_test_session(
2177 tessera_ui::testing::layout_test(move || {
2178 responsive_anchor_fixture(
2179 horizontal,
2180 tall,
2181 fixture_observed.clone(),
2182 );
2183 })
2184 .viewport_px(800, 800),
2185 );
2186 tessera_ui::testing::pump_layout_test_session(&mut session, 0);
2187 let controller = observed.get().programmatic_list.expect("list controller");
2188 let offset = if tall { Dp(60.0) } else { Dp(180.0) };
2189 controller.with_mut(|controller| {
2190 controller.scroll_to(
2191 if horizontal { offset } else { Dp::ZERO },
2192 if horizontal { Dp::ZERO } else { offset },
2193 );
2194 });
2195 let cross = observed.get().responsive_cross.expect("cross-axis state");
2196 let main_span = observed.get().responsive_main.expect("main-axis state");
2197 let mut frame = 1;
2198 for (width, span) in [
2199 (300.0, 120.0),
2200 (150.0, 60.0),
2201 (300.0, 120.0),
2202 (150.0, 60.0),
2203 (300.0, 120.0),
2204 ] {
2205 cross.set(Dp(width));
2206 main_span.set(Dp(span));
2207 let main = Dp(if width < 200.0 { 80.0 } else { 40.0 }).to_px().0
2208 * if tall { 10 } else { 1 };
2209 let position = Dp(if tall { -60.0 } else { 20.0 }).to_px().0;
2210 let cross_size = Dp(width).to_px().0;
2211 for _ in 0..4 {
2212 let snapshot = tessera_ui::testing::pump_layout_test_session(
2213 &mut session,
2214 frame * 16_666_667,
2215 );
2216 snapshot
2217 .node(if tall { "responsive_0" } else { "responsive_5" })
2218 .position(
2219 if horizontal { position } else { 0 },
2220 if horizontal { 0 } else { position },
2221 )
2222 .size(
2223 if horizontal { main } else { cross_size },
2224 if horizontal { cross_size } else { main },
2225 );
2226 frame += 1;
2227 }
2228 }
2229 }
2230 }
2231 },
2232 );
2233 }
2234
2235 #[tessera]
2236 fn collapsed_viewport_fixture(observed: tessera_ui::testing::TestProbe<FixtureHandles>) {
2237 let width = remember(|| Dp(300.0));
2238 let controller = remember(LazyListController::new);
2239 observed.with_mut(|handles| handles.collapsed_width = Some(width));
2240 observed.with_mut(|handles| handles.collapsed_list = Some(controller));
2241 material_theme().child(move || {
2242 lazy_column()
2243 .modifier(Modifier::new().width(width.get()).height(Dp(240.0)))
2244 .controller(controller)
2245 .estimated_item_size(Dp(60.0))
2246 .items(3, |_| {
2247 text("A responsive list retains measured geometry while its viewport is temporarily collapsed.");
2248 });
2249 });
2250 }
2251
2252 #[tessera]
2253 fn density_scroll_fixture(
2254 horizontal: bool,
2255 observed: tessera_ui::testing::TestProbe<FixtureHandles>,
2256 ) {
2257 let controller = remember(LazyListController::new);
2258 observed.with_mut(|handles| handles.programmatic_list = Some(controller));
2259 let items = RenderSlotWith::new(|index| {
2260 fixed_test_box()
2261 .tag(format!("density_{index}"))
2262 .width(Dp(20.0).to_px().0)
2263 .height(Dp(40.0).to_px().0);
2264 });
2265 material_theme().child(move || {
2266 crate::boxed::boxed().children(move || {
2267 if horizontal {
2268 lazy_row()
2269 .modifier(Modifier::new().size(Dp(100.0), Dp(100.0)))
2270 .controller(controller)
2271 .content_padding(Dp(4.0))
2272 .item_spacing(Dp(4.0))
2273 .scrollbar_behavior(crate::scrollable::ScrollBarBehavior::Hidden)
2274 .estimated_item_size(Dp(20.0))
2275 .items_with_key_shared(100, |index| index, items);
2276 } else {
2277 lazy_column()
2278 .modifier(Modifier::new().size(Dp(100.0), Dp(100.0)))
2279 .controller(controller)
2280 .content_padding(Dp(4.0))
2281 .item_spacing(Dp(4.0))
2282 .scrollbar_behavior(crate::scrollable::ScrollBarBehavior::Hidden)
2283 .estimated_item_size(Dp(40.0))
2284 .items_with_key_shared(100, |index| index, items);
2285 }
2286 });
2287 });
2288 }
2289
2290 #[test]
2291 fn density_changes_preserve_scrolled_items_and_new_programmatic_requests() {
2292 let runtime = tessera_ui::RuntimeSession::new();
2293 let _scope = runtime.enter();
2294 crate::test_utils::run_isolated_test(
2295 "lazy_list::tests::density_changes_preserve_scrolled_items_and_new_programmatic_requests",
2296 || {
2297 for horizontal in [false, true] {
2298 tessera_ui::dp::set_scale_factor(1.0);
2299 let observed = tessera_ui::testing::TestProbe::default();
2300 let fixture_observed = observed.clone();
2301 let mut session = tessera_ui::testing::start_layout_test_session(
2302 tessera_ui::testing::layout_test(move || {
2303 density_scroll_fixture(horizontal, fixture_observed.clone());
2304 })
2305 .viewport_px(400, 400),
2306 );
2307 for frame in 0..4 {
2308 tessera_ui::testing::pump_layout_test_session(
2309 &mut session,
2310 frame * 16_666_667,
2311 );
2312 }
2313 let controller = observed.get().programmatic_list.expect("controller");
2314 let offset = if horizontal { Dp(104.0) } else { Dp(204.0) };
2315 controller.with_mut(|controller| {
2316 controller.scroll_to(
2317 if horizontal { offset } else { Dp::ZERO },
2318 if horizontal { Dp::ZERO } else { offset },
2319 )
2320 });
2321 let mut frame = 4;
2322 for density in [1.0, 1.25, 1.5, 2.0, 1.0] {
2323 tessera_ui::dp::set_scale_factor(density);
2324 if density == 1.5 {
2325 controller.with_mut(|controller| {
2326 controller.scroll_to(
2327 if horizontal { offset } else { Dp::ZERO },
2328 if horizontal { Dp::ZERO } else { offset },
2329 )
2330 });
2331 }
2332 for _ in 0..4 {
2333 let snapshot = tessera_ui::testing::pump_layout_test_session(
2334 &mut session,
2335 frame * 16_666_667,
2336 );
2337 snapshot
2338 .node("density_5")
2339 .position(
2340 Dp(if horizontal { 20.0 } else { 4.0 }).to_px().0,
2341 Dp(if horizontal { 4.0 } else { 20.0 }).to_px().0,
2342 )
2343 .size(Dp(20.0).to_px().0, Dp(40.0).to_px().0);
2344 frame += 1;
2345 }
2346 }
2347 }
2348 },
2349 );
2350 }
2351
2352 #[test]
2353 fn collapsed_cross_axis_preserves_usable_measurements() {
2354 let observed = tessera_ui::testing::TestProbe::default();
2355 let fixture_observed = observed.clone();
2356 let mut session = tessera_ui::testing::start_layout_test_session(
2357 tessera_ui::testing::layout_test(move || {
2358 collapsed_viewport_fixture(fixture_observed.clone());
2359 })
2360 .viewport_px(300, 240),
2361 );
2362 for frame in 0..8 {
2363 let _ = tessera_ui::testing::pump_layout_test_session(&mut session, frame * 16_666_667);
2364 }
2365 let controller = observed.get().collapsed_list.expect("list controller");
2366 let before = controller.with(|state| state.cache.measured_main.clone());
2367 assert!(before.iter().all(Option::is_some));
2368 let width = observed.get().collapsed_width.expect("viewport width");
2369 width.set(Dp(0.0));
2370 for frame in 8..12 {
2371 let _ = tessera_ui::testing::pump_layout_test_session(&mut session, frame * 16_666_667);
2372 }
2373 assert_eq!(
2374 controller.with(|state| state.cache.measured_main.clone()),
2375 before
2376 );
2377 width.set(Dp(300.0));
2378 for frame in 12..16 {
2379 let _ = tessera_ui::testing::pump_layout_test_session(&mut session, frame * 16_666_667);
2380 }
2381 assert_eq!(
2382 controller.with(|state| state.cache.measured_main.clone()),
2383 before
2384 );
2385 }
2386
2387 #[test]
2388 fn estimate_changes_rebase_measured_and_unmeasured_offsets() {
2389 let mut cache = LazyListCache::default();
2390 cache.configure(5, Px(100));
2391 for (index, size) in [(0, 40), (1, 60), (3, 80)] {
2392 cache.record_measurement(index, Px(size), Px(100));
2393 }
2394 assert_eq!(cache.total_main_size(Px(100), Px(8)), Px(412));
2395 cache.configure(5, Px(20));
2396 assert_eq!(cache.total_main_size(Px(20), Px(8)), Px(252));
2397 assert_eq!(cache.offset_for(2, Px(20), Px(8)), Px(116));
2398 assert_eq!(cache.offset_for(4, Px(20), Px(8)), Px(232));
2399 assert_eq!(cache.index_for_offset(Px(115), Px(20), Px(8)), 1);
2400 assert_eq!(cache.index_for_offset(Px(116), Px(20), Px(8)), 2);
2401 cache.record_measurement(1, Px(75), Px(20));
2402 cache.configure(5, Px(100));
2403 assert_eq!(cache.total_main_size(Px(100), Px(8)), Px(427));
2404 cache.configure(0, Px(20));
2405 assert_eq!(cache.total_main_size(Px(20), Px(8)), Px::ZERO);
2406 cache.configure(3, Px(100));
2407 assert_eq!(cache.total_main_size(Px(100), Px(8)), Px(316));
2408 }
2409
2410 #[test]
2411 fn unvisited_measurements_rebase_density_without_round_trip_drift() {
2412 let runtime = tessera_ui::RuntimeSession::new();
2413 let _scope = runtime.enter();
2414 crate::test_utils::run_isolated_test(
2415 "lazy_list::tests::unvisited_measurements_rebase_density_without_round_trip_drift",
2416 || {
2417 tessera_ui::dp::set_scale_factor(1.0);
2418 let mut cache = LazyListCache::default();
2419 cache.configure(3, Px(20));
2420 cache.record_measurement(0, Px(33), Px(20));
2421 cache.record_measurement(2, Px(55), Px(20));
2422 for (density, estimated, spacing, offset, total) in [
2423 (1.25, 25, 5, 46, 144),
2424 (1.5, 30, 6, 55, 173),
2425 (2.0, 40, 8, 74, 232),
2426 (1.0, 20, 4, 37, 116),
2427 ] {
2428 tessera_ui::dp::set_scale_factor(density);
2429 cache.configure(3, Px(estimated));
2430 assert_eq!(cache.offset_for(1, Px(estimated), Px(spacing)), Px(offset));
2431 assert_eq!(cache.total_main_size(Px(estimated), Px(spacing)), Px(total));
2432 }
2433 },
2434 );
2435 }
2436
2437 #[tessera]
2438 fn changing_estimate_fixture(observed: tessera_ui::testing::TestProbe<FixtureHandles>) {
2439 let estimate = remember(|| Dp(100.0));
2440 observed.with_mut(|handles| handles.estimate = Some(estimate));
2441 material_theme().child(move || {
2442 lazy_column()
2443 .modifier(Modifier::new().fill_max_size())
2444 .cross_axis_alignment(CrossAxisAlignment::Start)
2445 .estimated_item_size(estimate.get())
2446 .item_spacing(Dp(8.0))
2447 .overscan(10)
2448 .items(4, |index| {
2449 fixed_test_box()
2450 .tag(format!("estimate_{index}"))
2451 .width(20)
2452 .height(40);
2453 });
2454 });
2455 }
2456
2457 #[test]
2458 fn changing_estimates_and_density_keep_items_separated() {
2459 let runtime = tessera_ui::RuntimeSession::new();
2460 let _scope = runtime.enter();
2461 crate::test_utils::run_isolated_test(
2462 "lazy_list::tests::changing_estimates_and_density_keep_items_separated",
2463 || {
2464 tessera_ui::dp::set_scale_factor(1.0);
2465 let observed = tessera_ui::testing::TestProbe::default();
2466 let fixture_observed = observed.clone();
2467 let mut session = tessera_ui::testing::start_layout_test_session(
2468 tessera_ui::testing::layout_test(move || {
2469 changing_estimate_fixture(fixture_observed.clone());
2470 })
2471 .viewport_px(200, 240),
2472 );
2473 let _ = tessera_ui::testing::pump_layout_test_session(&mut session, 0);
2474 let estimate = observed.get().estimate.expect("estimate state");
2475 for (frame, (density, value)) in [(1.0, 20.0), (1.5, 140.0), (1.0, 60.0)]
2476 .into_iter()
2477 .enumerate()
2478 {
2479 tessera_ui::dp::set_scale_factor(density);
2480 estimate.set(Dp(value));
2481 let snapshot = tessera_ui::testing::pump_layout_test_session(
2482 &mut session,
2483 (frame as u64 + 1) * 16_666_667,
2484 );
2485 for index in 0..4 {
2486 snapshot
2487 .node(&format!("estimate_{index}"))
2488 .position(0, index * (40 + (8.0 * density) as i32));
2489 }
2490 }
2491 tessera_ui::dp::set_scale_factor(1.0);
2492 },
2493 );
2494 }
2495
2496 #[derive(Clone, PartialEq)]
2497 struct FixedTestLayout {
2498 width: i32,
2499 height: i32,
2500 }
2501
2502 #[derive(Clone, PartialEq)]
2503 struct PreferredConstraintTestLayout {
2504 preferred_width: i32,
2505 preferred_height: i32,
2506 }
2507
2508 impl tessera_ui::LayoutModifierNode for FixedTestLayout {
2509 fn measure(&self, _input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
2510 Ok(LayoutResult::new(ComputedData {
2511 width: Px::new(self.width),
2512 height: Px::new(self.height),
2513 }))
2514 }
2515 }
2516
2517 impl tessera_ui::LayoutModifierNode for PreferredConstraintTestLayout {
2518 fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
2519 let preferred_width = Px::new(self.preferred_width);
2520 let preferred_height = Px::new(self.preferred_height);
2521 let width = AxisConstraint::exact(preferred_width)
2522 .intersect(input.parent_constraint().width())
2523 .clamp(preferred_width);
2524 let height = AxisConstraint::exact(preferred_height)
2525 .intersect(input.parent_constraint().height())
2526 .clamp(preferred_height);
2527 Ok(LayoutResult::new(ComputedData { width, height }))
2528 }
2529 }
2530
2531 #[tessera]
2532 fn fixed_test_box(tag: Option<String>, width: Option<i32>, height: Option<i32>) {
2533 let tag = tag.unwrap_or_default();
2534 let width = width.unwrap_or(0);
2535 let height = height.unwrap_or(0);
2536
2537 layout()
2538 .policy(FixedTestLayout { width, height })
2539 .draw(NoopDrawModifier)
2540 .modifier(Modifier::new().semantics(SemanticsArgs {
2541 test_tag: Some(tag),
2542 ..Default::default()
2543 }));
2544 }
2545
2546 #[tessera]
2547 fn preferred_constraint_test_box(tag: Option<String>, width: Option<i32>, height: Option<i32>) {
2548 let tag = tag.unwrap_or_default();
2549 let width = width.unwrap_or(0);
2550 let height = height.unwrap_or(0);
2551
2552 layout()
2553 .policy(PreferredConstraintTestLayout {
2554 preferred_width: width,
2555 preferred_height: height,
2556 })
2557 .draw(NoopDrawModifier)
2558 .modifier(Modifier::new().semantics(SemanticsArgs {
2559 test_tag: Some(tag),
2560 ..Default::default()
2561 }));
2562 }
2563
2564 #[tessera]
2565 fn lazy_column_layout_case() {
2566 lazy_column()
2567 .modifier(Modifier::new().constrain(
2568 Some(AxisConstraint::exact(Px::new(60))),
2569 Some(AxisConstraint::exact(Px::new(50))),
2570 ))
2571 .cross_axis_alignment(CrossAxisAlignment::Start)
2572 .item_spacing(Px::new(3).into())
2573 .estimated_item_size(Px::new(10).into())
2574 .content_padding(Px::new(4).into())
2575 .item(|| {
2576 fixed_test_box()
2577 .tag("lazy_column_first".to_string())
2578 .width(20)
2579 .height(10);
2580 })
2581 .item(|| {
2582 fixed_test_box()
2583 .tag("lazy_column_second".to_string())
2584 .width(18)
2585 .height(12);
2586 })
2587 .item(|| {
2588 fixed_test_box()
2589 .tag("lazy_column_third".to_string())
2590 .width(16)
2591 .height(8);
2592 });
2593 }
2594
2595 #[tessera]
2596 fn lazy_row_layout_case() {
2597 lazy_row()
2598 .modifier(Modifier::new().constrain(
2599 Some(AxisConstraint::exact(Px::new(70))),
2600 Some(AxisConstraint::exact(Px::new(30))),
2601 ))
2602 .cross_axis_alignment(CrossAxisAlignment::Start)
2603 .item_spacing(Px::new(3).into())
2604 .estimated_item_size(Px::new(10).into())
2605 .content_padding(Px::new(4).into())
2606 .item(|| {
2607 fixed_test_box()
2608 .tag("lazy_row_first".to_string())
2609 .width(20)
2610 .height(10);
2611 })
2612 .item(|| {
2613 fixed_test_box()
2614 .tag("lazy_row_second".to_string())
2615 .width(15)
2616 .height(12);
2617 })
2618 .item(|| {
2619 fixed_test_box()
2620 .tag("lazy_row_third".to_string())
2621 .width(18)
2622 .height(8);
2623 });
2624 }
2625
2626 #[tessera]
2627 fn lazy_column_scrolled_layout_case() {
2628 let controller = remember(LazyListController::new);
2629 let target_position = PxPosition::new(Px::ZERO, Px::new(-5));
2630 if controller.with(|c| c.scroll_controller().child_position()) != target_position {
2631 controller.with_mut(|c| {
2632 c.scroll_controller_mut()
2633 .set_scroll_position(target_position);
2634 });
2635 }
2636
2637 lazy_column()
2638 .modifier(Modifier::new().constrain(
2639 Some(AxisConstraint::exact(Px::new(60))),
2640 Some(AxisConstraint::exact(Px::new(39))),
2641 ))
2642 .controller(controller)
2643 .cross_axis_alignment(CrossAxisAlignment::Start)
2644 .item_spacing(Px::new(3).into())
2645 .estimated_item_size(Px::new(10).into())
2646 .content_padding(Px::new(4).into())
2647 .item(|| {
2648 fixed_test_box()
2649 .tag("lazy_column_scrolled_first".to_string())
2650 .width(20)
2651 .height(10);
2652 })
2653 .item(|| {
2654 fixed_test_box()
2655 .tag("lazy_column_scrolled_second".to_string())
2656 .width(18)
2657 .height(12);
2658 })
2659 .item(|| {
2660 fixed_test_box()
2661 .tag("lazy_column_scrolled_third".to_string())
2662 .width(16)
2663 .height(8);
2664 });
2665 }
2666
2667 #[tessera]
2668 fn lazy_column_sticky_layout_case() {
2669 let controller = remember(LazyListController::new);
2670 let target_position = PxPosition::new(Px::ZERO, Px::new(-6));
2671 if controller.with(|c| c.scroll_controller().child_position()) != target_position {
2672 controller.with_mut(|c| {
2673 c.scroll_controller_mut()
2674 .set_scroll_position(target_position);
2675 });
2676 }
2677
2678 lazy_column()
2679 .modifier(Modifier::new().constrain(
2680 Some(AxisConstraint::exact(Px::new(60))),
2681 Some(AxisConstraint::exact(Px::new(38))),
2682 ))
2683 .controller(controller)
2684 .cross_axis_alignment(CrossAxisAlignment::Start)
2685 .item_spacing(Px::new(3).into())
2686 .estimated_item_size(Px::new(10).into())
2687 .content_padding(Px::new(4).into())
2688 .sticky_header(|| {
2689 fixed_test_box()
2690 .tag("lazy_column_sticky_header".to_string())
2691 .width(20)
2692 .height(10);
2693 })
2694 .item(|| {
2695 fixed_test_box()
2696 .tag("lazy_column_sticky_body".to_string())
2697 .width(18)
2698 .height(12);
2699 })
2700 .item(|| {
2701 fixed_test_box()
2702 .tag("lazy_column_sticky_tail".to_string())
2703 .width(16)
2704 .height(8);
2705 });
2706 }
2707
2708 #[tessera]
2709 fn lazy_column_non_stretch_cross_axis_case() {
2710 lazy_column()
2711 .modifier(Modifier::new().constrain(
2712 Some(AxisConstraint::exact(Px::new(60))),
2713 Some(AxisConstraint::exact(Px::new(50))),
2714 ))
2715 .cross_axis_alignment(CrossAxisAlignment::Start)
2716 .estimated_item_size(Px::new(10).into())
2717 .content_padding(Px::new(4).into())
2718 .item(|| {
2719 preferred_constraint_test_box()
2720 .tag("lazy_column_non_stretch".to_string())
2721 .width(20)
2722 .height(10);
2723 });
2724 }
2725
2726 #[tessera]
2727 fn lazy_column_stable_visible_items_case() {
2728 let controller = remember(LazyListController::new);
2729 receive_frame_nanos(move |frame_nanos| {
2730 let offset = match frame_nanos {
2731 0 => 0,
2732 16_666_667 => 2,
2733 33_333_334 => 4,
2734 _ => 4,
2735 };
2736 let position = PxPosition::new(Px::ZERO, Px::new(-offset));
2737 controller.with_mut(|c| {
2738 c.scroll_controller_mut().set_scroll_position(position);
2739 });
2740 tessera_ui::FrameNanosControl::Continue
2741 });
2742
2743 material_theme()
2744 .theme(MaterialTheme::default)
2745 .child(move || {
2746 let item_width =
2747 controller.with(|c| 20 - c.scroll_controller().child_position().y as i32);
2748 lazy_column()
2749 .modifier(Modifier::new().constrain(
2750 Some(AxisConstraint::exact(Px::new(60))),
2751 Some(AxisConstraint::exact(Px::new(50))),
2752 ))
2753 .controller(controller)
2754 .cross_axis_alignment(CrossAxisAlignment::Start)
2755 .estimated_item_size(Px::new(20).into())
2756 .items(10, move |index| {
2757 fixed_test_box()
2758 .tag(format!("stable_visible_item_{index}"))
2759 .width(item_width)
2760 .height(20);
2761 });
2762 });
2763 }
2764
2765 #[test]
2766 fn lazy_column_positions_items_with_padding_and_spacing() {
2767 tessera_ui::assert_layout! {
2768 viewport: (80, 60),
2769 content: {
2770 material_theme().child(|| { lazy_column_layout_case(); });
2771 },
2772 expect: {
2773 node("lazy_column_first").position(4, 4).size(20, 10);
2774 node("lazy_column_second").position(4, 17).size(18, 12);
2775 node("lazy_column_third").position(4, 32).size(16, 8);
2776 }
2777 }
2778 }
2779
2780 #[test]
2781 fn lazy_row_positions_items_with_padding_and_spacing() {
2782 tessera_ui::assert_layout! {
2783 viewport: (90, 40),
2784 content: {
2785 material_theme().child(|| { lazy_row_layout_case(); });
2786 },
2787 expect: {
2788 node("lazy_row_first").position(4, 4).size(20, 10);
2789 node("lazy_row_second").position(27, 4).size(15, 12);
2790 node("lazy_row_third").position(45, 4).size(18, 8);
2791 }
2792 }
2793 }
2794
2795 #[test]
2796 fn lazy_column_scroll_offset_repositions_visible_items() {
2797 tessera_ui::assert_layout! {
2798 viewport: (80, 60),
2799 content: {
2800 material_theme().child(|| { lazy_column_scrolled_layout_case(); });
2801 },
2802 expect: {
2803 node("lazy_column_scrolled_first").position(4, -1).size(20, 10);
2804 node("lazy_column_scrolled_second").position(4, 12).size(18, 12);
2805 node("lazy_column_scrolled_third").position(4, 27).size(16, 8);
2806 }
2807 }
2808 }
2809
2810 #[test]
2811 fn lazy_column_sticky_header_stays_pinned_to_padding() {
2812 tessera_ui::assert_layout! {
2813 viewport: (80, 50),
2814 content: {
2815 material_theme().child(|| { lazy_column_sticky_layout_case(); });
2816 },
2817 expect: {
2818 node("lazy_column_sticky_header").position(4, 4).size(20, 10);
2819 node("lazy_column_sticky_body").position(4, 11).size(18, 12);
2820 node("lazy_column_sticky_tail").position(4, 26).size(16, 8);
2821 }
2822 }
2823 }
2824
2825 #[test]
2826 fn lazy_column_non_stretch_cross_axis_does_not_force_exact_parent_width() {
2827 tessera_ui::assert_layout! {
2828 viewport: (80, 60),
2829 content: {
2830 material_theme().child(|| { lazy_column_non_stretch_cross_axis_case(); });
2831 },
2832 expect: {
2833 node("lazy_column_non_stretch").position(4, 4).size(20, 10);
2834 }
2835 }
2836 }
2837
2838 #[test]
2839 fn lazy_column_rebuilds_visible_items_when_item_slot_updates() {
2840 let mut session = tessera_ui::testing::start_layout_test_session(
2841 tessera_ui::testing::layout_test(|| {
2842 lazy_column_stable_visible_items_case();
2843 })
2844 .viewport_px(80, 60),
2845 );
2846
2847 tessera_ui::testing::pump_layout_test_session(&mut session, 0)
2848 .node("stable_visible_item_0")
2849 .width(20);
2850 let first_builds = session
2851 .work()
2852 .count("fixed_test_box", tessera_ui::WorkPhase::Build);
2853 assert!(first_builds > 0);
2854
2855 tessera_ui::testing::pump_layout_test_session(&mut session, 16_666_667)
2856 .node("stable_visible_item_0")
2857 .width(22);
2858 let second_builds = session
2859 .work()
2860 .count("fixed_test_box", tessera_ui::WorkPhase::Build);
2861 assert!(second_builds > 0);
2862
2863 tessera_ui::testing::pump_layout_test_session(&mut session, 33_333_334)
2864 .node("stable_visible_item_0")
2865 .width(24);
2866 let third_builds = session
2867 .work()
2868 .count("fixed_test_box", tessera_ui::WorkPhase::Build);
2869 assert!(third_builds > 0);
2870 }
2871}
2872
2873#[cfg(all(test, not(target_family = "wasm")))]
2874#[path = "lazy_list_retention_tests.rs"]
2875mod retention_tests;