diff options
-rw-r--r-- | core/src/size.rs | 1 | ||||
-rw-r--r-- | examples/README.md | 2 | ||||
-rw-r--r-- | examples/scrollable/Cargo.toml | 1 | ||||
-rw-r--r-- | examples/scrollable/screenshot.png | bin | 104995 -> 521151 bytes | |||
-rw-r--r-- | examples/scrollable/src/main.rs | 506 | ||||
-rw-r--r-- | examples/websocket/src/main.rs | 7 | ||||
-rw-r--r-- | native/src/widget/column.rs | 2 | ||||
-rw-r--r-- | native/src/widget/operation/scrollable.rs | 19 | ||||
-rw-r--r-- | native/src/widget/scrollable.rs | 1116 | ||||
-rw-r--r-- | src/widget.rs | 3 | ||||
-rw-r--r-- | style/src/scrollable.rs | 17 | ||||
-rw-r--r-- | style/src/theme.rs | 24 |
12 files changed, 1135 insertions, 563 deletions
diff --git a/core/src/size.rs b/core/src/size.rs index 31f3171b..a2c72926 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 bb15dc2e..74cf145b 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 610c13b4..4bef4281 100644 --- a/examples/scrollable/Cargo.toml +++ b/examples/scrollable/Cargo.toml @@ -7,3 +7,4 @@ publish = false [dependencies] iced = { path = "../..", features = ["debug"] } +lazy_static = "1.4" diff --git a/examples/scrollable/screenshot.png b/examples/scrollable/screenshot.png Binary files differindex e91fd565..ee044447 100644 --- a/examples/scrollable/screenshot.png +++ b/examples/scrollable/screenshot.png diff --git a/examples/scrollable/src/main.rs b/examples/scrollable/src/main.rs index 6eba34e2..1481afcc 100644 --- a/examples/scrollable/src/main.rs +++ b/examples/scrollable/src/main.rs @@ -1,44 +1,60 @@ -use iced::executor; +use iced::widget::scrollable::{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, Vector}; use iced::{Application, Command, Element, Length, Settings, Theme}; +use lazy_static::lazy_static; + +lazy_static! { + static ref SCROLLABLE_ID: scrollable::Id = scrollable::Id::unique(); +} pub fn main() -> iced::Result { ScrollableDemo::run(Settings::default()) } struct ScrollableDemo { - theme: Theme, - variants: Vec<Variant>, + scrollable_direction: Direction, + scrollbar_width: u16, + scrollbar_margin: u16, + scroller_width: u16, + current_scroll_offset: Vector<f32>, } -#[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(scrollable::Direction), + ScrollToEnd(scrollable::Direction), + Scrolled(Vector<f32>), } 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<Message>) { ( ScrollableDemo { - theme: Default::default(), - variants: Variant::all(), + scrollable_direction: Direction::Vertical, + scrollbar_width: 10, + scrollbar_margin: 0, + scroller_width: 10, + current_scroll_offset: Vector::new(0.0, 0.0), }, Command::none(), ) @@ -50,209 +66,333 @@ impl Application for ScrollableDemo { fn update(&mut self, message: Message) -> Command<Message> { match message { - Message::ThemeChanged(theme) => { - self.theme = match theme { - ThemeType::Light => Theme::Light, - ThemeType::Dark => Theme::Dark, - }; + Message::SwitchDirection(direction) => { + self.current_scroll_offset = Vector::new(0.0, 0.0); + self.scrollable_direction = direction; Command::none() } - Message::ScrollToTop(i) => { - if let Some(variant) = self.variants.get_mut(i) { - variant.latest_offset = 0.0; + Message::ScrollbarWidthChanged(width) => { + self.scrollbar_width = width; - scrollable::snap_to(Variant::id(i), 0.0) - } else { - Command::none() - } + Command::none() + } + Message::ScrollbarMarginChanged(margin) => { + self.scrollbar_margin = margin; + + Command::none() } - Message::ScrollToBottom(i) => { - if let Some(variant) = self.variants.get_mut(i) { - variant.latest_offset = 1.0; + Message::ScrollerWidthChanged(width) => { + self.scroller_width = width; - scrollable::snap_to(Variant::id(i), 1.0) - } else { - Command::none() + Command::none() + } + Message::ScrollToBeginning(direction) => { + match direction { + scrollable::Direction::Horizontal => { + self.current_scroll_offset.x = 0.0; + } + scrollable::Direction::Vertical => { + self.current_scroll_offset.y = 0.0; + } } + + scrollable::snap_to( + SCROLLABLE_ID.clone(), + Vector::new( + self.current_scroll_offset.x, + self.current_scroll_offset.y, + ), + ) } - Message::Scrolled(i, offset) => { - if let Some(variant) = self.variants.get_mut(i) { - variant.latest_offset = offset; + Message::ScrollToEnd(direction) => { + match direction { + scrollable::Direction::Horizontal => { + self.current_scroll_offset.x = 1.0; + } + scrollable::Direction::Vertical => { + self.current_scroll_offset.y = 1.0; + } } + scrollable::snap_to( + SCROLLABLE_ID.clone(), + Vector::new( + self.current_scroll_offset.x, + self.current_scroll_offset.y, + ), + ) + } + Message::Scrolled(offset) => { + self.current_scroll_offset = offset; + Command::none() } } } fn view(&self) -> Element<Message> { - 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 - ))); - } + let scroll_slider_controls = column![ + text("Scrollbar width:"), + scrollbar_width_slider, + text("Scrollbar margin:"), + scrollbar_margin_slider, + text("Scroller width:"), + scroller_width_slider, + ] + .width(Length::Fill); - if let Some(scroller_width) = variant.scroller_width { - contents = contents.push(text(format!( - "scroller_width: {:?}", - scroller_width - ))); - } + 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(), + ]) + .width(Length::Fill); - 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); - } + let scroll_controls = + row![scroll_slider_controls, scroll_orientation_controls] + .spacing(20) + .width(Length::Fill); - if let Some(scrollbar_margin) = variant.scrollbar_margin { - scrollable = - scrollable.scrollbar_margin(scrollbar_margin); - } + let scroll_to_end_button = |direction: scrollable::Direction| { + button("Scroll to end") + .padding(10) + .width(Length::Units(120)) + .on_press(Message::ScrollToEnd(direction)) + }; - if let Some(scroller_width) = variant.scroller_width { - scrollable = scrollable.scroller_width(scroller_width); - } + let scroll_to_beginning_button = |direction: scrollable::Direction| { + button("Scroll to beginning") + .padding(10) + .width(Length::Units(120)) + .on_press(Message::ScrollToBeginning(direction)) + }; + let scrollable_content: Element<Message> = + Element::from(match self.scrollable_direction { + Direction::Vertical => scrollable( column![ - scrollable, - progress_bar(0.0..=1.0, variant.latest_offset,) + scroll_to_end_button(scrollable::Direction::Vertical), + text("Beginning!"), + vertical_space(Length::Units(1200)), + text("Middle!"), + vertical_space(Length::Units(1200)), + text("End!"), + scroll_to_beginning_button( + scrollable::Direction::Vertical + ), ] .width(Length::Fill) - .height(Length::Fill) - .spacing(10) + .align_items(Alignment::Center) + .padding([40, 0, 40, 0]) + .spacing(40), + ) + .height(Length::Fill) + .scrollbar_width(self.scrollbar_width) + .scrollbar_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(scrollable::Direction::Horizontal), + text("Beginning!"), + horizontal_space(Length::Units(1200)), + text("Middle!"), + horizontal_space(Length::Units(1200)), + text("End!"), + scroll_to_beginning_button( + scrollable::Direction::Horizontal + ), + ] + .height(Length::Units(450)) + .align_items(Alignment::Center) + .padding([0, 40, 0, 40]) + .spacing(40), + ) + .height(Length::Fill) + .horizontal_scroll( + scrollable::Horizontal::new() + .scrollbar_height(self.scrollbar_width) + .scrollbar_margin(self.scrollbar_margin) + .scroller_height(self.scroller_width), + ) + .style(theme::Scrollable::Custom(Box::new( + 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(scrollable::Direction::Horizontal), + text("Horizontal - Beginning!"), + horizontal_space(Length::Units(1200)), + //vertical content + column![ + text("Horizontal - Middle!"), + scroll_to_end_button( + scrollable::Direction::Vertical + ), + text("Vertical - Beginning!"), + vertical_space(Length::Units(1200)), + text("Vertical - Middle!"), + vertical_space(Length::Units(1200)), + text("Vertical - End!"), + scroll_to_beginning_button( + scrollable::Direction::Vertical + ) + ] + .align_items(Alignment::Fill) + .spacing(40), + horizontal_space(Length::Units(1200)), + text("Horizontal - End!"), + scroll_to_beginning_button( + scrollable::Direction::Horizontal + ), + ] + .align_items(Alignment::Center) + .padding([0, 40, 0, 40]) + .spacing(40), + ) + .height(Length::Fill) + .scrollbar_width(self.scrollbar_width) + .scrollbar_margin(self.scrollbar_margin) + .scroller_width(self.scroller_width) + .horizontal_scroll( + scrollable::Horizontal::new() + .scrollbar_height(self.scrollbar_width) + .scrollbar_margin(self.scrollbar_margin) + .scroller_height(self.scroller_width), + ) + .style(theme::Scrollable::Custom(Box::new( + ScrollbarCustomStyle, + ))) + .id(SCROLLABLE_ID.clone()) + .on_scroll(Message::Scrolled), + }); + + let progress_bars: Element<Message> = 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<Message> = + 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<u16>, - scrollbar_margin: Option<u16>, - scroller_width: Option<u16>, - latest_offset: f32, -} +struct ScrollbarCustomStyle; -impl Variant { - pub fn all() -> Vec<Self> { - 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(), }, - ] + } } +} - pub fn id(i: usize) -> scrollable::Id { - scrollable::Id::new(format!("scrollable-{}", i)) +struct ProgressBarCustomStyle; + +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 ff2929da..b10ef17e 100644 --- a/examples/websocket/src/main.rs +++ b/examples/websocket/src/main.rs @@ -1,10 +1,10 @@ mod echo; use iced::alignment::{self, Alignment}; -use iced::executor; use iced::widget::{ button, column, container, row, scrollable, text, text_input, Column, }; +use iced::{executor, Vector}; use iced::{ Application, Color, Command, Element, Length, Settings, Subscription, Theme, }; @@ -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(), + Vector::new(0.0, 1.0), + ) } }, Message::Server => Command::none(), diff --git a/native/src/widget/column.rs b/native/src/widget/column.rs index f2ef132a..5ad4d858 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 2210137d..1e8b7543 100644 --- a/native/src/widget/operation/scrollable.rs +++ b/native/src/widget/operation/scrollable.rs @@ -1,27 +1,22 @@ //! Operate on widgets that can be scrolled. use crate::widget::{Id, Operation}; +use iced_core::Vector; /// 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); + fn snap_to(&mut self, percentage: Vector<f32>); } /// Produces an [`Operation`] that snaps the widget with the given [`Id`] to /// the provided `percentage`. -pub fn snap_to<T>(target: Id, percentage: f32) -> impl Operation<T> { +pub fn snap_to<T>(target: Id, percentage: Vector<f32>) -> impl Operation<T> { struct SnapTo { target: Id, - percentage: f32, + percentage: Vector<f32>, } impl<T> Operation<T> 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,6 +24,12 @@ pub fn snap_to<T>(target: Id, percentage: f32) -> impl Operation<T> { ) { operate_on_children(self) } + + fn scrollable(&mut self, state: &mut dyn Scrollable, id: Option<&Id>) { + if Some(&self.target) == id { + state.snap_to(self.percentage); + } + } } SnapTo { target, percentage } diff --git a/native/src/widget/scrollable.rs b/native/src/widget/scrollable.rs index 20780f89..ec081343 100644 --- a/native/src/widget/scrollable.rs +++ b/native/src/widget/scrollable.rs @@ -13,8 +13,6 @@ use crate::{ Rectangle, Shell, Size, Vector, Widget, }; -use std::{f32, u32}; - pub use iced_style::scrollable::StyleSheet; pub mod style { @@ -37,8 +35,9 @@ where scrollbar_width: u16, scrollbar_margin: u16, scroller_width: u16, + scroll_horizontal: Option<Horizontal>, content: Element<'a, Message, Renderer>, - on_scroll: Option<Box<dyn Fn(f32) -> Message + 'a>>, + on_scroll: Option<Box<dyn Fn(Vector<f32>) -> Message + 'a>>, style: <Renderer::Theme as StyleSheet>::Style, } @@ -55,6 +54,7 @@ where scrollbar_width: 10, scrollbar_margin: 0, scroller_width: 10, + scroll_horizontal: None, content: content.into(), on_scroll: None, style: Default::default(), @@ -74,7 +74,7 @@ where } /// Sets the scrollbar width of the [`Scrollable`] . - /// Silently enforces a minimum value of 1. + /// Silently enforces a minimum width of 1. pub fn scrollbar_width(mut self, scrollbar_width: u16) -> Self { self.scrollbar_width = scrollbar_width.max(1); self @@ -88,17 +88,26 @@ where /// Sets the scroller width of the [`Scrollable`] . /// - /// It silently enforces a minimum value of 1. + /// It 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 } + /// Allow scrolling in a horizontal direction within the [`Scrollable`] . + pub fn horizontal_scroll(mut self, horizontal: Horizontal) -> Self { + self.scroll_horizontal = Some(horizontal); + 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(Vector<f32>) -> Message + 'a, + ) -> Self { self.on_scroll = Some(Box::new(f)); self } @@ -113,28 +122,57 @@ where } } -impl<'a, Message, Renderer> Widget<Message, Renderer> - for Scrollable<'a, Message, Renderer> -where - Renderer: crate::Renderer, - Renderer::Theme: StyleSheet, -{ - fn tag(&self) -> tree::Tag { - tree::Tag::of::<State>() +/// Properties of a horizontal scrollbar within a [`Scrollable`]. +#[derive(Debug)] +pub struct Horizontal { + scrollbar_height: u16, + scrollbar_margin: u16, + scroller_height: u16, +} + +impl Default for Horizontal { + fn default() -> Self { + Self { + scrollbar_height: 10, + scrollbar_margin: 0, + scroller_height: 10, + } } +} - fn state(&self) -> tree::State { - tree::State::new(State::new()) +impl Horizontal { + /// Creates a new [`Horizontal`] for use in a [`Scrollable`]. + pub fn new() -> Self { + Self::default() } - fn children(&self) -> Vec<Tree> { - vec![Tree::new(&self.content)] + /// Sets the [`Horizontal`] scrollbar height of the [`Scrollable`] . + /// Silently enforces a minimum height of 1. + pub fn scrollbar_height(mut self, scrollbar_height: u16) -> Self { + self.scrollbar_height = scrollbar_height.max(1); + self } - fn diff(&self, tree: &mut Tree) { - tree.diff_children(std::slice::from_ref(&self.content)) + /// Sets the [`Horizontal`] scrollbar margin of the [`Scrollable`] . + pub fn scrollbar_margin(mut self, scrollbar_margin: u16) -> Self { + self.scrollbar_margin = scrollbar_margin; + self } + /// Sets the scroller height of the [`Horizontal`] scrollbar of the [`Scrollable`] . + /// Silently enforces a minimum height of 1. + pub fn scroller_height(mut self, scroller_height: u16) -> Self { + self.scroller_height = scroller_height.max(1); + self + } +} + +impl<'a, Message, Renderer> Widget<Message, Renderer> + for Scrollable<'a, Message, Renderer> +where + Renderer: crate::Renderer, + Renderer::Theme: StyleSheet, +{ fn width(&self) -> Length { self.content.as_widget().width() } @@ -153,18 +191,64 @@ where limits, Widget::<Message, Renderer>::width(self), self.height, - u32::MAX, + self.scroll_horizontal.is_some(), |renderer, limits| { self.content.as_widget().layout(renderer, limits) }, ) } + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &Renderer::Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor_position: Point, + _viewport: &Rectangle, + ) { + draw( + tree.state.downcast_ref::<State>(), + renderer, + theme, + layout, + cursor_position, + &self.style, + |renderer, layout, cursor_position, viewport| { + self.content.as_widget().draw( + &tree.children[0], + renderer, + theme, + style, + layout, + cursor_position, + viewport, + ) + }, + ) + } + + fn tag(&self) -> tree::Tag { + tree::Tag::of::<State>() + } + + fn state(&self) -> tree::State { + tree::State::new(State::new()) + } + + fn children(&self) -> Vec<Tree> { + vec![Tree::new(&self.content)] + } + + fn diff(&self, tree: &mut Tree) { + tree.diff_children(std::slice::from_ref(&self.content)) + } + fn operate( &self, tree: &mut Tree, layout: Layout<'_>, - renderer: &Renderer, operation: &mut dyn Operation<Message>, ) { let state = tree.state.downcast_mut::<State>(); @@ -175,7 +259,6 @@ where self.content.as_widget().operate( &mut tree.children[0], layout.children().next().unwrap(), - renderer, operation, ); }); @@ -201,6 +284,7 @@ where self.scrollbar_width, self.scrollbar_margin, self.scroller_width, + self.scroll_horizontal.as_ref(), &self.on_scroll, |event, layout, cursor_position, clipboard, shell| { self.content.as_widget_mut().on_event( @@ -216,40 +300,6 @@ where ) } - fn draw( - &self, - tree: &Tree, - renderer: &mut Renderer, - theme: &Renderer::Theme, - style: &renderer::Style, - layout: Layout<'_>, - cursor_position: Point, - _viewport: &Rectangle, - ) { - draw( - tree.state.downcast_ref::<State>(), - renderer, - theme, - layout, - cursor_position, - self.scrollbar_width, - self.scrollbar_margin, - self.scroller_width, - &self.style, - |renderer, layout, cursor_position, viewport| { - self.content.as_widget().draw( - &tree.children[0], - renderer, - theme, - style, - layout, - cursor_position, - viewport, - ) - }, - ) - } - fn mouse_interaction( &self, tree: &Tree, @@ -262,9 +312,6 @@ where tree.state.downcast_ref::<State>(), layout, cursor_position, - self.scrollbar_width, - self.scrollbar_margin, - self.scroller_width, |layout, cursor_position, viewport| { self.content.as_widget().mouse_interaction( &tree.children[0], @@ -278,13 +325,13 @@ where } fn overlay<'b>( - &'b mut self, + &'b self, tree: &'b mut Tree, layout: Layout<'_>, renderer: &Renderer, ) -> Option<overlay::Element<'b, Message, Renderer>> { self.content - .as_widget_mut() + .as_widget() .overlay( &mut tree.children[0], layout.children().next().unwrap(), @@ -294,12 +341,15 @@ where let bounds = layout.bounds(); let content_layout = layout.children().next().unwrap(); let content_bounds = content_layout.bounds(); - let offset = tree + let (offset_x, offset_y) = tree .state .downcast_ref::<State>() .offset(bounds, content_bounds); - overlay.translate(Vector::new(0.0, -(offset as f32))) + overlay.translate(Vector::new( + -(offset_x as f32), + -(offset_y as f32), + )) }) } } @@ -344,7 +394,10 @@ impl From<Id> for widget::Id { /// Produces a [`Command`] that snaps the [`Scrollable`] with the given [`Id`] /// to the provided `percentage`. -pub fn snap_to<Message: 'static>(id: Id, percentage: f32) -> Command<Message> { +pub fn snap_to<Message: 'static>( + id: Id, + percentage: Vector<f32>, +) -> Command<Message> { Command::widget(operation::scrollable::snap_to(id.0, percentage)) } @@ -354,14 +407,29 @@ pub fn layout<Renderer>( 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); @@ -382,7 +450,8 @@ pub fn update<Message>( scrollbar_width: u16, scrollbar_margin: u16, scroller_width: u16, - on_scroll: &Option<Box<dyn Fn(f32) -> Message + '_>>, + horizontal: Option<&Horizontal>, + on_scroll: &Option<Box<dyn Fn(Vector<f32>) -> Message + '_>>, update_content: impl FnOnce( Event, Layout<'_>, @@ -392,36 +461,39 @@ pub fn update<Message>( ) -> 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, + state.create_scrollbars_maybe( + horizontal, 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 (mouse_over_x_scrollbar, mouse_over_y_scrollbar) = + state.mouse_over_scrollbars(cursor_position); let event_status = { - let cursor_position = if is_mouse_over && !is_mouse_over_scrollbar { + let cursor_position = if mouse_over_scrollable + && !(mouse_over_y_scrollbar || mouse_over_x_scrollbar) + { + let (offset_x, offset_y) = state.offset(bounds, content_bounds); + Point::new( - cursor_position.x, - cursor_position.y + state.offset(bounds, content_bounds) as f32, + cursor_position.x + offset_x as f32, + cursor_position.y + offset_y as f32, ) } else { // TODO: Make `cursor_position` an `Option<Point>` 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 +509,18 @@ pub fn update<Message>( return event::Status::Captured; } - if is_mouse_over { + 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 (?) + Vector::new(x * 60.0, y * 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, @@ -463,18 +535,21 @@ pub fn update<Message>( Event::Touch(event) => { 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 +562,7 @@ pub fn update<Message>( } touch::Event::FingerLifted { .. } | touch::Event::FingerLost { .. } => { - state.scroll_box_touched_at = None; + state.scroll_area_touched_at = None; } } @@ -497,21 +572,21 @@ pub fn update<Message>( } } - if state.is_scroller_grabbed() { - 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; + if let Some(scrollbar) = &mut state.scrollbar_y { + if let Some(scroller_grabbed_at) = scrollbar.scroller.grabbed_at { + match event { + Event::Mouse(mouse::Event::ButtonReleased( + mouse::Button::Left, + )) + | Event::Touch(touch::Event::FingerLifted { .. }) + | Event::Touch(touch::Event::FingerLost { .. }) => { + scrollbar.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( + return event::Status::Captured; + } + Event::Mouse(mouse::Event::CursorMoved { .. }) + | Event::Touch(touch::Event::FingerMoved { .. }) => { + scrollbar.scroll_to( scrollbar.scroll_percentage( scroller_grabbed_at, cursor_position, @@ -530,18 +605,18 @@ pub fn update<Message>( return event::Status::Captured; } + _ => {} } - _ => {} - } - } else if is_mouse_over_scrollbar { - match event { - Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) - | Event::Touch(touch::Event::FingerPressed { .. }) => { - if let Some(scrollbar) = scrollbar { + } else if scrollbar.is_mouse_over(cursor_position) { + match event { + Event::Mouse(mouse::Event::ButtonPressed( + mouse::Button::Left, + )) + | Event::Touch(touch::Event::FingerPressed { .. }) => { if let Some(scroller_grabbed_at) = scrollbar.grab_scroller(cursor_position) { - state.scroll_to( + scrollbar.scroll_to( scrollbar.scroll_percentage( scroller_grabbed_at, cursor_position, @@ -550,7 +625,8 @@ pub fn update<Message>( content_bounds, ); - state.scroller_grabbed_at = Some(scroller_grabbed_at); + scrollbar.scroller.grabbed_at = + Some(scroller_grabbed_at); notify_on_scroll( state, @@ -559,12 +635,84 @@ pub fn update<Message>( content_bounds, shell, ); + } - return event::Status::Captured; + return event::Status::Captured; + } + _ => {} + } + } + } + + if let Some(scrollbar) = &mut state.scrollbar_x { + if let Some(scroller_grabbed_at) = scrollbar.scroller.grabbed_at { + match event { + Event::Mouse(mouse::Event::ButtonReleased( + mouse::Button::Left, + )) + | Event::Touch(touch::Event::FingerLifted { .. }) + | Event::Touch(touch::Event::FingerLost { .. }) => { + scrollbar.scroller.grabbed_at = None; + + return event::Status::Captured; + } + Event::Mouse(mouse::Event::CursorMoved { .. }) + | Event::Touch(touch::Event::FingerMoved { .. }) => { + scrollbar.scroll_to( + scrollbar.scroll_percentage( + scroller_grabbed_at, + cursor_position, + ), + bounds, + content_bounds, + ); + + notify_on_scroll( + state, + on_scroll, + bounds, + content_bounds, + shell, + ); + + return event::Status::Captured; + } + _ => {} + } + } else if scrollbar.is_mouse_over(cursor_position) { + match event { + Event::Mouse(mouse::Event::ButtonPressed( + mouse::Button::Left, + )) + | Event::Touch(touch::Event::FingerPressed { .. }) => { + if let Some(scroller_grabbed_at) = + scrollbar.grab_scroller(cursor_position) + { + scrollbar.scroll_to( + scrollbar.scroll_percentage( + scroller_grabbed_at, + cursor_position, + ), + bounds, + content_bounds, + ); + + scrollbar.scroller.grabbed_at = + Some(scroller_grabbed_at); + + notify_on_scroll( + state, + on_scroll, + bounds, + content_bounds, + shell, + ); } + + return event::Status::Captured; } + _ => {} } - _ => {} } } @@ -576,9 +724,6 @@ pub fn mouse_interaction( state: &State, layout: Layout<'_>, cursor_position: Point, - scrollbar_width: u16, - scrollbar_margin: u16, - scroller_width: u16, content_interaction: impl FnOnce( Layout<'_>, Point, @@ -586,39 +731,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 (mouse_over_x_scrollbar, mouse_over_y_scrollbar) = + state.mouse_over_scrollbars(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 (offset_x, offset_y) = 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) + { + Point::new( + cursor_position.x + offset_x as f32, + cursor_position.y + offset_y as f32, + ) } 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 as f32, + x: bounds.x + offset_x as f32, ..bounds }, ) @@ -632,9 +776,6 @@ pub fn draw<Renderer>( theme: &Renderer::Theme, layout: Layout<'_>, cursor_position: Point, - scrollbar_width: u16, - scrollbar_margin: u16, - scroller_width: u16, style: &<Renderer::Theme as StyleSheet>::Style, draw_content: impl FnOnce(&mut Renderer, Layout<'_>, Point, &Rectangle), ) where @@ -644,39 +785,38 @@ pub fn draw<Renderer>( 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 (offset_x, offset_y) = 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 mouse_over_scrollable = bounds.contains(cursor_position); + + let (mouse_over_x_scrollbar, mouse_over_y_scrollbar) = + state.mouse_over_scrollbars(cursor_position); + + let cursor_position = if mouse_over_scrollable + && !(mouse_over_x_scrollbar || mouse_over_y_scrollbar) + { + Point::new( + cursor_position.x + offset_x as f32, + cursor_position.y + offset_y as f32, + ) } else { - Point::new(cursor_position.x, -1.0) + Point::new(-1.0, -1.0) }; - if let Some(scrollbar) = scrollbar { + // Draw inner content + if state.scrollbar_y.is_some() || state.scrollbar_x.is_some() { renderer.with_layer(bounds, |renderer| { renderer.with_translation( - Vector::new(0.0, -(offset as f32)), + Vector::new(-(offset_x as f32), -(offset_y as f32)), |renderer| { draw_content( renderer, content_layout, cursor_position, &Rectangle { - y: bounds.y + offset as f32, + y: bounds.y + offset_y as f32, + x: bounds.x + offset_x as f32, ..bounds }, ); @@ -684,16 +824,65 @@ pub fn draw<Renderer>( ); }); - let style = if state.is_scroller_grabbed() { - theme.dragging(style) - } else if is_mouse_over_scrollbar { - theme.hovered(style) - } else { - theme.active(style) - }; + let draw_scrollbar = + |renderer: &mut Renderer, scrollbar: Option<&Scrollbar>| { + if let Some(scrollbar) = scrollbar { + let style = match scrollbar.direction { + Direction::Vertical => { + if scrollbar.scroller.grabbed_at.is_some() { + theme.dragging(style) + } else if mouse_over_y_scrollbar { + theme.hovered(style) + } else { + theme.active(style) + } + } + Direction::Horizontal => { + if scrollbar.scroller.grabbed_at.is_some() { + theme.dragging_horizontal(style) + } else if mouse_over_x_scrollbar { + theme.hovered_horizontal(style) + } else { + theme.active_horizontal(style) + } + } + }; - let is_scrollbar_visible = - style.background.is_some() || style.border_width > 0.0; + //track + if style.background.is_some() + || (style.border_color != Color::TRANSPARENT + && style.border_width > 0.0) + { + renderer.fill_quad( + renderer::Quad { + bounds: scrollbar.bounds, + border_radius: style.border_radius, + border_width: style.border_width, + border_color: style.border_color, + }, + style.background.unwrap_or(Background::Color( + Color::TRANSPARENT, + )), + ); + } + + //thumb + if style.scroller.color != Color::TRANSPARENT + || (style.scroller.border_color != Color::TRANSPARENT + && style.scroller.border_width > 0.0) + { + renderer.fill_quad( + renderer::Quad { + bounds: scrollbar.scroller.bounds, + border_radius: style.scroller.border_radius, + border_width: style.scroller.border_width, + border_color: style.scroller.border_color, + }, + style.scroller.color, + ); + } + } + }; renderer.with_layer( Rectangle { @@ -702,33 +891,8 @@ pub fn draw<Renderer>( ..bounds }, |renderer| { - if is_scrollbar_visible { - renderer.fill_quad( - renderer::Quad { - bounds: scrollbar.bounds, - border_radius: style.border_radius.into(), - border_width: style.border_width, - border_color: style.border_color, - }, - style - .background - .unwrap_or(Background::Color(Color::TRANSPARENT)), - ); - } - - if (is_mouse_over || state.is_scroller_grabbed()) - && is_scrollbar_visible - { - renderer.fill_quad( - renderer::Quad { - bounds: scrollbar.scroller.bounds, - border_radius: style.scroller.border_radius.into(), - border_width: style.scroller.border_width, - border_color: style.scroller.border_color, - }, - style.scroller.color, - ); - } + draw_scrollbar(renderer, state.scrollbar_y.as_ref()); + draw_scrollbar(renderer, state.scrollbar_x.as_ref()); }, ); } else { @@ -737,226 +901,403 @@ pub fn draw<Renderer>( content_layout, cursor_position, &Rectangle { - y: bounds.y + offset as f32, + x: bounds.x + offset_x as f32, + y: bounds.y + offset_y as f32, ..bounds }, ); } } -fn scrollbar( - state: &State, - scrollbar_width: u16, - scrollbar_margin: u16, - scroller_width: u16, - bounds: Rectangle, - content_bounds: Rectangle, -) -> Option<Scrollbar> { - 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<Message>( state: &State, - on_scroll: &Option<Box<dyn Fn(f32) -> Message + '_>>, + on_scroll: &Option<Box<dyn Fn(Vector<f32>) -> 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), - )); - } -} + let delta_x = if content_bounds.width <= bounds.width { + 0.0 + } else { + state.scrollbar_x.map_or(0.0, |scrollbar| { + scrollbar.offset.absolute( + Direction::Horizontal, + bounds, + content_bounds, + ) / (content_bounds.width - bounds.width) + }) + }; -/// The local state of a [`Scrollable`]. -#[derive(Debug, Clone, Copy)] -pub struct State { - scroller_grabbed_at: Option<f32>, - scroll_box_touched_at: Option<Point>, - offset: Offset, -} + let delta_y = if content_bounds.height <= bounds.height { + 0.0 + } else { + state.scrollbar_y.map_or(0.0, |scrollbar| { + scrollbar.offset.absolute( + Direction::Vertical, + bounds, + content_bounds, + ) / (content_bounds.height - bounds.height) + }) + }; -impl Default for State { - fn default() -> Self { - Self { - scroller_grabbed_at: None, - scroll_box_touched_at: None, - offset: Offset::Absolute(0.0), - } + shell.publish(on_scroll(Vector::new(delta_x, delta_y))) } } -impl operation::Scrollable for State { - fn snap_to(&mut self, percentage: f32) { - State::snap_to(self, percentage); - } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// The direction of the [`Scrollable`]. +pub enum Direction { + /// X or horizontal + Horizontal, + /// Y or vertical + Vertical, } /// The local state of a [`Scrollable`]. -#[derive(Debug, Clone, Copy)] -enum Offset { - Absolute(f32), - Relative(f32), +#[derive(Debug, Clone, Copy, Default)] +pub struct State { + scroll_area_touched_at: Option<Point>, + scrollbar_x: Option<Scrollbar>, + scrollbar_y: Option<Scrollbar>, } -impl Offset { - fn absolute(self, bounds: Rectangle, content_bounds: Rectangle) -> f32 { - match self { - Self::Absolute(absolute) => { - let hidden_content = - (content_bounds.height - bounds.height).max(0.0); - - absolute.min(hidden_content) - } - Self::Relative(percentage) => { - ((content_bounds.height - bounds.height) * percentage).max(0.0) - } +impl operation::Scrollable for State { + fn snap_to(&mut self, percentage: Vector<f32>) { + if let Some(scrollbar) = &mut self.scrollbar_y { + scrollbar.snap_to(percentage.y) + } + if let Some(scrollbar) = &mut self.scrollbar_x { + scrollbar.snap_to(percentage.x) } } } impl State { - /// Creates a new [`State`] with the scrollbar located at the top. + /// Creates a new [`State`]. pub fn new() -> Self { State::default() } - /// Apply a scrolling offset to the current [`State`], given the bounds of - /// the [`Scrollable`] and its contents. - pub fn scroll( + /// Create y or x scrollbars if content is overflowing the [`Scrollable`] bounds. + pub fn create_scrollbars_maybe( &mut self, - delta_y: f32, + horizontal: Option<&Horizontal>, + scrollbar_width: u16, + scrollbar_margin: u16, + scroller_width: u16, bounds: Rectangle, content_bounds: Rectangle, ) { - if bounds.height >= content_bounds.height { - return; - } + let show_scrollbar_x = horizontal.and_then(|h| { + if content_bounds.width > bounds.width { + Some(h) + } else { + None + } + }); - self.offset = Offset::Absolute( - (self.offset.absolute(bounds, content_bounds) - delta_y) - .clamp(0.0, content_bounds.height - bounds.height), - ); - } + self.scrollbar_y = if content_bounds.height > bounds.height { + let (offset_y, scroller_grabbed) = + if let Some(scrollbar) = &self.scrollbar_y { + ( + scrollbar.offset.absolute( + scrollbar.direction, + bounds, + content_bounds, + ), + scrollbar.scroller.grabbed_at, + ) + } else { + (0.0, None) + }; + + // Need to adjust the height of the vertical scrollbar if the horizontal scrollbar + // is present + let scrollbar_x_height = show_scrollbar_x.map_or(0.0, |h| { + (h.scrollbar_height.max(h.scroller_height) + h.scrollbar_margin) + as f32 + }); + + let total_scrollbar_width = + scrollbar_width.max(scroller_width) + 2 * scrollbar_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 - scrollbar_x_height).max(0.0), + }; + + // Bounds of just the scrollbar + let scrollbar_bounds = Rectangle { + x: bounds.x + bounds.width + - f32::from( + total_scrollbar_width / 2 + scrollbar_width / 2, + ), + y: bounds.y, + width: scrollbar_width as f32, + height: (bounds.height - scrollbar_x_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 as f32 * 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 - scrollbar_x_height) + .max(0.0), + width: scroller_width as f32, + height: scroller_height, + }; + + Some(Scrollbar { + total_bounds: total_scrollbar_bounds, + bounds: scrollbar_bounds, + direction: Direction::Vertical, + scroller: Scroller { + bounds: scroller_bounds, + grabbed_at: scroller_grabbed, + }, + offset: Offset::Absolute(offset_y), + }) + } else { + None + }; - /// Scrolls the [`Scrollable`] to a relative amount. - /// - /// `0` represents scrollbar at the top, while `1` represents scrollbar at - /// the bottom. - pub fn scroll_to( - &mut self, - percentage: f32, - bounds: Rectangle, - content_bounds: Rectangle, - ) { - self.snap_to(percentage); - self.unsnap(bounds, content_bounds); + self.scrollbar_x = if let Some(horizontal) = show_scrollbar_x { + let (offset_x, scroller_grabbed) = + if let Some(scrollbar) = &self.scrollbar_x { + ( + scrollbar.offset.absolute( + scrollbar.direction, + bounds, + content_bounds, + ), + scrollbar.scroller.grabbed_at, + ) + } else { + (0.0, None) + }; + + // Need to adjust the width of the horizontal scrollbar if the vertical scrollbar + // is present + let scrollbar_y_width = self.scrollbar_y.map_or(0.0, |_| { + (scrollbar_width.max(scroller_width) + scrollbar_margin) as f32 + }); + + let total_scrollbar_height = + horizontal.scrollbar_height.max(horizontal.scroller_height) + + 2 * horizontal.scrollbar_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 + + horizontal.scrollbar_height / 2, + ), + width: (bounds.width - scrollbar_y_width).max(0.0), + height: horizontal.scrollbar_height as f32, + }; + + let ratio = bounds.width / content_bounds.width; + // min width for easier grabbing with extra wide content + let scroller_width = (bounds.width * ratio).max(2.0); + let scroller_offset = offset_x as f32 * 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 + + horizontal.scroller_height / 2, + ), + width: scroller_width, + height: horizontal.scroller_height as f32, + }; + + Some(Scrollbar { + total_bounds: total_scrollbar_bounds, + bounds: scrollbar_bounds, + direction: Direction::Horizontal, + scroller: Scroller { + bounds: scroller_bounds, + grabbed_at: scroller_grabbed, + }, + offset: Offset::Absolute(offset_x), + }) + } else { + None + }; } - /// Snaps the scroll position to a relative amount. - /// - /// `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)); + /// Returns whether the mouse is within the bounds of each scrollbar. + fn mouse_over_scrollbars(&self, cursor_position: Point) -> (bool, bool) { + ( + self.scrollbar_x.map_or(false, |scrollbar| { + scrollbar.is_mouse_over(cursor_position) + }), + self.scrollbar_y.map_or(false, |scrollbar| { + scrollbar.is_mouse_over(cursor_position) + }), + ) } - /// 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)); + /// Returns whether the scroller for either scrollbar is currently grabbed. + fn scrollers_grabbed(&self) -> bool { + self.scrollbar_x + .map_or(false, |scrollbar| scrollbar.scroller.grabbed_at.is_some()) + || self.scrollbar_y.map_or(false, |scrollbar| { + scrollbar.scroller.grabbed_at.is_some() + }) } - /// 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 - } + /// Apply a scrolling offset to the current [`State`], given the bounds of + /// the [`Scrollable`] and its contents. + pub fn scroll( + &mut self, + delta: Vector<f32>, + bounds: Rectangle, + content_bounds: Rectangle, + ) { + if delta.x != 0.0 && bounds.width < content_bounds.width { + if let Some(scrollbar) = &mut self.scrollbar_x { + scrollbar.offset = Offset::Absolute( + (scrollbar.offset.absolute( + Direction::Horizontal, + bounds, + content_bounds, + ) - delta.x) + .max(0.0) + .min((content_bounds.width - bounds.width) as f32), + ); + } + } - /// Returns whether the scroller is currently grabbed or not. - pub fn is_scroller_grabbed(&self) -> bool { - self.scroller_grabbed_at.is_some() + if delta.y != 0.0 && bounds.height < content_bounds.height { + if let Some(scrollbar) = &mut self.scrollbar_y { + scrollbar.offset = Offset::Absolute( + (scrollbar.offset.absolute( + Direction::Vertical, + bounds, + content_bounds, + ) - delta.y) + .max(0.0) + .min((content_bounds.height - bounds.height) as f32), + ) + } + } } - /// 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() + /// Returns the current x & y scrolling offset of the [`State`], given the bounds + /// of the [`Scrollable`] and its contents. + pub fn offset( + &self, + bounds: Rectangle, + content_bounds: Rectangle, + ) -> (f32, f32) { + ( + self.scrollbar_x.map_or(0.0, |scrollbar| { + scrollbar.offset.absolute( + Direction::Horizontal, + bounds, + content_bounds, + ) + }), + self.scrollbar_y.map_or(0.0, |scrollbar| { + scrollbar.offset.absolute( + Direction::Vertical, + bounds, + content_bounds, + ) + }), + ) } } /// 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 direction of the [`Scrollbar`]. + direction: Direction, + + /// The state of this scrollbar's [`Scroller`]. scroller: Scroller, + + /// The current offset of the [`Scrollbar`]. + offset: Offset, } impl Scrollbar { + /// Snaps the scroll position to a relative amount. + /// + /// `0` represents scrollbar at the beginning, while `1` represents scrollbar at + /// the end. + pub fn snap_to(&mut self, percentage: f32) { + self.offset = Offset::Relative(percentage.max(0.0).min(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( + self.direction, + bounds, + content_bounds, + )); + } + + /// Scrolls the [`Scrollbar`] to a certain percentage. + fn scroll_to( + &mut self, + percentage: f32, + bounds: Rectangle, + content_bounds: Rectangle, + ) { + self.snap_to(percentage); + self.unsnap(bounds, content_bounds); + } + + /// 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<f32> { - if self.outer_bounds.contains(cursor_position) { + if self.total_bounds.contains(cursor_position) { Some(if self.scroller.bounds.contains(cursor_position) { - (cursor_position.y - self.scroller.bounds.y) - / self.scroller.bounds.height + match self.direction { + Direction::Vertical => { + (cursor_position.y - self.scroller.bounds.y) + / self.scroller.bounds.height + } + Direction::Horizontal => { + (cursor_position.x - self.scroller.bounds.x) + / self.scroller.bounds.width + } + } } else { 0.5 }) @@ -970,10 +1311,56 @@ impl Scrollbar { 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) + match self.direction { + Direction::Vertical => { + (cursor_position.y + - self.bounds.y + - self.scroller.bounds.height * grabbed_at) + / (self.bounds.height - self.scroller.bounds.height) + } + Direction::Horizontal => { + (cursor_position.x + - self.bounds.x + - self.scroller.bounds.width * grabbed_at) + / (self.bounds.width - self.scroller.bounds.width) + } + } + } +} + +/// The directional offset of a [`Scrollable`]. +#[derive(Debug, Clone, Copy)] +enum Offset { + Absolute(f32), + Relative(f32), +} + +impl Offset { + fn absolute( + self, + direction: Direction, + bounds: Rectangle, + content_bounds: Rectangle, + ) -> f32 { + match self { + Self::Absolute(absolute) => match direction { + Direction::Horizontal => { + absolute.min((content_bounds.width - bounds.width).max(0.0)) + } + Direction::Vertical => absolute + .min((content_bounds.height - bounds.height).max(0.0)), + }, + Self::Relative(percentage) => match direction { + Direction::Horizontal => { + ((content_bounds.width - bounds.width) * percentage) + .max(0.0) + } + Direction::Vertical => { + ((content_bounds.height - bounds.height) * percentage) + .max(0.0) + } + }, + } } } @@ -982,4 +1369,7 @@ impl Scrollbar { struct Scroller { /// The bounds of the [`Scroller`]. bounds: Rectangle, + + /// Whether or not the scroller is currently grabbed. + grabbed_at: Option<f32>, } diff --git a/src/widget.rs b/src/widget.rs index 76cea7be..ee30548c 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, Direction, Horizontal, Id, + 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 c6d7d537..64ed8462 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 271d9a29..cef8f2be 100644 --- a/style/src/theme.rs +++ b/style/src/theme.rs @@ -925,6 +925,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. |