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, ScrollHandle, Size, StyleRefinement, Styled, Window,
11 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}
92
93#[derive(Clone, Debug, Default)]
94#[allow(missing_docs)]
95pub struct UniformListScrollState {
96 pub base_handle: ScrollHandle,
97 pub deferred_scroll_to_item: Option<(usize, ScrollStrategy)>,
98 /// Size of the item, captured during last layout.
99 pub last_item_size: Option<ItemSize>,
100 /// Whether the list was vertically flipped during last layout.
101 pub y_flipped: bool,
102}
103
104#[derive(Copy, Clone, Debug, Default)]
105/// The size of the item and its contents.
106pub struct ItemSize {
107 /// The size of the item.
108 pub item: Size<Pixels>,
109 /// The size of the item's contents, which may be larger than the item itself,
110 /// if the item was bounded by a parent element.
111 pub contents: Size<Pixels>,
112}
113
114impl UniformListScrollHandle {
115 /// Create a new scroll handle to bind to a uniform list.
116 pub fn new() -> Self {
117 Self(Rc::new(RefCell::new(UniformListScrollState {
118 base_handle: ScrollHandle::new(),
119 deferred_scroll_to_item: None,
120 last_item_size: None,
121 y_flipped: false,
122 })))
123 }
124
125 /// Scroll the list to the given item index.
126 pub fn scroll_to_item(&self, ix: usize, strategy: ScrollStrategy) {
127 self.0.borrow_mut().deferred_scroll_to_item = Some((ix, strategy));
128 }
129
130 /// Check if the list is flipped vertically.
131 pub fn y_flipped(&self) -> bool {
132 self.0.borrow().y_flipped
133 }
134
135 /// Get the index of the topmost visible child.
136 #[cfg(any(test, feature = "test-support"))]
137 pub fn logical_scroll_top_index(&self) -> usize {
138 let this = self.0.borrow();
139 this.deferred_scroll_to_item
140 .map(|(ix, _)| ix)
141 .unwrap_or_else(|| this.base_handle.logical_scroll_top().0)
142 }
143}
144
145impl Styled for UniformList {
146 fn style(&mut self) -> &mut StyleRefinement {
147 &mut self.interactivity.base_style
148 }
149}
150
151impl Element for UniformList {
152 type RequestLayoutState = UniformListFrameState;
153 type PrepaintState = Option<Hitbox>;
154
155 fn id(&self) -> Option<ElementId> {
156 self.interactivity.element_id.clone()
157 }
158
159 fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
160 None
161 }
162
163 fn request_layout(
164 &mut self,
165 global_id: Option<&GlobalElementId>,
166 inspector_id: Option<&InspectorElementId>,
167 window: &mut Window,
168 cx: &mut App,
169 ) -> (LayoutId, Self::RequestLayoutState) {
170 let max_items = self.item_count;
171 let item_size = self.measure_item(None, window, cx);
172 let layout_id = self.interactivity.request_layout(
173 global_id,
174 inspector_id,
175 window,
176 cx,
177 |style, window, cx| match self.sizing_behavior {
178 ListSizingBehavior::Infer => {
179 window.with_text_style(style.text_style().cloned(), |window| {
180 window.request_measured_layout(
181 style,
182 move |known_dimensions, available_space, _window, _cx| {
183 let desired_height = item_size.height * max_items;
184 let width = known_dimensions.width.unwrap_or(match available_space
185 .width
186 {
187 AvailableSpace::Definite(x) => x,
188 AvailableSpace::MinContent | AvailableSpace::MaxContent => {
189 item_size.width
190 }
191 });
192 let height = match available_space.height {
193 AvailableSpace::Definite(height) => desired_height.min(height),
194 AvailableSpace::MinContent | AvailableSpace::MaxContent => {
195 desired_height
196 }
197 };
198 size(width, height)
199 },
200 )
201 })
202 }
203 ListSizingBehavior::Auto => window
204 .with_text_style(style.text_style().cloned(), |window| {
205 window.request_layout(style, None, cx)
206 }),
207 },
208 );
209
210 (
211 layout_id,
212 UniformListFrameState {
213 items: SmallVec::new(),
214 decorations: SmallVec::new(),
215 },
216 )
217 }
218
219 fn prepaint(
220 &mut self,
221 global_id: Option<&GlobalElementId>,
222 inspector_id: Option<&InspectorElementId>,
223 bounds: Bounds<Pixels>,
224 frame_state: &mut Self::RequestLayoutState,
225 window: &mut Window,
226 cx: &mut App,
227 ) -> Option<Hitbox> {
228 let style = self
229 .interactivity
230 .compute_style(global_id, None, window, cx);
231 let border = style.border_widths.to_pixels(window.rem_size());
232 let padding = style
233 .padding
234 .to_pixels(bounds.size.into(), window.rem_size());
235
236 let padded_bounds = Bounds::from_corners(
237 bounds.origin + point(border.left + padding.left, border.top + padding.top),
238 bounds.bottom_right()
239 - point(border.right + padding.right, border.bottom + padding.bottom),
240 );
241
242 let can_scroll_horizontally = matches!(
243 self.horizontal_sizing_behavior,
244 ListHorizontalSizingBehavior::Unconstrained
245 );
246
247 let longest_item_size = self.measure_item(None, window, cx);
248 let content_width = if can_scroll_horizontally {
249 padded_bounds.size.width.max(longest_item_size.width)
250 } else {
251 padded_bounds.size.width
252 };
253 let content_size = Size {
254 width: content_width,
255 height: longest_item_size.height * self.item_count + padding.top + padding.bottom,
256 };
257
258 let shared_scroll_offset = self.interactivity.scroll_offset.clone().unwrap();
259 let item_height = longest_item_size.height;
260 let shared_scroll_to_item = self.scroll_handle.as_mut().and_then(|handle| {
261 let mut handle = handle.0.borrow_mut();
262 handle.last_item_size = Some(ItemSize {
263 item: padded_bounds.size,
264 contents: content_size,
265 });
266 handle.deferred_scroll_to_item.take()
267 });
268
269 self.interactivity.prepaint(
270 global_id,
271 inspector_id,
272 bounds,
273 content_size,
274 window,
275 cx,
276 |style, mut scroll_offset, hitbox, window, cx| {
277 let border = style.border_widths.to_pixels(window.rem_size());
278 let padding = style
279 .padding
280 .to_pixels(bounds.size.into(), window.rem_size());
281
282 let padded_bounds = Bounds::from_corners(
283 bounds.origin + point(border.left + padding.left, border.top),
284 bounds.bottom_right() - point(border.right + padding.right, border.bottom),
285 );
286
287 let y_flipped = if let Some(scroll_handle) = self.scroll_handle.as_mut() {
288 let mut scroll_state = scroll_handle.0.borrow_mut();
289 scroll_state.base_handle.set_bounds(bounds);
290 scroll_state.y_flipped
291 } else {
292 false
293 };
294
295 if self.item_count > 0 {
296 let content_height =
297 item_height * self.item_count + padding.top + padding.bottom;
298 let is_scrolled_vertically = !scroll_offset.y.is_zero();
299 let min_vertical_scroll_offset = padded_bounds.size.height - content_height;
300 if is_scrolled_vertically && scroll_offset.y < min_vertical_scroll_offset {
301 shared_scroll_offset.borrow_mut().y = min_vertical_scroll_offset;
302 scroll_offset.y = min_vertical_scroll_offset;
303 }
304
305 let content_width = content_size.width + padding.left + padding.right;
306 let is_scrolled_horizontally =
307 can_scroll_horizontally && !scroll_offset.x.is_zero();
308 if is_scrolled_horizontally && content_width <= padded_bounds.size.width {
309 shared_scroll_offset.borrow_mut().x = Pixels::ZERO;
310 scroll_offset.x = Pixels::ZERO;
311 }
312
313 if let Some((mut ix, scroll_strategy)) = shared_scroll_to_item {
314 if y_flipped {
315 ix = self.item_count.saturating_sub(ix + 1);
316 }
317 let list_height = padded_bounds.size.height;
318 let mut updated_scroll_offset = shared_scroll_offset.borrow_mut();
319 let item_top = item_height * ix + padding.top;
320 let item_bottom = item_top + item_height;
321 let scroll_top = -updated_scroll_offset.y;
322 let mut scrolled_to_top = false;
323 if item_top < scroll_top + padding.top {
324 scrolled_to_top = true;
325 updated_scroll_offset.y = -(item_top) + padding.top;
326 } else if item_bottom > scroll_top + list_height - padding.bottom {
327 scrolled_to_top = true;
328 updated_scroll_offset.y = -(item_bottom - list_height) - padding.bottom;
329 }
330
331 match scroll_strategy {
332 ScrollStrategy::Top => {}
333 ScrollStrategy::Center => {
334 if scrolled_to_top {
335 let item_center = item_top + item_height / 2.0;
336 let target_scroll_top = item_center - list_height / 2.0;
337
338 if item_top < scroll_top
339 || item_bottom > scroll_top + list_height
340 {
341 updated_scroll_offset.y = -target_scroll_top
342 .max(Pixels::ZERO)
343 .min(content_height - list_height)
344 .max(Pixels::ZERO);
345 }
346 }
347 }
348 }
349 scroll_offset = *updated_scroll_offset
350 }
351
352 let first_visible_element_ix =
353 (-(scroll_offset.y + padding.top) / item_height).floor() as usize;
354 let last_visible_element_ix = ((-scroll_offset.y + padded_bounds.size.height)
355 / item_height)
356 .ceil() as usize;
357 let visible_range = first_visible_element_ix
358 ..cmp::min(last_visible_element_ix, self.item_count);
359
360 let items = if y_flipped {
361 let flipped_range = self.item_count.saturating_sub(visible_range.end)
362 ..self.item_count.saturating_sub(visible_range.start);
363 let mut items = (self.render_items)(flipped_range, window, cx);
364 items.reverse();
365 items
366 } else {
367 (self.render_items)(visible_range.clone(), window, cx)
368 };
369
370 let content_mask = ContentMask { bounds };
371 window.with_content_mask(Some(content_mask), |window| {
372 for (mut item, ix) in items.into_iter().zip(visible_range.clone()) {
373 let item_origin = padded_bounds.origin
374 + point(
375 if can_scroll_horizontally {
376 scroll_offset.x + padding.left
377 } else {
378 scroll_offset.x
379 },
380 item_height * ix + scroll_offset.y + padding.top,
381 );
382 let available_width = if can_scroll_horizontally {
383 padded_bounds.size.width + scroll_offset.x.abs()
384 } else {
385 padded_bounds.size.width
386 };
387 let available_space = size(
388 AvailableSpace::Definite(available_width),
389 AvailableSpace::Definite(item_height),
390 );
391 item.layout_as_root(available_space, window, cx);
392 item.prepaint_at(item_origin, window, cx);
393 frame_state.items.push(item);
394 }
395
396 let bounds = Bounds::new(
397 padded_bounds.origin
398 + point(
399 if can_scroll_horizontally {
400 scroll_offset.x + padding.left
401 } else {
402 scroll_offset.x
403 },
404 scroll_offset.y + padding.top,
405 ),
406 padded_bounds.size,
407 );
408 for decoration in &self.decorations {
409 let mut decoration = decoration.as_ref().compute(
410 visible_range.clone(),
411 bounds,
412 item_height,
413 self.item_count,
414 window,
415 cx,
416 );
417 let available_space = size(
418 AvailableSpace::Definite(bounds.size.width),
419 AvailableSpace::Definite(bounds.size.height),
420 );
421 decoration.layout_as_root(available_space, window, cx);
422 decoration.prepaint_at(bounds.origin, window, cx);
423 frame_state.decorations.push(decoration);
424 }
425 });
426 }
427
428 hitbox
429 },
430 )
431 }
432
433 fn paint(
434 &mut self,
435 global_id: Option<&GlobalElementId>,
436 inspector_id: Option<&InspectorElementId>,
437 bounds: Bounds<crate::Pixels>,
438 request_layout: &mut Self::RequestLayoutState,
439 hitbox: &mut Option<Hitbox>,
440 window: &mut Window,
441 cx: &mut App,
442 ) {
443 self.interactivity.paint(
444 global_id,
445 inspector_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}