1
0
mirror of https://github.com/emilk/egui.git synced 2026-09-01 14:20:04 -04:00

Fade out the edges of ScrollAreas (#8018)

## Before:
<img width="381" height="307" alt="image"
src="https://github.com/user-attachments/assets/0528ae2a-44bf-4d9e-89a4-c3f4ab438eb2"
/>

It is very hard here to realize this is a scrollable area

## After
<img width="383" height="310" alt="image"
src="https://github.com/user-attachments/assets/9e0ee6de-8b96-4e5c-a505-f57977010990"
/>

The fade at the bottom tells the user they should try scrolling.

You can turn if off with `style.spacing.scroll.fade.enabled`
This commit is contained in:
Emil Ernerfeldt
2026-03-25 12:53:00 +01:00
committed by GitHub
parent 845b8c2f09
commit 0b0c561a81
22 changed files with 241 additions and 72 deletions

View File

@@ -5,7 +5,7 @@
use std::ops::{Add, AddAssign, BitOr, BitOrAssign}; use std::ops::{Add, AddAssign, BitOr, BitOrAssign};
use emath::GuiRounding as _; use emath::GuiRounding as _;
use epaint::Margin; use epaint::{Color32, Direction, Margin, Shape};
use crate::{ use crate::{
Context, CursorIcon, Id, NumExt as _, Pos2, Rangef, Rect, Response, Sense, Ui, UiBuilder, Context, CursorIcon, Id, NumExt as _, Pos2, Rangef, Rect, Response, Sense, Ui, UiBuilder,
@@ -1019,13 +1019,17 @@ impl ScrollArea {
.inner; .inner;
let (content_size, state) = prepared.end(ui); let (content_size, state) = prepared.end(ui);
ScrollAreaOutput { let output = ScrollAreaOutput {
inner, inner,
id, id,
state, state,
content_size, content_size,
inner_rect, inner_rect,
} };
paint_fade_areas(ui, &output);
output
} }
} }
@@ -1504,3 +1508,89 @@ impl Prepared {
(content_size, state) (content_size, state)
} }
} }
/// 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<R>(ui: &Ui, scroll_output: &ScrollAreaOutput<R>) {
let crate::style::ScrollFadeStyle {
enabled,
size: fade_size,
} = ui.spacing().scroll.fade;
if !enabled {
return;
}
let bg = ui.stack().bg_color();
let offset = scroll_output.state.offset;
let overflow = scroll_output.content_size - scroll_output.inner_rect.size();
let paint_rect = scroll_output
.inner_rect
.intersect(ui.min_rect())
.expand(ui.visuals().clip_rect_margin);
// 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);
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),
);
ui.painter().add(Shape::gradient_rect(
rect,
Direction::TopDown,
[bg_faded, Color32::TRANSPARENT],
));
}
// Bottom fade: animate opacity based on distance from the bottom.
let distance_from_bottom = overflow.y - offset.y;
if 0.0 < distance_from_bottom {
let t = (distance_from_bottom / fade_size).clamp(0.0, 1.0);
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(),
);
ui.painter().add(Shape::gradient_rect(
rect,
Direction::BottomUp,
[bg_faded, Color32::TRANSPARENT],
));
}
// Left fade: animate opacity based on how far we've scrolled right.
if 0.0 < offset.x {
let t = (offset.x / fade_size).clamp(0.0, 1.0);
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()),
);
ui.painter().add(Shape::gradient_rect(
rect,
Direction::LeftToRight,
[bg_faded, Color32::TRANSPARENT],
));
}
// Right fade: animate opacity based on distance from the right edge.
let distance_from_right = overflow.x - offset.x;
if 0.0 < distance_from_right {
let t = (distance_from_right / fade_size).clamp(0.0, 1.0);
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(),
);
ui.painter().add(Shape::gradient_rect(
rect,
Direction::RightToLeft,
[bg_faded, Color32::TRANSPARENT],
));
}
}

View File

@@ -1,7 +1,7 @@
use emath::GuiRounding as _; use emath::GuiRounding as _;
use crate::{ use crate::{
Align, Align, Direction,
emath::{Align2, NumExt as _, Pos2, Rect, Vec2, pos2, vec2}, emath::{Align2, NumExt as _, Pos2, Rect, Vec2, pos2, vec2},
}; };
const INFINITY: f32 = f32::INFINITY; const INFINITY: f32 = f32::INFINITY;
@@ -87,36 +87,6 @@ impl Region {
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
/// Layout direction, one of [`LeftToRight`](Direction::LeftToRight), [`RightToLeft`](Direction::RightToLeft), [`TopDown`](Direction::TopDown), [`BottomUp`](Direction::BottomUp).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub enum Direction {
LeftToRight,
RightToLeft,
TopDown,
BottomUp,
}
impl Direction {
#[inline(always)]
pub fn is_horizontal(self) -> bool {
match self {
Self::LeftToRight | Self::RightToLeft => true,
Self::TopDown | Self::BottomUp => false,
}
}
#[inline(always)]
pub fn is_vertical(self) -> bool {
match self {
Self::LeftToRight | Self::RightToLeft => false,
Self::TopDown | Self::BottomUp => true,
}
}
}
// ----------------------------------------------------------------------------
/// The layout of a [`Ui`][`crate::Ui`], e.g. "vertical & centered". /// The layout of a [`Ui`][`crate::Ui`], e.g. "vertical & centered".
/// ///
/// ``` /// ```

View File

@@ -449,7 +449,7 @@ pub use emath::{
remap_clamp, vec2, remap_clamp, vec2,
}; };
pub use epaint::{ pub use epaint::{
ClippedPrimitive, ColorImage, CornerRadius, ImageData, Margin, Mesh, PaintCallback, ClippedPrimitive, ColorImage, CornerRadius, Direction, ImageData, Margin, Mesh, PaintCallback,
PaintCallbackInfo, Shadow, Shape, Stroke, StrokeKind, TextureHandle, TextureId, mutex, PaintCallbackInfo, Shadow, Shape, Stroke, StrokeKind, TextureHandle, TextureId, mutex,
text::{FontData, FontDefinitions, FontFamily, FontId, FontTweak}, text::{FontData, FontDefinitions, FontFamily, FontId, FontTweak},
textures::{TextureFilter, TextureOptions, TextureWrapMode, TexturesDelta}, textures::{TextureFilter, TextureOptions, TextureWrapMode, TexturesDelta},

View File

@@ -586,6 +586,8 @@ pub struct ScrollStyle {
/// This is only for floating scroll bars. /// This is only for floating scroll bars.
/// Solid scroll bars are always opaque. /// Solid scroll bars are always opaque.
pub interact_handle_opacity: f32, pub interact_handle_opacity: f32,
pub fade: ScrollFadeStyle,
} }
impl Default for ScrollStyle { impl Default for ScrollStyle {
@@ -616,6 +618,8 @@ impl ScrollStyle {
dormant_handle_opacity: 0.0, dormant_handle_opacity: 0.0,
active_handle_opacity: 0.6, active_handle_opacity: 0.6,
interact_handle_opacity: 1.0, interact_handle_opacity: 1.0,
fade: Default::default(),
} }
} }
@@ -699,6 +703,8 @@ impl ScrollStyle {
dormant_handle_opacity, dormant_handle_opacity,
active_handle_opacity, active_handle_opacity,
interact_handle_opacity, interact_handle_opacity,
fade,
} = self; } = self;
ui.horizontal(|ui| { ui.horizontal(|ui| {
@@ -772,6 +778,49 @@ impl ScrollStyle {
ui.label("Inner margin"); ui.label("Inner margin");
}); });
} }
ui.separator();
fade.ui(ui);
}
}
/// Controls if and how to fade out the sides of a [`crate::ScrollArea`]
/// to indicate there is more there if you scroll.
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "serde", serde(default))]
pub struct ScrollFadeStyle {
/// Show fade areas?
pub enabled: bool,
/// Size of the fade-area (height for vertical scrolling,
/// width for horizontal scrolling).
pub size: f32,
}
impl Default for ScrollFadeStyle {
fn default() -> Self {
Self {
enabled: true,
size: 20.0,
}
}
}
impl ScrollFadeStyle {
pub fn ui(&mut self, ui: &mut Ui) {
let Self { enabled, size } = self;
ui.horizontal(|ui| {
ui.checkbox(enabled, "Fade edges");
});
if *enabled {
ui.horizontal(|ui| {
ui.add(DragValue::new(size).range(0.0..=64.0));
ui.label("Fade size");
});
}
} }
} }

View File

@@ -256,7 +256,7 @@ impl UiStack {
!self.info.frame.stroke.is_empty() !self.info.frame.stroke.is_empty()
} }
/// The background color of this [`Ui`]. /// The background color of this [`crate::Ui`].
/// ///
/// This blend together all [`Frame::fill`] colors /// This blend together all [`Frame::fill`] colors
/// up to the root. /// up to the root.

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b9ad01a55950f96a3ae9e48a2c026143d11ffee62bff4f83b4529cd884ce11f0 oid sha256:65776e4d9e31e7163117166915a0cfaaece98b1dd69695533655dee889ac5597
size 169683 size 169277

View File

@@ -27,7 +27,7 @@ impl crate::Demo for StripDemo {
impl crate::View for StripDemo { impl crate::View for StripDemo {
fn ui(&mut self, ui: &mut egui::Ui) { fn ui(&mut self, ui: &mut egui::Ui) {
let dark_mode = ui.visuals().dark_mode; let dark_mode = ui.visuals().dark_mode;
let faded_color = ui.visuals().window_fill(); let faded_color = ui.stack().bg_color();
let faded_color = |color: Color32| -> Color32 { let faded_color = |color: Color32| -> Color32 {
use egui::Rgba; use egui::Rgba;
let t = if dark_mode { 0.95 } else { 0.8 }; let t = if dark_mode { 0.95 } else { 0.8 };

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:8c630df841e98043132b920338793745549116890c1bc52d8d10b0def896a1e6 oid sha256:583003c59f40515a5de435ee1eea2ee6fceae409c7881456401b004c6409896a
size 114409 size 114575

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:627114dcbda4f3d2255d34926ed0b77c679d248ed1d822d723479b1e6652c67a oid sha256:8815473873602211a95e0a077dc77560de154a0a1a2f9c1418d90746670075f7
size 249258 size 248630

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b5b965a7c690fd8e8646812513e2417170b687fd37e29d220c29127ba0cc200c oid sha256:2881255fa694b713a3c3049f30143ee60a20b68105fb724fbba188f81d34e572
size 172609 size 171778

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:931f38ade8373ff79801c05c5d4397f2c5fcfa27022f2e1abe9eb29d561a3aef oid sha256:63fb9d15956efa69818cfd5398b4dfaa796dfbe1df3fb171b8ae413a6f641c9f
size 76022 size 76049

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:89986132c5d2a3ccccf0a16cb2b0be07b7c5512838cc10ec9067022e7a238515 oid sha256:4ac837d03e3e8959196942d030a8511d00cdd0d5ac9bfe761d80b59d32e73581
size 63918 size 63967

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9c81d5a5915dac60297293b90cd3fc0d188a9335e99b318996e3e2934de7bee1 oid sha256:4107ba4569b3e37ce6720595cfc4de60aec11dcfdb835720442b244e121209fc
size 483497 size 482862

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:eb8737af84c3d3b0c054b7e2a8bcb04685243d84cb13b72a1372dc40dbfd14fb oid sha256:6f6d516cc3d1439256a19f72153468125bac1647e2e211e841579545570faef7
size 7267 size 7366

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f1651bb1b9bbaa3c65ecd07c39c57527f4beb4c607581a5b2596a49dcf4c5db3 oid sha256:2e05d28e5541eb5926b4758e611f5c340311269fcd8b63055ff3a6793abbb140
size 7996 size 8279

View File

@@ -0,0 +1,27 @@
/// A cardinal direction, one of [`LeftToRight`](Direction::LeftToRight), [`RightToLeft`](Direction::RightToLeft), [`TopDown`](Direction::TopDown), [`BottomUp`](Direction::BottomUp).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub enum Direction {
LeftToRight,
RightToLeft,
TopDown,
BottomUp,
}
impl Direction {
#[inline(always)]
pub fn is_horizontal(self) -> bool {
match self {
Self::LeftToRight | Self::RightToLeft => true,
Self::TopDown | Self::BottomUp => false,
}
}
#[inline(always)]
pub fn is_vertical(self) -> bool {
match self {
Self::LeftToRight | Self::RightToLeft => false,
Self::TopDown | Self::BottomUp => true,
}
}
}

View File

@@ -27,6 +27,7 @@ mod brush;
pub mod color; pub mod color;
mod corner_radius; mod corner_radius;
mod corner_radius_f32; mod corner_radius_f32;
mod direction;
pub mod image; pub mod image;
mod margin; mod margin;
mod margin_f32; mod margin_f32;
@@ -50,6 +51,7 @@ pub use self::{
color::ColorMode, color::ColorMode,
corner_radius::CornerRadius, corner_radius::CornerRadius,
corner_radius_f32::CornerRadiusF32, corner_radius_f32::CornerRadiusF32,
direction::Direction,
image::{AlphaFromCoverage, ColorImage, ImageData, ImageDelta}, image::{AlphaFromCoverage, ColorImage, ImageData, ImageDelta},
margin::Margin, margin::Margin,
margin_f32::*, margin_f32::*,

View File

@@ -23,6 +23,18 @@ pub struct Vertex {
pub color: Color32, // 32 bit pub color: Color32, // 32 bit
} }
impl Vertex {
/// An untextured vertex
#[inline]
pub fn untextured(pos: Pos2, color: Color32) -> Self {
Self {
pos,
uv: WHITE_UV,
color,
}
}
}
#[repr(C)] #[repr(C)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[cfg(all(feature = "unity", not(feature = "_override_unity")))] #[cfg(all(feature = "unity", not(feature = "_override_unity")))]
@@ -159,11 +171,7 @@ impl Mesh {
self.texture_id == TextureId::default(), self.texture_id == TextureId::default(),
"Mesh has an assigned texture" "Mesh has an assigned texture"
); );
self.vertices.push(Vertex { self.vertices.push(Vertex::untextured(pos, color));
pos,
uv: WHITE_UV,
color,
});
} }
/// Add a triangle. /// Add a triangle.

View File

@@ -5,7 +5,7 @@ use std::sync::Arc;
use emath::{Align2, Pos2, Rangef, Rect, TSTransform, Vec2, pos2}; use emath::{Align2, Pos2, Rangef, Rect, TSTransform, Vec2, pos2};
use crate::{ use crate::{
Color32, CornerRadius, Mesh, Stroke, StrokeKind, TextureId, Color32, CornerRadius, Direction, Mesh, Stroke, StrokeKind, TextureId, Vertex,
stroke::PathStroke, stroke::PathStroke,
text::{FontId, FontsView, Galley}, text::{FontId, FontsView, Galley},
}; };
@@ -297,6 +297,32 @@ impl Shape {
Self::Rect(RectShape::stroke(rect, corner_radius, stroke, stroke_kind)) Self::Rect(RectShape::stroke(rect, corner_radius, stroke, stroke_kind))
} }
/// Paints a gradient rectangle that transitions from `color_from` to `color_to`
/// along the given `direction`.
///
/// For example, [`Direction::TopDown`] paints `color_from` at the top edge fading
/// to `color_to` at the bottom edge.
#[inline]
pub fn gradient_rect(rect: Rect, direction: Direction, [from, to]: [Color32; 2]) -> Self {
let (left_top, right_top, left_bottom, right_bottom) = match direction {
Direction::TopDown => (from, from, to, to),
Direction::BottomUp => (to, to, from, from),
Direction::LeftToRight => (from, to, from, to),
Direction::RightToLeft => (to, from, to, from),
};
Self::from(Mesh {
indices: vec![0, 1, 2, 2, 1, 3],
vertices: vec![
Vertex::untextured(rect.left_top(), left_top),
Vertex::untextured(rect.right_top(), right_top),
Vertex::untextured(rect.left_bottom(), left_bottom),
Vertex::untextured(rect.right_bottom(), right_bottom),
],
texture_id: Default::default(),
})
}
#[expect(clippy::needless_pass_by_value)] #[expect(clippy::needless_pass_by_value)]
pub fn text( pub fn text(
fonts: &mut FontsView<'_>, fonts: &mut FontsView<'_>,

View File

@@ -10,8 +10,8 @@ use emath::{GuiRounding as _, NumExt as _, Pos2, Rect, Rot2, Vec2, pos2, remap,
use crate::{ use crate::{
CircleShape, ClippedPrimitive, ClippedShape, Color32, CornerRadiusF32, CubicBezierShape, CircleShape, ClippedPrimitive, ClippedShape, Color32, CornerRadiusF32, CubicBezierShape,
EllipseShape, Mesh, PathShape, Primitive, QuadraticBezierShape, RectShape, Shape, Stroke, EllipseShape, Mesh, PathShape, Primitive, QuadraticBezierShape, RectShape, Shape, Stroke,
StrokeKind, TextShape, TextureId, Vertex, WHITE_UV, color::ColorMode, emath, StrokeKind, TextShape, TextureId, Vertex, color::ColorMode, emath, stroke::PathStroke,
stroke::PathStroke, texture_atlas::PreparedDisc, texture_atlas::PreparedDisc,
}; };
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
@@ -809,11 +809,8 @@ fn fill_closed_path(feathering: f32, path: &mut [PathPoint], fill_color: Color32
} else { } else {
out.reserve_triangles(n as usize); out.reserve_triangles(n as usize);
let idx = out.vertices.len() as u32; let idx = out.vertices.len() as u32;
out.vertices.extend(path.iter().map(|p| Vertex { out.vertices
pos: p.pos, .extend(path.iter().map(|p| Vertex::untextured(p.pos, fill_color)));
uv: WHITE_UV,
color: fill_color,
}));
for i in 2..n { for i in 2..n {
out.add_triangle(idx, idx + i - 1, idx + i); out.add_triangle(idx, idx + i - 1, idx + i);
} }

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:81620caad6d420f3bd0f224e5b07a02960a42436208a98d3aa012e5db61a743a oid sha256:5dfd9b576e0ab4b47a0f8c8acfc2664f92faa88cc7fb088409bff359fa1dfadd
size 1510 size 1861

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f915eafb6490ff456c5b0a7c74c38ef143262bdf74a0c6561b9cf6ee66a679ea oid sha256:4529530bb46af68260ca910a91f888e8b296790be2b976b450cec884799f53b4
size 1501 size 1953