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