use bevy::prelude::*;
use bevy::ui::{ComputedNode, ScrollPosition, UiGlobalTransform, UiStack};
use bevy::ui_widgets::{Scrollbar, ScrollbarThumb};
use bevy::window::PrimaryWindow;
use crate::plugin::PointerCapture;
use crate::reconcile::ActiveDrag;
use crate::scroll::{scroll_base, scroll_range, write_scroll};
use crate::transition::ScrollTransitionState;
const TOUCH_SLOP: f32 = 8.0;
#[derive(Resource, Default)]
pub struct TouchScrollState {
claims: Vec<TouchClaim>,
}
impl TouchScrollState {
pub(crate) fn is_suppressed(&self, id: u64) -> bool {
self.claims.iter().any(|c| c.id == id && c.moved_past_slop)
}
}
struct TouchClaim {
id: u64,
container: Entity,
last_pos: Vec2,
press_pos: Vec2,
moved_past_slop: bool,
}
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
pub fn apply_touch_scroll(
touches: Res<bevy::input::touch::Touches>,
windows: Query<&Window, With<PrimaryWindow>>,
ui_stack: Res<UiStack>,
mut scrollables: Query<(
&ComputedNode,
&UiGlobalTransform,
&Node,
&mut ScrollPosition,
Option<&mut ScrollTransitionState>,
)>,
scrollbar_parts: Query<
(&ComputedNode, &UiGlobalTransform),
Or<(With<Scrollbar>, With<ScrollbarThumb>)>,
>,
active_drag: Res<ActiveDrag>,
mut capture: ResMut<PointerCapture>,
mut state: ResMut<TouchScrollState>,
) {
state.claims.retain(|c| {
touches.get_pressed(c.id).is_some()
|| touches.just_released(c.id)
|| touches.just_canceled(c.id)
});
if let Ok(window) = windows.single() {
for touch in touches.iter_just_pressed() {
if state.claims.iter().any(|c| c.id == touch.id())
|| active_drag.touch_id() == Some(touch.id())
{
continue;
}
let point = touch.position() * window.scale_factor();
for &entity in ui_stack.uinodes.iter().rev() {
if let Ok((computed, transform)) = scrollbar_parts.get(entity) {
if computed.contains_point(*transform, point) {
break;
}
continue; }
let Ok((computed, transform, node, _, _)) = scrollables.get(entity) else {
continue;
};
if !computed.contains_point(*transform, point) {
continue;
}
let range = scroll_range(computed);
let scrollable = (node.overflow.x == OverflowAxis::Scroll && range.x > 0.0)
|| (node.overflow.y == OverflowAxis::Scroll && range.y > 0.0);
if !scrollable {
continue;
}
state.claims.push(TouchClaim {
id: touch.id(),
container: entity,
last_pos: touch.position(),
press_pos: touch.position(),
moved_past_slop: false,
});
break;
}
}
}
for claim in &mut state.claims {
let Some(touch) = touches.get_pressed(claim.id) else {
continue;
};
if !claim.moved_past_slop && (touch.position() - claim.press_pos).length() > TOUCH_SLOP {
claim.moved_past_slop = true;
}
let Ok((computed, _, node, mut pos, scroll_state)) = scrollables.get_mut(claim.container)
else {
continue;
};
let delta = touch.position() - claim.last_pos;
claim.last_pos = touch.position();
let max = scroll_range(computed);
let base = scroll_base(&pos, scroll_state.as_deref());
let mut next = base;
if node.overflow.x == OverflowAxis::Scroll && max.x > 0.0 {
next.x = (base.x - delta.x).clamp(0.0, max.x);
}
if node.overflow.y == OverflowAxis::Scroll && max.y > 0.0 {
next.y = (base.y - delta.y).clamp(0.0, max.y);
}
write_scroll(&mut pos, scroll_state, next);
capture.dragging = true;
}
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::picking::events::{Click, Pointer};
use bevy::picking::pointer::{PointerButton, PointerId};
fn touch_app() -> (App, Entity) {
use bevy::input::touch::{Touches, touch_screen_input_system};
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.init_resource::<Touches>();
app.init_resource::<PointerCapture>();
app.init_resource::<TouchScrollState>();
app.init_resource::<ActiveDrag>();
app.add_message::<bevy::input::touch::TouchInput>();
app.add_systems(
Update,
(touch_screen_input_system, apply_touch_scroll).chain(),
);
app.world_mut().spawn((Window::default(), PrimaryWindow));
let container = app
.world_mut()
.spawn((
Node {
overflow: Overflow::scroll_y(),
..default()
},
ComputedNode {
size: Vec2::new(200.0, 100.0),
content_size: Vec2::new(200.0, 300.0),
inverse_scale_factor: 1.0,
..default()
},
UiGlobalTransform::from_translation(Vec2::new(300.0, 200.0)),
ScrollPosition::default(),
))
.id();
app.world_mut().insert_resource(UiStack {
uinodes: vec![container],
partition: Vec::new(),
});
(app, container)
}
fn send_touch(app: &mut App, id: u64, phase: bevy::input::touch::TouchPhase, pos: Vec2) {
queue_touch(app, id, phase, pos);
app.update();
}
fn queue_touch(app: &mut App, id: u64, phase: bevy::input::touch::TouchPhase, pos: Vec2) {
let window = app
.world_mut()
.query_filtered::<Entity, With<PrimaryWindow>>()
.single(app.world())
.unwrap();
app.world_mut()
.write_message(bevy::input::touch::TouchInput {
phase,
position: pos,
window,
force: None,
id,
});
}
#[test]
fn touch_drag_scrolls_container() {
use bevy::input::touch::TouchPhase;
let (mut app, container) = touch_app();
let pos = |app: &App| {
app.world()
.entity(container)
.get::<ScrollPosition>()
.unwrap()
.0
};
let dragging = |app: &App| app.world().resource::<PointerCapture>().dragging;
send_touch(&mut app, 7, TouchPhase::Started, Vec2::new(300.0, 200.0));
assert_eq!(pos(&app), Vec2::ZERO, "a press alone must not scroll");
assert!(dragging(&app), "the gesture claims the pointer immediately");
send_touch(&mut app, 7, TouchPhase::Moved, Vec2::new(300.0, 150.0));
assert_eq!(pos(&app), Vec2::new(0.0, 50.0));
send_touch(&mut app, 7, TouchPhase::Moved, Vec2::new(300.0, -400.0));
assert_eq!(pos(&app), Vec2::new(0.0, 200.0));
send_touch(&mut app, 7, TouchPhase::Ended, Vec2::new(300.0, -400.0));
app.world_mut().resource_mut::<PointerCapture>().dragging = false;
app.update();
assert!(!dragging(&app), "lifting the finger releases the claim");
}
#[test]
fn touch_owned_by_ui_drag_does_not_scroll() {
use crate::reconcile::DragSource;
use bevy::input::touch::TouchPhase;
let (mut app, container) = touch_app();
app.world_mut().resource_mut::<ActiveDrag>().begin(
container,
DragSource::Touch { id: 7 },
Vec2::ZERO,
Vec2::ZERO,
);
send_touch(&mut app, 7, TouchPhase::Started, Vec2::new(300.0, 200.0));
send_touch(&mut app, 7, TouchPhase::Moved, Vec2::new(300.0, 150.0));
assert_eq!(
app.world()
.entity(container)
.get::<ScrollPosition>()
.unwrap()
.0,
Vec2::ZERO
);
}
#[test]
fn second_finger_scrolls_during_touch_handler_drag() {
use crate::reconcile::DragSource;
use bevy::input::touch::TouchPhase;
let (mut app, container) = touch_app();
let slider = app.world_mut().spawn_empty().id();
app.world_mut().resource_mut::<ActiveDrag>().begin(
slider,
DragSource::Touch { id: 7 },
Vec2::ZERO,
Vec2::ZERO,
);
app.world_mut().resource_mut::<PointerCapture>().dragging = true;
send_touch(&mut app, 8, TouchPhase::Started, Vec2::new(300.0, 200.0));
send_touch(&mut app, 8, TouchPhase::Moved, Vec2::new(300.0, 150.0));
assert_eq!(
app.world()
.entity(container)
.get::<ScrollPosition>()
.unwrap()
.0,
Vec2::new(0.0, 50.0)
);
}
#[test]
fn touch_claims_during_mouse_drag() {
use crate::reconcile::DragSource;
use bevy::input::touch::TouchPhase;
let (mut app, container) = touch_app();
let slider = app.world_mut().spawn_empty().id();
app.world_mut().resource_mut::<ActiveDrag>().begin(
slider,
DragSource::Mouse {
button: MouseButton::Left,
dom_button: 0,
},
Vec2::ZERO,
Vec2::ZERO,
);
app.world_mut().resource_mut::<PointerCapture>().dragging = true;
send_touch(&mut app, 7, TouchPhase::Started, Vec2::new(300.0, 200.0));
send_touch(&mut app, 7, TouchPhase::Moved, Vec2::new(300.0, 150.0));
assert_eq!(
app.world()
.entity(container)
.get::<ScrollPosition>()
.unwrap()
.0,
Vec2::new(0.0, 50.0)
);
}
#[test]
fn two_touches_scroll_two_containers() {
use bevy::input::touch::TouchPhase;
let (mut app, container_a) = touch_app();
let container_b = app
.world_mut()
.spawn((
Node {
overflow: Overflow::scroll_y(),
..default()
},
ComputedNode {
size: Vec2::new(200.0, 100.0),
content_size: Vec2::new(200.0, 300.0),
inverse_scale_factor: 1.0,
..default()
},
UiGlobalTransform::from_translation(Vec2::new(300.0, 500.0)),
ScrollPosition::default(),
))
.id();
app.world_mut().insert_resource(UiStack {
uinodes: vec![container_a, container_b],
partition: Vec::new(),
});
queue_touch(&mut app, 7, TouchPhase::Started, Vec2::new(300.0, 200.0));
queue_touch(&mut app, 8, TouchPhase::Started, Vec2::new(300.0, 500.0));
app.update();
queue_touch(&mut app, 7, TouchPhase::Moved, Vec2::new(300.0, 150.0));
queue_touch(&mut app, 8, TouchPhase::Moved, Vec2::new(300.0, 480.0));
app.update();
let pos = |app: &App, e: Entity| app.world().entity(e).get::<ScrollPosition>().unwrap().0;
assert_eq!(pos(&app, container_a), Vec2::new(0.0, 50.0));
assert_eq!(pos(&app, container_b), Vec2::new(0.0, 20.0));
}
fn spawn_scrollbar(app: &mut App, container: Entity) -> (Entity, Entity) {
use bevy::ui_widgets::ControlOrientation;
let track = app
.world_mut()
.spawn((
Scrollbar::new(container, ControlOrientation::Vertical, 20.0),
Node::default(),
ComputedNode {
size: Vec2::new(20.0, 100.0),
inverse_scale_factor: 1.0,
..default()
},
UiGlobalTransform::from_translation(Vec2::new(390.0, 200.0)),
))
.id();
let thumb = app
.world_mut()
.spawn((
ScrollbarThumb::default(),
ComputedNode {
size: Vec2::new(20.0, 40.0),
inverse_scale_factor: 1.0,
..default()
},
UiGlobalTransform::from_translation(Vec2::new(390.0, 180.0)),
ChildOf(track),
))
.id();
app.world_mut().insert_resource(UiStack {
uinodes: vec![container, track, thumb],
partition: Vec::new(),
});
(track, thumb)
}
#[test]
fn touch_on_scrollbar_track_never_claims_content_scroll() {
use bevy::input::touch::TouchPhase;
let (mut app, container) = touch_app();
spawn_scrollbar(&mut app, container);
send_touch(&mut app, 7, TouchPhase::Started, Vec2::new(390.0, 240.0));
send_touch(&mut app, 7, TouchPhase::Moved, Vec2::new(390.0, 200.0));
assert_eq!(
app.world()
.entity(container)
.get::<ScrollPosition>()
.unwrap()
.0,
Vec2::ZERO,
"the touch on the track must not drive a content scroll"
);
assert!(!app.world().resource::<PointerCapture>().dragging);
}
#[test]
fn touch_on_scrollbar_thumb_never_claims_content_scroll() {
use bevy::input::touch::TouchPhase;
let (mut app, container) = touch_app();
spawn_scrollbar(&mut app, container);
send_touch(&mut app, 7, TouchPhase::Started, Vec2::new(390.0, 180.0));
send_touch(&mut app, 7, TouchPhase::Moved, Vec2::new(390.0, 220.0));
assert_eq!(
app.world()
.entity(container)
.get::<ScrollPosition>()
.unwrap()
.0,
Vec2::ZERO,
"the touch on the thumb must not drive a content scroll"
);
assert!(!app.world().resource::<PointerCapture>().dragging);
}
#[test]
fn touch_beside_the_track_still_scrolls() {
use bevy::input::touch::TouchPhase;
let (mut app, container) = touch_app();
spawn_scrollbar(&mut app, container);
send_touch(&mut app, 7, TouchPhase::Started, Vec2::new(250.0, 200.0));
send_touch(&mut app, 7, TouchPhase::Moved, Vec2::new(250.0, 150.0));
assert_eq!(
app.world()
.entity(container)
.get::<ScrollPosition>()
.unwrap()
.0,
Vec2::new(0.0, 50.0)
);
}
#[test]
fn scroll_y_container_with_only_horizontal_overflow_stays_transparent() {
use bevy::input::touch::TouchPhase;
let (mut app, container) = touch_app();
*app.world_mut()
.entity_mut(container)
.get_mut::<ComputedNode>()
.unwrap() = ComputedNode {
size: Vec2::new(200.0, 100.0),
content_size: Vec2::new(400.0, 100.0),
inverse_scale_factor: 1.0,
..default()
};
send_touch(&mut app, 7, TouchPhase::Started, Vec2::new(300.0, 200.0));
send_touch(&mut app, 7, TouchPhase::Moved, Vec2::new(300.0, 150.0));
assert_eq!(
app.world()
.entity(container)
.get::<ScrollPosition>()
.unwrap()
.0,
Vec2::ZERO
);
assert!(
!app.world().resource::<PointerCapture>().dragging,
"an unclaimable container must not own the pointer"
);
}
fn click_scroll_app() -> (
App,
Entity,
tokio::sync::mpsc::UnboundedReceiver<crate::protocol::outbound::Outbound>,
) {
use crate::bridge::JsBridge;
use crate::protocol::{op::Op, outbound::Outbound};
let (mut app, container) = touch_app();
let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel::<Outbound>();
let (_ops_tx, ops_rx) = crossbeam_channel::unbounded::<Vec<Op>>();
std::mem::forget(_ops_tx); let root = app.world_mut().spawn_empty().id();
app.insert_resource(JsBridge::new(ops_rx, out_tx, root));
app.add_message::<Pointer<Click>>();
app.add_systems(
Update,
crate::reconcile::collect_ui_events.before(apply_touch_scroll),
);
app.world_mut()
.entity_mut(container)
.insert((crate::bridge::RNode(9), Interaction::None));
(app, container, out_rx)
}
fn synth_click(pointer_id: PointerId, entity: Entity) -> Pointer<Click> {
Pointer::new(
pointer_id,
bevy::picking::pointer::Location {
target: bevy::camera::NormalizedRenderTarget::Image(
Handle::<Image>::default().into(),
),
position: Vec2::ZERO,
},
Click {
button: PointerButton::Primary,
hit: bevy::picking::backend::HitData::new(Entity::PLACEHOLDER, 0.0, None, None),
duration: std::time::Duration::ZERO,
count: 1,
},
entity,
)
}
fn drain_clicks(
out_rx: &mut tokio::sync::mpsc::UnboundedReceiver<crate::protocol::outbound::Outbound>,
) -> Vec<String> {
use crate::protocol::outbound::Outbound;
std::iter::from_fn(|| out_rx.try_recv().ok())
.map(|o| match o {
Outbound::UiEvent { event } => event.kind,
other => panic!("expected a UiEvent, got {other:?}"),
})
.collect()
}
#[test]
fn scroll_past_slop_suppresses_the_tap() {
use bevy::input::touch::TouchPhase;
let (mut app, container, mut out_rx) = click_scroll_app();
send_touch(&mut app, 7, TouchPhase::Started, Vec2::new(300.0, 200.0));
send_touch(&mut app, 7, TouchPhase::Moved, Vec2::new(300.0, 150.0));
queue_touch(&mut app, 7, TouchPhase::Ended, Vec2::new(300.0, 150.0));
app.world_mut()
.write_message(synth_click(PointerId::Touch(7), container));
app.world_mut()
.write_message(synth_click(PointerId::Mouse, container));
app.update();
assert_eq!(
drain_clicks(&mut out_rx),
["click"],
"the scrolled touch's tap is suppressed; the mouse click still fires"
);
}
#[test]
fn sub_slop_tap_still_clicks() {
use bevy::input::touch::TouchPhase;
let (mut app, container, mut out_rx) = click_scroll_app();
send_touch(&mut app, 7, TouchPhase::Started, Vec2::new(300.0, 200.0));
send_touch(&mut app, 7, TouchPhase::Moved, Vec2::new(303.0, 200.0));
queue_touch(&mut app, 7, TouchPhase::Ended, Vec2::new(303.0, 200.0));
app.world_mut()
.write_message(synth_click(PointerId::Touch(7), container));
app.update();
assert_eq!(
drain_clicks(&mut out_rx),
["click"],
"a tap within the slop radius keeps its click"
);
}
#[test]
fn suppression_survives_the_release_frame() {
use bevy::input::touch::TouchPhase;
let (mut app, _container) = touch_app();
send_touch(&mut app, 7, TouchPhase::Started, Vec2::new(300.0, 200.0));
send_touch(&mut app, 7, TouchPhase::Moved, Vec2::new(300.0, 150.0));
assert!(app.world().resource::<TouchScrollState>().is_suppressed(7));
send_touch(&mut app, 7, TouchPhase::Ended, Vec2::new(300.0, 150.0));
assert!(
app.world().resource::<TouchScrollState>().is_suppressed(7),
"suppression is readable on the release frame"
);
app.update();
assert!(
!app.world().resource::<TouchScrollState>().is_suppressed(7),
"the claim drops the frame after release"
);
}
#[test]
fn touch_outside_containers_does_not_claim() {
use bevy::input::touch::TouchPhase;
let (mut app, container) = touch_app();
send_touch(&mut app, 7, TouchPhase::Started, Vec2::new(10.0, 10.0));
send_touch(&mut app, 7, TouchPhase::Moved, Vec2::new(10.0, 60.0));
assert_eq!(
app.world()
.entity(container)
.get::<ScrollPosition>()
.unwrap()
.0,
Vec2::ZERO
);
assert!(!app.world().resource::<PointerCapture>().dragging);
}
}