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