Merge pull request #2973 from nz366/auto-scroll

Add `auto_scroll` support for `scrollable`
This commit is contained in:
Héctor 2025-11-28 09:11:43 +01:00 • committed by GitHub
commit c21289df4a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 513 additions and 108 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

View file

@ -275,7 +275,9 @@ impl Gallery {
.height(grid::aspect_ratio(Preview::WIDTH, Preview::HEIGHT)) .height(grid::aspect_ratio(Preview::WIDTH, Preview::HEIGHT))
.spacing(10); .spacing(10);
let content = container(scrollable(gallery).spacing(10)).padding(10); let content =
container(scrollable(gallery).spacing(10).auto_scroll(true))
.padding(10);
let viewer = self.viewer.view(self.now); let viewer = self.viewer.view(self.now);
stack![content, viewer].into() stack![content, viewer].into()

View file

@ -210,7 +210,8 @@ impl ScrollableDemo {
.width(Fill) .width(Fill)
.height(Fill) .height(Fill)
.id(SCROLLABLE) .id(SCROLLABLE)
.on_scroll(Message::Scrolled), .on_scroll(Message::Scrolled)
.auto_scroll(true),
Direction::Horizontal => scrollable( Direction::Horizontal => scrollable(
row![ row![
scroll_to_end_button(), scroll_to_end_button(),
@ -236,7 +237,8 @@ impl ScrollableDemo {
.width(Fill) .width(Fill)
.height(Fill) .height(Fill)
.id(SCROLLABLE) .id(SCROLLABLE)
.on_scroll(Message::Scrolled), .on_scroll(Message::Scrolled)
.auto_scroll(true),
Direction::Multi => scrollable( Direction::Multi => scrollable(
//horizontal content //horizontal content
row![ row![
@ -283,7 +285,8 @@ impl ScrollableDemo {
.width(Fill) .width(Fill)
.height(Fill) .height(Fill)
.id(SCROLLABLE) .id(SCROLLABLE)
.on_scroll(Message::Scrolled), .on_scroll(Message::Scrolled)
.auto_scroll(true),
}); });
let progress_bars: Element<Message> = match self.scrollable_direction { let progress_bars: Element<Message> = match self.scrollable_direction {

View file

@ -131,7 +131,8 @@ impl Styling {
"You did it!" "You did it!"
]) ])
.width(Fill) .width(Fill)
.height(Fill); .height(Fill)
.auto_scroll(true);
let check = checkbox(self.checkbox_value) let check = checkbox(self.checkbox_value)
.label("Check me!") .label("Check me!")

View file

@ -174,7 +174,8 @@ impl Tour {
} else { } else {
content content
})) }))
.spacing(10); .spacing(10)
.auto_scroll(true);
center_y(scrollable).padding(10).into() center_y(scrollable).padding(10).into()
} }
@ -626,7 +627,7 @@ pub enum Layout {
impl Default for Tour { impl Default for Tour {
fn default() -> Self { fn default() -> Self {
Self { Self {
screen: Screen::Welcome, screen: Screen::Scrollable,
slider: 50, slider: 50,
layout: Layout::Row, layout: Layout::Row,
spacing: 20, spacing: 20,

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,12 +71,13 @@ 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,
height: Length, height: Length,
direction: Direction, direction: Direction,
auto_scroll: bool,
content: Element<'a, Message, Theme, Renderer>, content: Element<'a, Message, Theme, Renderer>,
on_scroll: Option<Box<dyn Fn(Viewport) -> Message + 'a>>, on_scroll: Option<Box<dyn Fn(Viewport) -> Message + 'a>>,
class: Theme::Class<'a>, class: Theme::Class<'a>,
@ -84,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(
@ -103,6 +106,7 @@ where
width: Length::Shrink, width: Length::Shrink,
height: Length::Shrink, height: Length::Shrink,
direction: direction.into(), direction: direction.into(),
auto_scroll: false,
content: content.into(), content: content.into(),
on_scroll: None, on_scroll: None,
class: Theme::default(), class: Theme::default(),
@ -225,6 +229,15 @@ where
self self
} }
/// Sets whether the user should be allowed to auto-scroll the [`Scrollable`]
/// with the middle mouse button.
///
/// By default, it is disabled.
pub fn auto_scroll(mut self, auto_scroll: bool) -> Self {
self.auto_scroll = auto_scroll;
self
}
/// Sets the style of this [`Scrollable`]. /// Sets the style of this [`Scrollable`].
#[must_use] #[must_use]
pub fn style(mut self, style: impl Fn(&Theme, Status) -> Style + 'a) -> Self pub fn style(mut self, style: impl Fn(&Theme, Status) -> Style + 'a) -> Self
@ -388,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>()
@ -567,6 +580,9 @@ where
shell: &mut Shell<'_, Message>, shell: &mut Shell<'_, Message>,
_viewport: &Rectangle, _viewport: &Rectangle,
) { ) {
const AUTOSCROLL_DEADZONE: f32 = 20.0;
const AUTOSCROLL_SMOOTHNESS: f32 = 1.5;
let state = tree.state.downcast_mut::<State>(); let state = tree.state.downcast_mut::<State>();
let bounds = layout.bounds(); let bounds = layout.bounds();
let cursor_over_scrollable = cursor.position_over(bounds); let cursor_over_scrollable = cursor.position_over(bounds);
@ -601,7 +617,7 @@ where
} }
let mut update = || { let mut update = || {
if let Some(scroller_grabbed_at) = state.y_scroller_grabbed_at { if let Some(scroller_grabbed_at) = state.y_scroller_grabbed_at() {
match event { match event {
Event::Mouse(mouse::Event::CursorMoved { .. }) Event::Mouse(mouse::Event::CursorMoved { .. })
| Event::Touch(touch::Event::FingerMoved { .. }) => { | Event::Touch(touch::Event::FingerMoved { .. }) => {
@ -657,8 +673,9 @@ where
content_bounds, content_bounds,
); );
state.y_scroller_grabbed_at = state.interaction = Interaction::YScrollerGrabbed(
Some(scroller_grabbed_at); scroller_grabbed_at,
);
let _ = notify_scroll( let _ = notify_scroll(
state, state,
@ -675,7 +692,7 @@ where
} }
} }
if let Some(scroller_grabbed_at) = state.x_scroller_grabbed_at { if let Some(scroller_grabbed_at) = state.x_scroller_grabbed_at() {
match event { match event {
Event::Mouse(mouse::Event::CursorMoved { .. }) Event::Mouse(mouse::Event::CursorMoved { .. })
| Event::Touch(touch::Event::FingerMoved { .. }) => { | Event::Touch(touch::Event::FingerMoved { .. }) => {
@ -730,8 +747,9 @@ where
content_bounds, content_bounds,
); );
state.x_scroller_grabbed_at = state.interaction = Interaction::XScrollerGrabbed(
Some(scroller_grabbed_at); scroller_grabbed_at,
);
let _ = notify_scroll( let _ = notify_scroll(
state, state,
@ -748,6 +766,23 @@ where
} }
} }
if matches!(state.interaction, Interaction::AutoScrolling { .. })
&& matches!(
event,
Event::Mouse(
mouse::Event::ButtonPressed(_)
| mouse::Event::WheelScrolled { .. }
) | Event::Touch(_)
| Event::Keyboard(_)
)
{
state.interaction = Interaction::None;
shell.capture_event();
shell.invalidate_layout();
shell.request_redraw();
return;
}
if state.last_scrolled.is_none() if state.last_scrolled.is_none()
|| !matches!( || !matches!(
event, event,
@ -800,10 +835,7 @@ where
| touch::Event::FingerLost { .. } | touch::Event::FingerLost { .. }
) )
) { ) {
state.scroll_area_touched_at = None; state.interaction = Interaction::None;
state.x_scroller_grabbed_at = None;
state.y_scroller_grabbed_at = None;
return; return;
} }
@ -811,15 +843,6 @@ where
return; return;
} }
if let Event::Keyboard(keyboard::Event::ModifiersChanged(
modifiers,
)) = event
{
state.keyboard_modifiers = *modifiers;
return;
}
match event { match event {
Event::Mouse(mouse::Event::WheelScrolled { delta }) => { Event::Mouse(mouse::Event::WheelScrolled { delta }) => {
if cursor_over_scrollable.is_none() { if cursor_over_scrollable.is_none() {
@ -874,58 +897,190 @@ where
shell.capture_event(); shell.capture_event();
} }
} }
Event::Mouse(mouse::Event::ButtonPressed(
mouse::Button::Middle,
)) if self.auto_scroll
&& matches!(state.interaction, Interaction::None) =>
{
let Some(origin) = cursor_over_scrollable else {
return;
};
state.interaction = Interaction::AutoScrolling {
origin,
current: origin,
last_frame: None,
};
shell.capture_event();
shell.invalidate_layout();
shell.request_redraw();
}
Event::Touch(event) Event::Touch(event)
if state.scroll_area_touched_at.is_some() if matches!(
|| (!mouse_over_y_scrollbar state.interaction,
&& !mouse_over_x_scrollbar) => Interaction::TouchScrolling(_)
) || (!mouse_over_y_scrollbar
&& !mouse_over_x_scrollbar) =>
{ {
match event { match event {
touch::Event::FingerPressed { .. } => { touch::Event::FingerPressed { .. } => {
if cursor_over_scrollable.is_none() { let Some(position) = cursor_over_scrollable else {
return; return;
} };
state.scroll_area_touched_at = cursor.position(); state.interaction =
Interaction::TouchScrolling(position);
} }
touch::Event::FingerMoved { .. } => { touch::Event::FingerMoved { .. } => {
if let Some(scroll_box_touched_at) = let Interaction::TouchScrolling(
state.scroll_area_touched_at scroll_box_touched_at,
{ ) = state.interaction
let Some(cursor_position) = cursor.position() else {
else { return;
return; };
};
let delta = Vector::new( let Some(cursor_position) = cursor.position()
scroll_box_touched_at.x - cursor_position.x, else {
scroll_box_touched_at.y - cursor_position.y, return;
); };
state.scroll( let delta = Vector::new(
self.direction.align(delta), scroll_box_touched_at.x - cursor_position.x,
bounds, scroll_box_touched_at.y - cursor_position.y,
content_bounds, );
);
state.scroll_area_touched_at = state.scroll(
Some(cursor_position); self.direction.align(delta),
bounds,
content_bounds,
);
// TODO: bubble up touch movements if not consumed. state.interaction =
let _ = notify_scroll( Interaction::TouchScrolling(cursor_position);
state,
&self.on_scroll, // TODO: bubble up touch movements if not consumed.
bounds, let _ = notify_scroll(
content_bounds, state,
shell, &self.on_scroll,
); bounds,
} content_bounds,
shell,
);
} }
_ => {} _ => {}
} }
shell.capture_event(); shell.capture_event();
} }
Event::Window(window::Event::RedrawRequested(_)) => { Event::Mouse(mouse::Event::CursorMoved { position }) => {
if let Interaction::AutoScrolling {
origin,
last_frame,
..
} = state.interaction
{
let delta = *position - origin;
state.interaction = Interaction::AutoScrolling {
origin,
current: *position,
last_frame,
};
if (delta.x.abs() >= AUTOSCROLL_DEADZONE
|| delta.y.abs() >= AUTOSCROLL_DEADZONE)
&& last_frame.is_none()
{
shell.request_redraw();
}
}
}
Event::Keyboard(keyboard::Event::ModifiersChanged(
modifiers,
)) => {
state.keyboard_modifiers = *modifiers;
}
Event::Window(window::Event::RedrawRequested(now)) => {
if let Interaction::AutoScrolling {
origin,
current,
last_frame,
} = state.interaction
{
if last_frame == Some(*now) {
shell.request_redraw();
return;
}
state.interaction = Interaction::AutoScrolling {
origin,
current,
last_frame: None,
};
let mut delta = current - origin;
if delta.x.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 =
if let Some(last_frame) = last_frame {
*now - last_frame
} else {
Duration::ZERO
};
let scroll_factor = time_delta.as_secs_f32();
state.scroll(
self.direction.align(Vector::new(
delta.x.signum()
* delta
.x
.abs()
.powf(AUTOSCROLL_SMOOTHNESS)
* scroll_factor,
delta.y.signum()
* delta
.y
.abs()
.powf(AUTOSCROLL_SMOOTHNESS)
* scroll_factor,
)),
bounds,
content_bounds,
);
let has_scrolled = notify_scroll(
state,
&self.on_scroll,
bounds,
content_bounds,
shell,
);
if has_scrolled || time_delta.is_zero() {
state.interaction =
Interaction::AutoScrolling {
origin,
current,
last_frame: Some(*now),
};
shell.request_redraw();
}
return;
}
}
let _ = notify_viewport( let _ = notify_viewport(
state, state,
&self.on_scroll, &self.on_scroll,
@ -940,15 +1095,13 @@ where
update(); update();
let status = if state.y_scroller_grabbed_at.is_some() let status = if state.scrollers_grabbed() {
|| state.x_scroller_grabbed_at.is_some()
{
Status::Dragged { Status::Dragged {
is_horizontal_scrollbar_dragged: state is_horizontal_scrollbar_dragged: state
.x_scroller_grabbed_at .x_scroller_grabbed_at()
.is_some(), .is_some(),
is_vertical_scrollbar_dragged: state is_vertical_scrollbar_dragged: state
.y_scroller_grabbed_at .y_scroller_grabbed_at()
.is_some(), .is_some(),
is_horizontal_scrollbar_disabled: scrollbars.is_x_disabled(), is_horizontal_scrollbar_disabled: scrollbars.is_x_disabled(),
is_vertical_scrollbar_disabled: scrollbars.is_y_disabled(), is_vertical_scrollbar_disabled: scrollbars.is_y_disabled(),
@ -1212,24 +1365,196 @@ 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(),
class: &self.class,
})))
} 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<'a, Class> {
origin: Point,
vertical: bool,
horizontal: bool,
class: &'a Class,
}
impl<Class> AutoScrollIcon<'_, Class> {
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<'_, Theme::Class<'_>>
where
Renderer: text::Renderer,
Theme: Catalog,
{
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();
let style = theme
.style(
self.class,
Status::Active {
is_horizontal_scrollbar_disabled: false,
is_vertical_scrollbar_disabled: false,
},
)
.auto_scroll;
renderer.with_layer(Rectangle::INFINITE, |renderer| {
renderer.fill_quad(
renderer::Quad {
bounds,
border: style.border,
shadow: core::Shadow {
color: Color::BLACK.scale_alpha(0.8),
offset: Vector::new(1.0, 1.0),
blur_radius: 3.0,
},
snap: false,
},
style.background,
);
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()
},
style.icon,
);
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,
),
style.icon,
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,
),
style.icon,
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,
),
style.icon,
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,
),
style.icon,
bounds,
);
}
});
}
fn index(&self) -> f32 {
f32::MAX
} }
} }
@ -1239,7 +1564,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>,
@ -1318,25 +1643,34 @@ fn notify_viewport<Message>(
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
struct State { struct State {
scroll_area_touched_at: Option<Point>,
offset_y: Offset, offset_y: Offset,
y_scroller_grabbed_at: Option<f32>,
offset_x: Offset, offset_x: Offset,
x_scroller_grabbed_at: Option<f32>, interaction: Interaction,
keyboard_modifiers: keyboard::Modifiers, keyboard_modifiers: keyboard::Modifiers,
last_notified: Option<Viewport>, last_notified: Option<Viewport>,
last_scrolled: Option<Instant>, last_scrolled: Option<Instant>,
is_scrollbar_visible: bool, is_scrollbar_visible: bool,
} }
#[derive(Debug, Clone, Copy)]
enum Interaction {
None,
YScrollerGrabbed(f32),
XScrollerGrabbed(f32),
TouchScrolling(Point),
AutoScrolling {
origin: Point,
current: Point,
last_frame: Option<Instant>,
},
}
impl Default for State { impl Default for State {
fn default() -> Self { fn default() -> Self {
Self { Self {
scroll_area_touched_at: None,
offset_y: Offset::Absolute(0.0), offset_y: Offset::Absolute(0.0),
y_scroller_grabbed_at: None,
offset_x: Offset::Absolute(0.0), offset_x: Offset::Absolute(0.0),
x_scroller_grabbed_at: None, interaction: Interaction::None,
keyboard_modifiers: keyboard::Modifiers::default(), keyboard_modifiers: keyboard::Modifiers::default(),
last_notified: None, last_notified: None,
last_scrolled: None, last_scrolled: None,
@ -1455,14 +1789,11 @@ impl Viewport {
} }
impl State { impl State {
/// Creates a new [`State`] with the scrollbar(s) at the beginning. fn new() -> Self {
pub fn new() -> Self {
State::default() State::default()
} }
/// Apply a scrolling offset to the current [`State`], given the bounds of fn scroll(
/// the [`Scrollable`] and its contents.
pub fn scroll(
&mut self, &mut self,
delta: Vector<f32>, delta: Vector<f32>,
bounds: Rectangle, bounds: Rectangle,
@ -1485,11 +1816,7 @@ impl State {
} }
} }
/// Scrolls the [`Scrollable`] to a relative amount along the y axis. fn scroll_y_to(
///
/// `0` represents scrollbar at the beginning, while `1` represents scrollbar at
/// the end.
pub fn scroll_y_to(
&mut self, &mut self,
percentage: f32, percentage: f32,
bounds: Rectangle, bounds: Rectangle,
@ -1499,11 +1826,7 @@ impl State {
self.unsnap(bounds, content_bounds); self.unsnap(bounds, content_bounds);
} }
/// Scrolls the [`Scrollable`] to a relative amount along the x axis. fn scroll_x_to(
///
/// `0` represents scrollbar at the beginning, while `1` represents scrollbar at
/// the end.
pub fn scroll_x_to(
&mut self, &mut self,
percentage: f32, percentage: f32,
bounds: Rectangle, bounds: Rectangle,
@ -1513,14 +1836,12 @@ impl State {
self.unsnap(bounds, content_bounds); self.unsnap(bounds, content_bounds);
} }
/// Snaps the scroll position to a [`RelativeOffset`]. fn snap_to(&mut self, offset: RelativeOffset) {
pub fn snap_to(&mut self, offset: RelativeOffset) {
self.offset_x = Offset::Relative(offset.x.clamp(0.0, 1.0)); self.offset_x = Offset::Relative(offset.x.clamp(0.0, 1.0));
self.offset_y = Offset::Relative(offset.y.clamp(0.0, 1.0)); self.offset_y = Offset::Relative(offset.y.clamp(0.0, 1.0));
} }
/// Scroll to the provided [`AbsoluteOffset`]. fn scroll_to(&mut self, offset: AbsoluteOffset) {
pub fn scroll_to(&mut self, offset: AbsoluteOffset) {
self.offset_x = Offset::Absolute(offset.x.max(0.0)); self.offset_x = Offset::Absolute(offset.x.max(0.0));
self.offset_y = Offset::Absolute(offset.y.max(0.0)); self.offset_y = Offset::Absolute(offset.y.max(0.0));
} }
@ -1580,10 +1901,27 @@ impl State {
) )
} }
/// Returns whether any scroller is currently grabbed or not. fn scrollers_grabbed(&self) -> bool {
pub fn scrollers_grabbed(&self) -> bool { matches!(
self.x_scroller_grabbed_at.is_some() self.interaction,
|| self.y_scroller_grabbed_at.is_some() Interaction::YScrollerGrabbed(_) | Interaction::XScrollerGrabbed(_),
)
}
pub fn y_scroller_grabbed_at(&self) -> Option<f32> {
let Interaction::YScrollerGrabbed(at) = self.interaction else {
return None;
};
Some(at)
}
pub fn x_scroller_grabbed_at(&self) -> Option<f32> {
let Interaction::XScrollerGrabbed(at) = self.interaction else {
return None;
};
Some(at)
} }
} }
@ -1935,6 +2273,8 @@ pub struct Style {
pub horizontal_rail: Rail, pub horizontal_rail: Rail,
/// The [`Background`] of the gap between a horizontal and vertical scrollbar. /// The [`Background`] of the gap between a horizontal and vertical scrollbar.
pub gap: Option<Background>, pub gap: Option<Background>,
/// The appearance of the [`AutoScroll`] overlay.
pub auto_scroll: AutoScroll,
} }
/// The appearance of the scrollbar of a scrollable. /// The appearance of the scrollbar of a scrollable.
@ -1957,6 +2297,17 @@ pub struct Scroller {
pub border: Border, pub border: Border,
} }
/// The appearance of the autoscroll overlay of a scrollable.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AutoScroll {
/// The [`Background`] of the [`AutoScroll`] overlay.
pub background: Background,
/// The [`Border`] of the [`AutoScroll`] overlay.
pub border: Border,
/// The [`Color`] for the arrow icons of the [`AutoScroll`] overlay.
pub icon: Color,
}
/// The theme catalog of a [`Scrollable`]. /// The theme catalog of a [`Scrollable`].
pub trait Catalog { pub trait Catalog {
/// The item class of the [`Catalog`]. /// The item class of the [`Catalog`].
@ -1997,12 +2348,21 @@ pub fn default(theme: &Theme, status: Status) -> Style {
}, },
}; };
let auto_scroll = AutoScroll {
background: palette.background.weak.color.scale_alpha(0.9).into(),
border: border::rounded(u32::MAX)
.width(1)
.color(palette.background.weak.text.scale_alpha(0.8)),
icon: palette.background.weak.text.scale_alpha(0.8),
};
match status { match status {
Status::Active { .. } => Style { Status::Active { .. } => Style {
container: container::Style::default(), container: container::Style::default(),
vertical_rail: scrollbar, vertical_rail: scrollbar,
horizontal_rail: scrollbar, horizontal_rail: scrollbar,
gap: None, gap: None,
auto_scroll,
}, },
Status::Hovered { Status::Hovered {
is_horizontal_scrollbar_hovered, is_horizontal_scrollbar_hovered,
@ -2030,6 +2390,7 @@ pub fn default(theme: &Theme, status: Status) -> Style {
scrollbar scrollbar
}, },
gap: None, gap: None,
auto_scroll,
} }
} }
Status::Dragged { Status::Dragged {
@ -2058,6 +2419,7 @@ pub fn default(theme: &Theme, status: Status) -> Style {
scrollbar scrollbar
}, },
gap: None, gap: None,
auto_scroll,
} }
} }
} }