diff --git a/crates/freya-code-editor/src/editor_ui.rs b/crates/freya-code-editor/src/editor_ui.rs index 5b369466a..fb425eb2d 100644 --- a/crates/freya-code-editor/src/editor_ui.rs +++ b/crates/freya-code-editor/src/editor_ui.rs @@ -276,12 +276,12 @@ impl Component for CodeEditor { }) .on_global_pointer_press(on_global_pointer_press) .child( - VirtualScrollView::new(move |line_index, _| { + VirtualScrollView::new(move |item, _| { EditorLineUI { editor: editor.clone(), font_size, line_height, - line_index, + line_index: item.index, read_only, gutter, show_whitespace, diff --git a/crates/freya-components/src/scrollviews/virtual_scrollview.rs b/crates/freya-components/src/scrollviews/virtual_scrollview.rs index d7119e8e8..b840fae2b 100644 --- a/crates/freya-components/src/scrollviews/virtual_scrollview.rs +++ b/crates/freya-components/src/scrollviews/virtual_scrollview.rs @@ -30,6 +30,128 @@ use crate::scrollviews::{ use_scroll_controller, }; +/// Defines how each item of a [`VirtualScrollView`] is sized along the scroll axis. +/// +/// Build one from a fixed value or from a closure that resolves the size of each +/// item by its index: +/// +/// ```rust +/// # use freya::prelude::*; +/// let fixed: ItemSize = 25.0f32.into(); +/// let dynamic: ItemSize = +/// (|index: usize| if index.is_multiple_of(2) { 25.0 } else { 50.0 }).into(); +/// ``` +#[derive(Clone, PartialEq)] +pub enum ItemSize { + /// Every item shares the same size in pixels. + Fixed(f32), + /// Each item is sized individually through a callback that receives its index. + Dynamic(Callback), +} + +impl ItemSize { + /// Size in pixels of the item at `index`. + fn at(&self, index: usize) -> f32 { + match self { + Self::Fixed(size) => *size, + Self::Dynamic(callback) => callback.call(index), + } + } + + /// Range of items that fall inside the viewport for the given scroll position, + /// together with the offset that positions the first visible item correctly. + fn visible_range( + &self, + viewport_size: f32, + scroll_position: f32, + length: usize, + ) -> (Range, f32) { + let scroll_distance = (-scroll_position).max(0.0); + match self { + Self::Fixed(size) => { + if *size <= 0.0 { + return (0..0, 0.0); + } + let start = scroll_distance / size; + let potentially_visible = (viewport_size / size) + 1.0; + let start_index = (start as usize).min(length); + let end_index = ((start + potentially_visible) as usize).min(length); + ( + start_index..end_index, + start.floor() * size - scroll_distance, + ) + } + Self::Dynamic(callback) => { + let mut start = 0; + let mut cumulative = 0.0; + while start < length { + let item = callback.call(start); + if cumulative + item > scroll_distance { + break; + } + cumulative += item; + start += 1; + } + let offset = cumulative - scroll_distance; + let mut end = start; + while end < length && cumulative < scroll_distance + viewport_size { + cumulative += callback.call(end); + end += 1; + } + (start..end, offset) + } + } + } + + /// Total size of the content along the scroll axis. + /// + /// [`Self::Fixed`] is exact. [`Self::Dynamic`] extrapolates from the average size of + /// the items down to the viewport bottom, keeping the scrollbar stable as it scrolls. + fn total_size(&self, viewport_size: f32, scroll_position: f32, length: usize) -> f32 { + match self { + Self::Fixed(size) => size * length as f32, + Self::Dynamic(callback) => { + if length == 0 { + return 0.0; + } + let viewport_bottom = (-scroll_position).max(0.0) + viewport_size; + let mut measured = callback.call(0); + let mut count = 1; + while count < length && measured < viewport_bottom { + measured += callback.call(count); + count += 1; + } + (measured / count as f32) * length as f32 + } + } + } +} + +impl Default for ItemSize { + fn default() -> Self { + Self::Fixed(0.) + } +} + +impl From for ItemSize { + fn from(size: f32) -> Self { + Self::Fixed(size) + } +} + +impl f32 + 'static> From for ItemSize { + fn from(callback: F) -> Self { + Self::Dynamic(Callback::new(callback)) + } +} + +/// Data passed to a [`VirtualScrollView`] builder for each rendered item. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct VirtualItem { + pub index: usize, + pub size: f32, +} + /// One-direction scrollable area that dynamically builds and renders items based in their size and current available size, /// this is intended for apps using large sets of data that need good performance. /// @@ -39,12 +161,12 @@ use crate::scrollviews::{ /// # use freya::prelude::*; /// fn app() -> impl IntoElement { /// rect().child( -/// VirtualScrollView::new(|i, _| { +/// VirtualScrollView::new(|item, _| { /// rect() -/// .key(i) -/// .height(Size::px(25.)) +/// .key(item.index) +/// .height(Size::px(item.size)) /// .padding(4.) -/// .child(format!("Item {i}")) +/// .child(format!("Item {}", item.index)) /// .into() /// }) /// .length(300usize) @@ -67,10 +189,10 @@ use crate::scrollviews::{ doc = embed_doc_image::embed_image!("virtual_scrollview", "images/gallery_virtual_scrollview.png") )] #[derive(Clone)] -pub struct VirtualScrollView Element> { +pub struct VirtualScrollView Element> { builder: B, builder_data: D, - item_size: f32, + item_size: ItemSize, length: usize, layout: LayoutData, show_scrollbar: bool, @@ -81,21 +203,21 @@ pub struct VirtualScrollView Element> { key: DiffKey, } -impl Element> LayoutExt for VirtualScrollView { +impl Element> LayoutExt for VirtualScrollView { fn get_layout(&mut self) -> &mut LayoutData { &mut self.layout } } -impl Element> ContainerSizeExt for VirtualScrollView {} +impl Element> ContainerSizeExt for VirtualScrollView {} -impl Element> KeyExt for VirtualScrollView { +impl Element> KeyExt for VirtualScrollView { fn write_key(&mut self) -> &mut DiffKey { &mut self.key } } -impl Element> PartialEq for VirtualScrollView { +impl Element> PartialEq for VirtualScrollView { fn eq(&self, other: &Self) -> bool { self.builder_data == other.builder_data && self.item_size == other.item_size @@ -108,12 +230,13 @@ impl Element> PartialEq for VirtualScrollView< } } -impl Element> VirtualScrollView<(), B> { +impl Element> VirtualScrollView<(), B> { + /// Creates a [`VirtualScrollView`] that builds each item from its [`VirtualItem`]. pub fn new(builder: B) -> Self { Self { builder, builder_data: (), - item_size: 0., + item_size: ItemSize::default(), length: 0, layout: { let mut l = LayoutData::default(); @@ -130,11 +253,12 @@ impl Element> VirtualScrollView<(), B> { } } + /// Same as [`Self::new`] but driven by an external [`ScrollController`]. pub fn new_controlled(builder: B, scroll_controller: ScrollController) -> Self { Self { builder, builder_data: (), - item_size: 0., + item_size: ItemSize::default(), length: 0, layout: { let mut l = LayoutData::default(); @@ -152,12 +276,17 @@ impl Element> VirtualScrollView<(), B> { } } -impl Element> VirtualScrollView { +impl Element> VirtualScrollView { + /// Same as [`Self::new`] but passes `builder_data` to the builder for every item. + /// + /// `builder_data` is owned by the scroll view and handed to the builder by reference + /// instead of being captured in the closure. It is part of the view's equality check, + /// so changing it rebuilds the visible items. pub fn new_with_data(builder_data: D, builder: B) -> Self { Self { builder, builder_data, - item_size: 0., + item_size: ItemSize::default(), length: 0, layout: Node { width: Size::fill(), @@ -174,6 +303,7 @@ impl Element> VirtualScrollView { } } + /// Same as [`Self::new_with_data`] but driven by an external [`ScrollController`]. pub fn new_with_data_controlled( builder_data: D, builder: B, @@ -182,7 +312,7 @@ impl Element> VirtualScrollView { Self { builder, builder_data, - item_size: 0., + item_size: ItemSize::default(), length: 0, layout: Node { @@ -215,7 +345,7 @@ impl Element> VirtualScrollView { self } - pub fn item_size(mut self, item_size: impl Into) -> Self { + pub fn item_size(mut self, item_size: impl Into) -> Self { self.item_size = item_size.into(); self } @@ -254,7 +384,7 @@ impl Element> VirtualScrollView { } } -impl Element + 'static> Component +impl Element + 'static> Component for VirtualScrollView { fn render(self: &VirtualScrollView) -> impl IntoElement { @@ -273,13 +403,18 @@ impl Element + 'static> Component let direction = layout.direction; let drag_scrolling = self.drag_scrolling; + let viewport_width = size.read().area.width(); + let viewport_height = size.read().area.height(); + let (inner_width, inner_height) = match direction { Direction::Vertical => ( size.read().inner_sizes.width, - self.item_size * self.length as f32, + self.item_size + .total_size(viewport_height, scrolled_y as f32, self.length), ), Direction::Horizontal => ( - self.item_size * self.length as f32, + self.item_size + .total_size(viewport_width, scrolled_x as f32, self.length), size.read().inner_sizes.height, ), }; @@ -483,37 +618,28 @@ impl Element + 'static> Component }; let (viewport_size, scroll_position) = if direction == Direction::vertical() { - (size.read().area.height(), corrected_scrolled_y) + (viewport_height, corrected_scrolled_y) } else { - (size.read().area.width(), corrected_scrolled_x) + (viewport_width, corrected_scrolled_x) }; - let render_range = get_render_range( - viewport_size, - scroll_position, - self.item_size, - self.length as f32, - ); + let (render_range, item_offset) = + self.item_size + .visible_range(viewport_size, scroll_position, self.length); let children = render_range - .map(|i| (self.builder)(i, &self.builder_data)) + .map(|i| { + let item = VirtualItem { + index: i, + size: self.item_size.at(i), + }; + (self.builder)(item, &self.builder_data) + }) .collect::>(); let (offset_x, offset_y) = match direction { - Direction::Vertical => { - let offset_y_min = - (-corrected_scrolled_y / self.item_size).floor() * self.item_size; - let offset_y = -(-corrected_scrolled_y - offset_y_min); - - (corrected_scrolled_x, offset_y) - } - Direction::Horizontal => { - let offset_x_min = - (-corrected_scrolled_x / self.item_size).floor() * self.item_size; - let offset_x = -(-corrected_scrolled_x - offset_x_min); - - (offset_x, corrected_scrolled_y) - } + Direction::Vertical => (corrected_scrolled_x, item_offset), + Direction::Horizontal => (item_offset, corrected_scrolled_y), }; let on_pointer_down = move |e: Event| { @@ -596,22 +722,3 @@ impl Element + 'static> Component self.key.clone().or(self.default_key()) } } - -fn get_render_range( - viewport_size: f32, - scroll_position: f32, - item_size: f32, - item_length: f32, -) -> Range { - let render_index_start = (-scroll_position) / item_size; - let potentially_visible_length = (viewport_size / item_size) + 1.0; - let remaining_length = item_length - render_index_start; - - let render_index_end = if remaining_length <= potentially_visible_length { - item_length - } else { - render_index_start + potentially_visible_length - }; - - render_index_start as usize..(render_index_end as usize) -} diff --git a/crates/freya-components/tests/virtual_scrollview.rs b/crates/freya-components/tests/virtual_scrollview.rs index 5a357d639..9748e761f 100644 --- a/crates/freya-components/tests/virtual_scrollview.rs +++ b/crates/freya-components/tests/virtual_scrollview.rs @@ -5,11 +5,11 @@ use freya_testing::prelude::*; #[test] pub fn virtual_scroll_view_wheel() { fn virtual_scroll_view_wheel_app() -> impl IntoElement { - VirtualScrollView::new(|i, _| { + VirtualScrollView::new(|item, _| { label() - .key(i) + .key(item.index) .height(Size::px(50.)) - .text(format!("{i} Hello, World!")) + .text(format!("{} Hello, World!", item.index)) .into() }) .length(30usize) @@ -57,11 +57,11 @@ pub fn virtual_scroll_view_wheel() { #[test] pub fn virtual_scroll_view_scrollbar() { fn virtual_scroll_view_scrollbar_app() -> impl IntoElement { - VirtualScrollView::new(|i, _| { + VirtualScrollView::new(|item, _| { label() - .key(i) + .key(item.index) .height(Size::px(50.)) - .text(format!("{i} Hello, World!")) + .text(format!("{} Hello, World!", item.index)) .into() }) .length(30usize) @@ -153,11 +153,11 @@ pub fn virtual_scroll_view_controlled() { .content(Content::Flex) .child( VirtualScrollView::new_controlled( - |i, _| { + |item, _| { label() - .key(i) + .key(item.index) .height(Size::px(50.)) - .text(format!("{i} Hello, World!")) + .text(format!("{} Hello, World!", item.index)) .into() }, scroll_controller, @@ -168,11 +168,11 @@ pub fn virtual_scroll_view_controlled() { ) .child( VirtualScrollView::new_controlled( - |i, _| { + |item, _| { label() - .key(i) + .key(item.index) .height(Size::px(50.)) - .text(format!("{i} Second View")) + .text(format!("{} Second View", item.index)) .into() }, scroll_controller, @@ -242,14 +242,94 @@ pub fn virtual_scroll_view_controlled() { ); } +#[test] +pub fn virtual_scroll_view_closure_item_size() { + fn virtual_scroll_view_closure_app() -> impl IntoElement { + VirtualScrollView::new(|item, _| { + label() + .key(item.index) + .height(Size::px(item.size)) + .text(format!("{}:{}", item.index, item.size)) + .into() + }) + .length(30usize) + .item_size(|index: usize| if index.is_multiple_of(2) { 100. } else { 50. }) + } + + let mut test = launch_test(virtual_scroll_view_closure_app); + test.sync_and_update(); + let scrollview = test + .find(|node, element| { + Rect::try_downcast(element) + .filter(|rect| rect.accessibility.builder.role() == AccessibilityRole::ScrollView) + .map(move |_| node) + }) + .unwrap(); + let content = scrollview.children()[0].children()[0].children(); + + // Heights accumulate as 100, 150, 250, 300, 400, 450, 550, so the 7th item + // is the one that crosses the 500px viewport. The closure size also reaches the builder. + assert_eq!(content.len(), 7); + + let expected = ["0:100", "1:50", "2:100", "3:50", "4:100", "5:50", "6:100"]; + for (child, text) in content.iter().zip(expected) { + assert_eq!(Label::try_downcast(&*child.element()).unwrap().text, text); + } + + // Scrolling 300 pixels lands on index 4, since 100 + 50 + 100 + 50 = 300. + test.scroll((5., 5.), (0., -300.)); + + let content = scrollview.children()[0].children()[0].children(); + assert_eq!(content.len(), 7); + assert_eq!( + Label::try_downcast(&*content[0].element()).unwrap().text, + "4:100" + ); +} + +#[test] +pub fn virtual_scroll_view_closure_item_size_horizontal() { + fn virtual_scroll_view_closure_horizontal_app() -> impl IntoElement { + VirtualScrollView::new(|item, _| { + label() + .key(item.index) + .width(Size::px(item.size)) + .text(format!("{}:{}", item.index, item.size)) + .into() + }) + .length(30usize) + .item_size(|index: usize| if index.is_multiple_of(2) { 120. } else { 60. }) + .direction(Direction::Horizontal) + } + + let mut test = launch_test(virtual_scroll_view_closure_horizontal_app); + test.sync_and_update(); + let scrollview = test + .find(|node, element| { + Rect::try_downcast(element) + .filter(|rect| rect.accessibility.builder.role() == AccessibilityRole::ScrollView) + .map(move |_| node) + }) + .unwrap(); + let content = scrollview.children()[0].children()[0].children(); + + // Widths accumulate as 120, 180, 300, 360, 480, 540, so 6 items cover the 500px viewport. + assert_eq!(content.len(), 6); + + let expected = ["0:120", "1:60", "2:120", "3:60", "4:120", "5:60"]; + for (child, text) in content.iter().zip(expected) { + assert_eq!(Label::try_downcast(&*child.element()).unwrap().text, text); + } +} + #[test] pub fn virtual_scroll_view_keyboard_navigation() { fn virtual_scroll_view_keyboard_app() -> impl IntoElement { - VirtualScrollView::new(|i, _| { + VirtualScrollView::new(|item, _| { label() - .key(i) + .key(item.index) .height(Size::px(50.)) - .text(format!("{i} Hello, World!")) + .text(format!("{} Hello, World!", item.index)) .into() }) .length(30usize) @@ -348,11 +428,11 @@ pub fn virtual_scroll_view_keyboard_navigation() { #[test] pub fn virtual_scroll_view_keyboard_navigation_horizontal() { fn virtual_scroll_view_horizontal_app() -> impl IntoElement { - VirtualScrollView::new(|i, _| { + VirtualScrollView::new(|item, _| { label() - .key(i) + .key(item.index) .width(Size::px(50.)) - .text(format!("{i}")) + .text(format!("{}", item.index)) .into() }) .length(30usize) diff --git a/crates/freya-devtools-app/src/tabs/tree.rs b/crates/freya-devtools-app/src/tabs/tree.rs index 7d61ff427..f09ab7170 100644 --- a/crates/freya-devtools-app/src/tabs/tree.rs +++ b/crates/freya-devtools-app/src/tabs/tree.rs @@ -132,12 +132,12 @@ impl Component for NodesTree { self.on_selected.clone(), self.on_hover.clone(), ), - move |i, (selected_node_id, selected_window_id, on_selected, on_hover)| { + move |item, (selected_node_id, selected_window_id, on_selected, on_hover)| { let NodeTreeItem { window_id, node_id, is_open, - } = items[i]; + } = items[item.index]; let on_selected = on_selected.clone(); let on_hover = on_hover.clone(); NodeElement { diff --git a/examples/android/src/app/routes/scroll.rs b/examples/android/src/app/routes/scroll.rs index a3cb107c1..41560a0af 100644 --- a/examples/android/src/app/routes/scroll.rs +++ b/examples/android/src/app/routes/scroll.rs @@ -8,10 +8,10 @@ pub struct ScrollViewDemo; impl Component for ScrollViewDemo { fn render(&self) -> impl IntoElement { - VirtualScrollView::new(|i, _| { + VirtualScrollView::new(|item, _| { AnimatedContainer { height: 70., - i, + i: item.index, children: rect() .width(Size::fill()) .height(Size::fill()) @@ -19,7 +19,7 @@ impl Component for ScrollViewDemo { .corner_radius(8.) .color((255, 255, 255)) .background((0, 119, 182)) - .child(format!("Item {i}")) + .child(format!("Item {}", item.index)) .into(), } .into() diff --git a/examples/animation_virtual_scrollview.rs b/examples/animation_virtual_scrollview.rs index 1df6f3eda..4352e15bb 100644 --- a/examples/animation_virtual_scrollview.rs +++ b/examples/animation_virtual_scrollview.rs @@ -14,10 +14,10 @@ fn main() { fn app() -> impl IntoElement { rect().child( - VirtualScrollView::new(|i, _| { + VirtualScrollView::new(|item, _| { AnimatedContainer { height: 70., - i, + i: item.index, children: rect() .width(Size::fill()) .height(Size::fill()) @@ -25,7 +25,7 @@ fn app() -> impl IntoElement { .corner_radius(8.) .color((255, 255, 255)) .background((0, 119, 182)) - .child(format!("Item {i}")) + .child(format!("Item {}", item.index)) .into(), } .into() diff --git a/examples/component_table_virtual.rs b/examples/component_table_virtual.rs index 85327473b..5d1019786 100644 --- a/examples/component_table_virtual.rs +++ b/examples/component_table_virtual.rs @@ -113,15 +113,15 @@ fn app() -> impl IntoElement { ) .child( TableBody::new().child( - VirtualScrollView::new_with_data(filtered_data, move |i, filtered_data| { - let items = &filtered_data[i]; + VirtualScrollView::new_with_data(filtered_data, move |item, filtered_data| { + let row = &filtered_data[item.index]; TableRow::new() - .key(i) - .children(items.iter().enumerate().map(|(n, item)| { + .key(item.index) + .children(row.iter().enumerate().map(|(n, cell)| { TableCell::new() .key(n) .height(Size::px(35.)) - .child(item.to_string()) + .child(cell.to_string()) .into() })) .into() diff --git a/examples/component_virtual_scrollview.rs b/examples/component_virtual_scrollview.rs index 2bd6d79c2..da1a8f505 100644 --- a/examples/component_virtual_scrollview.rs +++ b/examples/component_virtual_scrollview.rs @@ -9,24 +9,53 @@ fn main() { } fn app() -> impl IntoElement { - VirtualScrollView::new(|i, _| { - rect() - .key(i) - .height(Size::px(50.)) - .padding(4.) - .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .child( + VirtualScrollView::new(|item, _| { rect() - .width(Size::fill()) - .height(Size::fill()) + .key(item.index) + .height(Size::px(item.size)) .padding(4.) - .corner_radius(8.) - .color((255, 255, 255)) - .background((0, 119, 182)) - .child(format!("Item {i}")), - ) - .into() - }) - .length(300usize) - .item_size(50.) - .height(Size::percent(100.)) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .padding(4.) + .corner_radius(8.) + .color((255, 255, 255)) + .background((0, 119, 182)) + .child(format!("Item {}", item.index)), + ) + .into() + }) + .length(300usize) + .item_size(50.) + .height(Size::percent(50.)), + ) + .child( + VirtualScrollView::new(|item, _| { + rect() + .key(item.index) + .width(Size::px(item.size)) + .padding(4.) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .center() + .padding(4.) + .corner_radius(8.) + .color((255, 255, 255)) + .background((202, 103, 2)) + .child(format!("Item {}", item.index)), + ) + .into() + }) + .direction(Direction::horizontal()) + .length(300usize) + .item_size(|index: usize| if index.is_multiple_of(2) { 140. } else { 80. }) + .height(Size::percent(50.)), + ) } diff --git a/examples/shader_editor.rs b/examples/shader_editor.rs index b6eb53e7b..db0eb02f7 100644 --- a/examples/shader_editor.rs +++ b/examples/shader_editor.rs @@ -89,9 +89,9 @@ impl Component for ShaderEditor { .width(Size::percent(50.)) .height(Size::fill()) .child( - VirtualScrollView::new(move |line_index, _| { + VirtualScrollView::new(move |item, _| { EditingLine { - line_index, + line_index: item.index, editable, } .into()