Implement AutoScrollIcon overlay for scrollable

This commit is contained in:
Héctor Ramón Jiménez 2025-11-28 08:28:03 +01:00
parent eadd7b8e81
commit 99748b89de
No known key found for this signature in database
GPG key ID: 7CC46565708259A7
9 changed files with 238 additions and 29 deletions

View file

@ -45,6 +45,10 @@ impl text::Renderer for () {
const ICON_FONT: Font = Font::DEFAULT; const ICON_FONT: Font = Font::DEFAULT;
const CHECKMARK_ICON: char = '0'; const CHECKMARK_ICON: char = '0';
const ARROW_DOWN_ICON: char = '0'; const ARROW_DOWN_ICON: char = '0';
const SCROLL_UP_ICON: char = '0';
const SCROLL_DOWN_ICON: char = '0';
const SCROLL_LEFT_ICON: char = '0';
const SCROLL_RIGHT_ICON: char = '0';
const ICED_LOGO: char = '0'; const ICED_LOGO: char = '0';
fn default_font(&self) -> Self::Font { fn default_font(&self) -> Self::Font {

View file

@ -312,6 +312,26 @@ pub trait Renderer: crate::Renderer {
/// [`ICON_FONT`]: Self::ICON_FONT /// [`ICON_FONT`]: Self::ICON_FONT
const ARROW_DOWN_ICON: char; const ARROW_DOWN_ICON: char;
/// The `char` representing a ^ icon in the built-in [`ICON_FONT`].
///
/// [`ICON_FONT`]: Self::ICON_FONT
const SCROLL_UP_ICON: char;
/// The `char` representing a v icon in the built-in [`ICON_FONT`].
///
/// [`ICON_FONT`]: Self::ICON_FONT
const SCROLL_DOWN_ICON: char;
/// The `char` representing a < icon in the built-in [`ICON_FONT`].
///
/// [`ICON_FONT`]: Self::ICON_FONT
const SCROLL_LEFT_ICON: char;
/// The `char` representing a > icon in the built-in [`ICON_FONT`].
///
/// [`ICON_FONT`]: Self::ICON_FONT
const SCROLL_RIGHT_ICON: char;
/// The 'char' representing the iced logo in the built-in ['ICON_FONT']. /// The 'char' representing the iced logo in the built-in ['ICON_FONT'].
/// ///
/// ['ICON_FONT']: Self::ICON_FONT /// ['ICON_FONT']: Self::ICON_FONT

Binary file not shown.

View file

@ -98,6 +98,10 @@ where
const ICON_FONT: Self::Font = A::ICON_FONT; const ICON_FONT: Self::Font = A::ICON_FONT;
const CHECKMARK_ICON: char = A::CHECKMARK_ICON; const CHECKMARK_ICON: char = A::CHECKMARK_ICON;
const ARROW_DOWN_ICON: char = A::ARROW_DOWN_ICON; const ARROW_DOWN_ICON: char = A::ARROW_DOWN_ICON;
const SCROLL_UP_ICON: char = A::SCROLL_UP_ICON;
const SCROLL_DOWN_ICON: char = A::SCROLL_DOWN_ICON;
const SCROLL_LEFT_ICON: char = A::SCROLL_LEFT_ICON;
const SCROLL_RIGHT_ICON: char = A::SCROLL_RIGHT_ICON;
const ICED_LOGO: char = A::ICED_LOGO; const ICED_LOGO: char = A::ICED_LOGO;
fn default_font(&self) -> Self::Font { fn default_font(&self) -> Self::Font {

View file

@ -256,6 +256,10 @@ impl core::text::Renderer for Renderer {
const CHECKMARK_ICON: char = '\u{f00c}'; const CHECKMARK_ICON: char = '\u{f00c}';
const ARROW_DOWN_ICON: char = '\u{e800}'; const ARROW_DOWN_ICON: char = '\u{e800}';
const ICED_LOGO: char = '\u{e801}'; const ICED_LOGO: char = '\u{e801}';
const SCROLL_UP_ICON: char = '\u{e802}';
const SCROLL_DOWN_ICON: char = '\u{e803}';
const SCROLL_LEFT_ICON: char = '\u{e804}';
const SCROLL_RIGHT_ICON: char = '\u{e805}';
fn default_font(&self) -> Self::Font { fn default_font(&self) -> Self::Font {
self.default_font self.default_font

View file

@ -722,6 +722,10 @@ impl core::text::Renderer for Renderer {
const CHECKMARK_ICON: char = '\u{f00c}'; const CHECKMARK_ICON: char = '\u{f00c}';
const ARROW_DOWN_ICON: char = '\u{e800}'; const ARROW_DOWN_ICON: char = '\u{e800}';
const ICED_LOGO: char = '\u{e801}'; const ICED_LOGO: char = '\u{e801}';
const SCROLL_UP_ICON: char = '\u{e802}';
const SCROLL_DOWN_ICON: char = '\u{e803}';
const SCROLL_LEFT_ICON: char = '\u{e804}';
const SCROLL_RIGHT_ICON: char = '\u{e805}';
fn default_font(&self) -> Self::Font { fn default_font(&self) -> Self::Font {
self.default_font self.default_font

View file

@ -1048,7 +1048,7 @@ pub fn scrollable<'a, Message, Theme, Renderer>(
) -> Scrollable<'a, Message, Theme, Renderer> ) -> Scrollable<'a, Message, Theme, Renderer>
where where
Theme: scrollable::Catalog + 'a, Theme: scrollable::Catalog + 'a,
Renderer: core::Renderer, Renderer: core::text::Renderer,
{ {
Scrollable::new(content) Scrollable::new(content)
} }

View file

@ -164,7 +164,7 @@ impl Default for State {
struct Overlay<'a, 'b, Message, Theme, Renderer> struct Overlay<'a, 'b, Message, Theme, Renderer>
where where
Theme: Catalog, Theme: Catalog,
Renderer: crate::core::Renderer, Renderer: text::Renderer,
{ {
position: Point, position: Point,
viewport: Rectangle, viewport: Rectangle,

View file

@ -20,12 +20,14 @@
//! } //! }
//! ``` //! ```
use crate::container; use crate::container;
use crate::core::alignment;
use crate::core::border::{self, Border}; use crate::core::border::{self, Border};
use crate::core::keyboard; use crate::core::keyboard;
use crate::core::layout; use crate::core::layout;
use crate::core::mouse; use crate::core::mouse;
use crate::core::overlay; use crate::core::overlay;
use crate::core::renderer; use crate::core::renderer;
use crate::core::text;
use crate::core::time::{Duration, Instant}; use crate::core::time::{Duration, Instant};
use crate::core::touch; use crate::core::touch;
use crate::core::widget; use crate::core::widget;
@ -69,7 +71,7 @@ pub struct Scrollable<
Renderer = crate::Renderer, Renderer = crate::Renderer,
> where > where
Theme: Catalog, Theme: Catalog,
Renderer: core::Renderer, Renderer: text::Renderer,
{ {
id: Option<widget::Id>, id: Option<widget::Id>,
width: Length, width: Length,
@ -85,7 +87,7 @@ pub struct Scrollable<
impl<'a, Message, Theme, Renderer> Scrollable<'a, Message, Theme, Renderer> impl<'a, Message, Theme, Renderer> Scrollable<'a, Message, Theme, Renderer>
where where
Theme: Catalog, Theme: Catalog,
Renderer: core::Renderer, Renderer: text::Renderer,
{ {
/// Creates a new vertical [`Scrollable`]. /// Creates a new vertical [`Scrollable`].
pub fn new( pub fn new(
@ -399,7 +401,7 @@ impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
for Scrollable<'_, Message, Theme, Renderer> for Scrollable<'_, Message, Theme, Renderer>
where where
Theme: Catalog, Theme: Catalog,
Renderer: core::Renderer, Renderer: text::Renderer,
{ {
fn tag(&self) -> tree::Tag { fn tag(&self) -> tree::Tag {
tree::Tag::of::<State>() tree::Tag::of::<State>()
@ -776,6 +778,8 @@ where
{ {
state.interaction = Interaction::None; state.interaction = Interaction::None;
shell.capture_event(); shell.capture_event();
shell.invalidate_layout();
shell.request_redraw();
return; return;
} }
@ -909,6 +913,8 @@ where
}; };
shell.capture_event(); shell.capture_event();
shell.invalidate_layout();
shell.request_redraw();
} }
Event::Touch(event) Event::Touch(event)
if matches!( if matches!(
@ -976,18 +982,17 @@ where
{ {
let delta = *position - origin; let delta = *position - origin;
if delta.x.abs() >= AUTOSCROLL_DEADZONE state.interaction = Interaction::AutoScrolling {
|| delta.y.abs() >= AUTOSCROLL_DEADZONE origin,
{ current: *position,
state.interaction = Interaction::AutoScrolling { last_frame,
origin, };
current: *position,
last_frame,
};
if last_frame.is_none() { if (delta.x.abs() >= AUTOSCROLL_DEADZONE
shell.request_redraw(); || delta.y.abs() >= AUTOSCROLL_DEADZONE)
} && last_frame.is_none()
{
shell.request_redraw();
} }
} }
} }
@ -1014,11 +1019,17 @@ where
last_frame: None, last_frame: None,
}; };
let delta = current - origin; let mut delta = current - origin;
if delta.x.abs() >= AUTOSCROLL_DEADZONE if delta.x.abs() < AUTOSCROLL_DEADZONE {
|| delta.y.abs() >= AUTOSCROLL_DEADZONE delta.x = 0.0;
{ }
if delta.y.abs() < AUTOSCROLL_DEADZONE {
delta.y = 0.0;
}
if delta.x != 0.0 || delta.y != 0.0 {
let time_delta = let time_delta =
if let Some(last_frame) = last_frame { if let Some(last_frame) = last_frame {
*now - last_frame *now - last_frame
@ -1354,24 +1365,186 @@ where
viewport: &Rectangle, viewport: &Rectangle,
translation: Vector, translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, Renderer>> { ) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
let state = tree.state.downcast_ref::<State>();
let bounds = layout.bounds(); let bounds = layout.bounds();
let content_layout = layout.children().next().unwrap(); let content_layout = layout.children().next().unwrap();
let content_bounds = content_layout.bounds(); let content_bounds = content_layout.bounds();
let visible_bounds = bounds.intersection(viewport).unwrap_or(*viewport); let visible_bounds = bounds.intersection(viewport).unwrap_or(*viewport);
let offset = state.translation(self.direction, bounds, content_bounds);
let offset = tree.state.downcast_ref::<State>().translation( let overlay = self.content.as_widget_mut().overlay(
self.direction,
bounds,
content_bounds,
);
self.content.as_widget_mut().overlay(
&mut tree.children[0], &mut tree.children[0],
layout.children().next().unwrap(), layout.children().next().unwrap(),
renderer, renderer,
&visible_bounds, &visible_bounds,
translation - offset, translation - offset,
) );
let icon = if let Interaction::AutoScrolling { origin, .. } =
state.interaction
{
let scrollbars =
Scrollbars::new(state, self.direction, bounds, content_bounds);
Some(overlay::Element::new(Box::new(AutoScrollIcon {
origin,
vertical: scrollbars.y.is_some(),
horizontal: scrollbars.x.is_some(),
})))
} else {
None
};
match (overlay, icon) {
(None, None) => None,
(None, Some(icon)) => Some(icon),
(Some(overlay), None) => Some(overlay),
(Some(overlay), Some(icon)) => Some(overlay::Element::new(
Box::new(overlay::Group::with_children(vec![overlay, icon])),
)),
}
}
}
struct AutoScrollIcon {
origin: Point,
vertical: bool,
horizontal: bool,
}
impl AutoScrollIcon {
const SIZE: f32 = 40.0;
const DOT: f32 = Self::SIZE / 10.0;
const PADDING: f32 = Self::SIZE / 10.0;
}
impl<Message, Theme, Renderer> core::Overlay<Message, Theme, Renderer>
for AutoScrollIcon
where
Renderer: text::Renderer,
{
fn layout(&mut self, _renderer: &Renderer, _bounds: Size) -> layout::Node {
layout::Node::new(Size::new(Self::SIZE, Self::SIZE))
.move_to(self.origin - Vector::new(Self::SIZE, Self::SIZE) / 2.0)
}
fn draw(
&self,
renderer: &mut Renderer,
_theme: &Theme,
_style: &renderer::Style,
layout: Layout<'_>,
_cursor: mouse::Cursor,
) {
let bounds = layout.bounds();
renderer.with_layer(Rectangle::INFINITE, |renderer| {
renderer.fill_quad(
renderer::Quad {
bounds,
border: border::rounded(bounds.width)
.color(Color::BLACK)
.width(1.0),
shadow: core::Shadow {
color: Color::BLACK.scale_alpha(0.8),
offset: Vector::new(1.0, 1.0),
blur_radius: 3.0,
},
snap: false,
},
Color::WHITE.scale_alpha(0.8),
);
renderer.fill_quad(
renderer::Quad {
bounds: Rectangle::new(
bounds.center()
- Vector::new(Self::DOT, Self::DOT) / 2.0,
Size::new(Self::DOT, Self::DOT),
),
border: border::rounded(bounds.width),
snap: false,
..renderer::Quad::default()
},
Color::BLACK.scale_alpha(0.8),
);
let arrow = core::Text {
content: String::new(),
bounds: bounds.size(),
size: Pixels::from(12),
line_height: text::LineHeight::Relative(1.0),
font: Renderer::ICON_FONT,
align_x: text::Alignment::Center,
align_y: alignment::Vertical::Center,
shaping: text::Shaping::Basic,
wrapping: text::Wrapping::None,
};
if self.vertical {
renderer.fill_text(
core::Text {
content: Renderer::SCROLL_UP_ICON.to_string(),
align_y: alignment::Vertical::Top,
..arrow
},
Point::new(
bounds.center_x(),
bounds.y + Self::PADDING - 1.0,
),
Color::BLACK.scale_alpha(0.8),
bounds,
);
renderer.fill_text(
core::Text {
content: Renderer::SCROLL_DOWN_ICON.to_string(),
align_y: alignment::Vertical::Bottom,
..arrow
},
Point::new(
bounds.center_x(),
bounds.y + bounds.height - Self::PADDING + 1.0,
),
Color::BLACK.scale_alpha(0.8),
bounds,
);
}
if self.horizontal {
renderer.fill_text(
core::Text {
content: Renderer::SCROLL_LEFT_ICON.to_string(),
align_x: text::Alignment::Left,
..arrow
},
Point::new(
bounds.x + Self::PADDING,
bounds.center_y() + 1.0,
),
Color::BLACK.scale_alpha(0.8),
bounds,
);
renderer.fill_text(
core::Text {
content: Renderer::SCROLL_RIGHT_ICON.to_string(),
align_x: text::Alignment::Right,
..arrow
},
Point::new(
bounds.x + bounds.width - Self::PADDING,
bounds.center_y() + 1.0,
),
Color::BLACK.scale_alpha(0.8),
bounds,
);
}
});
}
fn index(&self) -> f32 {
f32::MAX
} }
} }
@ -1381,7 +1554,7 @@ impl<'a, Message, Theme, Renderer>
where where
Message: 'a, Message: 'a,
Theme: 'a + Catalog, Theme: 'a + Catalog,
Renderer: 'a + core::Renderer, Renderer: 'a + text::Renderer,
{ {
fn from( fn from(
text_input: Scrollable<'a, Message, Theme, Renderer>, text_input: Scrollable<'a, Message, Theme, Renderer>,