Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: Support scrolling (Overflow::Scroll) in bevy_ui #8104

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
12 changes: 11 additions & 1 deletion crates/bevy_ui/src/layout/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
mod convert;

use crate::{ContentSize, Node, Style, UiScale};
use crate::{ContentSize, Node, ScrollPosition, Style, UiScale};
use bevy_ecs::{
change_detection::DetectChanges,
entity::Entity,
Expand Down Expand Up @@ -221,6 +221,7 @@ pub fn ui_layout_system(
root_node_query: Query<Entity, (With<Node>, Without<Parent>)>,
style_query: Query<(Entity, Ref<Style>), With<Node>>,
mut measure_query: Query<(Entity, &mut ContentSize)>,
scroll_position_query: Query<(Entity, Option<&ScrollPosition>), With<Node>>,
children_query: Query<(Entity, &Children), (With<Node>, Changed<Children>)>,
mut removed_children: RemovedComponents<Children>,
mut removed_content_sizes: RemovedComponents<ContentSize>,
Expand Down Expand Up @@ -321,6 +322,15 @@ pub fn ui_layout_system(
new_position.x -= to_logical(parent_layout.size.width / 2.0);
new_position.y -= to_logical(parent_layout.size.height / 2.0);
}

// Adjust position by the scroll offset of the parent
let (x_scroll_position, y_scroll_position) = scroll_position_query
.get(**parent)
.ok()
.and_then(|(_, p)| p.map(|p| (p.offset_x, p.offset_y)))
.unwrap_or((0.0, 0.0));
new_position.x += x_scroll_position;
new_position.y += y_scroll_position;
}
// only trigger change detection when the new value is different
if transform.translation != new_position {
Expand Down
6 changes: 5 additions & 1 deletion crates/bevy_ui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ use bevy_input::InputSystem;
use bevy_transform::TransformSystem;
use stack::ui_stack_system;
pub use stack::UiStack;
use update::update_clipping_system;
use update::{update_clipping_system, update_scroll_interaction, update_scroll_position};

/// The basic plugin for Bevy UI
#[derive(Default)]
Expand All @@ -59,6 +59,8 @@ pub enum UiSystem {
Layout,
/// After this label, input interactions with UI entities have been updated for this frame
Focus,
/// After this label, scroll positions have been updated
Scroll,
/// After this label, the [`UiStack`] resource has been updated
Stack,
}
Expand Down Expand Up @@ -164,6 +166,8 @@ impl Plugin for UiPlugin {
.before(TransformSystem::TransformPropagate),
ui_stack_system.in_set(UiSystem::Stack),
update_clipping_system.after(TransformSystem::TransformPropagate),
update_scroll_interaction.in_set(UiSystem::Focus),
update_scroll_position.in_set(UiSystem::Scroll),
),
);

Expand Down
9 changes: 8 additions & 1 deletion crates/bevy_ui/src/node_bundles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

use crate::{
widget::{Button, UiImageSize},
BackgroundColor, ContentSize, FocusPolicy, Interaction, Node, Style, UiImage, ZIndex,
BackgroundColor, ContentSize, FocusPolicy, Interaction, Node, ScrollPosition, Style, UiImage,
ZIndex,
};
use bevy_ecs::bundle::Bundle;
use bevy_render::{
Expand All @@ -27,6 +28,10 @@ pub struct NodeBundle {
pub background_color: BackgroundColor,
/// Whether this node should block interaction with lower nodes
pub focus_policy: FocusPolicy,
/// Describes whether and how the button has been interacted with by the input
pub interaction: Interaction,
/// Scroll position
pub scroll_position: ScrollPosition,
/// The transform of the node
///
/// This field is automatically managed by the UI layout system.
Expand All @@ -53,6 +58,8 @@ impl Default for NodeBundle {
node: Default::default(),
style: Default::default(),
focus_policy: Default::default(),
interaction: Interaction::default(),
scroll_position: Default::default(),
transform: Default::default(),
global_transform: Default::default(),
visibility: Default::default(),
Expand Down
51 changes: 51 additions & 0 deletions crates/bevy_ui/src/ui_node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,31 @@ impl Default for Node {
}
}

/// The scroll position on the node
#[derive(Component, Debug, Clone, Reflect)]
#[reflect(Component, Default)]
pub struct ScrollPosition {
pub is_hovered: bool,
/// How far accross the node is scrolled (0 = not scrolled / scrolled to right)
pub offset_x: f32,
/// How far down the node is scrolled (0 = not scrolled / scrolled to top)
pub offset_y: f32,
}

impl ScrollPosition {
pub const DEFAULT: Self = Self {
is_hovered: false,
offset_x: 0.0,
offset_y: 0.0,
};
}

impl Default for ScrollPosition {
fn default() -> Self {
Self::DEFAULT
}
}

/// Represents the possible value types for layout properties.
///
/// This enum allows specifying values for various [`Style`] properties in different units,
Expand Down Expand Up @@ -922,6 +947,30 @@ impl Overflow {
}
}

/// Scroll overflowing items on both axes
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,
}
}

/// Overflow is visible on both axes
pub const fn is_visible(&self) -> bool {
self.x.is_visible() && self.y.is_visible()
Expand All @@ -942,6 +991,8 @@ pub enum OverflowAxis {
Visible,
/// Hide overflowing items.
Clip,
/// Scroll overflowing items.
Scroll,
}

impl OverflowAxis {
Expand Down
64 changes: 61 additions & 3 deletions crates/bevy_ui/src/update.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
//! This module contains systems that update the UI when something changes

use crate::{CalculatedClip, OverflowAxis, Style};
use crate::{CalculatedClip, Interaction, OverflowAxis, ScrollPosition, Style};

use super::Node;
use bevy_ecs::{
entity::Entity,
query::{With, Without},
event::EventReader,
query::{Changed, With, Without},
system::{Commands, Query},
};
use bevy_hierarchy::{Children, Parent};
use bevy_math::Rect;
use bevy_input::mouse::{MouseScrollUnit, MouseWheel};
use bevy_math::{Rect, Vec2};
use bevy_transform::components::GlobalTransform;

/// Updates clipping for all nodes
Expand Down Expand Up @@ -90,3 +92,59 @@ fn update_clipping(
}
}
}

pub fn update_scroll_interaction(
mut interaction_query: Query<(&Interaction, &mut ScrollPosition), Changed<Interaction>>,
) {
for (interaction, mut scroll) in &mut interaction_query {
match *interaction {
Interaction::Hovered | Interaction::Clicked => {
scroll.is_hovered = true;
}
Interaction::None => {
scroll.is_hovered = false;
}
}
}
}

pub fn update_scroll_position(
mut mouse_wheel_events: EventReader<MouseWheel>,
mut query_list: Query<(&mut ScrollPosition, &Style, &Children, &Node)>,
query_node: Query<&Node>,
) {
for mouse_wheel_event in mouse_wheel_events.iter() {
let (dx, 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),
};

for (mut scroll_container, style, children, list_node) in &mut query_list {
let is_scrollable = (style.overflow.x == OverflowAxis::Scroll)
| (style.overflow.y == OverflowAxis::Scroll);
if is_scrollable && scroll_container.is_hovered {
// Compute container content sizes
let Vec2 {
x: container_width,
y: container_height,
} = list_node.size();
let (items_width, items_height): (f32, f32) =
children.iter().fold((0.0, 0.0), |sum, child| {
let size = query_node.get(*child).unwrap().size();
(sum.0 + size.x, sum.1 + size.y)
});

if style.overflow.x == OverflowAxis::Scroll {
let max_scroll_x = (items_width - container_width).max(0.);
scroll_container.offset_x =
(scroll_container.offset_x + dx).clamp(-max_scroll_x, 0.);
}
if style.overflow.y == OverflowAxis::Scroll {
let max_scroll_y = (items_height - container_height).max(0.);
scroll_container.offset_y =
(scroll_container.offset_y + dy).clamp(-max_scroll_y, 0.);
}
}
}
}
}
92 changes: 56 additions & 36 deletions examples/ui/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ use bevy::{
accesskit::{NodeBuilder, Role},
AccessibilityNode,
},
input::mouse::{MouseScrollUnit, MouseWheel},
prelude::*,
winit::WinitSettings,
};
Expand All @@ -16,7 +15,6 @@ 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)
.run();
}

Expand Down Expand Up @@ -51,6 +49,7 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
parent
.spawn(NodeBundle {
style: Style {
flex_direction: FlexDirection::Column,
size: Size::width(Val::Percent(100.)),
..default()
},
Expand All @@ -77,6 +76,57 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
// for accessibility to treat the text accordingly.
Label,
));

// Scrolling list
parent
.spawn(NodeBundle {
style: Style {
flex_direction: FlexDirection::Column,
align_self: AlignSelf::Stretch,
size: Size::height(Val::Percent(50.)),
align_items: AlignItems::Start,
overflow: Overflow::scroll(),
..default()
},
background_color: Color::rgb(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::Start,
size: Size::width(Val::Px(600.)),
..default()
},
..default()
},
AccessibilityNode(NodeBuilder::new(Role::List)),
))
.with_children(|parent| {
// List items
for i in 0..100 {
parent.spawn((
TextBundle::from_section(
format!("Item {} rhgiuherwiofhioewhfgioewhgioewhioghweioghiowhewiohgio", i + 1),
TextStyle {
font: asset_server
.load("fonts/FiraSans-Bold.ttf"),
font_size: 20.,
color: Color::WHITE,
},
),
Label,
AccessibilityNode(NodeBuilder::new(
Role::ListItem,
)),
));
}
});
});
});
});
// right vertical fill
Expand Down Expand Up @@ -105,14 +155,14 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
),
Label,
));
// List with hidden overflow
// Scrolling list
parent
.spawn(NodeBundle {
style: Style {
flex_direction: FlexDirection::Column,
align_self: AlignSelf::Stretch,
size: Size::height(Val::Percent(50.)),
overflow: Overflow::clip_y(),
overflow: Overflow::scroll_y(),
..default()
},
background_color: Color::rgb(0.10, 0.10, 0.10).into(),
Expand All @@ -130,15 +180,14 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
},
..default()
},
ScrollingList::default(),
AccessibilityNode(NodeBuilder::new(Role::List)),
))
.with_children(|parent| {
// List items
for i in 0..30 {
for i in 0..100 {
parent.spawn((
TextBundle::from_section(
format!("Item {i}"),
format!("Item {}", i + 1),
TextStyle {
font: asset_server
.load("fonts/FiraSans-Bold.ttf"),
Expand Down Expand Up @@ -278,32 +327,3 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
});
});
}

#[derive(Component, Default)]
struct ScrollingList {
position: f32,
}

fn mouse_scroll(
mut mouse_wheel_events: EventReader<MouseWheel>,
mut query_list: Query<(&mut ScrollingList, &mut Style, &Parent, &Node)>,
query_node: Query<&Node>,
) {
for mouse_wheel_event in mouse_wheel_events.iter() {
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);
}
}
}