Implement efficent scrolling of large content (#457)

This commit is contained in:
Emil Ernerfeldt 2021-06-03 18:48:45 +02:00 committed by GitHub
parent 2cdd90b111
commit 6468b2b84e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 209 additions and 16 deletions

View file

@ -12,6 +12,7 @@ NOTE: [`eframe`](eframe/CHANGELOG.md), [`egui_web`](egui_web/CHANGELOG.md) and [
* Add right and bottom panels (`SidePanel::right` and `Panel::bottom`).
* Add resizable panels.
* Add an option to overwrite frame of a `Panel`.
* Add `ScrollArea::show_rows` for efficient scrolling of huge UI:s.
* Add `Style::override_text_style` to easily change the text style of everything in a `Ui` (or globally).
* You can now change `TextStyle` on checkboxes, radio buttons and `SelectableLabel`.
* Add support for [cint](https://crates.io/crates/cint) under `cint` feature.

View file

@ -263,9 +263,9 @@ For "atomic" widgets (e.g. a button) `egui` knows the size before showing it, so
#### CPU usage
Since an immediate mode GUI does a full layout each frame, the layout code needs to be quick. If you have a very complex GUI this can tax the CPU. In particular, having a very large UI in a scroll area (with very long scrollback) can be slow, as the content needs to be layed out each frame.
If you design the GUI with this in mind and refrain from huge scroll areas then the performance hit is generally pretty small. For most cases you can expect `egui` to take up 1-2 ms per frame, but `egui` still has a lot of room for optimization (it's not something I've focused on yet). You can also set up `egui` to only repaint when there is interaction (e.g. mouse movement).
If you design the GUI with this in mind and refrain from huge scroll areas (or only lay out the part that is in view) then the performance hit is generally pretty small. For most cases you can expect `egui` to take up 1-2 ms per frame, but `egui` still has a lot of room for optimization (it's not something I've focused on yet). You can also set up `egui` to only repaint when there is interaction (e.g. mouse movement).
If your GUI is highly interactive, then immediate mode may actually be more performant compared to retained mode. Go to any web page and resize the browser window, and you'll notice that the browser is very slow to do the layout and eats a lot of CPU doing it. Resize a window in `egui` by contrast, and you'll get smooth 60 FPS for no extra CPU cost.
If your GUI is highly interactive, then immediate mode may actually be more performant compared to retained mode. Go to any web page and resize the browser window, and you'll notice that the browser is very slow to do the layout and eats a lot of CPU doing it. Resize a window in `egui` by contrast, and you'll get smooth 60 FPS at no extra CPU cost.
#### IDs

View file

@ -84,6 +84,9 @@ struct Prepared {
always_show_scroll: bool,
inner_rect: Rect,
content_ui: Ui,
/// Relative coordinates: the offset and size of the view of the inner UI.
/// `viewport.min == ZERO` means we scrolled to the top.
viewport: Rect,
}
impl ScrollArea {
@ -139,6 +142,8 @@ impl ScrollArea {
content_clip_rect.max.x = ui.clip_rect().max.x - current_scroll_bar_width; // Nice handling of forced resizing beyond the possible
content_ui.set_clip_rect(content_clip_rect);
let viewport = Rect::from_min_size(Pos2::ZERO + state.offset, inner_size);
Prepared {
id,
state,
@ -146,12 +151,69 @@ impl ScrollArea {
always_show_scroll,
inner_rect,
content_ui,
viewport,
}
}
/// Show the `ScrollArea`, and add the contents to the viewport.
///
/// If the inner area can be very long, consider using [`Self::show_rows`] instead.
pub fn show<R>(self, ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> R {
self.show_viewport(ui, |ui, _viewport| add_contents(ui))
}
/// Efficiently show only the visible part of a large number of rows.
///
/// ```
/// # let ui = &mut egui::Ui::__test();
/// let text_style = egui::TextStyle::Body;
/// let row_height = ui.fonts()[text_style].row_height();
/// // let row_height = ui.spacing().interact_size.y; // if you are adding buttons instead of labels.
/// let num_rows = 10_000;
/// egui::ScrollArea::auto_sized().show_rows(ui, row_height, num_rows, |ui, row_range| {
/// for row in row_range {
/// let text = format!("Row {}/{}", row + 1, num_rows);
/// ui.label(text);
/// }
/// });
pub fn show_rows<R>(
self,
ui: &mut Ui,
row_height_sans_spacing: f32,
num_rows: usize,
add_contents: impl FnOnce(&mut Ui, std::ops::Range<usize>) -> R,
) -> R {
let spacing = ui.spacing().item_spacing;
let row_height_with_spacing = row_height_sans_spacing + spacing.y;
self.show_viewport(ui, |ui, viewport| {
ui.set_height((row_height_with_spacing * num_rows as f32 - spacing.y).at_least(0.0));
let min_row = (viewport.min.y / row_height_with_spacing)
.floor()
.at_least(0.0) as usize;
let max_row = (viewport.max.y / row_height_with_spacing).ceil() as usize + 1;
let max_row = max_row.at_most(num_rows);
let y_min = ui.max_rect().top() + min_row as f32 * row_height_with_spacing;
let y_max = ui.max_rect().top() + max_row as f32 * row_height_with_spacing;
let mut viewport_ui = ui.child_ui(
Rect::from_x_y_ranges(ui.max_rect().x_range(), y_min..=y_max),
*ui.layout(),
);
viewport_ui.skip_ahead_auto_ids(min_row); // Make sure we get consistent IDs.
add_contents(&mut viewport_ui, min_row..max_row)
})
}
/// This can be used to only paint the visible part of the contents.
///
/// `add_contents` is past the viewport, which is the relative view of the content.
/// So if the passed rect has min = zero, then show the top left content (the user has not scrolled).
pub fn show_viewport<R>(self, ui: &mut Ui, add_contents: impl FnOnce(&mut Ui, Rect) -> R) -> R {
let mut prepared = self.begin(ui);
let ret = add_contents(&mut prepared.content_ui);
let ret = add_contents(&mut prepared.content_ui, prepared.viewport);
prepared.end(ui);
ret
}
@ -166,6 +228,7 @@ impl Prepared {
always_show_scroll,
mut current_scroll_bar_width,
content_ui,
viewport: _,
} = self;
let content_size = content_ui.min_size();

View file

@ -254,6 +254,7 @@ pub struct Grid {
min_row_height: Option<f32>,
max_cell_size: Vec2,
spacing: Option<Vec2>,
start_row: usize,
}
impl Grid {
@ -266,6 +267,7 @@ impl Grid {
min_row_height: None,
max_cell_size: Vec2::INFINITY,
spacing: None,
start_row: 0,
}
}
@ -304,6 +306,13 @@ impl Grid {
self.spacing = Some(spacing.into());
self
}
/// Change which row number the grid starts on.
/// This can be useful when you have a large `Grid` inside of [`ScrollArea::show_rows`].
pub fn start_row(mut self, start_row: usize) -> Self {
self.start_row = start_row;
self
}
}
impl Grid {
@ -315,6 +324,7 @@ impl Grid {
min_row_height,
max_cell_size,
spacing,
start_row,
} = self;
let min_col_width = min_col_width.unwrap_or_else(|| ui.spacing().interact_size.x);
let min_row_height = min_row_height.unwrap_or_else(|| ui.spacing().interact_size.y);
@ -331,6 +341,7 @@ impl Grid {
spacing,
min_cell_size: vec2(min_col_width, min_row_height),
max_cell_size,
row: start_row,
..GridLayout::new(ui, id)
};

View file

@ -401,12 +401,18 @@ impl Ui {
self.set_max_width(*width.end());
}
/// `ui.set_width_range(width);` is equivalent to `ui.set_min_width(width); ui.set_max_width(width);`.
/// Set both the minimum and maximum width.
pub fn set_width(&mut self, width: f32) {
self.set_min_width(width);
self.set_max_width(width);
}
/// Set both the minimum and maximum height.
pub fn set_height(&mut self, height: f32) {
self.set_min_height(height);
self.set_max_height(height);
}
/// Ensure we are big enough to contain the given x-coordinate.
/// This is sometimes useful to expand an ui to stretch to a certain place.
pub fn expand_to_include_x(&mut self, x: f32) {
@ -473,6 +479,10 @@ impl Ui {
{
Id::new(self.next_auto_id_source).with(id_source)
}
pub fn skip_ahead_auto_ids(&mut self, count: usize) {
self.next_auto_id_source = self.next_auto_id_source.wrapping_add(count as u64);
}
}
/// # Interaction

View file

@ -1,22 +1,25 @@
use egui::{color::*, *};
#[cfg_attr(feature = "persistence", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "persistence", serde(default))]
#[derive(PartialEq)]
pub struct Scrolling {
track_item: usize,
tack_item_align: Align,
offset: f32,
#[derive(Clone, Copy, Debug, PartialEq)]
enum ScrollDemo {
ScrollTo,
ManyLines,
LargeCanvas,
}
impl Default for Scrolling {
impl Default for ScrollDemo {
fn default() -> Self {
Self {
track_item: 25,
tack_item_align: Align::Center,
offset: 0.0,
Self::ScrollTo
}
}
#[cfg_attr(feature = "persistence", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "persistence", serde(default))]
#[derive(Default, PartialEq)]
pub struct Scrolling {
demo: ScrollDemo,
scroll_to: ScrollTo,
}
impl super::Demo for Scrolling {
@ -36,6 +39,111 @@ impl super::Demo for Scrolling {
}
impl super::View for Scrolling {
fn ui(&mut self, ui: &mut Ui) {
ui.horizontal(|ui| {
ui.selectable_value(&mut self.demo, ScrollDemo::ScrollTo, "Scroll to");
ui.selectable_value(
&mut self.demo,
ScrollDemo::ManyLines,
"Scroll a lot of lines",
);
ui.selectable_value(
&mut self.demo,
ScrollDemo::LargeCanvas,
"Scroll a large canvas",
);
});
ui.separator();
match self.demo {
ScrollDemo::ScrollTo => {
self.scroll_to.ui(ui);
}
ScrollDemo::ManyLines => {
huge_content_lines(ui);
}
ScrollDemo::LargeCanvas => {
huge_content_painter(ui);
}
}
}
}
fn huge_content_lines(ui: &mut egui::Ui) {
ui.label(
"A lot of rows, but only the visible ones are layed out, so performance is still good:",
);
ui.add_space(4.0);
let text_style = TextStyle::Body;
let row_height = ui.fonts()[text_style].row_height();
let num_rows = 10_000;
ScrollArea::auto_sized().show_rows(ui, row_height, num_rows, |ui, row_range| {
for row in row_range {
let text = format!("This is row {}/{}", row + 1, num_rows);
ui.label(text);
}
});
}
fn huge_content_painter(ui: &mut egui::Ui) {
// This is similar to the other demo, but is fully manual, for when you want to do custom painting.
ui.label("A lot of rows, but only the visible ones are painted, so performance is still good:");
ui.add_space(4.0);
let text_style = TextStyle::Body;
let row_height = ui.fonts()[text_style].row_height() + ui.spacing().item_spacing.y;
let num_rows = 10_000;
ScrollArea::auto_sized().show_viewport(ui, |ui, viewport| {
ui.set_height(row_height * num_rows as f32);
let first_item = (viewport.min.y / row_height).floor().at_least(0.0) as usize;
let last_item = (viewport.max.y / row_height).ceil() as usize + 1;
let last_item = last_item.at_most(num_rows);
for i in first_item..last_item {
let indentation = (i % 100) as f32;
let x = ui.min_rect().left() + indentation;
let y = ui.min_rect().top() + i as f32 * row_height;
let text = format!(
"This is row {}/{}, indented by {} pixels",
i + 1,
num_rows,
indentation
);
ui.painter().text(
pos2(x, y),
Align2::LEFT_TOP,
text,
text_style,
ui.visuals().text_color(),
);
}
});
}
// ----------------------------------------------------------------------------
#[cfg_attr(feature = "persistence", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "persistence", serde(default))]
#[derive(PartialEq)]
struct ScrollTo {
track_item: usize,
tack_item_align: Align,
offset: f32,
}
impl Default for ScrollTo {
fn default() -> Self {
Self {
track_item: 25,
tack_item_align: Align::Center,
offset: 0.0,
}
}
}
impl super::View for ScrollTo {
fn ui(&mut self, ui: &mut Ui) {
ui.label("This shows how you can scroll to a specific item or pixel offset");