1
0
mirror of https://github.com/emilk/egui.git synced 2026-08-29 04:40:03 -04:00

Implement full bleed scrollarea

This commit is contained in:
Lucas Meurer
2026-08-24 16:32:10 +02:00
parent 97a170941c
commit 39796a9b78
4 changed files with 252 additions and 18 deletions

View File

@@ -361,6 +361,8 @@ pub struct ScrollArea {
content_margin: Option<Margin>,
overflow_margin: Option<Margin>,
extend_into_parent_margin: bool,
/// If true for vertical or horizontal the scroll wheel will stick to the
/// end position until user manually changes position. It will become true
/// again once scroll handle makes contact with end.
@@ -415,6 +417,7 @@ impl ScrollArea {
wheel_scroll_multiplier: Vec2::splat(1.0),
content_margin: None,
overflow_margin: None,
extend_into_parent_margin: false,
stick_to_end: Vec2b::FALSE,
animated: true,
}
@@ -654,6 +657,29 @@ impl ScrollArea {
self
}
/// Grow into the inner margin of the closest parent [`crate::Frame`], e.g. a
/// [`crate::Popup`] or a [`crate::containers::menu::MenuButton`] menu.
///
/// The viewport, the clip rect and the scroll bar then reach all the way to the
/// edge of the frame, while the margin is added to [`Self::content_margin`],
/// so the contents keep the same padding as before.
///
/// The scroll area paints into the margin, but does not allocate it: the frame
/// adds its margin back around the rect the scroll area painted into, so the
/// frame ends up the same size as it would be without this.
///
/// Only the sides where the scroll area is flush with the frame are used, so a
/// widget added _before_ the scroll area keeps its normal padding. A widget added
/// _after_ the scroll area would be overlapped by it, so reserve the space for it
/// first, e.g. with [`Layout::bottom_up`](crate::Layout::bottom_up).
///
/// Default: `false`.
#[inline]
pub fn extend_into_parent_margin(mut self, extend: bool) -> Self {
self.extend_into_parent_margin = extend;
self
}
/// The scroll handle will stick to the rightmost position even while the content size
/// changes dynamically. This can be useful to simulate text scrollers coming in from right
/// hand side. The scroll handle remains stuck until user manually changes position. Once "unstuck"
@@ -708,6 +734,11 @@ struct Prepared {
/// Where on the screen the content is (excludes scroll bars; includes `content_margin`).
inner_rect: Rect,
/// The part of the parent frame's inner margin that we paint into, but do not allocate.
///
/// See [`ScrollArea::extend_into_parent_margin`].
claimed_margin: Margin,
content_ui: Ui,
/// Relative coordinates: the offset and size of the view of the inner UI.
@@ -746,6 +777,7 @@ impl ScrollArea {
wheel_scroll_multiplier,
content_margin: _, // Used elsewhere
overflow_margin,
extend_into_parent_margin,
stick_to_end,
animated,
} = self;
@@ -782,7 +814,16 @@ impl ScrollArea {
show_bars_factor.yx() * scroll_style.allocated_width()
};
let available_outer = ui.available_rect_before_wrap();
// We paint into the margin of the parent frame, but we don't _allocate_ it.
// The frame will then add its margin around the rect we paint into, so it ends up
// exactly where the contents already are. See `Self::extend_into_parent_margin`.
let claimed_margin = if extend_into_parent_margin {
claimable_parent_margin(ui)
} else {
Margin::ZERO
};
let available_outer = ui.available_rect_before_wrap() + claimed_margin;
let outer_size = available_outer.size().at_most(max_size);
@@ -979,6 +1020,7 @@ impl ScrollArea {
scroll_bar_visibility,
scroll_bar_rect,
inner_rect,
claimed_margin,
content_ui,
viewport,
scroll_source,
@@ -1067,14 +1109,17 @@ impl ScrollArea {
ui: &mut Ui,
add_contents: Box<dyn FnOnce(&mut Ui, Rect) -> R + 'c>,
) -> ScrollAreaOutput<R> {
let margin = self
.content_margin
.unwrap_or_else(|| ui.spacing().scroll.content_margin);
let content_margin = self.content_margin;
let mut prepared = self.begin(ui);
let id = prepared.id;
let inner_rect = prepared.inner_rect;
// The margin we took over from the parent frame is applied to the contents instead,
// so they keep the same padding:
let margin = content_margin.unwrap_or_else(|| ui.spacing().scroll.content_margin)
+ prepared.claimed_margin;
let inner = crate::Frame::NONE
.inner_margin(margin)
.show(&mut prepared.content_ui, |ui| {
@@ -1106,6 +1151,7 @@ impl Prepared {
current_bar_use,
scroll_bar_visibility,
scroll_bar_rect,
claimed_margin,
content_ui,
viewport: _,
scroll_source,
@@ -1306,10 +1352,11 @@ impl Prepared {
let scroll_style = ui.spacing().scroll;
// Reserve the scroll area before painting fades, because fade painting uses ui.min_rect().
ui.advance_cursor_after_rect(outer_rect);
// We paint into the margin of the parent frame, but we allocate only the rect we would
// have used without it. The frame then adds that margin back around what we painted.
ui.advance_cursor_after_rect(shrink_at_least_to_nothing(outer_rect, claimed_margin));
paint_fade_areas_impl(ui, inner_rect, content_size, state.offset);
paint_fade_areas_impl(ui, inner_rect, claimed_margin, content_size, state.offset);
// Paint the bars:
let scroll_bar_rect = scroll_bar_rect.unwrap_or(inner_rect);
@@ -1599,7 +1646,13 @@ impl Prepared {
/// Paint fade-out gradients at the top and/or bottom of a scroll area to
/// indicate that more content is available beyond the visible region.
fn paint_fade_areas_impl(ui: &Ui, inner_rect: Rect, content_size: Vec2, offset: Vec2) {
fn paint_fade_areas_impl(
ui: &Ui,
inner_rect: Rect,
corner_inset: Margin,
content_size: Vec2,
offset: Vec2,
) {
let crate::style::ScrollFadeStyle {
strength,
size: fade_size,
@@ -1613,15 +1666,31 @@ fn paint_fade_areas_impl(ui: &Ui, inner_rect: Rect, content_size: Vec2, offset:
let overflow = content_size - inner_rect.size();
let paint_rect = inner_rect.intersect(ui.min_rect());
let paint_rect = inner_rect.intersect(ui.min_rect().union(inner_rect));
// A fade runs along one edge. We keep it clear of the corners of the parent frame by
// insetting it _across_ its direction, so it cannot paint over a rounded corner.
// No content can be there anyway, because it is inset by the same margin.
let horizontal_fade_rect = paint_rect
- Margin {
left: corner_inset.left,
right: corner_inset.right,
..Margin::ZERO
};
let vertical_fade_rect = paint_rect
- Margin {
top: corner_inset.top,
bottom: corner_inset.bottom,
..Margin::ZERO
};
// Top fade: animate opacity based on how far we've scrolled down.
if 0.0 < offset.y {
let t = (offset.y / fade_size).clamp(0.0, 1.0) * strength;
let bg_faded = bg.gamma_multiply(t);
let rect = Rect::from_min_max(
paint_rect.left_top(),
pos2(paint_rect.right(), paint_rect.top() + fade_size),
horizontal_fade_rect.left_top(),
pos2(horizontal_fade_rect.right(), paint_rect.top() + fade_size),
);
ui.painter().add(Shape::gradient_rect(
rect,
@@ -1636,8 +1705,8 @@ fn paint_fade_areas_impl(ui: &Ui, inner_rect: Rect, content_size: Vec2, offset:
let t = (distance_from_bottom / fade_size).clamp(0.0, 1.0) * strength;
let bg_faded = bg.gamma_multiply(t);
let rect = Rect::from_min_max(
pos2(paint_rect.left(), paint_rect.bottom() - fade_size),
paint_rect.right_bottom(),
pos2(horizontal_fade_rect.left(), paint_rect.bottom() - fade_size),
horizontal_fade_rect.right_bottom(),
);
ui.painter().add(Shape::gradient_rect(
rect,
@@ -1651,8 +1720,8 @@ fn paint_fade_areas_impl(ui: &Ui, inner_rect: Rect, content_size: Vec2, offset:
let t = (offset.x / fade_size).clamp(0.0, 1.0) * strength;
let bg_faded = bg.gamma_multiply(t);
let rect = Rect::from_min_max(
paint_rect.left_top(),
pos2(paint_rect.left() + fade_size, paint_rect.bottom()),
vertical_fade_rect.left_top(),
pos2(paint_rect.left() + fade_size, vertical_fade_rect.bottom()),
);
ui.painter().add(Shape::gradient_rect(
rect,
@@ -1667,8 +1736,8 @@ fn paint_fade_areas_impl(ui: &Ui, inner_rect: Rect, content_size: Vec2, offset:
let t = (distance_from_right / fade_size).clamp(0.0, 1.0) * strength;
let bg_faded = bg.gamma_multiply(t);
let rect = Rect::from_min_max(
pos2(paint_rect.right() - fade_size, paint_rect.top()),
paint_rect.right_bottom(),
pos2(paint_rect.right() - fade_size, vertical_fade_rect.top()),
vertical_fade_rect.right_bottom(),
);
ui.painter().add(Shape::gradient_rect(
rect,
@@ -1677,3 +1746,61 @@ fn paint_fade_areas_impl(ui: &Ui, inner_rect: Rect, content_size: Vec2, offset:
));
}
}
/// The inner margin of the closest parent [`crate::Frame`] that a [`ScrollArea`] may paint into.
///
/// Only the sides that the scroll area is flush with are returned; the others are zero, so that
/// e.g. a widget above the scroll area keeps its normal padding.
///
/// See [`ScrollArea::extend_into_parent_margin`].
fn claimable_parent_margin(ui: &Ui) -> Margin {
let mut frame_node = None;
for node in ui.stack().iter() {
if node.frame().inner_margin != Margin::ZERO {
frame_node = Some(node);
break;
}
if node.is_area_ui() || node.kind() == Some(UiKind::ScrollArea) {
// Don't reach outside of the closest container.
break;
}
}
let Some(frame_node) = frame_node else {
return Margin::ZERO;
};
// The rect the frame gave its contents, i.e. the frame rect minus the inner margin.
// If we are the content `Ui` of the frame we use its current `max_rect`, which honors
// e.g. `Ui::set_width`. The rect in the stack is a snapshot from when the `Ui` was created.
let frame_rect = if frame_node.id == ui.unique_id() {
ui.max_rect()
} else {
frame_node.max_rect
};
let full_margin = frame_node.frame().inner_margin;
let available = ui.available_rect_before_wrap();
// Only take over the sides we are flush with:
let tolerance = 0.1;
let mut margin = Margin::ZERO;
if available.left() <= frame_rect.left() + tolerance {
margin.left = full_margin.left;
}
if available.top() <= frame_rect.top() + tolerance {
margin.top = full_margin.top;
}
if frame_rect.right() - tolerance <= available.right() {
margin.right = full_margin.right;
}
if frame_rect.bottom() - tolerance <= available.bottom() {
margin.bottom = full_margin.bottom;
}
margin
}
/// Shrink the rect by the margin, but never past nothing.
fn shrink_at_least_to_nothing(rect: Rect, margin: Margin) -> Rect {
let shrunk = rect - margin;
Rect::from_min_max(shrunk.min, shrunk.max.max(shrunk.min))
}

View File

@@ -3,7 +3,7 @@ use egui::color_picker::{Alpha, color_picker_color32};
use egui::containers::menu::{MenuConfig, SubMenuButton};
use egui::{
Align, Align2, Atom, Button, ComboBox, Frame, Id, Layout, Popup, PopupCloseBehavior, RectAlign,
RichText, Tooltip, Ui, UiBuilder, include_image,
RichText, ScrollArea, Tooltip, Ui, UiBuilder, include_image,
};
/// Showcase [`Popup`].
@@ -66,6 +66,57 @@ impl PopupsDemo {
ui.close();
}
});
ui.menu_button("Full-bleed ScrollArea", |ui| {
ui.menu_button("…with a header", |ui| {
ui.set_width(180.0);
// The header is added before the scroll area, so it keeps the padding
// of the menu frame:
ui.label(RichText::new("Pick a number").strong());
ScrollArea::vertical()
.max_height(150.0)
.auto_shrink([false, true])
// The scroll area is flush with the left, right and bottom of the
// menu frame, so it grows into the padding on those sides. The scroll
// bar ends up at the very edge of the menu:
.extend_into_parent_margin(true)
.show(ui, |ui| {
for i in 0..30 {
if ui.button(format!("Item {i}")).clicked() {
ui.close();
}
}
});
});
ui.menu_button("…with a footer", |ui| {
ui.set_width(180.0);
ui.set_height(200.0);
// A widget _after_ the scroll area would be overlapped by it, so we
// reserve the space for the footer first, from the bottom up:
ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
if ui.button("Footers keep their padding").clicked() {
ui.close();
}
ui.separator();
ui.with_layout(Layout::top_down(Align::Min), |ui| {
ScrollArea::vertical()
.auto_shrink(false)
.extend_into_parent_margin(true)
.show(ui, |ui| {
for i in 0..30 {
if ui.button(format!("Item {i}")).clicked() {
ui.close();
}
}
});
});
});
});
});
ui.add_enabled_ui(false, |ui| {
ui.menu_button("SubMenus can be disabled", |_| {});
});

View File

@@ -175,3 +175,56 @@ fn test_interactive_tooltip() {
assert!(harness.state().link_clicked);
}
#[cfg(all(feature = "snapshot", feature = "wgpu"))]
#[test]
fn scroll_area_extends_into_popup_margin() {
fn scroll_area(ui: &mut egui::Ui, extend: bool) {
egui::ScrollArea::vertical()
.max_height(110.0)
.auto_shrink([false, true])
.extend_into_parent_margin(extend)
.show(ui, |ui| {
for i in 0..20 {
_ = ui.selectable_label(i == 2, format!("Item {i}"));
}
});
}
fn popup_with_scroll_area(ui: &mut egui::Ui, label: &str, extend: bool, nested: bool) {
let response = ui.button(label);
Popup::from_response(&response).open(true).show(|ui| {
ui.set_width(120.0);
if nested {
// The scroll area is not a direct child of the popup frame here,
// so we must find the frame by walking up the `UiStack`:
ui.vertical(|ui| {
ui.label("Header");
scroll_area(ui, extend);
});
} else {
ui.label("Header");
scroll_area(ui, extend);
}
});
}
let mut harness = Harness::builder()
.with_size(egui::Vec2::new(600.0, 220.0))
.with_pixels_per_point(2.0)
.build_ui(|ui| {
// Solid scroll bars make it easy to see where the scroll area ends:
ui.ctx()
.all_styles_mut(|style| style.spacing.scroll = egui::style::ScrollStyle::solid());
ui.horizontal(|ui| {
popup_with_scroll_area(ui, "normal", false, false);
ui.add_space(140.0);
popup_with_scroll_area(ui, "full bleed", true, false);
ui.add_space(140.0);
popup_with_scroll_area(ui, "full bleed, nested", true, true);
});
});
harness.run();
harness.snapshot("popup_full_bleed_scroll_area");
}

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d817452786ded288a2e62a1c45066e8b23b1582880fded27a434aabd8298bd3e
size 54764