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