bevy_simple_scroll_view 0.4.0

Simple to use plugin implementing ScrollView into Bevy engine.
Documentation
#![doc = include_str!("../README.md")]

use bevy::{
    input::mouse::{MouseMotion, MouseWheel},
    prelude::*,
};

/// A `Plugin` providing the systems and components required to make a ScrollView work.
///
/// # Example
/// ```no_run
/// use bevy::prelude::*;
/// use bevy_simple_scroll_view::*;
///
/// App::new()
///     .add_plugins((DefaultPlugins,ScrollViewPlugin))
///     .run();
/// ```
pub struct ScrollViewPlugin;

impl Plugin for ScrollViewPlugin {
    fn build(&self, app: &mut App) {
        app.register_type::<ScrollView>()
            .register_type::<ScrollableContent>()
            .add_systems(
                Update,
                (
                    create_scroll_view,
                    update_size,
                    input_mouse_pressed_move,
                    input_touch_pressed_move,
                    scroll_events,
                    scroll_update,
                )
                    .chain(),
            );
    }
}

/// Root component of scroll, it should have clipped style.
#[derive(Component, Debug, Reflect)]
#[require(Interaction, Node = scroll_view_node())]
pub struct ScrollView {
    /// Field which control speed of the scrolling.
    /// Could be negative number to implement invert scroll
    pub scroll_speed: f32,
}

impl Default for ScrollView {
    fn default() -> Self {
        Self {
            scroll_speed: 1200.0,
        }
    }
}

/// Component containing offset value of the scroll container to the parent.
/// It is possible to update the field `pos_y` manually to move scrollview to desired location.
#[derive(Component, Debug, Reflect, Default)]
#[require(Node = scroll_content_node())]
pub struct ScrollableContent {
    /// Scroll container offset to the `ScrollView`.
    pub pos_y: f32,
    /// Maximum value for the scroll. It is updated automatically based on the size of the children nodes.
    pub max_scroll: f32,
}

impl ScrollableContent {
    /// Scrolls to the top of the scroll view.
    pub fn scroll_to_top(&mut self) {
        self.pos_y = 0.0;
    }
    /// Scrolls to the bottom of the scroll view.
    pub fn scroll_to_bottom(&mut self) {
        self.pos_y = -self.max_scroll;
    }

    /// Scrolls by a specified amount.
    ///
    /// # Parameters
    /// - `value`: The amount to scroll vertically. Positive values scroll down,
    ///   and negative values scroll up.
    ///
    /// Ensures the new position is clamped between the valid scroll range.
    pub fn scroll_by(&mut self, value: f32) {
        self.pos_y += value;
        self.pos_y = self.pos_y.clamp(-self.max_scroll, 0.);
    }
}

/// Creates a default scroll view node.
///
/// This function defines the visual and layout properties of a scrollable container.
pub fn scroll_view_node() -> Node {
    Node {
        overflow: Overflow::clip(),
        align_items: AlignItems::Start,
        align_self: AlignSelf::Stretch,
        flex_direction: FlexDirection::Row,
        ..default()
    }
}

/// Creates a default scroll content node.
pub fn scroll_content_node() -> Node {
    Node {
        flex_direction: bevy::ui::FlexDirection::Column,
        width: Val::Percent(100.0),
        ..default()
    }
}

/// Applies the default scroll view style to newly added `ScrollView` components.
///
/// This function updates the style of all new `ScrollView` nodes with the default
/// properties defined in `scroll_view_node`.
pub fn create_scroll_view(mut q: Query<&mut Node, Added<ScrollView>>) {
    let Node {
        overflow,
        align_items,
        align_self,
        flex_direction,
        ..
    } = scroll_view_node();
    for mut style in q.iter_mut() {
        style.overflow = overflow;
        style.align_items = align_items;
        style.align_self = align_self;
        style.flex_direction = flex_direction;
    }
}

fn input_mouse_pressed_move(
    mut motion_evr: EventReader<MouseMotion>,
    mut q: Query<(&Children, &Interaction), With<ScrollView>>,
    mut content_q: Query<&mut ScrollableContent>,
) {
    for evt in motion_evr.read() {
        for (children, &interaction) in q.iter_mut() {
            if interaction != Interaction::Pressed {
                continue;
            }
            for child in children.iter() {
                let Ok(mut scroll) = content_q.get_mut(child) else {
                    continue;
                };
                scroll.scroll_by(evt.delta.y);
            }
        }
    }
}

fn update_size(
    mut q: Query<(&Children, &ComputedNode), With<ScrollView>>,
    mut content_q: Query<(&mut ScrollableContent, &ComputedNode), Changed<ComputedNode>>,
) {
    for (children, scroll_view_node) in q.iter_mut() {
        let container_height = scroll_view_node.size().y * scroll_view_node.inverse_scale_factor();
        for child in children.iter() {
            let Ok((mut scroll, node)) = content_q.get_mut(child) else {
                continue;
            };

            scroll.max_scroll =
                (node.size().y * node.inverse_scale_factor() - container_height).max(0.0);
            #[cfg(feature = "extra_logs")]
            info!(
                "CONTAINER {}, max_scroll: {}",
                container_height, scroll.max_scroll
            );
        }
    }
}

fn input_touch_pressed_move(
    touches: Res<Touches>,
    mut q: Query<(&Children, &Interaction), With<ScrollView>>,
    mut content_q: Query<&mut ScrollableContent>,
) {
    for t in touches.iter() {
        let Some(touch) = touches.get_pressed(t.id()) else {
            continue;
        };

        for (children, &interaction) in q.iter_mut() {
            if interaction != Interaction::Pressed {
                continue;
            }
            for child in children.iter() {
                let Ok(mut scroll) = content_q.get_mut(child) else {
                    continue;
                };
                scroll.scroll_by(touch.delta().y);
            }
        }
    }
}

fn scroll_events(
    mut scroll_evr: EventReader<MouseWheel>,
    mut q: Query<(&Children, &Interaction, &ScrollView), With<ScrollView>>,
    time: Res<Time>,
    mut content_q: Query<&mut ScrollableContent>,
) {
    use bevy::input::mouse::MouseScrollUnit;
    for ev in scroll_evr.read() {
        for (children, &interaction, scroll_view) in q.iter_mut() {
            if interaction != Interaction::Hovered {
                continue;
            }
            let y = match ev.unit {
                MouseScrollUnit::Line => {
                    ev.y * time.delta().as_secs_f32() * scroll_view.scroll_speed
                }
                MouseScrollUnit::Pixel => ev.y,
            };
            #[cfg(feature = "extra_logs")]
            info!("Scroolling by {:#?}: {} movement", ev.unit, y);

            for child in children.iter() {
                let Ok(mut scroll) = content_q.get_mut(child) else {
                    continue;
                };
                scroll.scroll_by(y);
            }
        }
    }
}

fn scroll_update(mut q: Query<(&ScrollableContent, &mut Node), Changed<ScrollableContent>>) {
    for (scroll, mut style) in q.iter_mut() {
        style.top = Val::Px(scroll.pos_y);
    }
}