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.as_mut() {
299 let mut scroll_state = scroll_handle.0.borrow_mut();
300 scroll_state.base_handle.set_bounds(bounds);
301 scroll_state.y_flipped
302 } else {
303 false
304 };
305
306 if self.item_count > 0 {
307 let content_height =
308 item_height * self.item_count + padding.top + padding.bottom;
309 let is_scrolled_vertically = !scroll_offset.y.is_zero();
310 let min_vertical_scroll_offset = padded_bounds.size.height - content_height;
311 if is_scrolled_vertically && scroll_offset.y < min_vertical_scroll_offset {
312 shared_scroll_offset.borrow_mut().y = min_vertical_scroll_offset;
313 scroll_offset.y = min_vertical_scroll_offset;
314 }
315
316 let content_width = content_size.width + padding.left + padding.right;
317 let is_scrolled_horizontally =
318 can_scroll_horizontally && !scroll_offset.x.is_zero();
319 if is_scrolled_horizontally && content_width <= padded_bounds.size.width {
320 shared_scroll_offset.borrow_mut().x = Pixels::ZERO;
321 scroll_offset.x = Pixels::ZERO;
322 }
323
324 if let Some((mut ix, scroll_strategy)) = shared_scroll_to_item {
325 if y_flipped {
326 ix = self.item_count.saturating_sub(ix + 1);
327 }
328 let list_height = padded_bounds.size.height;
329 let mut updated_scroll_offset = shared_scroll_offset.borrow_mut();
330 let item_top = item_height * ix + padding.top;
331 let item_bottom = item_top + item_height;
332 let scroll_top = -updated_scroll_offset.y;
333 let mut scrolled_to_top = false;
334 if item_top < scroll_top + padding.top {
335 scrolled_to_top = true;
336 updated_scroll_offset.y = -(item_top) + padding.top;
337 } else if item_bottom > scroll_top + list_height - padding.bottom {
338 scrolled_to_top = true;
339 updated_scroll_offset.y = -(item_bottom - list_height) - padding.bottom;
340 }
341
342 match scroll_strategy {
343 ScrollStrategy::Top => {}
344 ScrollStrategy::Center => {
345 if scrolled_to_top {
346 let item_center = item_top + item_height / 2.0;
347 let target_scroll_top = item_center - list_height / 2.0;
348
349 if item_top < scroll_top
350 || item_bottom > scroll_top + list_height
351 {
352 updated_scroll_offset.y = -target_scroll_top
353 .max(Pixels::ZERO)
354 .min(content_height - list_height)
355 .max(Pixels::ZERO);
356 }
357 }
358 }
359 ScrollStrategy::ToPosition(sticky_index) => {
360 let target_y_in_viewport = item_height * sticky_index;
361 let target_scroll_top = item_top - target_y_in_viewport;
362 let max_scroll_top =
363 (content_height - list_height).max(Pixels::ZERO);
364 let new_scroll_top =
365 target_scroll_top.clamp(Pixels::ZERO, max_scroll_top);
366 updated_scroll_offset.y = -new_scroll_top;
367 }
368 }
369 scroll_offset = *updated_scroll_offset
370 }
371
372 let first_visible_element_ix =
373 (-(scroll_offset.y + padding.top) / item_height).floor() as usize;
374 let last_visible_element_ix = ((-scroll_offset.y + padded_bounds.size.height)
375 / item_height)
376 .ceil() as usize;
377
378 let visible_range = first_visible_element_ix
379 ..cmp::min(last_visible_element_ix, self.item_count);
380
381 let items = if y_flipped {
382 let flipped_range = self.item_count.saturating_sub(visible_range.end)
383 ..self.item_count.saturating_sub(visible_range.start);
384 let mut items = (self.render_items)(flipped_range, window, cx);
385 items.reverse();
386 items
387 } else {
388 (self.render_items)(visible_range.clone(), window, cx)
389 };
390
391 let content_mask = ContentMask { bounds };
392 window.with_content_mask(Some(content_mask), |window| {
393 for (mut item, ix) in items.into_iter().zip(visible_range.clone()) {
394 let item_origin = padded_bounds.origin
395 + point(
396 if can_scroll_horizontally {
397 scroll_offset.x + padding.left
398 } else {
399 scroll_offset.x
400 },
401 item_height * ix + scroll_offset.y + padding.top,
402 );
403 let available_width = if can_scroll_horizontally {
404 padded_bounds.size.width + scroll_offset.x.abs()
405 } else {
406 padded_bounds.size.width
407 };
408 let available_space = size(
409 AvailableSpace::Definite(available_width),
410 AvailableSpace::Definite(item_height),
411 );
412 item.layout_as_root(available_space, window, cx);
413 item.prepaint_at(item_origin, window, cx);
414 frame_state.items.push(item);
415 }
416
417 let bounds = Bounds::new(
418 padded_bounds.origin
419 + point(
420 if can_scroll_horizontally {
421 scroll_offset.x + padding.left
422 } else {
423 scroll_offset.x
424 },
425 scroll_offset.y + padding.top,
426 ),
427 padded_bounds.size,
428 );
429 for decoration in &self.decorations {
430 let mut decoration = decoration.as_ref().compute(
431 visible_range.clone(),
432 bounds,
433 scroll_offset,
434 item_height,
435 self.item_count,
436 window,
437 cx,
438 );
439 let available_space = size(
440 AvailableSpace::Definite(bounds.size.width),
441 AvailableSpace::Definite(bounds.size.height),
442 );
443 decoration.layout_as_root(available_space, window, cx);
444 decoration.prepaint_at(bounds.origin, window, cx);
445 frame_state.decorations.push(decoration);
446 }
447 });
448 }
449
450 hitbox
451 },
452 )
453 }
454
455 fn paint(
456 &mut self,
457 global_id: Option<&GlobalElementId>,
458 inspector_id: Option<&InspectorElementId>,
459 bounds: Bounds<crate::Pixels>,
460 request_layout: &mut Self::RequestLayoutState,
461 hitbox: &mut Option<Hitbox>,
462 window: &mut Window,
463 cx: &mut App,
464 ) {
465 self.interactivity.paint(
466 global_id,
467 inspector_id,
468 bounds,
469 hitbox.as_ref(),
470 window,
471 cx,
472 |_, window, cx| {
473 for item in &mut request_layout.items {
474 item.paint(window, cx);
475 }
476 for decoration in &mut request_layout.decorations {
477 decoration.paint(window, cx);
478 }
479 },
480 )
481 }
482}
483
484impl IntoElement for UniformList {
485 type Element = Self;
486
487 fn into_element(self) -> Self::Element {
488 self
489 }
490}
491
492/// A decoration for a [`UniformList`]. This can be used for various things,
493/// such as rendering indent guides, or other visual effects.
494pub trait UniformListDecoration {
495 /// Compute the decoration element, given the visible range of list items,
496 /// the bounds of the list, and the height of each item.
497 fn compute(
498 &self,
499 visible_range: Range<usize>,
500 bounds: Bounds<Pixels>,
501 scroll_offset: Point<Pixels>,
502 item_height: Pixels,
503 item_count: usize,
504 window: &mut Window,
505 cx: &mut App,
506 ) -> AnyElement;
507}
508
509impl UniformList {
510 /// Selects a specific list item for measurement.
511 pub fn with_width_from_item(mut self, item_index: Option<usize>) -> Self {
512 self.item_to_measure_index = item_index.unwrap_or(0);
513 self
514 }
515
516 /// Sets the sizing behavior, similar to the `List` element.
517 pub fn with_sizing_behavior(mut self, behavior: ListSizingBehavior) -> Self {
518 self.sizing_behavior = behavior;
519 self
520 }
521
522 /// Sets the horizontal sizing behavior, controlling the way list items laid out horizontally.
523 /// With [`ListHorizontalSizingBehavior::Unconstrained`] behavior, every item and the list itself will
524 /// have the size of the widest item and lay out pushing the `end_slot` to the right end.
525 pub fn with_horizontal_sizing_behavior(
526 mut self,
527 behavior: ListHorizontalSizingBehavior,
528 ) -> Self {
529 self.horizontal_sizing_behavior = behavior;
530 match behavior {
531 ListHorizontalSizingBehavior::FitList => {
532 self.interactivity.base_style.overflow.x = None;
533 }
534 ListHorizontalSizingBehavior::Unconstrained => {
535 self.interactivity.base_style.overflow.x = Some(Overflow::Scroll);
536 }
537 }
538 self
539 }
540
541 /// Adds a decoration element to the list.
542 pub fn with_decoration(mut self, decoration: impl UniformListDecoration + 'static) -> Self {
543 self.decorations.push(Box::new(decoration));
544 self
545 }
546
547 fn measure_item(
548 &self,
549 list_width: Option<Pixels>,
550 window: &mut Window,
551 cx: &mut App,
552 ) -> Size<Pixels> {
553 if self.item_count == 0 {
554 return Size::default();
555 }
556
557 let item_ix = cmp::min(self.item_to_measure_index, self.item_count - 1);
558 let mut items = (self.render_items)(item_ix..item_ix + 1, window, cx);
559 let Some(mut item_to_measure) = items.pop() else {
560 return Size::default();
561 };
562 let available_space = size(
563 list_width.map_or(AvailableSpace::MinContent, |width| {
564 AvailableSpace::Definite(width)
565 }),
566 AvailableSpace::MinContent,
567 );
568 item_to_measure.layout_as_root(available_space, window, cx)
569 }
570
571 /// Track and render scroll state of this list with reference to the given scroll handle.
572 pub fn track_scroll(mut self, handle: UniformListScrollHandle) -> Self {
573 self.interactivity.tracked_scroll_handle = Some(handle.0.borrow().base_handle.clone());
574 self.scroll_handle = Some(handle);
575 self
576 }
577
578 /// Sets whether the list is flipped vertically, such that item 0 appears at the bottom.
579 pub fn y_flipped(mut self, y_flipped: bool) -> Self {
580 if let Some(ref scroll_handle) = self.scroll_handle {
581 let mut scroll_state = scroll_handle.0.borrow_mut();
582 let mut base_handle = &scroll_state.base_handle;
583 let offset = base_handle.offset();
584 match scroll_state.last_item_size {
585 Some(last_size) if scroll_state.y_flipped != y_flipped => {
586 let new_y_offset =
587 -(offset.y + last_size.contents.height - last_size.item.height);
588 base_handle.set_offset(point(offset.x, new_y_offset));
589 scroll_state.y_flipped = y_flipped;
590 }
591 // Handle case where list is initially flipped.
592 None if y_flipped => {
593 base_handle.set_offset(point(offset.x, Pixels::MIN));
594 scroll_state.y_flipped = y_flipped;
595 }
596 _ => {}
597 }
598 }
599 self
600 }
601}
602
603impl InteractiveElement for UniformList {
604 fn interactivity(&mut self) -> &mut crate::Interactivity {
605 &mut self.interactivity
606 }
607}