1//! A scrollable list of elements with uniform height, optimized for large lists.
2//! Rather than use the full taffy layout system, uniform_list simply measures
3//! the first element and then lays out all remaining elements in a line based on that
4//! measurement. This is much faster than the full layout system, but only works for
5//! elements with uniform height.
6
7use crate::{
8 AnyElement, App, AvailableSpace, Bounds, ContentMask, Element, ElementId, GlobalElementId,
9 Hitbox, InspectorElementId, InteractiveElement, Interactivity, IntoElement, IsZero, LayoutId,
10 ListSizingBehavior, Overflow, Pixels, Point, ScrollHandle, Size, StyleRefinement, Styled,
11 Window, point, size,
12};
13use smallvec::SmallVec;
14use std::{cell::RefCell, cmp, ops::Range, rc::Rc};
15
16use super::ListHorizontalSizingBehavior;
17
18/// uniform_list provides lazy rendering for a set of items that are of uniform height.
19/// When rendered into a container with overflow-y: hidden and a fixed (or max) height,
20/// uniform_list will only render the visible subset of items.
21#[track_caller]
22pub fn uniform_list<R>(
23 id: impl Into<ElementId>,
24 item_count: usize,
25 f: impl 'static + Fn(Range<usize>, &mut Window, &mut App) -> Vec<R>,
26) -> UniformList
27where
28 R: IntoElement,
29{
30 let id = id.into();
31 let mut base_style = StyleRefinement::default();
32 base_style.overflow.y = Some(Overflow::Scroll);
33
34 let render_range = move |range: Range<usize>, window: &mut Window, cx: &mut App| {
35 f(range, window, cx)
36 .into_iter()
37 .map(|component| component.into_any_element())
38 .collect()
39 };
40
41 UniformList {
42 item_count,
43 item_to_measure_index: 0,
44 render_items: Box::new(render_range),
45 decorations: Vec::new(),
46 interactivity: Interactivity {
47 element_id: Some(id),
48 base_style: Box::new(base_style),
49 ..Interactivity::new()
50 },
51 scroll_handle: None,
52 sizing_behavior: ListSizingBehavior::default(),
53 horizontal_sizing_behavior: ListHorizontalSizingBehavior::default(),
54 }
55}
56
57/// A list element for efficiently laying out and displaying a list of uniform-height elements.
58pub struct UniformList {
59 item_count: usize,
60 item_to_measure_index: usize,
61 render_items: Box<
62 dyn for<'a> Fn(Range<usize>, &'a mut Window, &'a mut App) -> SmallVec<[AnyElement; 64]>,
63 >,
64 decorations: Vec<Box<dyn UniformListDecoration>>,
65 interactivity: Interactivity,
66 scroll_handle: Option<UniformListScrollHandle>,
67 sizing_behavior: ListSizingBehavior,
68 horizontal_sizing_behavior: ListHorizontalSizingBehavior,
69}
70
71/// Frame state used by the [UniformList].
72pub struct UniformListFrameState {
73 items: SmallVec<[AnyElement; 32]>,
74 decorations: SmallVec<[AnyElement; 1]>,
75}
76
77/// A handle for controlling the scroll position of a uniform list.
78/// This should be stored in your view and passed to the uniform_list on each frame.
79#[derive(Clone, Debug, Default)]
80pub struct UniformListScrollHandle(pub Rc<RefCell<UniformListScrollState>>);
81
82/// Where to place the element scrolled to.
83#[derive(Clone, Copy, Debug, PartialEq, Eq)]
84pub enum ScrollStrategy {
85 /// Place the element at the top of the list's viewport.
86 Top,
87 /// Attempt to place the element in the middle of the list's viewport.
88 /// May not be possible if there's not enough list items above the item scrolled to:
89 /// in this case, the element will be placed at the closest possible position.
90 Center,
91 /// Scrolls the element to be at the given item index from the top of the viewport.
92 ToPosition(usize),
93}
94
95#[derive(Clone, Debug, Default)]
96#[allow(missing_docs)]
97pub struct UniformListScrollState {
98 pub base_handle: ScrollHandle,
99 pub deferred_scroll_to_item: Option<(usize, ScrollStrategy)>,
100 /// Size of the item, captured during last layout.
101 pub last_item_size: Option<ItemSize>,
102 /// Whether the list was vertically flipped during last layout.
103 pub y_flipped: bool,
104}
105
106#[derive(Copy, Clone, Debug, Default)]
107/// The size of the item and its contents.
108pub struct ItemSize {
109 /// The size of the item.
110 pub item: Size<Pixels>,
111 /// The size of the item's contents, which may be larger than the item itself,
112 /// if the item was bounded by a parent element.
113 pub contents: Size<Pixels>,
114}
115
116impl UniformListScrollHandle {
117 /// Create a new scroll handle to bind to a uniform list.
118 pub fn new() -> Self {
119 Self(Rc::new(RefCell::new(UniformListScrollState {
120 base_handle: ScrollHandle::new(),
121 deferred_scroll_to_item: None,
122 last_item_size: None,
123 y_flipped: false,
124 })))
125 }
126
127 /// Scroll the list to the given item index.
128 pub fn scroll_to_item(&self, ix: usize, strategy: ScrollStrategy) {
129 self.0.borrow_mut().deferred_scroll_to_item = Some((ix, strategy));
130 }
131
132 /// Check if the list is flipped vertically.
133 pub fn y_flipped(&self) -> bool {
134 self.0.borrow().y_flipped
135 }
136
137 /// Get the index of the topmost visible child.
138 #[cfg(any(test, feature = "test-support"))]
139 pub fn logical_scroll_top_index(&self) -> usize {
140 let this = self.0.borrow();
141 this.deferred_scroll_to_item
142 .map(|(ix, _)| ix)
143 .unwrap_or_else(|| this.base_handle.logical_scroll_top().0)
144 }
145
146 /// Checks if the list can be scrolled vertically.
147 pub fn is_scrollable(&self) -> bool {
148 if let Some(size) = self.0.borrow().last_item_size {
149 size.contents.height > size.item.height
150 } else {
151 false
152 }
153 }
154}
155
156impl Styled for UniformList {
157 fn style(&mut self) -> &mut StyleRefinement {
158 &mut self.interactivity.base_style
159 }
160}
161
162impl Element for UniformList {
163 type RequestLayoutState = UniformListFrameState;
164 type PrepaintState = Option<Hitbox>;
165
166 fn id(&self) -> Option<ElementId> {
167 self.interactivity.element_id.clone()
168 }
169
170 fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
171 None
172 }
173
174 fn request_layout(
175 &mut self,
176 global_id: Option<&GlobalElementId>,
177 inspector_id: Option<&InspectorElementId>,
178 window: &mut Window,
179 cx: &mut App,
180 ) -> (LayoutId, Self::RequestLayoutState) {
181 let max_items = self.item_count;
182 let item_size = self.measure_item(None, window, cx);
183 let layout_id = self.interactivity.request_layout(
184 global_id,
185 inspector_id,
186 window,
187 cx,
188 |style, window, cx| match self.sizing_behavior {
189 ListSizingBehavior::Infer => {
190 window.with_text_style(style.text_style().cloned(), |window| {
191 window.request_measured_layout(
192 style,
193 move |known_dimensions, available_space, _window, _cx| {
194 let desired_height = item_size.height * max_items;
195 let width = known_dimensions.width.unwrap_or(match available_space
196 .width
197 {
198 AvailableSpace::Definite(x) => x,
199 AvailableSpace::MinContent | AvailableSpace::MaxContent => {
200 item_size.width
201 }
202 });
203 let height = match available_space.height {
204 AvailableSpace::Definite(height) => desired_height.min(height),
205 AvailableSpace::MinContent | AvailableSpace::MaxContent => {
206 desired_height
207 }
208 };
209 size(width, height)
210 },
211 )
212 })
213 }
214 ListSizingBehavior::Auto => window
215 .with_text_style(style.text_style().cloned(), |window| {
216 window.request_layout(style, None, cx)
217 }),
218 },
219 );
220
221 (
222 layout_id,
223 UniformListFrameState {
224 items: SmallVec::new(),
225 decorations: SmallVec::new(),
226 },
227 )
228 }
229
230 fn prepaint(
231 &mut self,
232 global_id: Option<&GlobalElementId>,
233 inspector_id: Option<&InspectorElementId>,
234 bounds: Bounds<Pixels>,
235 frame_state: &mut Self::RequestLayoutState,
236 window: &mut Window,
237 cx: &mut App,
238 ) -> Option<Hitbox> {
239 let style = self
240 .interactivity
241 .compute_style(global_id, None, window, cx);
242 let border = style.border_widths.to_pixels(window.rem_size());
243 let padding = style
244 .padding
245 .to_pixels(bounds.size.into(), window.rem_size());
246
247 let padded_bounds = Bounds::from_corners(
248 bounds.origin + point(border.left + padding.left, border.top + padding.top),
249 bounds.bottom_right()
250 - point(border.right + padding.right, border.bottom + padding.bottom),
251 );
252
253 let can_scroll_horizontally = matches!(
254 self.horizontal_sizing_behavior,
255 ListHorizontalSizingBehavior::Unconstrained
256 );
257
258 let longest_item_size = self.measure_item(None, window, cx);
259 let content_width = if can_scroll_horizontally {
260 padded_bounds.size.width.max(longest_item_size.width)
261 } else {
262 padded_bounds.size.width
263 };
264 let content_size = Size {
265 width: content_width,
266 height: longest_item_size.height * self.item_count + padding.top + padding.bottom,
267 };
268
269 let shared_scroll_offset = self.interactivity.scroll_offset.clone().unwrap();
270 let item_height = longest_item_size.height;
271 let shared_scroll_to_item = self.scroll_handle.as_mut().and_then(|handle| {
272 let mut handle = handle.0.borrow_mut();
273 handle.last_item_size = Some(ItemSize {
274 item: padded_bounds.size,
275 contents: content_size,
276 });
277 handle.deferred_scroll_to_item.take()
278 });
279
280 self.interactivity.prepaint(
281 global_id,
282 inspector_id,
283 bounds,
284 content_size,
285 window,
286 cx,
287 |style, mut scroll_offset, hitbox, window, cx| {
288 let border = style.border_widths.to_pixels(window.rem_size());
289 let padding = style
290 .padding
291 .to_pixels(bounds.size.into(), window.rem_size());
292
293 let padded_bounds = Bounds::from_corners(
294 bounds.origin + point(border.left + padding.left, border.top),
295 bounds.bottom_right() - point(border.right + padding.right, border.bottom),
296 );
297
298 let y_flipped = if let Some(scroll_handle) = &self.scroll_handle {
299 let scroll_state = scroll_handle.0.borrow();
300 scroll_state.y_flipped
301 } else {
302 false
303 };
304
305 if self.item_count > 0 {
306 let content_height =
307 item_height * self.item_count + padding.top + padding.bottom;
308 let is_scrolled_vertically = !scroll_offset.y.is_zero();
309 let min_vertical_scroll_offset = padded_bounds.size.height - content_height;
310 if is_scrolled_vertically && scroll_offset.y < min_vertical_scroll_offset {
311 shared_scroll_offset.borrow_mut().y = min_vertical_scroll_offset;
312 scroll_offset.y = min_vertical_scroll_offset;
313 }
314
315 let content_width = content_size.width + padding.left + padding.right;
316 let is_scrolled_horizontally =
317 can_scroll_horizontally && !scroll_offset.x.is_zero();
318 if is_scrolled_horizontally && content_width <= padded_bounds.size.width {
319 shared_scroll_offset.borrow_mut().x = Pixels::ZERO;
320 scroll_offset.x = Pixels::ZERO;
321 }
322
323 if let Some((mut ix, scroll_strategy)) = shared_scroll_to_item {
324 if y_flipped {
325 ix = self.item_count.saturating_sub(ix + 1);
326 }
327 let list_height = padded_bounds.size.height;
328 let mut updated_scroll_offset = shared_scroll_offset.borrow_mut();
329 let item_top = item_height * ix + padding.top;
330 let item_bottom = item_top + item_height;
331 let scroll_top = -updated_scroll_offset.y;
332 let mut scrolled_to_top = false;
333 if item_top < scroll_top + padding.top {
334 scrolled_to_top = true;
335 updated_scroll_offset.y = -(item_top) + padding.top;
336 } else if item_bottom > scroll_top + list_height - padding.bottom {
337 scrolled_to_top = true;
338 updated_scroll_offset.y = -(item_bottom - list_height) - padding.bottom;
339 }
340
341 match scroll_strategy {
342 ScrollStrategy::Top => {}
343 ScrollStrategy::Center => {
344 if scrolled_to_top {
345 let item_center = item_top + item_height / 2.0;
346 let target_scroll_top = item_center - list_height / 2.0;
347
348 if item_top < scroll_top
349 || item_bottom > scroll_top + list_height
350 {
351 updated_scroll_offset.y = -target_scroll_top
352 .max(Pixels::ZERO)
353 .min(content_height - list_height)
354 .max(Pixels::ZERO);
355 }
356 }
357 }
358 ScrollStrategy::ToPosition(sticky_index) => {
359 let target_y_in_viewport = item_height * sticky_index;
360 let target_scroll_top = item_top - target_y_in_viewport;
361 let max_scroll_top =
362 (content_height - list_height).max(Pixels::ZERO);
363 let new_scroll_top =
364 target_scroll_top.clamp(Pixels::ZERO, max_scroll_top);
365 updated_scroll_offset.y = -new_scroll_top;
366 }
367 }
368 scroll_offset = *updated_scroll_offset
369 }
370
371 let first_visible_element_ix =
372 (-(scroll_offset.y + padding.top) / item_height).floor() as usize;
373 let last_visible_element_ix = ((-scroll_offset.y + padded_bounds.size.height)
374 / item_height)
375 .ceil() as usize;
376
377 let visible_range = first_visible_element_ix
378 ..cmp::min(last_visible_element_ix, self.item_count);
379
380 let items = if y_flipped {
381 let flipped_range = self.item_count.saturating_sub(visible_range.end)
382 ..self.item_count.saturating_sub(visible_range.start);
383 let mut items = (self.render_items)(flipped_range, window, cx);
384 items.reverse();
385 items
386 } else {
387 (self.render_items)(visible_range.clone(), window, cx)
388 };
389
390 let content_mask = ContentMask { bounds };
391 window.with_content_mask(Some(content_mask), |window| {
392 for (mut item, ix) in items.into_iter().zip(visible_range.clone()) {
393 let item_origin = padded_bounds.origin
394 + point(
395 if can_scroll_horizontally {
396 scroll_offset.x + padding.left
397 } else {
398 scroll_offset.x
399 },
400 item_height * ix + scroll_offset.y + padding.top,
401 );
402 let available_width = if can_scroll_horizontally {
403 padded_bounds.size.width + scroll_offset.x.abs()
404 } else {
405 padded_bounds.size.width
406 };
407 let available_space = size(
408 AvailableSpace::Definite(available_width),
409 AvailableSpace::Definite(item_height),
410 );
411 item.layout_as_root(available_space, window, cx);
412 item.prepaint_at(item_origin, window, cx);
413 frame_state.items.push(item);
414 }
415
416 let bounds = Bounds::new(
417 padded_bounds.origin
418 + point(
419 if can_scroll_horizontally {
420 scroll_offset.x + padding.left
421 } else {
422 scroll_offset.x
423 },
424 scroll_offset.y + padding.top,
425 ),
426 padded_bounds.size,
427 );
428 for decoration in &self.decorations {
429 let mut decoration = decoration.as_ref().compute(
430 visible_range.clone(),
431 bounds,
432 scroll_offset,
433 item_height,
434 self.item_count,
435 window,
436 cx,
437 );
438 let available_space = size(
439 AvailableSpace::Definite(bounds.size.width),
440 AvailableSpace::Definite(bounds.size.height),
441 );
442 decoration.layout_as_root(available_space, window, cx);
443 decoration.prepaint_at(bounds.origin, window, cx);
444 frame_state.decorations.push(decoration);
445 }
446 });
447 }
448
449 hitbox
450 },
451 )
452 }
453
454 fn paint(
455 &mut self,
456 global_id: Option<&GlobalElementId>,
457 inspector_id: Option<&InspectorElementId>,
458 bounds: Bounds<crate::Pixels>,
459 request_layout: &mut Self::RequestLayoutState,
460 hitbox: &mut Option<Hitbox>,
461 window: &mut Window,
462 cx: &mut App,
463 ) {
464 self.interactivity.paint(
465 global_id,
466 inspector_id,
467 bounds,
468 hitbox.as_ref(),
469 window,
470 cx,
471 |_, window, cx| {
472 for item in &mut request_layout.items {
473 item.paint(window, cx);
474 }
475 for decoration in &mut request_layout.decorations {
476 decoration.paint(window, cx);
477 }
478 },
479 )
480 }
481}
482
483impl IntoElement for UniformList {
484 type Element = Self;
485
486 fn into_element(self) -> Self::Element {
487 self
488 }
489}
490
491/// A decoration for a [`UniformList`]. This can be used for various things,
492/// such as rendering indent guides, or other visual effects.
493pub trait UniformListDecoration {
494 /// Compute the decoration element, given the visible range of list items,
495 /// the bounds of the list, and the height of each item.
496 fn compute(
497 &self,
498 visible_range: Range<usize>,
499 bounds: Bounds<Pixels>,
500 scroll_offset: Point<Pixels>,
501 item_height: Pixels,
502 item_count: usize,
503 window: &mut Window,
504 cx: &mut App,
505 ) -> AnyElement;
506}
507
508impl UniformList {
509 /// Selects a specific list item for measurement.
510 pub fn with_width_from_item(mut self, item_index: Option<usize>) -> Self {
511 self.item_to_measure_index = item_index.unwrap_or(0);
512 self
513 }
514
515 /// Sets the sizing behavior, similar to the `List` element.
516 pub fn with_sizing_behavior(mut self, behavior: ListSizingBehavior) -> Self {
517 self.sizing_behavior = behavior;
518 self
519 }
520
521 /// Sets the horizontal sizing behavior, controlling the way list items laid out horizontally.
522 /// With [`ListHorizontalSizingBehavior::Unconstrained`] behavior, every item and the list itself will
523 /// have the size of the widest item and lay out pushing the `end_slot` to the right end.
524 pub fn with_horizontal_sizing_behavior(
525 mut self,
526 behavior: ListHorizontalSizingBehavior,
527 ) -> Self {
528 self.horizontal_sizing_behavior = behavior;
529 match behavior {
530 ListHorizontalSizingBehavior::FitList => {
531 self.interactivity.base_style.overflow.x = None;
532 }
533 ListHorizontalSizingBehavior::Unconstrained => {
534 self.interactivity.base_style.overflow.x = Some(Overflow::Scroll);
535 }
536 }
537 self
538 }
539
540 /// Adds a decoration element to the list.
541 pub fn with_decoration(mut self, decoration: impl UniformListDecoration + 'static) -> Self {
542 self.decorations.push(Box::new(decoration));
543 self
544 }
545
546 fn measure_item(
547 &self,
548 list_width: Option<Pixels>,
549 window: &mut Window,
550 cx: &mut App,
551 ) -> Size<Pixels> {
552 if self.item_count == 0 {
553 return Size::default();
554 }
555
556 let item_ix = cmp::min(self.item_to_measure_index, self.item_count - 1);
557 let mut items = (self.render_items)(item_ix..item_ix + 1, window, cx);
558 let Some(mut item_to_measure) = items.pop() else {
559 return Size::default();
560 };
561 let available_space = size(
562 list_width.map_or(AvailableSpace::MinContent, |width| {
563 AvailableSpace::Definite(width)
564 }),
565 AvailableSpace::MinContent,
566 );
567 item_to_measure.layout_as_root(available_space, window, cx)
568 }
569
570 /// Track and render scroll state of this list with reference to the given scroll handle.
571 pub fn track_scroll(mut self, handle: UniformListScrollHandle) -> Self {
572 self.interactivity.tracked_scroll_handle = Some(handle.0.borrow().base_handle.clone());
573 self.scroll_handle = Some(handle);
574 self
575 }
576
577 /// Sets whether the list is flipped vertically, such that item 0 appears at the bottom.
578 pub fn y_flipped(mut self, y_flipped: bool) -> Self {
579 if let Some(ref scroll_handle) = self.scroll_handle {
580 let mut scroll_state = scroll_handle.0.borrow_mut();
581 let mut base_handle = &scroll_state.base_handle;
582 let offset = base_handle.offset();
583 match scroll_state.last_item_size {
584 Some(last_size) if scroll_state.y_flipped != y_flipped => {
585 let new_y_offset =
586 -(offset.y + last_size.contents.height - last_size.item.height);
587 base_handle.set_offset(point(offset.x, new_y_offset));
588 scroll_state.y_flipped = y_flipped;
589 }
590 // Handle case where list is initially flipped.
591 None if y_flipped => {
592 base_handle.set_offset(point(offset.x, Pixels::MIN));
593 scroll_state.y_flipped = y_flipped;
594 }
595 _ => {}
596 }
597 }
598 self
599 }
600}
601
602impl InteractiveElement for UniformList {
603 fn interactivity(&mut self) -> &mut crate::Interactivity {
604 &mut self.interactivity
605 }
606}