1
0
mirror of https://github.com/emilk/egui.git synced 2026-06-26 14:49:06 -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 emath::GuiRounding as _;
use epaint::Margin;
use epaint::{Color32, Direction, Margin, Shape};
use crate::{
Context, CursorIcon, Id, NumExt as _, Pos2, Rangef, Rect, Response, Sense, Ui, UiBuilder,
@@ -1019,13 +1019,17 @@ impl ScrollArea {
.inner;
let (content_size, state) = prepared.end(ui);
ScrollAreaOutput {
let output = ScrollAreaOutput {
inner,
id,
state,
content_size,
inner_rect,
}
};
paint_fade_areas(ui, &output);
output
}
}
@@ -1504,3 +1508,89 @@ impl Prepared {
(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 crate::{
Align,
Align, Direction,
emath::{Align2, NumExt as _, Pos2, Rect, Vec2, pos2, vec2},
};
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".
///
/// ```

View File

@@ -449,7 +449,7 @@ pub use emath::{
remap_clamp, vec2,
};
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,
text::{FontData, FontDefinitions, FontFamily, FontId, FontTweak},
textures::{TextureFilter, TextureOptions, TextureWrapMode, TexturesDelta},

View File

@@ -586,6 +586,8 @@ pub struct ScrollStyle {
/// This is only for floating scroll bars.
/// Solid scroll bars are always opaque.
pub interact_handle_opacity: f32,
pub fade: ScrollFadeStyle,
}
impl Default for ScrollStyle {
@@ -616,6 +618,8 @@ impl ScrollStyle {
dormant_handle_opacity: 0.0,
active_handle_opacity: 0.6,
interact_handle_opacity: 1.0,
fade: Default::default(),
}
}
@@ -699,6 +703,8 @@ impl ScrollStyle {
dormant_handle_opacity,
active_handle_opacity,
interact_handle_opacity,
fade,
} = self;
ui.horizontal(|ui| {
@@ -772,6 +778,49 @@ impl ScrollStyle {
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()
}
/// The background color of this [`Ui`].
/// The background color of this [`crate::Ui`].
///
/// This blend together all [`Frame::fill`] colors
/// up to the root.

View File

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

View File

@@ -27,7 +27,7 @@ impl crate::Demo for StripDemo {
impl crate::View for StripDemo {
fn ui(&mut self, ui: &mut egui::Ui) {
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 {
use egui::Rgba;
let t = if dark_mode { 0.95 } else { 0.8 };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f1651bb1b9bbaa3c65ecd07c39c57527f4beb4c607581a5b2596a49dcf4c5db3
size 7996
oid sha256:2e05d28e5541eb5926b4758e611f5c340311269fcd8b63055ff3a6793abbb140
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;
mod corner_radius;
mod corner_radius_f32;
mod direction;
pub mod image;
mod margin;
mod margin_f32;
@@ -50,6 +51,7 @@ pub use self::{
color::ColorMode,
corner_radius::CornerRadius,
corner_radius_f32::CornerRadiusF32,
direction::Direction,
image::{AlphaFromCoverage, ColorImage, ImageData, ImageDelta},
margin::Margin,
margin_f32::*,

View File

@@ -23,6 +23,18 @@ pub struct Vertex {
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)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[cfg(all(feature = "unity", not(feature = "_override_unity")))]
@@ -159,11 +171,7 @@ impl Mesh {
self.texture_id == TextureId::default(),
"Mesh has an assigned texture"
);
self.vertices.push(Vertex {
pos,
uv: WHITE_UV,
color,
});
self.vertices.push(Vertex::untextured(pos, color));
}
/// Add a triangle.

View File

@@ -5,7 +5,7 @@ use std::sync::Arc;
use emath::{Align2, Pos2, Rangef, Rect, TSTransform, Vec2, pos2};
use crate::{
Color32, CornerRadius, Mesh, Stroke, StrokeKind, TextureId,
Color32, CornerRadius, Direction, Mesh, Stroke, StrokeKind, TextureId, Vertex,
stroke::PathStroke,
text::{FontId, FontsView, Galley},
};
@@ -297,6 +297,32 @@ impl Shape {
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)]
pub fn text(
fonts: &mut FontsView<'_>,

View File

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

View File

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

View File

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