diff --git a/crates/components/src/scroll_views/mod.rs b/crates/components/src/scroll_views/mod.rs index b7fcf8f2c..d543830b4 100644 --- a/crates/components/src/scroll_views/mod.rs +++ b/crates/components/src/scroll_views/mod.rs @@ -1,6 +1,7 @@ mod scroll_bar; mod scroll_thumb; mod scroll_view; +mod use_scroll_controller; mod virtual_scroll_view; use freya_elements::events::{ @@ -10,6 +11,7 @@ use freya_elements::events::{ pub use scroll_bar::*; pub use scroll_thumb::*; pub use scroll_view::*; +pub use use_scroll_controller::*; pub use virtual_scroll_view::*; // Holding alt while scrolling makes it 5x faster (VSCode behavior). diff --git a/crates/components/src/scroll_views/scroll_view.rs b/crates/components/src/scroll_views/scroll_view.rs index e513a761a..6e7243a0f 100644 --- a/crates/components/src/scroll_views/scroll_view.rs +++ b/crates/components/src/scroll_views/scroll_view.rs @@ -16,6 +16,7 @@ use freya_hooks::{ ScrollViewThemeWith, }; +use super::use_scroll_controller::ScrollController; use crate::{ get_container_size, get_corrected_scroll_position, @@ -24,6 +25,10 @@ use crate::{ get_scrollbar_pos_and_size, is_scrollbar_visible, manage_key_event, + scroll_views::use_scroll_controller::{ + use_scroll_controller, + ScrollConfig, + }, Axis, ScrollBar, ScrollThumb, @@ -48,6 +53,8 @@ pub struct ScrollViewProps { /// Enable scrolling with arrow keys. #[props(default = true, into)] pub scroll_with_arrows: bool, + + pub scroll_controller: Option, } /// Scrollable area with bidirectional support and scrollbars. @@ -59,28 +66,66 @@ pub struct ScrollViewProps { /// fn app() -> Element { /// rsx!( /// ScrollView { -/// theme: theme_with!(ScrollViewTheme { -/// width: "100%".into(), -/// height: "300".into(), -/// }), -/// show_scrollbar: true, -/// rect { +/// rect { /// background: "blue", -/// height: "500", +/// height: "400", +/// width: "100%" +/// } +/// rect { +/// background: "red", +/// height: "400", /// width: "100%" /// } /// } /// ) /// } /// ``` +/// +/// # With a Scroll Controller +/// +/// ```no_run +/// # use freya::prelude::*; +/// fn app() -> Element { +/// let mut scroll_controller = use_scroll_controller(|| ScrollConfig::default()); +/// +/// rsx!( +/// ScrollView { +/// scroll_controller, +/// rect { +/// background: "blue", +/// height: "400", +/// width: "100%" +/// } +/// Button { +/// label { +/// onclick: move |_| { +/// scroll_controller.scroll_to(ScrollPosition::Start, ScrollDirection::Vertical); +/// }, +/// label { +/// "Scroll up" +/// } +/// } +/// } +/// rect { +/// background: "red", +/// height: "400", +/// width: "100%" +/// } +/// } +/// ) +/// } +/// ``` #[allow(non_snake_case)] pub fn ScrollView(props: ScrollViewProps) -> Element { let mut clicking_scrollbar = use_signal::>(|| None); let mut clicking_shift = use_signal(|| false); let mut clicking_alt = use_signal(|| false); - let mut scrolled_y = use_signal(|| 0); - let mut scrolled_x = use_signal(|| 0); + let mut scroll_controller = props + .scroll_controller + .unwrap_or_else(|| use_scroll_controller(ScrollConfig::default)); + let (mut scrolled_x, mut scrolled_y) = scroll_controller.into(); let (node_ref, size) = use_node(); + let mut focus = use_focus(); let theme = use_applied_theme!(&props.theme, scroll_view); let scrollbar_theme = use_applied_theme!(&props.scrollbar_theme, scroll_bar); @@ -92,6 +137,8 @@ pub fn ScrollView(props: ScrollViewProps) -> Element { let show_scrollbar = props.show_scrollbar; let scroll_with_arrows = props.scroll_with_arrows; + scroll_controller.use_apply(size.inner.width, size.inner.height); + let direction_is_vertical = user_direction == "vertical"; let vertical_scrollbar_is_visible = diff --git a/crates/components/src/scroll_views/use_scroll_controller.rs b/crates/components/src/scroll_views/use_scroll_controller.rs new file mode 100644 index 000000000..ea3ccac66 --- /dev/null +++ b/crates/components/src/scroll_views/use_scroll_controller.rs @@ -0,0 +1,278 @@ +use std::collections::HashSet; + +use dioxus::prelude::{ + current_scope_id, + schedule_update_any, + use_drop, + use_hook, + Readable, + ScopeId, + Signal, + Writable, + WritableVecExt, +}; + +#[derive(Default, PartialEq, Eq)] +pub enum ScrollPosition { + #[default] + Start, + End, + // Specific +} + +#[derive(Default, PartialEq, Eq)] +pub enum ScrollDirection { + #[default] + Vertical, + Horizontal, +} + +#[derive(Default)] +pub struct ScrollConfig { + pub default_vertical_position: ScrollPosition, + pub default_horizontal_position: ScrollPosition, +} + +pub struct ScrollRequest { + pub(crate) position: ScrollPosition, + pub(crate) direction: ScrollDirection, + pub(crate) init: bool, + pub(crate) applied_by: HashSet, +} + +impl ScrollRequest { + pub fn new(position: ScrollPosition, direction: ScrollDirection) -> ScrollRequest { + ScrollRequest { + position, + direction, + init: false, + applied_by: HashSet::default(), + } + } +} + +#[derive(PartialEq, Eq, Clone, Copy)] +pub struct ScrollController { + requests_subscribers: Signal>, + requests: Signal>, + x: Signal, + y: Signal, +} + +impl From for (Signal, Signal) { + fn from(val: ScrollController) -> Self { + (val.x, val.y) + } +} + +impl ScrollController { + pub fn new(x: i32, y: i32, initial_requests: Vec) -> Self { + Self { + x: Signal::new(x), + y: Signal::new(y), + requests_subscribers: Signal::new(HashSet::new()), + requests: Signal::new(initial_requests), + } + } + + pub fn use_apply(&mut self, width: f32, height: f32) { + let scope_id = current_scope_id().unwrap(); + + if !self.requests_subscribers.peek().contains(&scope_id) { + self.requests_subscribers.write().insert(scope_id); + } + + let mut requests_subscribers = self.requests_subscribers; + use_drop(move || { + requests_subscribers.write().remove(&scope_id); + }); + + self.requests.write().retain_mut(|request| { + if request.applied_by.contains(&scope_id) { + return true; + } + + match request { + ScrollRequest { + position: ScrollPosition::Start, + direction: ScrollDirection::Vertical, + .. + } => { + *self.y.write() = 0; + } + ScrollRequest { + position: ScrollPosition::Start, + direction: ScrollDirection::Horizontal, + .. + } => { + *self.x.write() = 0; + } + ScrollRequest { + position: ScrollPosition::End, + direction: ScrollDirection::Vertical, + init, + .. + } => { + if *init && height == 0. { + return true; + } + *self.y.write() = -height as i32; + } + ScrollRequest { + position: ScrollPosition::End, + direction: ScrollDirection::Horizontal, + init, + .. + } => { + if *init && width == 0. { + return true; + } + *self.x.write() = -width as i32; + } + } + + request.applied_by.insert(scope_id); + + *self.requests_subscribers.peek() != request.applied_by + }); + } + + pub fn scroll_to_x(&mut self, to: i32) { + self.x.set(to); + } + + pub fn scroll_to_y(&mut self, to: i32) { + self.y.set(to); + } + + pub fn scroll_to( + &mut self, + scroll_position: ScrollPosition, + scroll_direction: ScrollDirection, + ) { + self.requests + .push(ScrollRequest::new(scroll_position, scroll_direction)); + let schedule = schedule_update_any(); + for scope_id in self.requests_subscribers.read().iter() { + schedule(*scope_id); + } + } +} + +pub fn use_scroll_controller(init: impl FnOnce() -> ScrollConfig) -> ScrollController { + use_hook(|| { + let config = init(); + ScrollController::new( + 0, + 0, + vec![ + ScrollRequest { + position: config.default_vertical_position, + direction: ScrollDirection::Vertical, + init: true, + applied_by: HashSet::default(), + }, + ScrollRequest { + position: config.default_horizontal_position, + direction: ScrollDirection::Horizontal, + init: true, + applied_by: HashSet::default(), + }, + ], + ) + }) +} + +#[cfg(test)] +mod test { + use freya::prelude::*; + use freya_testing::prelude::*; + + #[tokio::test] + pub async fn controlled_scroll_view() { + fn scroll_view_app() -> Element { + let mut scroll_controller = use_scroll_controller(|| ScrollConfig { + default_vertical_position: ScrollPosition::End, + ..Default::default() + }); + + rsx!( + ScrollView { + scroll_controller, + Button { + onclick: move |_| { + scroll_controller.scroll_to(ScrollPosition::End, ScrollDirection::Vertical); + }, + label { + "Scroll Down" + } + } + rect { + height: "200", + width: "200", + }, + rect { + height: "200", + width: "200", + }, + rect { + height: "200", + width: "200", + } + rect { + height: "200", + width: "200", + } + Button { + onclick: move |_| { + scroll_controller.scroll_to(ScrollPosition::Start, ScrollDirection::Vertical); + }, + label { + "Scroll up" + } + } + } + ) + } + + let mut utils = launch_test(scroll_view_app); + let root = utils.root(); + let content = root.get(0).get(0).get(0); + utils.wait_for_update().await; + + // Only the last three items are visible + assert!(!content.get(1).is_visible()); + assert!(content.get(2).is_visible()); + assert!(content.get(3).is_visible()); + assert!(content.get(4).is_visible()); + + // Click on the button to scroll up + utils.push_event(PlatformEvent::Mouse { + name: EventName::Click, + cursor: (15., 480.).into(), + button: Some(MouseButton::Left), + }); + utils.wait_for_update().await; + + // Only the first three items are visible + assert!(content.get(1).is_visible()); + assert!(content.get(2).is_visible()); + assert!(content.get(3).is_visible()); + assert!(!content.get(4).is_visible()); + + // Click on the button to scroll down + utils.push_event(PlatformEvent::Mouse { + name: EventName::Click, + cursor: (15., 15.).into(), + button: Some(MouseButton::Left), + }); + + utils.wait_for_update().await; + + // Only the first three items are visible + assert!(!content.get(1).is_visible()); + assert!(content.get(2).is_visible()); + assert!(content.get(3).is_visible()); + assert!(content.get(4).is_visible()); + } +} diff --git a/crates/components/src/scroll_views/virtual_scroll_view.rs b/crates/components/src/scroll_views/virtual_scroll_view.rs index 77afcf6df..c3610def0 100644 --- a/crates/components/src/scroll_views/virtual_scroll_view.rs +++ b/crates/components/src/scroll_views/virtual_scroll_view.rs @@ -28,8 +28,11 @@ use crate::{ get_scrollbar_pos_and_size, is_scrollbar_visible, manage_key_event, + scroll_views::use_scroll_controller, Axis, ScrollBar, + ScrollConfig, + ScrollController, ScrollThumb, SCROLL_SPEED_MULTIPLIER, }; @@ -66,6 +69,8 @@ pub struct VirtualScrollViewProps< /// Default is `true`. #[props(default = true, into)] pub cache_elements: bool, + + pub scroll_controller: Option, } impl< @@ -81,6 +86,7 @@ impl< && self.show_scrollbar == other.show_scrollbar && self.scroll_with_arrows == other.scroll_with_arrows && self.builder_args == other.builder_args + && self.scroll_controller == other.scroll_controller } } @@ -115,7 +121,6 @@ fn get_render_range( /// # use std::rc::Rc; /// fn app() -> Element { /// rsx!(VirtualScrollView { -/// show_scrollbar: true, /// length: 5, /// item_size: 80.0, /// direction: "vertical", @@ -131,6 +136,35 @@ fn get_render_range( /// }) /// } /// ``` +/// +/// # With a Scroll Controller +/// +/// ```no_run +/// # use freya::prelude::*; +/// # use std::rc::Rc; +/// fn app() -> Element { +/// let mut scroll_controller = use_scroll_controller(|| ScrollConfig::default()); +/// +/// rsx!(VirtualScrollView { +/// scroll_controller, +/// length: 5, +/// item_size: 80.0, +/// direction: "vertical", +/// builder: move |i, _other_args: &Option<()>| { +/// rsx! { +/// label { +/// key: "{i}", +/// height: "80", +/// onclick: move |_| { +/// scroll_controller.scroll_to(ScrollPosition::Start, ScrollDirection::Vertical); +/// }, +/// "Number {i}" +/// } +/// } +/// } +/// }) +/// } +/// ``` #[allow(non_snake_case)] pub fn VirtualScrollView< Builder: Clone + Fn(usize, &Option) -> Element, @@ -141,8 +175,10 @@ pub fn VirtualScrollView< let mut clicking_scrollbar = use_signal::>(|| None); let mut clicking_shift = use_signal(|| false); let mut clicking_alt = use_signal(|| false); - let mut scrolled_y = use_signal(|| 0); - let mut scrolled_x = use_signal(|| 0); + let mut scroll_controller = props + .scroll_controller + .unwrap_or_else(|| use_scroll_controller(ScrollConfig::default)); + let (mut scrolled_x, mut scrolled_y) = scroll_controller.into(); let (node_ref, size) = use_node(); let mut focus = use_focus(); let theme = use_applied_theme!(&props.theme, scroll_view); @@ -161,6 +197,8 @@ pub fn VirtualScrollView< let inner_size = items_size + (items_size * items_length as f32); + scroll_controller.use_apply(inner_size, inner_size); + let vertical_scrollbar_is_visible = user_direction != "horizontal" && is_scrollbar_visible(show_scrollbar, inner_size, size.area.height()); let horizontal_scrollbar_is_visible = user_direction != "vertical" diff --git a/examples/controlled_scroll.rs b/examples/controlled_scroll.rs new file mode 100644 index 000000000..8318559dd --- /dev/null +++ b/examples/controlled_scroll.rs @@ -0,0 +1,77 @@ +#![cfg_attr( + all(not(debug_assertions), target_os = "windows"), + windows_subsystem = "windows" +)] + +use freya::prelude::*; + +fn main() { + launch_with_props(app, "Controlled Example", (600.0, 600.0)); +} + +fn app() -> Element { + let mut scroll_controller = use_scroll_controller(|| ScrollConfig { + default_vertical_position: ScrollPosition::End, + ..Default::default() + }); + + let scroll_to_top = move |_| { + scroll_controller.scroll_to(ScrollPosition::Start, ScrollDirection::Vertical); + }; + + let scroll_to_bottom = move |_| { + scroll_controller.scroll_to(ScrollPosition::End, ScrollDirection::Vertical); + }; + + rsx!( + rect { + height: "fill", + width: "fill", + direction: "horizontal", + ScrollView { + scroll_controller, + theme: theme_with!(ScrollViewTheme { + width: "50%".into(), + }), + Button { + onclick: scroll_to_bottom, + label { + "Scroll to Bottom" + } + } + Card {} + Card {} + Card {} + } + ScrollView { + scroll_controller, + theme: theme_with!(ScrollViewTheme { + width: "50%".into(), + }), + Card {} + Card {} + Card {} + Button { + onclick: scroll_to_top, + label { + "Scroll to Top" + } + } + } + } + ) +} + +#[component] +fn Card() -> Element { + rsx!( + rect { + border: "15 solid rgb(43,106,208)", + height: "220", + width: "420", + background: "white", + padding: "25", + label { "Scroll..." } + } + ) +}