diff --git a/core/src/size.rs b/core/src/size.rs index 31f3171b66..a2c7292631 100644 --- a/core/src/size.rs +++ b/core/src/size.rs @@ -1,5 +1,4 @@ use crate::{Padding, Vector}; -use std::f32; /// An amount of space in 2 dimensions. #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/examples/README.md b/examples/README.md index bb15dc2ed2..74cf145b57 100644 --- a/examples/README.md +++ b/examples/README.md @@ -99,7 +99,7 @@ A bunch of simpler examples exist: - [`pick_list`](pick_list), a dropdown list of selectable options. - [`pokedex`](pokedex), an application that displays a random Pokédex entry (sprite included!) by using the [PokéAPI]. - [`progress_bar`](progress_bar), a simple progress bar that can be filled by using a slider. -- [`scrollable`](scrollable), a showcase of the various scrollbar width options. +- [`scrollable`](scrollable), a showcase of various scrollable content configurations. - [`sierpinski_triangle`](sierpinski_triangle), a [sierpiński triangle](https://en.wikipedia.org/wiki/Sierpi%C5%84ski_triangle) Emulator, use `Canvas` and `Slider`. - [`solar_system`](solar_system), an animated solar system drawn using the `Canvas` widget and showcasing how to compose different transforms. - [`stopwatch`](stopwatch), a watch with start/stop and reset buttons showcasing how to listen to time. diff --git a/examples/scrollable/Cargo.toml b/examples/scrollable/Cargo.toml index 610c13b4b1..e6411e26ec 100644 --- a/examples/scrollable/Cargo.toml +++ b/examples/scrollable/Cargo.toml @@ -7,3 +7,4 @@ publish = false [dependencies] iced = { path = "../..", features = ["debug"] } +once_cell = "1.16.0" diff --git a/examples/scrollable/screenshot.png b/examples/scrollable/screenshot.png index e91fd565c5..ee044447b6 100644 Binary files a/examples/scrollable/screenshot.png and b/examples/scrollable/screenshot.png differ diff --git a/examples/scrollable/src/main.rs b/examples/scrollable/src/main.rs index 6eba34e26c..128d98b220 100644 --- a/examples/scrollable/src/main.rs +++ b/examples/scrollable/src/main.rs @@ -1,44 +1,58 @@ -use iced::executor; +use iced::widget::scrollable::{Properties, Scrollbar, Scroller}; use iced::widget::{ - button, column, container, horizontal_rule, progress_bar, radio, - scrollable, text, vertical_space, Row, + button, column, container, horizontal_space, progress_bar, radio, row, + scrollable, slider, text, vertical_space, }; +use iced::{executor, theme, Alignment, Color}; use iced::{Application, Command, Element, Length, Settings, Theme}; +use once_cell::sync::Lazy; + +static SCROLLABLE_ID: Lazy = Lazy::new(scrollable::Id::unique); pub fn main() -> iced::Result { ScrollableDemo::run(Settings::default()) } struct ScrollableDemo { - theme: Theme, - variants: Vec, + scrollable_direction: Direction, + scrollbar_width: u16, + scrollbar_margin: u16, + scroller_width: u16, + current_scroll_offset: scrollable::RelativeOffset, } -#[derive(Debug, PartialEq, Eq, Clone, Copy)] -enum ThemeType { - Light, - Dark, +#[derive(Debug, Clone, Eq, PartialEq, Copy)] +enum Direction { + Vertical, + Horizontal, + Multi, } #[derive(Debug, Clone)] enum Message { - ThemeChanged(ThemeType), - ScrollToTop(usize), - ScrollToBottom(usize), - Scrolled(usize, f32), + SwitchDirection(Direction), + ScrollbarWidthChanged(u16), + ScrollbarMarginChanged(u16), + ScrollerWidthChanged(u16), + ScrollToBeginning, + ScrollToEnd, + Scrolled(scrollable::RelativeOffset), } impl Application for ScrollableDemo { + type Executor = executor::Default; type Message = Message; type Theme = Theme; - type Executor = executor::Default; type Flags = (); fn new(_flags: Self::Flags) -> (Self, Command) { ( ScrollableDemo { - theme: Default::default(), - variants: Variant::all(), + scrollable_direction: Direction::Vertical, + scrollbar_width: 10, + scrollbar_margin: 0, + scroller_width: 10, + current_scroll_offset: scrollable::RelativeOffset::START, }, Command::none(), ) @@ -50,36 +64,48 @@ impl Application for ScrollableDemo { fn update(&mut self, message: Message) -> Command { match message { - Message::ThemeChanged(theme) => { - self.theme = match theme { - ThemeType::Light => Theme::Light, - ThemeType::Dark => Theme::Dark, - }; + Message::SwitchDirection(direction) => { + self.current_scroll_offset = scrollable::RelativeOffset::START; + self.scrollable_direction = direction; + + scrollable::snap_to( + SCROLLABLE_ID.clone(), + self.current_scroll_offset, + ) + } + Message::ScrollbarWidthChanged(width) => { + self.scrollbar_width = width; + + Command::none() + } + Message::ScrollbarMarginChanged(margin) => { + self.scrollbar_margin = margin; Command::none() } - Message::ScrollToTop(i) => { - if let Some(variant) = self.variants.get_mut(i) { - variant.latest_offset = 0.0; - - scrollable::snap_to(Variant::id(i), 0.0) - } else { - Command::none() - } + Message::ScrollerWidthChanged(width) => { + self.scroller_width = width; + + Command::none() } - Message::ScrollToBottom(i) => { - if let Some(variant) = self.variants.get_mut(i) { - variant.latest_offset = 1.0; - - scrollable::snap_to(Variant::id(i), 1.0) - } else { - Command::none() - } + Message::ScrollToBeginning => { + self.current_scroll_offset = scrollable::RelativeOffset::START; + + scrollable::snap_to( + SCROLLABLE_ID.clone(), + self.current_scroll_offset, + ) } - Message::Scrolled(i, offset) => { - if let Some(variant) = self.variants.get_mut(i) { - variant.latest_offset = offset; - } + Message::ScrollToEnd => { + self.current_scroll_offset = scrollable::RelativeOffset::END; + + scrollable::snap_to( + SCROLLABLE_ID.clone(), + self.current_scroll_offset, + ) + } + Message::Scrolled(offset) => { + self.current_scroll_offset = offset; Command::none() } @@ -87,172 +113,262 @@ impl Application for ScrollableDemo { } fn view(&self) -> Element { - let ScrollableDemo { variants, .. } = self; - - let choose_theme = [ThemeType::Light, ThemeType::Dark].iter().fold( - column!["Choose a theme:"].spacing(10), - |column, option| { - column.push(radio( - format!("{:?}", option), - *option, - Some(*option), - Message::ThemeChanged, - )) - }, + let scrollbar_width_slider = slider( + 0..=15, + self.scrollbar_width, + Message::ScrollbarWidthChanged, + ); + let scrollbar_margin_slider = slider( + 0..=15, + self.scrollbar_margin, + Message::ScrollbarMarginChanged, ); + let scroller_width_slider = + slider(0..=15, self.scroller_width, Message::ScrollerWidthChanged); - let scrollable_row = Row::with_children( - variants - .iter() - .enumerate() - .map(|(i, variant)| { - let mut contents = column![ - variant.title, - button("Scroll to bottom",) - .width(Length::Fill) - .padding(10) - .on_press(Message::ScrollToBottom(i)), - ] - .padding(10) - .spacing(10) - .width(Length::Fill); - - if let Some(scrollbar_width) = variant.scrollbar_width { - contents = contents.push(text(format!( - "scrollbar_width: {:?}", - scrollbar_width - ))); - } - - if let Some(scrollbar_margin) = variant.scrollbar_margin { - contents = contents.push(text(format!( - "scrollbar_margin: {:?}", - scrollbar_margin - ))); - } - - if let Some(scroller_width) = variant.scroller_width { - contents = contents.push(text(format!( - "scroller_width: {:?}", - scroller_width - ))); - } - - contents = contents - .push(vertical_space(Length::Units(100))) - .push( - "Some content that should wrap within the \ - scrollable. Let's output a lot of short words, so \ - that we'll make sure to see how wrapping works \ - with these scrollbars.", - ) - .push(vertical_space(Length::Units(1200))) - .push("Middle") - .push(vertical_space(Length::Units(1200))) - .push("The End.") - .push( - button("Scroll to top") - .width(Length::Fill) - .padding(10) - .on_press(Message::ScrollToTop(i)), - ); - - let mut scrollable = scrollable(contents) - .id(Variant::id(i)) - .height(Length::Fill) - .on_scroll(move |offset| Message::Scrolled(i, offset)); - - if let Some(scrollbar_width) = variant.scrollbar_width { - scrollable = - scrollable.scrollbar_width(scrollbar_width); - } - - if let Some(scrollbar_margin) = variant.scrollbar_margin { - scrollable = - scrollable.scrollbar_margin(scrollbar_margin); - } - - if let Some(scroller_width) = variant.scroller_width { - scrollable = scrollable.scroller_width(scroller_width); - } + let scroll_slider_controls = column![ + text("Scrollbar width:"), + scrollbar_width_slider, + text("Scrollbar margin:"), + scrollbar_margin_slider, + text("Scroller width:"), + scroller_width_slider, + ] + .spacing(10) + .width(Length::Fill); + + let scroll_orientation_controls = column(vec![ + text("Scrollbar direction:").into(), + radio( + "Vertical", + Direction::Vertical, + Some(self.scrollable_direction), + Message::SwitchDirection, + ) + .into(), + radio( + "Horizontal", + Direction::Horizontal, + Some(self.scrollable_direction), + Message::SwitchDirection, + ) + .into(), + radio( + "Both!", + Direction::Multi, + Some(self.scrollable_direction), + Message::SwitchDirection, + ) + .into(), + ]) + .spacing(10) + .width(Length::Fill); + let scroll_controls = + row![scroll_slider_controls, scroll_orientation_controls] + .spacing(20) + .width(Length::Fill); + + let scroll_to_end_button = || { + button("Scroll to end") + .padding(10) + .on_press(Message::ScrollToEnd) + }; + + let scroll_to_beginning_button = || { + button("Scroll to beginning") + .padding(10) + .on_press(Message::ScrollToBeginning) + }; + + let scrollable_content: Element = + Element::from(match self.scrollable_direction { + Direction::Vertical => scrollable( column![ - scrollable, - progress_bar(0.0..=1.0, variant.latest_offset,) + scroll_to_end_button(), + text("Beginning!"), + vertical_space(Length::Units(1200)), + text("Middle!"), + vertical_space(Length::Units(1200)), + text("End!"), + scroll_to_beginning_button(), ] .width(Length::Fill) - .height(Length::Fill) - .spacing(10) + .align_items(Alignment::Center) + .padding([40, 0, 40, 0]) + .spacing(40), + ) + .height(Length::Fill) + .vertical_scroll( + Properties::new() + .width(self.scrollbar_width) + .margin(self.scrollbar_margin) + .scroller_width(self.scroller_width), + ) + .id(SCROLLABLE_ID.clone()) + .on_scroll(Message::Scrolled), + Direction::Horizontal => scrollable( + row![ + scroll_to_end_button(), + text("Beginning!"), + horizontal_space(Length::Units(1200)), + text("Middle!"), + horizontal_space(Length::Units(1200)), + text("End!"), + scroll_to_beginning_button(), + ] + .height(Length::Units(450)) + .align_items(Alignment::Center) + .padding([0, 40, 0, 40]) + .spacing(40), + ) + .height(Length::Fill) + .horizontal_scroll( + Properties::new() + .width(self.scrollbar_width) + .margin(self.scrollbar_margin) + .scroller_width(self.scroller_width), + ) + .style(theme::Scrollable::custom(ScrollbarCustomStyle)) + .id(SCROLLABLE_ID.clone()) + .on_scroll(Message::Scrolled), + Direction::Multi => scrollable( + //horizontal content + row![ + column![ + text("Let's do some scrolling!"), + vertical_space(Length::Units(2400)) + ], + scroll_to_end_button(), + text("Horizontal - Beginning!"), + horizontal_space(Length::Units(1200)), + //vertical content + column![ + text("Horizontal - Middle!"), + scroll_to_end_button(), + text("Vertical - Beginning!"), + vertical_space(Length::Units(1200)), + text("Vertical - Middle!"), + vertical_space(Length::Units(1200)), + text("Vertical - End!"), + scroll_to_beginning_button(), + vertical_space(Length::Units(40)), + ] + .align_items(Alignment::Fill) + .spacing(40), + horizontal_space(Length::Units(1200)), + text("Horizontal - End!"), + scroll_to_beginning_button(), + ] + .align_items(Alignment::Center) + .padding([0, 40, 0, 40]) + .spacing(40), + ) + .height(Length::Fill) + .vertical_scroll( + Properties::new() + .width(self.scrollbar_width) + .margin(self.scrollbar_margin) + .scroller_width(self.scroller_width), + ) + .horizontal_scroll( + Properties::new() + .width(self.scrollbar_width) + .margin(self.scrollbar_margin) + .scroller_width(self.scroller_width), + ) + .style(theme::Scrollable::Custom(Box::new( + ScrollbarCustomStyle, + ))) + .id(SCROLLABLE_ID.clone()) + .on_scroll(Message::Scrolled), + }); + + let progress_bars: Element = match self.scrollable_direction { + Direction::Vertical => { + progress_bar(0.0..=1.0, self.current_scroll_offset.y).into() + } + Direction::Horizontal => { + progress_bar(0.0..=1.0, self.current_scroll_offset.x) + .style(theme::ProgressBar::Custom(Box::new( + ProgressBarCustomStyle, + ))) .into() - }) - .collect(), - ) - .spacing(20) - .width(Length::Fill) - .height(Length::Fill); + } + Direction::Multi => column![ + progress_bar(0.0..=1.0, self.current_scroll_offset.y), + progress_bar(0.0..=1.0, self.current_scroll_offset.x).style( + theme::ProgressBar::Custom(Box::new( + ProgressBarCustomStyle, + )) + ) + ] + .spacing(10) + .into(), + }; - let content = - column![choose_theme, horizontal_rule(20), scrollable_row] - .spacing(20) - .padding(20); - - container(content) - .width(Length::Fill) - .height(Length::Fill) - .center_x() - .center_y() - .into() + let content: Element = + column![scroll_controls, scrollable_content, progress_bars] + .width(Length::Fill) + .height(Length::Fill) + .align_items(Alignment::Center) + .spacing(10) + .into(); + + Element::from( + container(content) + .width(Length::Fill) + .height(Length::Fill) + .padding(40) + .center_x() + .center_y(), + ) } - fn theme(&self) -> Theme { - self.theme.clone() + fn theme(&self) -> Self::Theme { + Theme::Dark } } -/// A version of a scrollable -struct Variant { - title: &'static str, - scrollbar_width: Option, - scrollbar_margin: Option, - scroller_width: Option, - latest_offset: f32, -} +struct ScrollbarCustomStyle; -impl Variant { - pub fn all() -> Vec { - vec![ - Self { - title: "Default Scrollbar", - scrollbar_width: None, - scrollbar_margin: None, - scroller_width: None, - latest_offset: 0.0, - }, - Self { - title: "Slimmed & Margin", - scrollbar_width: Some(4), - scrollbar_margin: Some(3), - scroller_width: Some(4), - latest_offset: 0.0, - }, - Self { - title: "Wide Scroller", - scrollbar_width: Some(4), - scrollbar_margin: None, - scroller_width: Some(10), - latest_offset: 0.0, - }, - Self { - title: "Narrow Scroller", - scrollbar_width: Some(10), - scrollbar_margin: None, - scroller_width: Some(4), - latest_offset: 0.0, +impl scrollable::StyleSheet for ScrollbarCustomStyle { + type Style = Theme; + + fn active(&self, style: &Self::Style) -> Scrollbar { + style.active(&theme::Scrollable::Default) + } + + fn hovered(&self, style: &Self::Style) -> Scrollbar { + style.hovered(&theme::Scrollable::Default) + } + + fn hovered_horizontal(&self, style: &Self::Style) -> Scrollbar { + Scrollbar { + background: style.active(&theme::Scrollable::default()).background, + border_radius: 0.0, + border_width: 0.0, + border_color: Default::default(), + scroller: Scroller { + color: Color::from_rgb8(250, 85, 134), + border_radius: 0.0, + border_width: 0.0, + border_color: Default::default(), }, - ] + } } +} + +struct ProgressBarCustomStyle; - pub fn id(i: usize) -> scrollable::Id { - scrollable::Id::new(format!("scrollable-{}", i)) +impl progress_bar::StyleSheet for ProgressBarCustomStyle { + type Style = Theme; + + fn appearance(&self, style: &Self::Style) -> progress_bar::Appearance { + progress_bar::Appearance { + background: style.extended_palette().background.strong.color.into(), + bar: Color::from_rgb8(250, 85, 134).into(), + border_radius: 0.0, + } } } diff --git a/examples/websocket/src/main.rs b/examples/websocket/src/main.rs index ff2929da84..ccd9c81582 100644 --- a/examples/websocket/src/main.rs +++ b/examples/websocket/src/main.rs @@ -81,7 +81,10 @@ impl Application for WebSocket { echo::Event::MessageReceived(message) => { self.messages.push(message); - scrollable::snap_to(MESSAGE_LOG.clone(), 1.0) + scrollable::snap_to( + MESSAGE_LOG.clone(), + scrollable::RelativeOffset::END, + ) } }, Message::Server => Command::none(), diff --git a/native/src/widget/column.rs b/native/src/widget/column.rs index f2ef132a8d..5ad4d85892 100644 --- a/native/src/widget/column.rs +++ b/native/src/widget/column.rs @@ -10,8 +10,6 @@ use crate::{ Shell, Widget, }; -use std::u32; - /// A container that distributes its contents vertically. #[allow(missing_debug_implementations)] pub struct Column<'a, Message, Renderer> { diff --git a/native/src/widget/operation/scrollable.rs b/native/src/widget/operation/scrollable.rs index 2210137d53..3b20631f30 100644 --- a/native/src/widget/operation/scrollable.rs +++ b/native/src/widget/operation/scrollable.rs @@ -3,25 +3,19 @@ use crate::widget::{Id, Operation}; /// The internal state of a widget that can be scrolled. pub trait Scrollable { - /// Snaps the scroll of the widget to the given `percentage`. - fn snap_to(&mut self, percentage: f32); + /// Snaps the scroll of the widget to the given `percentage` along the horizontal & vertical axis. + fn snap_to(&mut self, offset: RelativeOffset); } /// Produces an [`Operation`] that snaps the widget with the given [`Id`] to /// the provided `percentage`. -pub fn snap_to(target: Id, percentage: f32) -> impl Operation { +pub fn snap_to(target: Id, offset: RelativeOffset) -> impl Operation { struct SnapTo { target: Id, - percentage: f32, + offset: RelativeOffset, } impl Operation for SnapTo { - fn scrollable(&mut self, state: &mut dyn Scrollable, id: Option<&Id>) { - if Some(&self.target) == id { - state.snap_to(self.percentage); - } - } - fn container( &mut self, _id: Option<&Id>, @@ -29,7 +23,32 @@ pub fn snap_to(target: Id, percentage: f32) -> impl Operation { ) { operate_on_children(self) } + + fn scrollable(&mut self, state: &mut dyn Scrollable, id: Option<&Id>) { + if Some(&self.target) == id { + state.snap_to(self.offset); + } + } } - SnapTo { target, percentage } + SnapTo { target, offset } +} + +/// The amount of offset in each direction of a [`Scrollable`]. +/// +/// A value of `0.0` means start, while `1.0` means end. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct RelativeOffset { + /// The amount of horizontal offset + pub x: f32, + /// The amount of vertical offset + pub y: f32, +} + +impl RelativeOffset { + /// A relative offset that points to the top-left of a [`Scrollable`]. + pub const START: Self = Self { x: 0.0, y: 0.0 }; + + /// A relative offset that points to the bottom-right of a [`Scrollable`]. + pub const END: Self = Self { x: 1.0, y: 1.0 }; } diff --git a/native/src/widget/scrollable.rs b/native/src/widget/scrollable.rs index 20780f899a..822860364d 100644 --- a/native/src/widget/scrollable.rs +++ b/native/src/widget/scrollable.rs @@ -1,5 +1,6 @@ //! Navigate an endless amount of content with a scrollbar. use crate::event::{self, Event}; +use crate::keyboard; use crate::layout; use crate::mouse; use crate::overlay; @@ -13,9 +14,8 @@ use crate::{ Rectangle, Shell, Size, Vector, Widget, }; -use std::{f32, u32}; - pub use iced_style::scrollable::StyleSheet; +pub use operation::scrollable::RelativeOffset; pub mod style { //! The styles of a [`Scrollable`]. @@ -34,11 +34,10 @@ where { id: Option, height: Length, - scrollbar_width: u16, - scrollbar_margin: u16, - scroller_width: u16, + vertical: Properties, + horizontal: Option, content: Element<'a, Message, Renderer>, - on_scroll: Option Message + 'a>>, + on_scroll: Option Message + 'a>>, style: ::Style, } @@ -52,9 +51,8 @@ where Scrollable { id: None, height: Length::Shrink, - scrollbar_width: 10, - scrollbar_margin: 0, - scroller_width: 10, + vertical: Properties::default(), + horizontal: None, content: content.into(), on_scroll: None, style: Default::default(), @@ -73,32 +71,26 @@ where self } - /// Sets the scrollbar width of the [`Scrollable`] . - /// Silently enforces a minimum value of 1. - pub fn scrollbar_width(mut self, scrollbar_width: u16) -> Self { - self.scrollbar_width = scrollbar_width.max(1); + /// Configures the vertical scrollbar of the [`Scrollable`] . + pub fn vertical_scroll(mut self, properties: Properties) -> Self { + self.vertical = properties; self } - /// Sets the scrollbar margin of the [`Scrollable`] . - pub fn scrollbar_margin(mut self, scrollbar_margin: u16) -> Self { - self.scrollbar_margin = scrollbar_margin; - self - } - - /// Sets the scroller width of the [`Scrollable`] . - /// - /// It silently enforces a minimum value of 1. - pub fn scroller_width(mut self, scroller_width: u16) -> Self { - self.scroller_width = scroller_width.max(1); + /// Configures the horizontal scrollbar of the [`Scrollable`] . + pub fn horizontal_scroll(mut self, properties: Properties) -> Self { + self.horizontal = Some(properties); self } /// Sets a function to call when the [`Scrollable`] is scrolled. /// - /// The function takes the new relative offset of the [`Scrollable`] - /// (e.g. `0` means top, while `1` means bottom). - pub fn on_scroll(mut self, f: impl Fn(f32) -> Message + 'a) -> Self { + /// The function takes the new relative x & y offset of the [`Scrollable`] + /// (e.g. `0` means beginning, while `1` means end). + pub fn on_scroll( + mut self, + f: impl Fn(RelativeOffset) -> Message + 'a, + ) -> Self { self.on_scroll = Some(Box::new(f)); self } @@ -113,6 +105,51 @@ where } } +/// Properties of a scrollbar within a [`Scrollable`]. +#[derive(Debug)] +pub struct Properties { + width: u16, + margin: u16, + scroller_width: u16, +} + +impl Default for Properties { + fn default() -> Self { + Self { + width: 10, + margin: 0, + scroller_width: 10, + } + } +} + +impl Properties { + /// Creates new [`Properties`] for use in a [`Scrollable`]. + pub fn new() -> Self { + Self::default() + } + + /// Sets the scrollbar width of the [`Scrollable`] . + /// Silently enforces a minimum width of 1. + pub fn width(mut self, width: u16) -> Self { + self.width = width.max(1); + self + } + + /// Sets the scrollbar margin of the [`Scrollable`] . + pub fn margin(mut self, margin: u16) -> Self { + self.margin = margin; + self + } + + /// Sets the scroller width of the [`Scrollable`] . + /// Silently enforces a minimum width of 1. + pub fn scroller_width(mut self, scroller_width: u16) -> Self { + self.scroller_width = scroller_width.max(1); + self + } +} + impl<'a, Message, Renderer> Widget for Scrollable<'a, Message, Renderer> where @@ -153,7 +190,7 @@ where limits, Widget::::width(self), self.height, - u32::MAX, + self.horizontal.is_some(), |renderer, limits| { self.content.as_widget().layout(renderer, limits) }, @@ -198,9 +235,8 @@ where cursor_position, clipboard, shell, - self.scrollbar_width, - self.scrollbar_margin, - self.scroller_width, + &self.vertical, + self.horizontal.as_ref(), &self.on_scroll, |event, layout, cursor_position, clipboard, shell| { self.content.as_widget_mut().on_event( @@ -232,9 +268,8 @@ where theme, layout, cursor_position, - self.scrollbar_width, - self.scrollbar_margin, - self.scroller_width, + &self.vertical, + self.horizontal.as_ref(), &self.style, |renderer, layout, cursor_position, viewport| { self.content.as_widget().draw( @@ -262,9 +297,8 @@ where tree.state.downcast_ref::(), layout, cursor_position, - self.scrollbar_width, - self.scrollbar_margin, - self.scroller_width, + &self.vertical, + self.horizontal.as_ref(), |layout, cursor_position, viewport| { self.content.as_widget().mouse_interaction( &tree.children[0], @@ -299,7 +333,7 @@ where .downcast_ref::() .offset(bounds, content_bounds); - overlay.translate(Vector::new(0.0, -(offset as f32))) + overlay.translate(Vector::new(-offset.x, -offset.y)) }) } } @@ -343,9 +377,12 @@ impl From for widget::Id { } /// Produces a [`Command`] that snaps the [`Scrollable`] with the given [`Id`] -/// to the provided `percentage`. -pub fn snap_to(id: Id, percentage: f32) -> Command { - Command::widget(operation::scrollable::snap_to(id.0, percentage)) +/// to the provided `percentage` along the x & y axis. +pub fn snap_to( + id: Id, + offset: RelativeOffset, +) -> Command { + Command::widget(operation::scrollable::snap_to(id.0, offset)) } /// Computes the layout of a [`Scrollable`]. @@ -354,14 +391,29 @@ pub fn layout( limits: &layout::Limits, width: Length, height: Length, - max_height: u32, + horizontal_enabled: bool, layout_content: impl FnOnce(&Renderer, &layout::Limits) -> layout::Node, ) -> layout::Node { - let limits = limits.max_height(max_height).width(width).height(height); + let limits = limits + .max_height(u32::MAX) + .max_width(if horizontal_enabled { + u32::MAX + } else { + limits.max().width as u32 + }) + .width(width) + .height(height); let child_limits = layout::Limits::new( Size::new(limits.min().width, 0.0), - Size::new(limits.max().width, f32::INFINITY), + Size::new( + if horizontal_enabled { + f32::INFINITY + } else { + limits.max().width + }, + f32::MAX, + ), ); let content = layout_content(renderer, &child_limits); @@ -379,10 +431,9 @@ pub fn update( cursor_position: Point, clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, - scrollbar_width: u16, - scrollbar_margin: u16, - scroller_width: u16, - on_scroll: &Option Message + '_>>, + vertical: &Properties, + horizontal: Option<&Properties>, + on_scroll: &Option Message + '_>>, update_content: impl FnOnce( Event, Layout<'_>, @@ -392,36 +443,28 @@ pub fn update( ) -> event::Status, ) -> event::Status { let bounds = layout.bounds(); - let is_mouse_over = bounds.contains(cursor_position); + let mouse_over_scrollable = bounds.contains(cursor_position); let content = layout.children().next().unwrap(); let content_bounds = content.bounds(); - let scrollbar = scrollbar( - state, - scrollbar_width, - scrollbar_margin, - scroller_width, - bounds, - content_bounds, - ); - let is_mouse_over_scrollbar = scrollbar - .as_ref() - .map(|scrollbar| scrollbar.is_mouse_over(cursor_position)) - .unwrap_or(false); + let scrollbars = + Scrollbars::new(state, vertical, horizontal, bounds, content_bounds); + + let (mouse_over_y_scrollbar, mouse_over_x_scrollbar) = + scrollbars.is_mouse_over(cursor_position); let event_status = { - let cursor_position = if is_mouse_over && !is_mouse_over_scrollbar { - Point::new( - cursor_position.x, - cursor_position.y + state.offset(bounds, content_bounds) as f32, - ) + let cursor_position = if mouse_over_scrollable + && !(mouse_over_y_scrollbar || mouse_over_x_scrollbar) + { + cursor_position + state.offset(bounds, content_bounds) } else { // TODO: Make `cursor_position` an `Option` so we can encode // cursor availability. // This will probably happen naturally once we add multi-window // support. - Point::new(cursor_position.x, -1.0) + Point::new(-1.0, -1.0) }; update_content( @@ -437,18 +480,31 @@ pub fn update( return event::Status::Captured; } - if is_mouse_over { + if let Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) = event + { + state.keyboard_modifiers = modifiers; + + return event::Status::Ignored; + } + + if mouse_over_scrollable { match event { Event::Mouse(mouse::Event::WheelScrolled { delta }) => { - match delta { - mouse::ScrollDelta::Lines { y, .. } => { - // TODO: Configurable speed (?) - state.scroll(y * 60.0, bounds, content_bounds); + let delta = match delta { + mouse::ScrollDelta::Lines { x, y } => { + // TODO: Configurable speed/friction (?) + let movement = if state.keyboard_modifiers.shift() { + Vector::new(y, x) + } else { + Vector::new(x, y) + }; + + movement * 60.0 } - mouse::ScrollDelta::Pixels { y, .. } => { - state.scroll(y, bounds, content_bounds); - } - } + mouse::ScrollDelta::Pixels { x, y } => Vector::new(x, y), + }; + + state.scroll(delta, bounds, content_bounds); notify_on_scroll( state, @@ -460,21 +516,27 @@ pub fn update( return event::Status::Captured; } - Event::Touch(event) => { + Event::Touch(event) + if state.scroll_area_touched_at.is_some() + || !mouse_over_y_scrollbar && !mouse_over_x_scrollbar => + { match event { touch::Event::FingerPressed { .. } => { - state.scroll_box_touched_at = Some(cursor_position); + state.scroll_area_touched_at = Some(cursor_position); } touch::Event::FingerMoved { .. } => { if let Some(scroll_box_touched_at) = - state.scroll_box_touched_at + state.scroll_area_touched_at { - let delta = - cursor_position.y - scroll_box_touched_at.y; + let delta = Vector::new( + cursor_position.x - scroll_box_touched_at.x, + cursor_position.y - scroll_box_touched_at.y, + ); state.scroll(delta, bounds, content_bounds); - state.scroll_box_touched_at = Some(cursor_position); + state.scroll_area_touched_at = + Some(cursor_position); notify_on_scroll( state, @@ -487,7 +549,7 @@ pub fn update( } touch::Event::FingerLifted { .. } | touch::Event::FingerLost { .. } => { - state.scroll_box_touched_at = None; + state.scroll_area_touched_at = None; } } @@ -497,22 +559,20 @@ pub fn update( } } - if state.is_scroller_grabbed() { + if let Some(scroller_grabbed_at) = state.y_scroller_grabbed_at { match event { Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) | Event::Touch(touch::Event::FingerLifted { .. }) | Event::Touch(touch::Event::FingerLost { .. }) => { - state.scroller_grabbed_at = None; + state.y_scroller_grabbed_at = None; return event::Status::Captured; } Event::Mouse(mouse::Event::CursorMoved { .. }) | Event::Touch(touch::Event::FingerMoved { .. }) => { - if let (Some(scrollbar), Some(scroller_grabbed_at)) = - (scrollbar, state.scroller_grabbed_at) - { - state.scroll_to( - scrollbar.scroll_percentage( + if let Some(scrollbar) = scrollbars.y { + state.scroll_y_to( + scrollbar.scroll_percentage_y( scroller_grabbed_at, cursor_position, ), @@ -533,35 +593,100 @@ pub fn update( } _ => {} } - } else if is_mouse_over_scrollbar { + } else if mouse_over_y_scrollbar { match event { Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) | Event::Touch(touch::Event::FingerPressed { .. }) => { - if let Some(scrollbar) = scrollbar { - if let Some(scroller_grabbed_at) = - scrollbar.grab_scroller(cursor_position) - { - state.scroll_to( - scrollbar.scroll_percentage( - scroller_grabbed_at, - cursor_position, - ), - bounds, - content_bounds, - ); - - state.scroller_grabbed_at = Some(scroller_grabbed_at); - - notify_on_scroll( - state, - on_scroll, - bounds, - content_bounds, - shell, - ); - - return event::Status::Captured; - } + if let (Some(scroller_grabbed_at), Some(scrollbar)) = + (scrollbars.grab_y_scroller(cursor_position), scrollbars.y) + { + state.scroll_y_to( + scrollbar.scroll_percentage_y( + scroller_grabbed_at, + cursor_position, + ), + bounds, + content_bounds, + ); + + state.y_scroller_grabbed_at = Some(scroller_grabbed_at); + + notify_on_scroll( + state, + on_scroll, + bounds, + content_bounds, + shell, + ); + } + + return event::Status::Captured; + } + _ => {} + } + } + + if let Some(scroller_grabbed_at) = state.x_scroller_grabbed_at { + match event { + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) + | Event::Touch(touch::Event::FingerLifted { .. }) + | Event::Touch(touch::Event::FingerLost { .. }) => { + state.x_scroller_grabbed_at = None; + + return event::Status::Captured; + } + Event::Mouse(mouse::Event::CursorMoved { .. }) + | Event::Touch(touch::Event::FingerMoved { .. }) => { + if let Some(scrollbar) = scrollbars.x { + state.scroll_x_to( + scrollbar.scroll_percentage_x( + scroller_grabbed_at, + cursor_position, + ), + bounds, + content_bounds, + ); + + notify_on_scroll( + state, + on_scroll, + bounds, + content_bounds, + shell, + ); + } + + return event::Status::Captured; + } + _ => {} + } + } else if mouse_over_x_scrollbar { + match event { + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) + | Event::Touch(touch::Event::FingerPressed { .. }) => { + if let (Some(scroller_grabbed_at), Some(scrollbar)) = + (scrollbars.grab_x_scroller(cursor_position), scrollbars.x) + { + state.scroll_x_to( + scrollbar.scroll_percentage_x( + scroller_grabbed_at, + cursor_position, + ), + bounds, + content_bounds, + ); + + state.x_scroller_grabbed_at = Some(scroller_grabbed_at); + + notify_on_scroll( + state, + on_scroll, + bounds, + content_bounds, + shell, + ); + + return event::Status::Captured; } } _ => {} @@ -576,9 +701,8 @@ pub fn mouse_interaction( state: &State, layout: Layout<'_>, cursor_position: Point, - scrollbar_width: u16, - scrollbar_margin: u16, - scroller_width: u16, + vertical: &Properties, + horizontal: Option<&Properties>, content_interaction: impl FnOnce( Layout<'_>, Point, @@ -586,39 +710,38 @@ pub fn mouse_interaction( ) -> mouse::Interaction, ) -> mouse::Interaction { let bounds = layout.bounds(); + let mouse_over_scrollable = bounds.contains(cursor_position); + let content_layout = layout.children().next().unwrap(); let content_bounds = content_layout.bounds(); - let scrollbar = scrollbar( - state, - scrollbar_width, - scrollbar_margin, - scroller_width, - bounds, - content_bounds, - ); - let is_mouse_over = bounds.contains(cursor_position); - let is_mouse_over_scrollbar = scrollbar - .as_ref() - .map(|scrollbar| scrollbar.is_mouse_over(cursor_position)) - .unwrap_or(false); + let scrollbars = + Scrollbars::new(state, vertical, horizontal, bounds, content_bounds); + + let (mouse_over_y_scrollbar, mouse_over_x_scrollbar) = + scrollbars.is_mouse_over(cursor_position); - if is_mouse_over_scrollbar || state.is_scroller_grabbed() { + if (mouse_over_x_scrollbar || mouse_over_y_scrollbar) + || state.scrollers_grabbed() + { mouse::Interaction::Idle } else { let offset = state.offset(bounds, content_bounds); - let cursor_position = if is_mouse_over && !is_mouse_over_scrollbar { - Point::new(cursor_position.x, cursor_position.y + offset as f32) + let cursor_position = if mouse_over_scrollable + && !(mouse_over_y_scrollbar || mouse_over_x_scrollbar) + { + cursor_position + offset } else { - Point::new(cursor_position.x, -1.0) + Point::new(-1.0, -1.0) }; content_interaction( content_layout, cursor_position, &Rectangle { - y: bounds.y + offset as f32, + y: bounds.y + offset.y, + x: bounds.x + offset.x, ..bounds }, ) @@ -632,9 +755,8 @@ pub fn draw( theme: &Renderer::Theme, layout: Layout<'_>, cursor_position: Point, - scrollbar_width: u16, - scrollbar_margin: u16, - scroller_width: u16, + vertical: &Properties, + horizontal: Option<&Properties>, style: &::Style, draw_content: impl FnOnce(&mut Renderer, Layout<'_>, Point, &Rectangle), ) where @@ -644,39 +766,37 @@ pub fn draw( let bounds = layout.bounds(); let content_layout = layout.children().next().unwrap(); let content_bounds = content_layout.bounds(); - let offset = state.offset(bounds, content_bounds); - let scrollbar = scrollbar( - state, - scrollbar_width, - scrollbar_margin, - scroller_width, - bounds, - content_bounds, - ); - let is_mouse_over = bounds.contains(cursor_position); - let is_mouse_over_scrollbar = scrollbar - .as_ref() - .map(|scrollbar| scrollbar.is_mouse_over(cursor_position)) - .unwrap_or(false); + let scrollbars = + Scrollbars::new(state, vertical, horizontal, bounds, content_bounds); + + let mouse_over_scrollable = bounds.contains(cursor_position); + let (mouse_over_y_scrollbar, mouse_over_x_scrollbar) = + scrollbars.is_mouse_over(cursor_position); + + let offset = state.offset(bounds, content_bounds); - let cursor_position = if is_mouse_over && !is_mouse_over_scrollbar { - Point::new(cursor_position.x, cursor_position.y + offset as f32) + let cursor_position = if mouse_over_scrollable + && !(mouse_over_x_scrollbar || mouse_over_y_scrollbar) + { + cursor_position + offset } else { - Point::new(cursor_position.x, -1.0) + Point::new(-1.0, -1.0) }; - if let Some(scrollbar) = scrollbar { + // Draw inner content + if scrollbars.active() { renderer.with_layer(bounds, |renderer| { renderer.with_translation( - Vector::new(0.0, -(offset as f32)), + Vector::new(-offset.x, -offset.y), |renderer| { draw_content( renderer, content_layout, cursor_position, &Rectangle { - y: bounds.y + offset as f32, + y: bounds.y + offset.y, + x: bounds.x + offset.x, ..bounds }, ); @@ -684,25 +804,15 @@ pub fn draw( ); }); - let style = if state.is_scroller_grabbed() { - theme.dragging(style) - } else if is_mouse_over_scrollbar { - theme.hovered(style) - } else { - theme.active(style) - }; - - let is_scrollbar_visible = - style.background.is_some() || style.border_width > 0.0; - - renderer.with_layer( - Rectangle { - width: bounds.width + 2.0, - height: bounds.height + 2.0, - ..bounds - }, - |renderer| { - if is_scrollbar_visible { + let draw_scrollbar = + |renderer: &mut Renderer, + style: style::Scrollbar, + scrollbar: &Scrollbar| { + //track + if style.background.is_some() + || (style.border_color != Color::TRANSPARENT + && style.border_width > 0.0) + { renderer.fill_quad( renderer::Quad { bounds: scrollbar.bounds, @@ -716,8 +826,10 @@ pub fn draw( ); } - if (is_mouse_over || state.is_scroller_grabbed()) - && is_scrollbar_visible + //thumb + if style.scroller.color != Color::TRANSPARENT + || (style.scroller.border_color != Color::TRANSPARENT + && style.scroller.border_width > 0.0) { renderer.fill_quad( renderer::Quad { @@ -729,6 +841,40 @@ pub fn draw( style.scroller.color, ); } + }; + + renderer.with_layer( + Rectangle { + width: bounds.width + 2.0, + height: bounds.height + 2.0, + ..bounds + }, + |renderer| { + //draw y scrollbar + if let Some(scrollbar) = scrollbars.y { + let style = if state.y_scroller_grabbed_at.is_some() { + theme.dragging(style) + } else if mouse_over_y_scrollbar { + theme.hovered(style) + } else { + theme.active(style) + }; + + draw_scrollbar(renderer, style, &scrollbar); + } + + //draw x scrollbar + if let Some(scrollbar) = scrollbars.x { + let style = if state.x_scroller_grabbed_at.is_some() { + theme.dragging_horizontal(style) + } else if mouse_over_x_scrollbar { + theme.hovered_horizontal(style) + } else { + theme.active_horizontal(style) + }; + + draw_scrollbar(renderer, style, &scrollbar); + } }, ); } else { @@ -737,110 +883,70 @@ pub fn draw( content_layout, cursor_position, &Rectangle { - y: bounds.y + offset as f32, + x: bounds.x + offset.x, + y: bounds.y + offset.y, ..bounds }, ); } } -fn scrollbar( - state: &State, - scrollbar_width: u16, - scrollbar_margin: u16, - scroller_width: u16, - bounds: Rectangle, - content_bounds: Rectangle, -) -> Option { - let offset = state.offset(bounds, content_bounds); - - if content_bounds.height > bounds.height { - let outer_width = - scrollbar_width.max(scroller_width) + 2 * scrollbar_margin; - - let outer_bounds = Rectangle { - x: bounds.x + bounds.width - outer_width as f32, - y: bounds.y, - width: outer_width as f32, - height: bounds.height, - }; - - let scrollbar_bounds = Rectangle { - x: bounds.x + bounds.width - - f32::from(outer_width / 2 + scrollbar_width / 2), - y: bounds.y, - width: scrollbar_width as f32, - height: bounds.height, - }; - - let ratio = bounds.height / content_bounds.height; - let scroller_height = bounds.height * ratio; - let y_offset = offset as f32 * ratio; - - let scroller_bounds = Rectangle { - x: bounds.x + bounds.width - - f32::from(outer_width / 2 + scroller_width / 2), - y: scrollbar_bounds.y + y_offset, - width: scroller_width as f32, - height: scroller_height, - }; - - Some(Scrollbar { - outer_bounds, - bounds: scrollbar_bounds, - scroller: Scroller { - bounds: scroller_bounds, - }, - }) - } else { - None - } -} - fn notify_on_scroll( state: &State, - on_scroll: &Option Message + '_>>, + on_scroll: &Option Message + '_>>, bounds: Rectangle, content_bounds: Rectangle, shell: &mut Shell<'_, Message>, ) { - if content_bounds.height <= bounds.height { - return; - } - if let Some(on_scroll) = on_scroll { - shell.publish(on_scroll( - state.offset.absolute(bounds, content_bounds) - / (content_bounds.height - bounds.height), - )); + if content_bounds.width <= bounds.width + && content_bounds.height <= bounds.height + { + return; + } + + let x = state.offset_x.absolute(bounds.width, content_bounds.width) + / (content_bounds.width - bounds.width); + + let y = state + .offset_y + .absolute(bounds.height, content_bounds.height) + / (content_bounds.height - bounds.height); + + shell.publish(on_scroll(RelativeOffset { x, y })) } } /// The local state of a [`Scrollable`]. #[derive(Debug, Clone, Copy)] pub struct State { - scroller_grabbed_at: Option, - scroll_box_touched_at: Option, - offset: Offset, + scroll_area_touched_at: Option, + offset_y: Offset, + y_scroller_grabbed_at: Option, + offset_x: Offset, + x_scroller_grabbed_at: Option, + keyboard_modifiers: keyboard::Modifiers, } impl Default for State { fn default() -> Self { Self { - scroller_grabbed_at: None, - scroll_box_touched_at: None, - offset: Offset::Absolute(0.0), + scroll_area_touched_at: None, + offset_y: Offset::Absolute(0.0), + y_scroller_grabbed_at: None, + offset_x: Offset::Absolute(0.0), + x_scroller_grabbed_at: None, + keyboard_modifiers: keyboard::Modifiers::default(), } } } impl operation::Scrollable for State { - fn snap_to(&mut self, percentage: f32) { - State::snap_to(self, percentage); + fn snap_to(&mut self, offset: RelativeOffset) { + State::snap_to(self, offset); } } -/// The local state of a [`Scrollable`]. #[derive(Debug, Clone, Copy)] enum Offset { Absolute(f32), @@ -848,23 +954,20 @@ enum Offset { } impl Offset { - fn absolute(self, bounds: Rectangle, content_bounds: Rectangle) -> f32 { + fn absolute(self, window: f32, content: f32) -> f32 { match self { - Self::Absolute(absolute) => { - let hidden_content = - (content_bounds.height - bounds.height).max(0.0); - - absolute.min(hidden_content) + Offset::Absolute(absolute) => { + absolute.min((content - window).max(0.0)) } - Self::Relative(percentage) => { - ((content_bounds.height - bounds.height) * percentage).max(0.0) + Offset::Relative(percentage) => { + ((content - window) * percentage).max(0.0) } } } } impl State { - /// Creates a new [`State`] with the scrollbar located at the top. + /// Creates a new [`State`] with the scrollbar(s) at the beginning. pub fn new() -> Self { State::default() } @@ -873,107 +976,341 @@ impl State { /// the [`Scrollable`] and its contents. pub fn scroll( &mut self, - delta_y: f32, + delta: Vector, bounds: Rectangle, content_bounds: Rectangle, ) { - if bounds.height >= content_bounds.height { - return; + if bounds.height < content_bounds.height { + self.offset_y = Offset::Absolute( + (self.offset_y.absolute(bounds.height, content_bounds.height) + - delta.y) + .clamp(0.0, content_bounds.height - bounds.height), + ) } - self.offset = Offset::Absolute( - (self.offset.absolute(bounds, content_bounds) - delta_y) - .clamp(0.0, content_bounds.height - bounds.height), - ); + if bounds.width < content_bounds.width { + self.offset_x = Offset::Absolute( + (self.offset_x.absolute(bounds.width, content_bounds.width) + - delta.x) + .clamp(0.0, content_bounds.width - bounds.width), + ); + } } - /// Scrolls the [`Scrollable`] to a relative amount. + /// Scrolls the [`Scrollable`] to a relative amount along the y axis. /// - /// `0` represents scrollbar at the top, while `1` represents scrollbar at - /// the bottom. - pub fn scroll_to( + /// `0` represents scrollbar at the beginning, while `1` represents scrollbar at + /// the end. + pub fn scroll_y_to( &mut self, percentage: f32, bounds: Rectangle, content_bounds: Rectangle, ) { - self.snap_to(percentage); + self.offset_y = Offset::Relative(percentage.clamp(0.0, 1.0)); self.unsnap(bounds, content_bounds); } - /// Snaps the scroll position to a relative amount. + /// Scrolls the [`Scrollable`] to a relative amount along the x axis. /// - /// `0` represents scrollbar at the top, while `1` represents scrollbar at - /// the bottom. - pub fn snap_to(&mut self, percentage: f32) { - self.offset = Offset::Relative(percentage.clamp(0.0, 1.0)); + /// `0` represents scrollbar at the beginning, while `1` represents scrollbar at + /// the end. + pub fn scroll_x_to( + &mut self, + percentage: f32, + bounds: Rectangle, + content_bounds: Rectangle, + ) { + self.offset_x = Offset::Relative(percentage.clamp(0.0, 1.0)); + self.unsnap(bounds, content_bounds); + } + + /// Snaps the scroll position to a [`RelativeOffset`]. + pub fn snap_to(&mut self, offset: RelativeOffset) { + self.offset_x = Offset::Relative(offset.x.clamp(0.0, 1.0)); + self.offset_y = Offset::Relative(offset.y.clamp(0.0, 1.0)); } /// Unsnaps the current scroll position, if snapped, given the bounds of the /// [`Scrollable`] and its contents. pub fn unsnap(&mut self, bounds: Rectangle, content_bounds: Rectangle) { - self.offset = - Offset::Absolute(self.offset.absolute(bounds, content_bounds)); + self.offset_x = Offset::Absolute( + self.offset_x.absolute(bounds.width, content_bounds.width), + ); + self.offset_y = Offset::Absolute( + self.offset_y.absolute(bounds.height, content_bounds.height), + ); } - /// Returns the current scrolling offset of the [`State`], given the bounds - /// of the [`Scrollable`] and its contents. - pub fn offset(&self, bounds: Rectangle, content_bounds: Rectangle) -> u32 { - self.offset.absolute(bounds, content_bounds) as u32 + /// Returns the scrolling offset of the [`State`], given the bounds of the + /// [`Scrollable`] and its contents. + pub fn offset( + &self, + bounds: Rectangle, + content_bounds: Rectangle, + ) -> Vector { + Vector::new( + self.offset_x.absolute(bounds.width, content_bounds.width), + self.offset_y.absolute(bounds.height, content_bounds.height), + ) } - /// Returns whether the scroller is currently grabbed or not. - pub fn is_scroller_grabbed(&self) -> bool { - self.scroller_grabbed_at.is_some() + /// Returns whether any scroller is currently grabbed or not. + pub fn scrollers_grabbed(&self) -> bool { + self.x_scroller_grabbed_at.is_some() + || self.y_scroller_grabbed_at.is_some() } +} + +#[derive(Debug)] +/// State of both [`Scrollbar`]s. +struct Scrollbars { + y: Option, + x: Option, +} - /// Returns whether the scroll box is currently touched or not. - pub fn is_scroll_box_touched(&self) -> bool { - self.scroll_box_touched_at.is_some() +impl Scrollbars { + /// Create y and/or x scrollbar(s) if content is overflowing the [`Scrollable`] bounds. + fn new( + state: &State, + vertical: &Properties, + horizontal: Option<&Properties>, + bounds: Rectangle, + content_bounds: Rectangle, + ) -> Self { + let offset = state.offset(bounds, content_bounds); + + let show_scrollbar_x = horizontal.and_then(|h| { + if content_bounds.width > bounds.width { + Some(h) + } else { + None + } + }); + + let y_scrollbar = if content_bounds.height > bounds.height { + let Properties { + width, + margin, + scroller_width, + } = *vertical; + + // Adjust the height of the vertical scrollbar if the horizontal scrollbar + // is present + let x_scrollbar_height = show_scrollbar_x.map_or(0.0, |h| { + (h.width.max(h.scroller_width) + h.margin) as f32 + }); + + let total_scrollbar_width = width.max(scroller_width) + 2 * margin; + + // Total bounds of the scrollbar + margin + scroller width + let total_scrollbar_bounds = Rectangle { + x: bounds.x + bounds.width - total_scrollbar_width as f32, + y: bounds.y, + width: total_scrollbar_width as f32, + height: (bounds.height - x_scrollbar_height).max(0.0), + }; + + // Bounds of just the scrollbar + let scrollbar_bounds = Rectangle { + x: bounds.x + bounds.width + - f32::from(total_scrollbar_width / 2 + width / 2), + y: bounds.y, + width: width as f32, + height: (bounds.height - x_scrollbar_height).max(0.0), + }; + + let ratio = bounds.height / content_bounds.height; + // min height for easier grabbing with super tall content + let scroller_height = (bounds.height * ratio).max(2.0); + let scroller_offset = offset.y * ratio; + + let scroller_bounds = Rectangle { + x: bounds.x + bounds.width + - f32::from(total_scrollbar_width / 2 + scroller_width / 2), + y: (scrollbar_bounds.y + scroller_offset - x_scrollbar_height) + .max(0.0), + width: scroller_width as f32, + height: scroller_height, + }; + + Some(Scrollbar { + total_bounds: total_scrollbar_bounds, + bounds: scrollbar_bounds, + scroller: Scroller { + bounds: scroller_bounds, + }, + }) + } else { + None + }; + + let x_scrollbar = if let Some(horizontal) = show_scrollbar_x { + let Properties { + width, + margin, + scroller_width, + } = *horizontal; + + // Need to adjust the width of the horizontal scrollbar if the vertical scrollbar + // is present + let scrollbar_y_width = y_scrollbar.map_or(0.0, |_| { + (vertical.width.max(vertical.scroller_width) + vertical.margin) + as f32 + }); + + let total_scrollbar_height = width.max(scroller_width) + 2 * margin; + + // Total bounds of the scrollbar + margin + scroller width + let total_scrollbar_bounds = Rectangle { + x: bounds.x, + y: bounds.y + bounds.height - total_scrollbar_height as f32, + width: (bounds.width - scrollbar_y_width).max(0.0), + height: total_scrollbar_height as f32, + }; + + // Bounds of just the scrollbar + let scrollbar_bounds = Rectangle { + x: bounds.x, + y: bounds.y + bounds.height + - f32::from(total_scrollbar_height / 2 + width / 2), + width: (bounds.width - scrollbar_y_width).max(0.0), + height: width as f32, + }; + + let ratio = bounds.width / content_bounds.width; + // min width for easier grabbing with extra wide content + let scroller_length = (bounds.width * ratio).max(2.0); + let scroller_offset = offset.x * ratio; + + let scroller_bounds = Rectangle { + x: (scrollbar_bounds.x + scroller_offset - scrollbar_y_width) + .max(0.0), + y: bounds.y + bounds.height + - f32::from( + total_scrollbar_height / 2 + scroller_width / 2, + ), + width: scroller_length, + height: scroller_width as f32, + }; + + Some(Scrollbar { + total_bounds: total_scrollbar_bounds, + bounds: scrollbar_bounds, + scroller: Scroller { + bounds: scroller_bounds, + }, + }) + } else { + None + }; + + Self { + y: y_scrollbar, + x: x_scrollbar, + } + } + + fn is_mouse_over(&self, cursor_position: Point) -> (bool, bool) { + ( + self.y + .as_ref() + .map(|scrollbar| scrollbar.is_mouse_over(cursor_position)) + .unwrap_or(false), + self.x + .as_ref() + .map(|scrollbar| scrollbar.is_mouse_over(cursor_position)) + .unwrap_or(false), + ) + } + + fn grab_y_scroller(&self, cursor_position: Point) -> Option { + self.y.and_then(|scrollbar| { + if scrollbar.total_bounds.contains(cursor_position) { + Some(if scrollbar.scroller.bounds.contains(cursor_position) { + (cursor_position.y - scrollbar.scroller.bounds.y) + / scrollbar.scroller.bounds.height + } else { + 0.5 + }) + } else { + None + } + }) + } + + fn grab_x_scroller(&self, cursor_position: Point) -> Option { + self.x.and_then(|scrollbar| { + if scrollbar.total_bounds.contains(cursor_position) { + Some(if scrollbar.scroller.bounds.contains(cursor_position) { + (cursor_position.x - scrollbar.scroller.bounds.x) + / scrollbar.scroller.bounds.width + } else { + 0.5 + }) + } else { + None + } + }) + } + + fn active(&self) -> bool { + self.y.is_some() || self.x.is_some() } } /// The scrollbar of a [`Scrollable`]. -#[derive(Debug)] +#[derive(Debug, Copy, Clone)] struct Scrollbar { - /// The outer bounds of the scrollable, including the [`Scrollbar`] and - /// [`Scroller`]. - outer_bounds: Rectangle, + /// The total bounds of the [`Scrollbar`], including the scrollbar, the scroller, + /// and the scrollbar margin. + total_bounds: Rectangle, - /// The bounds of the [`Scrollbar`]. + /// The bounds of just the [`Scrollbar`]. bounds: Rectangle, - /// The bounds of the [`Scroller`]. + /// The state of this scrollbar's [`Scroller`]. scroller: Scroller, } impl Scrollbar { + /// Returns whether the mouse is over the scrollbar or not. fn is_mouse_over(&self, cursor_position: Point) -> bool { - self.outer_bounds.contains(cursor_position) + self.total_bounds.contains(cursor_position) } - fn grab_scroller(&self, cursor_position: Point) -> Option { - if self.outer_bounds.contains(cursor_position) { - Some(if self.scroller.bounds.contains(cursor_position) { - (cursor_position.y - self.scroller.bounds.y) - / self.scroller.bounds.height - } else { - 0.5 - }) + /// Returns the y-axis scrolled percentage from the cursor position. + fn scroll_percentage_y( + &self, + grabbed_at: f32, + cursor_position: Point, + ) -> f32 { + if cursor_position.x < 0.0 && cursor_position.y < 0.0 { + // cursor position is unavailable! Set to either end or beginning of scrollbar depending + // on where the thumb currently is in the track + (self.scroller.bounds.y / self.total_bounds.height).round() } else { - None + (cursor_position.y + - self.bounds.y + - self.scroller.bounds.height * grabbed_at) + / (self.bounds.height - self.scroller.bounds.height) } } - fn scroll_percentage( + /// Returns the x-axis scrolled percentage from the cursor position. + fn scroll_percentage_x( &self, grabbed_at: f32, cursor_position: Point, ) -> f32 { - (cursor_position.y - - self.bounds.y - - self.scroller.bounds.height * grabbed_at) - / (self.bounds.height - self.scroller.bounds.height) + if cursor_position.x < 0.0 && cursor_position.y < 0.0 { + (self.scroller.bounds.x / self.total_bounds.width).round() + } else { + (cursor_position.x + - self.bounds.x + - self.scroller.bounds.width * grabbed_at) + / (self.bounds.width - self.scroller.bounds.width) + } } } diff --git a/src/widget.rs b/src/widget.rs index d2d4a1b832..f71bf7ff6b 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -99,7 +99,8 @@ pub mod radio { pub mod scrollable { //! Navigate an endless amount of content with a scrollbar. pub use iced_native::widget::scrollable::{ - snap_to, style::Scrollbar, style::Scroller, Id, StyleSheet, + snap_to, style::Scrollbar, style::Scroller, Id, Properties, + RelativeOffset, StyleSheet, }; /// A widget that can vertically display an infinite amount of content diff --git a/style/src/scrollable.rs b/style/src/scrollable.rs index c6d7d53745..64ed8462f4 100644 --- a/style/src/scrollable.rs +++ b/style/src/scrollable.rs @@ -37,11 +37,26 @@ pub trait StyleSheet { /// Produces the style of an active scrollbar. fn active(&self, style: &Self::Style) -> Scrollbar; - /// Produces the style of an hovered scrollbar. + /// Produces the style of a hovered scrollbar. fn hovered(&self, style: &Self::Style) -> Scrollbar; /// Produces the style of a scrollbar that is being dragged. fn dragging(&self, style: &Self::Style) -> Scrollbar { self.hovered(style) } + + /// Produces the style of an active horizontal scrollbar. + fn active_horizontal(&self, style: &Self::Style) -> Scrollbar { + self.active(style) + } + + /// Produces the style of a hovered horizontal scrollbar. + fn hovered_horizontal(&self, style: &Self::Style) -> Scrollbar { + self.hovered(style) + } + + /// Produces the style of a horizontal scrollbar that is being dragged. + fn dragging_horizontal(&self, style: &Self::Style) -> Scrollbar { + self.hovered_horizontal(style) + } } diff --git a/style/src/theme.rs b/style/src/theme.rs index a766b27901..55bfa4cab4 100644 --- a/style/src/theme.rs +++ b/style/src/theme.rs @@ -872,6 +872,15 @@ pub enum Scrollable { Custom(Box>), } +impl Scrollable { + /// Creates a custom [`Scrollable`] theme. + pub fn custom + 'static>( + style: T, + ) -> Self { + Self::Custom(Box::new(style)) + } +} + impl scrollable::StyleSheet for Theme { type Style = Scrollable; @@ -925,6 +934,30 @@ impl scrollable::StyleSheet for Theme { Scrollable::Custom(custom) => custom.dragging(self), } } + + fn active_horizontal(&self, style: &Self::Style) -> scrollable::Scrollbar { + match style { + Scrollable::Default => self.active(style), + Scrollable::Custom(custom) => custom.active_horizontal(self), + } + } + + fn hovered_horizontal(&self, style: &Self::Style) -> scrollable::Scrollbar { + match style { + Scrollable::Default => self.hovered(style), + Scrollable::Custom(custom) => custom.hovered_horizontal(self), + } + } + + fn dragging_horizontal( + &self, + style: &Self::Style, + ) -> scrollable::Scrollbar { + match style { + Scrollable::Default => self.hovered_horizontal(style), + Scrollable::Custom(custom) => custom.dragging_horizontal(self), + } + } } /// The style of text.