Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions crates/bevy_ui/src/layout/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use crate::{
experimental::{UiChildren, UiRootNodes},
ui_transform::{UiGlobalTransform, UiTransform},
BorderRadius, ComputedNode, ComputedUiRenderTargetInfo, ContentSize, Display, LayoutConfig,
Node, Outline, OverflowAxis, ScrollPosition,
Node, Outline, OverflowAxis, ScrollPosition, ScrollSticky,
};
use bevy_ecs::{
change_detection::{DetectChanges, DetectChangesMut},
Expand Down Expand Up @@ -90,6 +90,7 @@ pub fn ui_layout_system(
Option<&BorderRadius>,
Option<&Outline>,
Option<&ScrollPosition>,
Option<&ScrollSticky>,
)>,
mut buffer_query: Query<&mut ComputedTextBlock>,
mut font_system: ResMut<CosmicFontSystem>,
Expand Down Expand Up @@ -201,6 +202,7 @@ pub fn ui_layout_system(
Option<&BorderRadius>,
Option<&Outline>,
Option<&ScrollPosition>,
Option<&ScrollSticky>,
)>,
ui_children: &UiChildren,
inverse_target_scale_factor: f32,
Expand All @@ -216,6 +218,7 @@ pub fn ui_layout_system(
maybe_border_radius,
maybe_outline,
maybe_scroll_position,
maybe_scroll_sticky,
)) = node_update_query.get_mut(entity)
{
let use_rounding = maybe_layout_config
Expand All @@ -231,9 +234,13 @@ pub fn ui_layout_system(
// Taffy layout position of the top-left corner of the node, relative to its parent.
let layout_location = Vec2::new(layout.location.x, layout.location.y);

let node_parent_scroll_position = maybe_scroll_sticky
.map(|scroll_sticky| parent_scroll_position * Vec2::from(!scroll_sticky.0))
.unwrap_or(parent_scroll_position);

// The position of the center of the node relative to its top-left corner.
let local_center =
layout_location - parent_scroll_position + 0.5 * (layout_size - parent_size);
layout_location - node_parent_scroll_position + 0.5 * (layout_size - parent_size);

// only trigger change detection when the new values are different
if node.size != layout_size
Expand Down
16 changes: 15 additions & 1 deletion crates/bevy_ui/src/ui_node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use bevy_camera::{visibility::Visibility, Camera, RenderTarget};
use bevy_color::{Alpha, Color};
use bevy_derive::{Deref, DerefMut};
use bevy_ecs::{prelude::*, system::SystemParam};
use bevy_math::{vec4, Rect, UVec2, Vec2, Vec4Swizzles};
use bevy_math::{vec4, BVec2, Rect, UVec2, Vec2, Vec4Swizzles};
use bevy_reflect::prelude::*;
use bevy_sprite::BorderRect;
use bevy_utils::once;
Expand Down Expand Up @@ -347,6 +347,20 @@ impl From<Vec2> for ScrollPosition {
}
}

/// Determines which axes of a node are *sticky* during scrolling.
///
/// A **sticky** node maintains its position along the specified axes
/// instead of moving with its scrolled parent content when [`ScrollPosition`] is applied.
#[derive(Component, Debug, Clone, Default, Deref, DerefMut, Reflect)]
#[reflect(Component, Default, Clone)]
pub struct ScrollSticky(pub BVec2);

impl From<BVec2> for ScrollSticky {
fn from(value: BVec2) -> Self {
Self(value)
}
}
Comment on lines +350 to +362
Copy link
Contributor

@ickshonpe ickshonpe Oct 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like the plain BVec2 here, I'd prefer named fields or some helpers like:

impl ScrollSticky {
    fn left() -> Self { 
        Self(BVec2::new(true, false))
    }
    
    // etc..
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would like to avoid implying in API that ScrollSticky has method left. What does it mean?

Sticky headers can be achieved with this feature only by combining multiple features:
ZIndex, BackgroundColor, ScrollSticky, parent with display Grid.

Users need to carefully mix multiple features to get the final behavior working. So I would like to avoid implying any specific use case in this level of API.

See example: 5ab4c9a


/// The base component for UI entities. It describes UI layout and style properties.
///
/// When defining new types of UI entities, require [`Node`] to make them behave like UI nodes.
Expand Down
74 changes: 74 additions & 0 deletions examples/ui/scroll.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
use accesskit::{Node as Accessible, Role};
use bevy::{
a11y::AccessibilityNode,
color::palettes::css::{BLACK, BLUE, RED},
ecs::spawn::SpawnIter,
input::mouse::{MouseScrollUnit, MouseWheel},
picking::hover::HoverMap,
Expand Down Expand Up @@ -193,6 +194,9 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
children![
vertically_scrolling_list(asset_server.load("fonts/FiraSans-Bold.ttf")),
bidirectional_scrolling_list(asset_server.load("fonts/FiraSans-Bold.ttf")),
bidirectional_scrolling_list_with_sticky(
asset_server.load("fonts/FiraSans-Bold.ttf")
),
nested_scrolling_list(asset_server.load("fonts/FiraSans-Bold.ttf")),
],
));
Expand Down Expand Up @@ -307,6 +311,76 @@ fn bidirectional_scrolling_list(font_handle: Handle<Font>) -> impl Bundle {
)
}

fn bidirectional_scrolling_list_with_sticky(font_handle: Handle<Font>) -> impl Bundle {
(
Node {
flex_direction: FlexDirection::Column,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
width: px(200),
..default()
},
children![
(
Text::new("Bidirectionally Scrolling List With Sticky Nodes"),
TextFont {
font: font_handle.clone(),
font_size: FONT_SIZE,
..default()
},
Label,
),
(
Node {
display: Display::Grid,
align_self: AlignSelf::Stretch,
height: percent(50),
overflow: Overflow::scroll(), // n.b.
grid_template_columns: RepeatedGridTrack::auto(30),
..default()
},
Children::spawn(SpawnIter(
(0..30)
.flat_map(|y| (0..30).map(move |x| (y, x)))
.map(move |(y, x)| {
let value = font_handle.clone();
// Sticky elements are not affected by scrolling
// Some cells can be made sticky and drawn on top
// of other cells with background to
// create effects like sticky headers.
let sticky = BVec2 {
x: x == 0,
y: y == 0,
};
let (z_index, background_color, role) = match (x == 0, y == 0) {
(true, true) => (2, RED, Role::RowHeader),
(true, false) => (1, BLUE, Role::RowHeader),
(false, true) => (1, BLUE, Role::ColumnHeader),
(false, false) => (0, BLACK, Role::Cell),
};
(
Text(format!("|{},{}|", y, x)),
TextFont {
font: value.clone(),
..default()
},
TextLayout {
linebreak: LineBreak::NoWrap,
..default()
},
Label,
AccessibilityNode(Accessible::new(role)),
ScrollSticky(sticky),
ZIndex(z_index),
BackgroundColor(Color::Srgba(background_color)),
)
})
))
)
],
)
}

fn nested_scrolling_list(font_handle: Handle<Font>) -> impl Bundle {
(
Node {
Expand Down
8 changes: 8 additions & 0 deletions release-content/release-notes/scroll-sticky-ui-node.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
title: Scroll sticky ui nodes
authors: ["@PPakalns"]
pull_requests: [21648]
---

Adds the `ScrollSticky` component to control sticky behavior in scrollable UI containers.
Nodes with stickiness enabled on specific axes remain pinned along those axes while other content scrolls, making it possible to build simple variants of fixed column and row headers.