Fix UI accessibility transforms and update ordering (#23859)

# Objective

* Bevy UI's accessibility module doesn't set the transform on
accessiblity nodes, ignoring scaling and rotation.
* UI accessibility nodes should be updated before `bevy_winit` updates
the accesskit adaptors, otherwise there will be a frames delay.


## Solution

Replaced the `calc_bounds` systems with a new system
`sync_bounds_and_transforms`.

Each accesskit `Node` corresponding to an `AccessibleNode` UI entity is
now given object-centered coordinates for its bounding rect (instead of
window coordinates) and a transform.

Accesskit uses local transforms so if an accessible node also has an
accessible parent, its transform has to be recomputed relative to its
parent.

## Testing

I modified the button example so that accesskit integration is enabled
by default and the button is drawn at a 45 degrees angle.

```
cargo run --example button
```

Screen readers should only react when the pointer is directly over the
rotated button if the changes are working.

The example changes should be reverted before merging.
This commit is contained in:
ickshonpe
2026-04-18 05:35:53 +01:00
committed by GitHub
parent 7a42034ffa
commit bb1d51623b
2 changed files with 59 additions and 22 deletions
+48 -22
View File
@@ -5,18 +5,20 @@ use crate::{
widget::{ImageNode, TextUiReader},
ComputedNode, UiSystems,
};
use bevy_a11y::AccessibilityNode;
use bevy_a11y::{AccessibilityNode, AccessibilitySystems};
use bevy_app::{App, Plugin, PostUpdate};
use bevy_ecs::{
prelude::{DetectChanges, Entity},
query::{Changed, Without},
change_detection::DetectChanges,
hierarchy::ChildOf,
prelude::Entity,
query::{Changed, With, Without},
schedule::IntoScheduleConfigs,
system::{Commands, Query},
world::Ref,
};
use bevy_math::Affine2;
use accesskit::{Node, Rect, Role};
use bevy_camera::CameraUpdateSystems;
use accesskit::{Affine, Node, Rect, Role};
fn calc_label(
text_reader: &mut TextUiReader,
@@ -35,21 +37,43 @@ fn calc_label(
name.map(String::into_boxed_str)
}
fn calc_bounds(
mut nodes: Query<(
fn sync_bounds_and_transforms(
mut accessible_nodes_query: Query<(
&mut AccessibilityNode,
Ref<ComputedNode>,
Ref<UiGlobalTransform>,
Option<&ChildOf>,
)>,
accessible_transform_query: Query<Ref<UiGlobalTransform>, With<AccessibilityNode>>,
) {
for (mut accessible, node, transform) in &mut nodes {
if node.is_changed() || transform.is_changed() {
let center = transform.translation;
let half_size = 0.5 * node.size;
let min = center - half_size;
let max = center + half_size;
let bounds = Rect::new(min.x as f64, min.y as f64, max.x as f64, max.y as f64);
accessible.set_bounds(bounds);
for (mut accessible, node, ui_transform, maybe_child_of) in &mut accessible_nodes_query {
let maybe_parent_transform = maybe_child_of
.and_then(|child_of| accessible_transform_query.get(child_of.parent()).ok());
if !(node.is_changed()
|| ui_transform.is_changed()
|| maybe_parent_transform.is_some_and(|transform| transform.is_changed()))
{
continue;
}
accessible.set_bounds(Rect::new(
-0.5 * node.size.x as f64,
-0.5 * node.size.y as f64,
0.5 * node.size.x as f64,
0.5 * node.size.y as f64,
));
// If the node has an accessible parent, its transform in the accessibility tree must be relative to the parent.
let transform = maybe_parent_transform
.and_then(|transform| transform.try_inverse())
.unwrap_or_default()
* ui_transform.affine();
if transform.is_finite() && transform != Affine2::IDENTITY {
accessible.set_transform(Affine::new(transform.to_cols_array().map(f64::from)));
} else {
accessible.clear_transform();
}
}
}
@@ -149,16 +173,18 @@ impl Plugin for AccessibilityPlugin {
app.add_systems(
PostUpdate,
(
calc_bounds
.after(bevy_transform::TransformSystems::Propagate)
.after(CameraUpdateSystems)
// the listed systems do not affect calculated size
.ambiguous_with(crate::ui_stack_system)
.before(UiSystems::PostLayout),
button_changed,
image_changed,
label_changed,
),
sync_bounds_and_transforms
.after(button_changed)
.after(image_changed)
.after(label_changed)
// the listed systems do not affect calculated size
.ambiguous_with(crate::ui_stack_system),
)
.in_set(UiSystems::PostLayout)
.before(AccessibilitySystems::Update),
);
}
}
+11
View File
@@ -8,6 +8,12 @@ fn main() {
.add_plugins(DefaultPlugins)
// `InputFocus` must be set for accessibility to recognize the button.
.init_resource::<InputFocus>()
.add_systems(
Startup,
|requested: Res<bevy::a11y::AccessibilityRequested>| {
requested.set(true);
},
)
.add_systems(Startup, setup)
.add_systems(Update, button_system)
.run();
@@ -92,6 +98,11 @@ fn button(asset_server: &AssetServer) -> impl Bundle {
border_radius: BorderRadius::MAX,
..default()
},
UiTransform {
translation: default(),
scale: 2. * Vec2::ONE,
rotation: Rot2::degrees(45.)
},
BorderColor::all(Color::WHITE),
BackgroundColor(Color::BLACK),
children![(