uniform_list.rs

  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        interactivity: Interactivity {
 52            element_id: Some(id),
 53            base_style: Box::new(base_style),
 54
 55            #[cfg(debug_assertions)]
 56            location: Some(*core::panic::Location::caller()),
 57
 58            ..Default::default()
 59        },
 60        scroll_handle: None,
 61        sizing_behavior: ListSizingBehavior::default(),
 62        horizontal_sizing_behavior: ListHorizontalSizingBehavior::default(),
 63    }
 64}
 65
 66/// A list element for efficiently laying out and displaying a list of uniform-height elements.
 67pub struct UniformList {
 68    item_count: usize,
 69    item_to_measure_index: usize,
 70    render_items:
 71        Box<dyn for<'a> Fn(Range<usize>, &'a mut WindowContext) -> SmallVec<[AnyElement; 64]>>,
 72    interactivity: Interactivity,
 73    scroll_handle: Option<UniformListScrollHandle>,
 74    sizing_behavior: ListSizingBehavior,
 75    horizontal_sizing_behavior: ListHorizontalSizingBehavior,
 76}
 77
 78/// Frame state used by the [UniformList].
 79pub struct UniformListFrameState {
 80    items: SmallVec<[AnyElement; 32]>,
 81}
 82
 83/// A handle for controlling the scroll position of a uniform list.
 84/// This should be stored in your view and passed to the uniform_list on each frame.
 85#[derive(Clone, Debug, Default)]
 86pub struct UniformListScrollHandle(pub Rc<RefCell<UniformListScrollState>>);
 87
 88#[derive(Clone, Debug, Default)]
 89#[allow(missing_docs)]
 90pub struct UniformListScrollState {
 91    pub base_handle: ScrollHandle,
 92    pub deferred_scroll_to_item: Option<usize>,
 93    /// Size of the item, captured during last layout.
 94    pub last_item_size: Option<ItemSize>,
 95}
 96
 97#[derive(Copy, Clone, Debug, Default)]
 98/// The size of the item and its contents.
 99pub struct ItemSize {
100    /// The size of the item.
101    pub item: Size<Pixels>,
102    /// The size of the item's contents, which may be larger than the item itself,
103    /// if the item was bounded by a parent element.
104    pub contents: Size<Pixels>,
105}
106
107impl UniformListScrollHandle {
108    /// Create a new scroll handle to bind to a uniform list.
109    pub fn new() -> Self {
110        Self(Rc::new(RefCell::new(UniformListScrollState {
111            base_handle: ScrollHandle::new(),
112            deferred_scroll_to_item: None,
113            last_item_size: None,
114        })))
115    }
116
117    /// Scroll the list to the given item index.
118    pub fn scroll_to_item(&mut self, ix: usize) {
119        self.0.borrow_mut().deferred_scroll_to_item = Some(ix);
120    }
121
122    /// Get the index of the topmost visible child.
123    pub fn logical_scroll_top_index(&self) -> usize {
124        let this = self.0.borrow();
125        this.deferred_scroll_to_item
126            .unwrap_or_else(|| this.base_handle.logical_scroll_top().0)
127    }
128}
129
130impl Styled for UniformList {
131    fn style(&mut self) -> &mut StyleRefinement {
132        &mut self.interactivity.base_style
133    }
134}
135
136impl Element for UniformList {
137    type RequestLayoutState = UniformListFrameState;
138    type PrepaintState = Option<Hitbox>;
139
140    fn id(&self) -> Option<ElementId> {
141        self.interactivity.element_id.clone()
142    }
143
144    fn request_layout(
145        &mut self,
146        global_id: Option<&GlobalElementId>,
147        cx: &mut WindowContext,
148    ) -> (LayoutId, Self::RequestLayoutState) {
149        let max_items = self.item_count;
150        let item_size = self.measure_item(None, cx);
151        let layout_id = self
152            .interactivity
153            .request_layout(global_id, cx, |style, cx| match self.sizing_behavior {
154                ListSizingBehavior::Infer => {
155                    cx.with_text_style(style.text_style().cloned(), |cx| {
156                        cx.request_measured_layout(
157                            style,
158                            move |known_dimensions, available_space, _cx| {
159                                let desired_height = item_size.height * max_items;
160                                let width = known_dimensions.width.unwrap_or(match available_space
161                                    .width
162                                {
163                                    AvailableSpace::Definite(x) => x,
164                                    AvailableSpace::MinContent | AvailableSpace::MaxContent => {
165                                        item_size.width
166                                    }
167                                });
168                                let height = match available_space.height {
169                                    AvailableSpace::Definite(height) => desired_height.min(height),
170                                    AvailableSpace::MinContent | AvailableSpace::MaxContent => {
171                                        desired_height
172                                    }
173                                };
174                                size(width, height)
175                            },
176                        )
177                    })
178                }
179                ListSizingBehavior::Auto => cx.with_text_style(style.text_style().cloned(), |cx| {
180                    cx.request_layout(style, None)
181                }),
182            });
183
184        (
185            layout_id,
186            UniformListFrameState {
187                items: SmallVec::new(),
188            },
189        )
190    }
191
192    fn prepaint(
193        &mut self,
194        global_id: Option<&GlobalElementId>,
195        bounds: Bounds<Pixels>,
196        frame_state: &mut Self::RequestLayoutState,
197        cx: &mut WindowContext,
198    ) -> Option<Hitbox> {
199        let style = self.interactivity.compute_style(global_id, None, cx);
200        let border = style.border_widths.to_pixels(cx.rem_size());
201        let padding = style.padding.to_pixels(bounds.size.into(), cx.rem_size());
202
203        let padded_bounds = Bounds::from_corners(
204            bounds.origin + point(border.left + padding.left, border.top + padding.top),
205            bounds.lower_right()
206                - point(border.right + padding.right, border.bottom + padding.bottom),
207        );
208
209        let can_scroll_horizontally = matches!(
210            self.horizontal_sizing_behavior,
211            ListHorizontalSizingBehavior::Unconstrained
212        );
213
214        let longest_item_size = self.measure_item(None, cx);
215        let content_width = if can_scroll_horizontally {
216            padded_bounds.size.width.max(longest_item_size.width)
217        } else {
218            padded_bounds.size.width
219        };
220        let content_size = Size {
221            width: content_width,
222            height: longest_item_size.height * self.item_count + padding.top + padding.bottom,
223        };
224
225        let shared_scroll_offset = self.interactivity.scroll_offset.clone().unwrap();
226        let item_height = longest_item_size.height;
227        let shared_scroll_to_item = self.scroll_handle.as_mut().and_then(|handle| {
228            let mut handle = handle.0.borrow_mut();
229            handle.last_item_size = Some(ItemSize {
230                item: padded_bounds.size,
231                contents: content_size,
232            });
233            handle.deferred_scroll_to_item.take()
234        });
235
236        self.interactivity.prepaint(
237            global_id,
238            bounds,
239            content_size,
240            cx,
241            |style, mut scroll_offset, hitbox, cx| {
242                let border = style.border_widths.to_pixels(cx.rem_size());
243                let padding = style.padding.to_pixels(bounds.size.into(), cx.rem_size());
244
245                let padded_bounds = Bounds::from_corners(
246                    bounds.origin + point(border.left + padding.left, border.top),
247                    bounds.lower_right() - point(border.right + padding.right, border.bottom),
248                );
249
250                if let Some(handle) = self.scroll_handle.as_mut() {
251                    handle.0.borrow_mut().base_handle.set_bounds(bounds);
252                }
253
254                if self.item_count > 0 {
255                    let content_height =
256                        item_height * self.item_count + padding.top + padding.bottom;
257                    let is_scrolled_vertically = !scroll_offset.y.is_zero();
258                    let min_vertical_scroll_offset = padded_bounds.size.height - content_height;
259                    if is_scrolled_vertically && scroll_offset.y < min_vertical_scroll_offset {
260                        shared_scroll_offset.borrow_mut().y = min_vertical_scroll_offset;
261                        scroll_offset.y = min_vertical_scroll_offset;
262                    }
263
264                    let content_width = content_size.width + padding.left + padding.right;
265                    let is_scrolled_horizontally =
266                        can_scroll_horizontally && !scroll_offset.x.is_zero();
267                    if is_scrolled_horizontally && content_width <= padded_bounds.size.width {
268                        shared_scroll_offset.borrow_mut().x = Pixels::ZERO;
269                        scroll_offset.x = Pixels::ZERO;
270                    }
271
272                    if let Some(ix) = shared_scroll_to_item {
273                        let list_height = padded_bounds.size.height;
274                        let mut updated_scroll_offset = shared_scroll_offset.borrow_mut();
275                        let item_top = item_height * ix + padding.top;
276                        let item_bottom = item_top + item_height;
277                        let scroll_top = -updated_scroll_offset.y;
278                        if item_top < scroll_top + padding.top {
279                            updated_scroll_offset.y = -(item_top) + padding.top;
280                        } else if item_bottom > scroll_top + list_height - padding.bottom {
281                            updated_scroll_offset.y = -(item_bottom - list_height) - padding.bottom;
282                        }
283                        scroll_offset = *updated_scroll_offset;
284                    }
285
286                    let first_visible_element_ix =
287                        (-(scroll_offset.y + padding.top) / item_height).floor() as usize;
288                    let last_visible_element_ix = ((-scroll_offset.y + padded_bounds.size.height)
289                        / item_height)
290                        .ceil() as usize;
291                    let visible_range = first_visible_element_ix
292                        ..cmp::min(last_visible_element_ix, self.item_count);
293
294                    let mut items = (self.render_items)(visible_range.clone(), cx);
295                    let content_mask = ContentMask { bounds };
296                    cx.with_content_mask(Some(content_mask), |cx| {
297                        for (mut item, ix) in items.into_iter().zip(visible_range) {
298                            let item_origin = padded_bounds.origin
299                                + point(
300                                    if can_scroll_horizontally {
301                                        scroll_offset.x + padding.left
302                                    } else {
303                                        scroll_offset.x
304                                    },
305                                    item_height * ix + scroll_offset.y + padding.top,
306                                );
307                            let available_width = if can_scroll_horizontally {
308                                padded_bounds.size.width + scroll_offset.x.abs()
309                            } else {
310                                padded_bounds.size.width
311                            };
312                            let available_space = size(
313                                AvailableSpace::Definite(available_width),
314                                AvailableSpace::Definite(item_height),
315                            );
316                            item.layout_as_root(available_space, cx);
317                            item.prepaint_at(item_origin, cx);
318                            frame_state.items.push(item);
319                        }
320                    });
321                }
322
323                hitbox
324            },
325        )
326    }
327
328    fn paint(
329        &mut self,
330        global_id: Option<&GlobalElementId>,
331        bounds: Bounds<crate::Pixels>,
332        request_layout: &mut Self::RequestLayoutState,
333        hitbox: &mut Option<Hitbox>,
334        cx: &mut WindowContext,
335    ) {
336        self.interactivity
337            .paint(global_id, bounds, hitbox.as_ref(), cx, |_, cx| {
338                for item in &mut request_layout.items {
339                    item.paint(cx);
340                }
341            })
342    }
343}
344
345impl IntoElement for UniformList {
346    type Element = Self;
347
348    fn into_element(self) -> Self::Element {
349        self
350    }
351}
352
353impl UniformList {
354    /// Selects a specific list item for measurement.
355    pub fn with_width_from_item(mut self, item_index: Option<usize>) -> Self {
356        self.item_to_measure_index = item_index.unwrap_or(0);
357        self
358    }
359
360    /// Sets the sizing behavior, similar to the `List` element.
361    pub fn with_sizing_behavior(mut self, behavior: ListSizingBehavior) -> Self {
362        self.sizing_behavior = behavior;
363        self
364    }
365
366    /// Sets the horizontal sizing behavior, controlling the way list items laid out horizontally.
367    /// With [`ListHorizontalSizingBehavior::Unconstrained`] behavior, every item and the list itself will
368    /// have the size of the widest item and lay out pushing the `end_slot` to the right end.
369    pub fn with_horizontal_sizing_behavior(
370        mut self,
371        behavior: ListHorizontalSizingBehavior,
372    ) -> Self {
373        self.horizontal_sizing_behavior = behavior;
374        match behavior {
375            ListHorizontalSizingBehavior::FitList => {
376                self.interactivity.base_style.overflow.x = None;
377            }
378            ListHorizontalSizingBehavior::Unconstrained => {
379                self.interactivity.base_style.overflow.x = Some(Overflow::Scroll);
380            }
381        }
382        self
383    }
384
385    fn measure_item(&self, list_width: Option<Pixels>, cx: &mut WindowContext) -> Size<Pixels> {
386        if self.item_count == 0 {
387            return Size::default();
388        }
389
390        let item_ix = cmp::min(self.item_to_measure_index, self.item_count - 1);
391        let mut items = (self.render_items)(item_ix..item_ix + 1, cx);
392        let Some(mut item_to_measure) = items.pop() else {
393            return Size::default();
394        };
395        let available_space = size(
396            list_width.map_or(AvailableSpace::MinContent, |width| {
397                AvailableSpace::Definite(width)
398            }),
399            AvailableSpace::MinContent,
400        );
401        item_to_measure.layout_as_root(available_space, cx)
402    }
403
404    /// Track and render scroll state of this list with reference to the given scroll handle.
405    pub fn track_scroll(mut self, handle: UniformListScrollHandle) -> Self {
406        self.interactivity.tracked_scroll_handle = Some(handle.0.borrow().base_handle.clone());
407        self.scroll_handle = Some(handle);
408        self
409    }
410}
411
412impl InteractiveElement for UniformList {
413    fn interactivity(&mut self) -> &mut crate::Interactivity {
414        &mut self.interactivity
415    }
416}