I've created a UWP custom (i.e. templated) control. How do I make it accessible?
Right now when I try to use it with the Windows Narrator tool it behaves poorly. Sometimes Narrator doesn't see it at all, and sometimes it drills down into the tree of UI elements within my control when I don't want it to.
Initially I thought I just needed to set some of the Automation attached properties, but they had no apparent effect.
So I got somewhere with this when I found out about AutomationPeer objects. Basically each control class needs an associated AutomationPeer class that maps the behavior of your specific control to a set of standards to be consumed by accessibility tools.
For the sake of simplicity I made a trivial AccessibleButton class that derives directly from Control. (If you were really making a button you would probably want to derive from Button or ButtonBase, but that already has an associated AutomationPeer class. I'm just doing it the hard way for explanatory purposes.)
Here's the Generic.xaml code:
<Style TargetType="local:AccessibleButton">
<Setter Property="UseSystemFocusVisuals" Value="True"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="local:AccessibleButton">
<Grid Background="{TemplateBinding Background}">
<Border BorderThickness="10">
<TextBlock x:Name="Name" Text="{TemplateBinding Label}"/>
</Border>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
Here's the code behind:
public sealed class AccessibleButton : Control
{
public AccessibleButton()
{
this.DefaultStyleKey = typeof(AccessibleButton);
}
public static DependencyProperty LabelProperty = DependencyProperty.Register(
"Label", typeof(string), typeof(AccessibleButton),
PropertyMetadata.Create(string.Empty));
public string Label
{
set { SetValue(LabelProperty, value); }
get { return (string)GetValue(LabelProperty); }
}
protected override void OnPointerPressed(PointerRoutedEventArgs e)
{
Click?.Invoke(this, EventArgs.Empty);
}
public event EventHandler Click;
}
And some sample usage in MainPage.xaml:
<StackPanel Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
<local:AccessibleButton Background="Red" Label="Click Me!" Click="AccessibleButton_Click"/>
<local:AccessibleButton Background="Green" Label="No, Click Me!" Click="AccessibleButton_Click"/>
<local:AccessibleButton Background="Blue" Label="Ignore them, Click Me!" Click="AccessibleButton_Click"/>
</StackPanel>
If you run Narrator and mouse over each button you'll find that it ignores the border of the button (you just get a little taptaptap sound). If you mouse over the text it will sometimes read the entire text but often will read individual words plus a mysterious "empty line" at the end of the text. I'm guessing the TextBlock control breaks its input up in to individual objects for each word...
It's pretty bad.
The fix is an AutomationPeer. Here's the basic class:
public class AccessibleButtonAutomationPeer : FrameworkElementAutomationPeer
{
public AccessibleButtonAutomationPeer(FrameworkElement owner): base(owner)
{
}
}
(FrameworkElementAutomationPeer is in the Windows.UI.Xaml.Automation.Peers namespace.)
And in the AccessibleButton class add this override to create it:
protected override AutomationPeer OnCreateAutomationPeer()
{
return new AccessibleButtonAutomationPeer(this);
}
This does nothing useful yet. We need to add a few methods. First lets stop the screen reader from being able to see the guts of our button by implementing the GetChildrenCore() method:
protected override IList<AutomationPeer> GetChildrenCore()
{
return null;
}
If you run with just this you'll find it does nothing much at all now. If a control does get focus it'll just say nothing but "Custom". We can make it say the text in the control label by implementing the GetNameCore() method:
protected override string GetNameCore()
{
return ((AccessibleButton)Owner).Label;
}
This helps. Now it speaks the button label text whenever we select a control. But it still says "Custom" at the end. To fix that we have to tell the system what kind of control it is by implementing the GetAutomationControlTypeCore() method to indicate that the control is a button:
protected override AutomationControlType GetAutomationControlTypeCore()
{
return AutomationControlType.Button;
}
Now it will say the button label followed by "Button".
This is actually useful now!
A user with poor vision but an ability to navigate the screen by mouse or touch would at least know what the labels on these buttons say.
AutomationPeers also allow you support "interaction patterns" by implementing a pattern provider interface. For this button example the Invoke pattern is appropriate. We need to implement IInvokeProvider so add it to the class declaration:
public class AccessibleButtonAutomationPeer :
FrameworkElementAutomationPeer,
IInvokeProvider
(IInvokeProvider is in the using using Windows.UI.Xaml.Automation.Provider namespace.)
Then override GetPatternCore to indicate that it is supported:
protected override object GetPatternCore(PatternInterface patternInterface)
{
if (patternInterface == PatternInterface.Invoke)
{
return this;
}
return base.GetPatternCore(patternInterface);
}
And implement the IInvokeProvider.Invoke method:
public void Invoke()
{
((AccessibleButton)Owner).DoClick();
}
(To support this I move the body of the AccessibleButton.OnPointerPressed method out into its own DoClick() method so that I could call it from here.)
To test this select the button using narrator and press Caps Lock + Space to invoke the default function of the control. It'll use the Invoke method to call DoClick().
The AutomationPeer class supports a lot more functionality than this. If I get around to implementing it I'll update this post with more details.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With