diff --git a/Cargo.toml b/Cargo.toml index 7aa303021c..b53b4109a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2882,7 +2882,17 @@ doc-scrape-examples = true [package.metadata.example.grid] name = "CSS Grid" description = "An example for CSS Grid layout" +category = "UI (User Interface)" +wasm = true +[[example]] +name = "scroll" +path = "examples/ui/scroll.rs" +doc-scrape-examples = true + +[package.metadata.example.scroll] +name = "Scroll" +description = "Demonstrates scrolling UI containers" category = "UI (User Interface)" wasm = true diff --git a/crates/bevy_ui/src/layout/convert.rs b/crates/bevy_ui/src/layout/convert.rs index 240f899656..f936855c11 100644 --- a/crates/bevy_ui/src/layout/convert.rs +++ b/crates/bevy_ui/src/layout/convert.rs @@ -258,6 +258,7 @@ impl From for taffy::style::Overflow { OverflowAxis::Visible => taffy::style::Overflow::Visible, OverflowAxis::Clip => taffy::style::Overflow::Clip, OverflowAxis::Hidden => taffy::style::Overflow::Hidden, + OverflowAxis::Scroll => taffy::style::Overflow::Scroll, } } } diff --git a/crates/bevy_ui/src/layout/mod.rs b/crates/bevy_ui/src/layout/mod.rs index 269af520b1..43e7fa428f 100644 --- a/crates/bevy_ui/src/layout/mod.rs +++ b/crates/bevy_ui/src/layout/mod.rs @@ -1,5 +1,6 @@ use crate::{ - BorderRadius, ContentSize, DefaultUiCamera, Node, Outline, Style, TargetCamera, UiScale, + BorderRadius, ContentSize, DefaultUiCamera, Node, Outline, OverflowAxis, ScrollPosition, Style, + TargetCamera, UiScale, }; use bevy_ecs::{ change_detection::{DetectChanges, DetectChangesMut}, @@ -7,7 +8,7 @@ use bevy_ecs::{ event::EventReader, query::{With, Without}, removal_detection::RemovedComponents, - system::{Local, Query, Res, ResMut, SystemParam}, + system::{Commands, Local, Query, Res, ResMut, SystemParam}, world::Ref, }; use bevy_hierarchy::{Children, Parent}; @@ -91,10 +92,10 @@ struct CameraLayoutInfo { /// Updates the UI's layout tree, computes the new layout geometry and then updates the sizes and transforms of all the UI nodes. #[allow(clippy::too_many_arguments)] pub fn ui_layout_system( + mut commands: Commands, mut buffers: Local, primary_window: Query<(Entity, &Window), With>, - cameras: Query<(Entity, &Camera)>, - default_ui_camera: DefaultUiCamera, + camera_data: (Query<(Entity, &Camera)>, DefaultUiCamera), ui_scale: Res, mut scale_factor_events: EventReader, mut resize_events: EventReader, @@ -115,8 +116,10 @@ pub fn ui_layout_system( mut node_transform_query: Query<( &mut Node, &mut Transform, + &Style, Option<&BorderRadius>, Option<&Outline>, + Option<&ScrollPosition>, )>, #[cfg(feature = "bevy_text")] mut buffer_query: Query<&mut CosmicBuffer>, #[cfg(feature = "bevy_text")] mut text_pipeline: ResMut, @@ -127,6 +130,8 @@ pub fn ui_layout_system( camera_layout_info, } = &mut *buffers; + let (cameras, default_ui_camera) = camera_data; + let default_camera = default_ui_camera.get(); let camera_with_default = |target_camera: Option<&TargetCamera>| { target_camera.map(TargetCamera::entity).or(default_camera) @@ -266,8 +271,10 @@ pub fn ui_layout_system( #[cfg(feature = "bevy_text")] font_system, ); + for root in &camera.root_nodes { update_uinode_geometry_recursive( + &mut commands, *root, &ui_surface, None, @@ -276,6 +283,7 @@ pub fn ui_layout_system( inverse_target_scale_factor, Vec2::ZERO, Vec2::ZERO, + Vec2::ZERO, ); } @@ -283,27 +291,39 @@ pub fn ui_layout_system( interned_root_nodes.push(camera.root_nodes); } + // Returns the combined bounding box of the node and any of its overflowing children. fn update_uinode_geometry_recursive( + commands: &mut Commands, entity: Entity, ui_surface: &UiSurface, root_size: Option, node_transform_query: &mut Query<( &mut Node, &mut Transform, + &Style, Option<&BorderRadius>, Option<&Outline>, + Option<&ScrollPosition>, )>, children_query: &Query<&Children>, inverse_target_scale_factor: f32, parent_size: Vec2, + parent_scroll_position: Vec2, mut absolute_location: Vec2, ) { - if let Ok((mut node, mut transform, maybe_border_radius, maybe_outline)) = - node_transform_query.get_mut(entity) + if let Ok(( + mut node, + mut transform, + style, + maybe_border_radius, + maybe_outline, + maybe_scroll_position, + )) = node_transform_query.get_mut(entity) { let Ok(layout) = ui_surface.get_layout(entity) else { return; }; + let layout_size = inverse_target_scale_factor * Vec2::new(layout.size.width, layout.size.height); let layout_location = @@ -315,7 +335,8 @@ pub fn ui_layout_system( - approx_round_layout_coords(absolute_location); let rounded_location = - approx_round_layout_coords(layout_location) + 0.5 * (rounded_size - parent_size); + approx_round_layout_coords(layout_location - parent_scroll_position) + + 0.5 * (rounded_size - parent_size); // only trigger change detection when the new values are different if node.calculated_size != rounded_size || node.unrounded_size != layout_size { @@ -351,9 +372,40 @@ pub fn ui_layout_system( transform.translation = rounded_location.extend(0.); } + let scroll_position: Vec2 = maybe_scroll_position + .map(|scroll_pos| { + Vec2::new( + if style.overflow.x == OverflowAxis::Scroll { + scroll_pos.offset_x + } else { + 0.0 + }, + if style.overflow.y == OverflowAxis::Scroll { + scroll_pos.offset_y + } else { + 0.0 + }, + ) + }) + .unwrap_or_default(); + + let round_content_size = approx_round_layout_coords( + Vec2::new(layout.content_size.width, layout.content_size.height) + * inverse_target_scale_factor, + ); + let max_possible_offset = (round_content_size - rounded_size).max(Vec2::ZERO); + let clamped_scroll_position = scroll_position.clamp(Vec2::ZERO, max_possible_offset); + + if clamped_scroll_position != scroll_position { + commands + .entity(entity) + .insert(ScrollPosition::from(&clamped_scroll_position)); + } + if let Ok(children) = children_query.get(entity) { for &child_uinode in children { update_uinode_geometry_recursive( + commands, child_uinode, ui_surface, Some(viewport_size), @@ -361,6 +413,7 @@ pub fn ui_layout_system( children_query, inverse_target_scale_factor, rounded_size, + clamped_scroll_position, absolute_location, ); } diff --git a/crates/bevy_ui/src/node_bundles.rs b/crates/bevy_ui/src/node_bundles.rs index a093cf13cf..b99681ff39 100644 --- a/crates/bevy_ui/src/node_bundles.rs +++ b/crates/bevy_ui/src/node_bundles.rs @@ -4,8 +4,8 @@ use crate::widget::TextFlags; use crate::{ widget::{Button, UiImageSize}, - BackgroundColor, BorderColor, BorderRadius, ContentSize, FocusPolicy, Interaction, Node, Style, - UiImage, UiMaterial, ZIndex, + BackgroundColor, BorderColor, BorderRadius, ContentSize, FocusPolicy, Interaction, Node, + ScrollPosition, Style, UiImage, UiMaterial, ZIndex, }; use bevy_asset::Handle; #[cfg(feature = "bevy_text")] @@ -38,6 +38,8 @@ pub struct NodeBundle { pub border_radius: BorderRadius, /// Whether this node should block interaction with lower nodes pub focus_policy: FocusPolicy, + /// The scroll position of the node, + pub scroll_position: ScrollPosition, /// The transform of the node /// /// This component is automatically managed by the UI layout system. diff --git a/crates/bevy_ui/src/ui_node.rs b/crates/bevy_ui/src/ui_node.rs index 8a2502e7d8..00e68b2e7d 100644 --- a/crates/bevy_ui/src/ui_node.rs +++ b/crates/bevy_ui/src/ui_node.rs @@ -150,6 +150,47 @@ impl Default for Node { } } +/// The scroll position of the node. +/// Updating the values of `ScrollPosition` will reposition the children of the node by the offset amount. +/// `ScrollPosition` may be updated by the layout system when a layout change makes a previously valid `ScrollPosition` invalid. +/// Changing this does nothing on a `Node` without a `Style` setting at least one `OverflowAxis` to `OverflowAxis::Scroll`. +#[derive(Component, Debug, Clone, Reflect)] +#[reflect(Component, Default)] +pub struct ScrollPosition { + /// How far across the node is scrolled, in pixels. (0 = not scrolled / scrolled to right) + pub offset_x: f32, + /// How far down the node is scrolled, in pixels. (0 = not scrolled / scrolled to top) + pub offset_y: f32, +} + +impl ScrollPosition { + pub const DEFAULT: Self = Self { + offset_x: 0.0, + offset_y: 0.0, + }; +} + +impl Default for ScrollPosition { + fn default() -> Self { + Self::DEFAULT + } +} + +impl From<&ScrollPosition> for Vec2 { + fn from(scroll_pos: &ScrollPosition) -> Self { + Vec2::new(scroll_pos.offset_x, scroll_pos.offset_y) + } +} + +impl From<&Vec2> for ScrollPosition { + fn from(vec: &Vec2) -> Self { + ScrollPosition { + offset_x: vec.x, + offset_y: vec.y, + } + } +} + /// Describes the style of a UI container node /// /// Nodes can be laid out using either Flexbox or CSS Grid Layout. @@ -865,6 +906,29 @@ impl Overflow { pub const fn is_visible(&self) -> bool { self.x.is_visible() && self.y.is_visible() } + + pub const fn scroll() -> Self { + Self { + x: OverflowAxis::Scroll, + y: OverflowAxis::Scroll, + } + } + + /// Scroll overflowing items on the x axis + pub const fn scroll_x() -> Self { + Self { + x: OverflowAxis::Scroll, + y: OverflowAxis::Visible, + } + } + + /// Scroll overflowing items on the y axis + pub const fn scroll_y() -> Self { + Self { + x: OverflowAxis::Visible, + y: OverflowAxis::Scroll, + } + } } impl Default for Overflow { @@ -888,6 +952,8 @@ pub enum OverflowAxis { Clip, /// Hide overflowing items by influencing layout and then clipping. Hidden, + /// Scroll overflowing items. + Scroll, } impl OverflowAxis { diff --git a/examples/README.md b/examples/README.md index 147d5ee3c6..70f0d9b652 100644 --- a/examples/README.md +++ b/examples/README.md @@ -486,6 +486,7 @@ Example | Description [Overflow and Clipping Debug](../examples/ui/overflow_debug.rs) | An example to debug overflow and clipping behavior [Relative Cursor Position](../examples/ui/relative_cursor_position.rs) | Showcases the RelativeCursorPosition component [Render UI to Texture](../examples/ui/render_ui_to_texture.rs) | An example of rendering UI as a part of a 3D world +[Scroll](../examples/ui/scroll.rs) | Demonstrates scrolling UI containers [Size Constraints](../examples/ui/size_constraints.rs) | Demonstrates how the to use the size constraints to control the size of a UI node. [Text](../examples/ui/text.rs) | Illustrates creating and updating text [Text Debug](../examples/ui/text_debug.rs) | An example for debugging text layout diff --git a/examples/ui/scroll.rs b/examples/ui/scroll.rs new file mode 100644 index 0000000000..135ae688c9 --- /dev/null +++ b/examples/ui/scroll.rs @@ -0,0 +1,408 @@ +//! This example illustrates scrolling in Bevy UI. + +use bevy::{ + a11y::{ + accesskit::{NodeBuilder, Role}, + AccessibilityNode, + }, + input::mouse::{MouseScrollUnit, MouseWheel}, + picking::focus::HoverMap, + prelude::*, + winit::WinitSettings, +}; + +fn main() { + let mut app = App::new(); + app.add_plugins(DefaultPlugins) + .insert_resource(WinitSettings::desktop_app()) + .add_systems(Startup, setup) + .add_systems(Update, update_scroll_position); + + app.run(); +} + +const FONT_SIZE: f32 = 20.; +const LINE_HEIGHT: f32 = 21.; + +fn setup(mut commands: Commands, asset_server: Res) { + // Camera + commands.spawn((Camera2dBundle::default(), IsDefaultUiCamera)); + + //root node + commands + .spawn(NodeBundle { + style: Style { + width: Val::Percent(100.0), + height: Val::Percent(100.0), + justify_content: JustifyContent::SpaceBetween, + flex_direction: FlexDirection::Column, + ..default() + }, + ..default() + }) + .insert(Pickable::IGNORE) + .with_children(|parent| { + // horizontal scroll example + parent + .spawn(NodeBundle { + style: Style { + width: Val::Percent(100.), + flex_direction: FlexDirection::Column, + ..default() + }, + ..default() + }) + .with_children(|parent| { + // header + parent.spawn(( + TextBundle::from_section( + "Horizontally Scrolling list (Shift + Mousewheel)", + TextStyle { + font: asset_server.load("fonts/FiraSans-Bold.ttf"), + font_size: FONT_SIZE, + ..default() + }, + ), + Label, + )); + + // horizontal scroll container + parent + .spawn(NodeBundle { + style: Style { + width: Val::Percent(80.), + margin: UiRect::all(Val::Px(10.)), + flex_direction: FlexDirection::Row, + overflow: Overflow::scroll_x(), // n.b. + ..default() + }, + background_color: Color::srgb(0.10, 0.10, 0.10).into(), + ..default() + }) + .with_children(|parent| { + for i in 0..100 { + parent.spawn(( + TextBundle::from_section( + format!("Item {i}"), + TextStyle { + font: asset_server + .load("fonts/FiraSans-Bold.ttf"), + ..default() + }, + ), + Label, + AccessibilityNode(NodeBuilder::new(Role::ListItem)), + )) + .insert(Style { + min_width: Val::Px(200.), + align_content: AlignContent::Center, + ..default() + }) + .insert(Pickable { + should_block_lower: false, + ..default() + }) + .observe(| + trigger: Trigger>, + mut commands: Commands + | { + if trigger.event().button == PointerButton::Primary { + commands.entity(trigger.entity()).despawn_recursive(); + } + }); + } + }); + }); + + // container for all other examples + parent + .spawn(NodeBundle { + style: Style { + width: Val::Percent(100.), + height: Val::Percent(100.), + flex_direction: FlexDirection::Row, + justify_content: JustifyContent::SpaceBetween, + ..default() + }, + ..default() + }) + .with_children(|parent| { + // vertical scroll example + parent + .spawn(NodeBundle { + style: Style { + flex_direction: FlexDirection::Column, + justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + width: Val::Px(200.), + ..default() + }, + ..default() + }) + .with_children(|parent| { + // Title + parent.spawn(( + TextBundle::from_section( + "Vertically Scrolling List", + TextStyle { + font: asset_server.load("fonts/FiraSans-Bold.ttf"), + font_size: FONT_SIZE, + ..default() + }, + ), + Label, + )); + // Scrolling list + parent + .spawn(NodeBundle { + style: Style { + flex_direction: FlexDirection::Column, + align_self: AlignSelf::Stretch, + height: Val::Percent(50.), + overflow: Overflow::scroll_y(), // n.b. + ..default() + }, + background_color: Color::srgb(0.10, 0.10, 0.10).into(), + ..default() + }) + .with_children(|parent| { + // List items + for i in 0..25 { + parent + .spawn(NodeBundle { + style: Style { + min_height: Val::Px(LINE_HEIGHT), + max_height: Val::Px(LINE_HEIGHT), + ..default() + }, + ..default() + }) + .insert(Pickable { + should_block_lower: false, + ..default() + }) + .with_children(|parent| { + parent + .spawn(( + TextBundle::from_section( + format!("Item {i}"), + TextStyle { + font: asset_server.load( + "fonts/FiraSans-Bold.ttf", + ), + ..default() + }, + ), + Label, + AccessibilityNode(NodeBuilder::new( + Role::ListItem, + )), + )) + .insert(Pickable { + should_block_lower: false, + ..default() + }); + }); + } + }); + }); + + // Bidirectional scroll example + parent + .spawn(NodeBundle { + style: Style { + flex_direction: FlexDirection::Column, + justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + width: Val::Px(200.), + ..default() + }, + ..default() + }) + .with_children(|parent| { + // Title + parent.spawn(( + TextBundle::from_section( + "Bidirectionally Scrolling List", + TextStyle { + font: asset_server.load("fonts/FiraSans-Bold.ttf"), + font_size: FONT_SIZE, + ..default() + }, + ), + Label, + )); + // Scrolling list + parent + .spawn(NodeBundle { + style: Style { + flex_direction: FlexDirection::Column, + align_self: AlignSelf::Stretch, + height: Val::Percent(50.), + overflow: Overflow::scroll(), // n.b. + ..default() + }, + background_color: Color::srgb(0.10, 0.10, 0.10).into(), + ..default() + }) + .with_children(|parent| { + // Rows in each column + for oi in 0..10 { + parent + .spawn(NodeBundle { + style: Style { + flex_direction: FlexDirection::Row, + ..default() + }, + ..default() + }) + .insert(Pickable::IGNORE) + .with_children(|parent| { + // Elements in each row + for i in 0..25 { + parent + .spawn(( + TextBundle::from_section( + format!("Item {}", (oi * 25) + i), + TextStyle { + font: asset_server.load( + "fonts/FiraSans-Bold.ttf", + ), + ..default() + }, + ), + Label, + AccessibilityNode(NodeBuilder::new( + Role::ListItem, + )), + )) + .insert(Pickable { + should_block_lower: false, + ..default() + }); + } + }); + } + }); + }); + + //Nested scrolls example + parent + .spawn(NodeBundle { + style: Style { + flex_direction: FlexDirection::Column, + justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + width: Val::Px(200.), + ..default() + }, + ..default() + }) + .with_children(|parent| { + // Title + parent.spawn(( + TextBundle::from_section( + "Nested Scrolling Lists", + TextStyle { + font: asset_server.load("fonts/FiraSans-Bold.ttf"), + font_size: FONT_SIZE, + ..default() + }, + ), + Label, + )); + // Outer, horizontal scrolling container + parent + .spawn(NodeBundle { + style: Style { + column_gap: Val::Px(20.), + flex_direction: FlexDirection::Row, + align_self: AlignSelf::Stretch, + height: Val::Percent(50.), + overflow: Overflow::scroll_x(), // n.b. + ..default() + }, + background_color: Color::srgb(0.10, 0.10, 0.10).into(), + ..default() + }) + .with_children(|parent| { + // Inner, scrolling columns + for oi in 0..30 { + parent + .spawn(NodeBundle { + style: Style { + flex_direction: FlexDirection::Column, + align_self: AlignSelf::Stretch, + overflow: Overflow::scroll_y(), + ..default() + }, + background_color: Color::srgb(0.05, 0.05, 0.05) + .into(), + ..default() + }) + .insert(Pickable { + should_block_lower: false, + ..default() + }) + .with_children(|parent| { + for i in 0..25 { + parent + .spawn(( + TextBundle::from_section( + format!("Item {}", (oi * 25) + i), + TextStyle { + font: asset_server.load( + "fonts/FiraSans-Bold.ttf", + ), + ..default() + }, + ), + Label, + AccessibilityNode(NodeBuilder::new( + Role::ListItem, + )), + )) + .insert(Pickable { + should_block_lower: false, + ..default() + }); + } + }); + } + }); + }); + }); + }); +} + +/// Updates the scroll position of scrollable nodes in response to mouse input +pub fn update_scroll_position( + mut mouse_wheel_events: EventReader, + hover_map: Res, + mut scrolled_node_query: Query<&mut ScrollPosition>, + keyboard_input: Res>, +) { + for mouse_wheel_event in mouse_wheel_events.read() { + let (mut dx, mut dy) = match mouse_wheel_event.unit { + MouseScrollUnit::Line => ( + mouse_wheel_event.x * LINE_HEIGHT, + mouse_wheel_event.y * LINE_HEIGHT, + ), + MouseScrollUnit::Pixel => (mouse_wheel_event.x, mouse_wheel_event.y), + }; + + if keyboard_input.pressed(KeyCode::ShiftLeft) || keyboard_input.pressed(KeyCode::ShiftRight) + { + std::mem::swap(&mut dx, &mut dy); + } + + for (_pointer, pointer_map) in hover_map.iter() { + for (entity, _hit) in pointer_map.iter() { + if let Ok(mut scroll_position) = scrolled_node_query.get_mut(*entity) { + scroll_position.offset_x -= dx; + scroll_position.offset_y -= dy; + } + } + } + } +} diff --git a/examples/ui/ui.rs b/examples/ui/ui.rs index 9887d42270..0669002c88 100644 --- a/examples/ui/ui.rs +++ b/examples/ui/ui.rs @@ -7,6 +7,7 @@ use bevy::{ }, color::palettes::basic::LIME, input::mouse::{MouseScrollUnit, MouseWheel}, + picking::focus::HoverMap, prelude::*, winit::WinitSettings, }; @@ -17,7 +18,7 @@ fn main() { // Only run the app when there is user input. This will significantly reduce CPU/GPU use. .insert_resource(WinitSettings::desktop_app()) .add_systems(Startup, setup) - .add_systems(Update, mouse_scroll); + .add_systems(Update, update_scroll_position); #[cfg(feature = "bevy_dev_tools")] { @@ -43,6 +44,7 @@ fn setup(mut commands: Commands, asset_server: Res) { }, ..default() }) + .insert(Pickable::IGNORE) .with_children(|parent| { // left vertical fill (border) parent @@ -122,7 +124,6 @@ fn setup(mut commands: Commands, asset_server: Res) { width: Val::Px(200.), ..default() }, - background_color: Color::srgb(0.15, 0.15, 0.15).into(), ..default() }) .with_children(|parent| { @@ -138,53 +139,42 @@ fn setup(mut commands: Commands, asset_server: Res) { ), Label, )); - // List with hidden overflow + // Scrolling list parent .spawn(NodeBundle { style: Style { flex_direction: FlexDirection::Column, align_self: AlignSelf::Stretch, height: Val::Percent(50.), - overflow: Overflow::clip_y(), + overflow: Overflow::scroll_y(), ..default() }, background_color: Color::srgb(0.10, 0.10, 0.10).into(), ..default() }) .with_children(|parent| { - // Moving panel - parent - .spawn(( - NodeBundle { - style: Style { - flex_direction: FlexDirection::Column, - align_items: AlignItems::Center, - ..default() - }, + // List items + for i in 0..25 { + parent + .spawn(( + TextBundle::from_section( + format!("Item {i}"), + TextStyle { + font: asset_server.load("fonts/FiraSans-Bold.ttf"), + ..default() + }, + ), + Label, + AccessibilityNode(NodeBuilder::new(Role::ListItem)), + )) + .insert(Pickable { + should_block_lower: false, ..default() - }, - ScrollingList::default(), - AccessibilityNode(NodeBuilder::new(Role::List)), - )) - .with_children(|parent| { - // List items - for i in 0..30 { - parent.spawn(( - TextBundle::from_section( - format!("Item {i}"), - TextStyle { - font: asset_server - .load("fonts/FiraSans-Bold.ttf"), - ..default() - }, - ), - Label, - AccessibilityNode(NodeBuilder::new(Role::ListItem)), - )); - } - }); + }); + } }); }); + parent .spawn(NodeBundle { style: Style { @@ -224,6 +214,7 @@ fn setup(mut commands: Commands, asset_server: Res) { }, ..default() }) + .insert(Pickable::IGNORE) .with_children(|parent| { parent .spawn(NodeBundle { @@ -336,35 +327,6 @@ fn setup(mut commands: Commands, asset_server: Res) { }); } -#[derive(Component, Default)] -struct ScrollingList { - position: f32, -} - -fn mouse_scroll( - mut mouse_wheel_events: EventReader, - mut query_list: Query<(&mut ScrollingList, &mut Style, &Parent, &Node)>, - query_node: Query<&Node>, -) { - for mouse_wheel_event in mouse_wheel_events.read() { - for (mut scrolling_list, mut style, parent, list_node) in &mut query_list { - let items_height = list_node.size().y; - let container_height = query_node.get(parent.get()).unwrap().size().y; - - let max_scroll = (items_height - container_height).max(0.); - - let dy = match mouse_wheel_event.unit { - MouseScrollUnit::Line => mouse_wheel_event.y * 20., - MouseScrollUnit::Pixel => mouse_wheel_event.y, - }; - - scrolling_list.position += dy; - scrolling_list.position = scrolling_list.position.clamp(-max_scroll, 0.); - style.top = Val::Px(scrolling_list.position); - } - } -} - #[cfg(feature = "bevy_dev_tools")] // The system that will enable/disable the debug outlines around the nodes fn toggle_overlay( @@ -377,3 +339,32 @@ fn toggle_overlay( options.toggle(); } } + +/// Updates the scroll position of scrollable nodes in response to mouse input +pub fn update_scroll_position( + mut mouse_wheel_events: EventReader, + hover_map: Res, + mut scrolled_node_query: Query<&mut ScrollPosition>, + keyboard_input: Res>, +) { + for mouse_wheel_event in mouse_wheel_events.read() { + let (mut dx, mut dy) = match mouse_wheel_event.unit { + MouseScrollUnit::Line => (mouse_wheel_event.x * 20., mouse_wheel_event.y * 20.), + MouseScrollUnit::Pixel => (mouse_wheel_event.x, mouse_wheel_event.y), + }; + + if keyboard_input.pressed(KeyCode::ShiftLeft) || keyboard_input.pressed(KeyCode::ShiftRight) + { + std::mem::swap(&mut dx, &mut dy); + } + + for (_pointer, pointer_map) in hover_map.iter() { + for (entity, _hit) in pointer_map.iter() { + if let Ok(mut scroll_position) = scrolled_node_query.get_mut(*entity) { + scroll_position.offset_x -= dx; + scroll_position.offset_y -= dy; + } + } + } + } +}