An exploration into adding support for heading navigation in WPF and Win32 apps
This article describes an experiment around adding support for headings to a WPF app and a Win32 app, with the intent of enabling a more efficient experience for the apps' customers who use screen readers.
The main article relates to the WPF app, and the postscript contains details on the Win32.
Acknowledgement: My WPF experiment here is based on code supplied to me some time back by Jeff Robison, who’s far more familiar with what’s possible with .NET than I am. I’m deeply grateful to all the work that Jeff did while exploring options for how WPF apps might enable more people.
Introduction
I was recently asked how someone using a screen reader might navigate via headings in a WPF app. After all, heading navigation in content on the web has been very important to people for many years, so surely the same applies for desktop apps too? Indeed, if an app presents UI where semantically headings exist, for a customer of that app to be able to navigate through the UI via those headings makes perfect sense.
The UWP XAML framework does have native support for headings, as described at Landmarks and Headings. But as far as I know, at the time of writing this, native support for headings does not exist in WPF. (I’ve not found anything at What's new in accessibility in the .NET Framework that relates to WPF headings.)
So this article describes one potential approach for adding support for headings to your WPF app today. This approach is not an officially documented approach, but it seems worth sharing the code anyway, so that you can decide for yourself as to whether it’s something that you would consider using.
Heading for trouble
At this point, it’s worth taking a moment to talk about what a heading is.
When I set up my own personal web site some time back, I had a page of text, and the page contained some headings to logically organize the content on the page. To create these headings, I selected some text, and made the text big. That seemed fine to me. Over time however, I learnt that text that’s big isn’t technically a heading, rather it’s what’s known as “big text”. So what I’d done is create UI that worked for me, and not for all my customers. This will not do.
This was a classic case of me focusing on the UI’s visual attributes, and not focusing on how I efficiently convey the UI’s meaning to all my customers. I want all my customers to know that the text is semantically a heading, regardless of how they consume the UI, be it through its visuals or with a screen reader.
So for that content on my old web page, I added use of some heading tags in order to fix my broken heading experience. For WPF, I felt I’d try to provide a dev experience similar to that of UWP XAML. As such, the text element would expose a HeadingLevel property through its AutomationPeer, and that property would be associated with the UI Automation (UIA) API’s UIA HeadingLevel property.
Introducing the HeadingTextBlock
For this experiment, I focused on adding support for headings to the WPF TextBlock. It seemed likely that that would enable most heading-related scenarios in practice.
And it goes something like this…
I first created a new WPF app in Visual Studio, called WPFHeadingTextBlock. In the project’s Reference section, I added a reference to UIAutomationTypes, as that’d be needed soon. I then added the code below.
For more information on how Reflection can be used to find class properties, (which is part of adding support for the UIA HeadingLevel property to the set of WPF AutomationPeer properties,) visit Reflection in the .NET Framework.
public class HeadingTextBlock : TextBlock
{
static HeadingTextBlock()
{
DefaultStyleKeyProperty.OverrideMetadata(
typeof(HeadingTextBlock),
new FrameworkPropertyMetadata(
typeof(HeadingTextBlock)));
}
// Note: This is defined is such a way that no events
// will be raised when this HeadingLevel property changes.
// That's fine for this experiment.
public static readonly DependencyProperty HeadingLevelProperty =
DependencyProperty.Register("HeadingLevel",
typeof(HeadingLevel),
typeof(HeadingTextBlock),
null);
}
public class CustomAutomationIdentifier
{
protected static T CreateAutomationIdentifier<T>(
int id,
string programmaticName)
where T : AutomationIdentifier
{
const BindingFlags bindingFlags =
BindingFlags.Instance |
BindingFlags.Public |
BindingFlags.NonPublic;
ConstructorInfo[] ctors =
typeof(T).GetConstructors(bindingFlags);
ConstructorInfo ctorToInvoke = null;
foreach (ConstructorInfo ctor in ctors)
{
ParameterInfo[] parameters = ctor.GetParameters();
if ((parameters.Length == 3) &&
(parameters[0].ParameterType == typeof(int)) &&
(parameters[1].ParameterType == typeof(Guid)) &&
(parameters[2].ParameterType == typeof(string)))
{
ctorToInvoke = ctor;
break;
}
}
T automationIdentifier = null;
if (ctorToInvoke != null)
{
object[] parameters = { id, Guid.Empty, programmaticName };
automationIdentifier = (T)ctorToInvoke.Invoke(parameters);
}
return automationIdentifier;
}
}
public static class CustomAutomationProperties
{
static CustomAutomationProperties()
{
Type automationPeerType = typeof(AutomationPeer);
FieldInfo[] fields =
automationPeerType.GetFields(
BindingFlags.Static | BindingFlags.NonPublic);
FieldInfo field =
fields.FirstOrDefault(f => f.Name == "s_propertyInfo");
Hashtable s_propertyInfo = field?.GetValue(null) as Hashtable;
if (s_propertyInfo == null)
{
return;
}
Type delegateType = automationPeerType.GetNestedType(
"GetProperty", BindingFlags.NonPublic);
if (delegateType == null)
{
return;
}
s_propertyInfo.MaybeAddProperty(
UIA_HeadingLevelPropertyId,
delegateType,
"GetHeadingLevelAutomationProperty");
}
// From https://docs.microsoft.com/en-us/windows/desktop/WinAuto/uiauto-automation-element-propids
public const int UIA_HeadingLevelPropertyId = 30173;
public static readonly CustomAutomationProperty HeadingLevelAutomationProperty =
new CustomAutomationProperty(
UIA_HeadingLevelPropertyId,
"AutomationElementIdentifiers.HeadingLevelProperty");
public static readonly DependencyProperty HeadingLevelProperty =
DependencyProperty.RegisterAttached(
"HeadingLevel",
typeof(HeadingLevel),
typeof(CustomAutomationProperties),
new UIPropertyMetadata(HeadingLevel.HeadingLevel_None));
public static HeadingLevel GetHeadingLevel(DependencyObject element)
{
return ((HeadingLevel)element.GetValue(HeadingLevelProperty));
}
public static void SetHeadingLevel(DependencyObject element, HeadingLevel value)
{
element.SetValue(HeadingLevelProperty, value);
}
static void MaybeAddProperty( this Hashtable s_propertyInfo, int propertyId, Type delegateType, string getterMethodName)
{
if (!s_propertyInfo.ContainsKey(propertyId))
{
s_propertyInfo[propertyId] = Delegate.CreateDelegate(
delegateType,
typeof(CustomAutomationProperties),
getterMethodName);
}
}
static object GetHeadingLevelAutomationProperty(AutomationPeer peer)
{
UIElement owner = (peer as FrameworkElementAutomationPeer)?.Owner;
if (owner == null)
{
return null;
}
return GetHeadingLevel(owner);
}
}
public class CustomAutomationProperty : CustomAutomationIdentifier
{
public CustomAutomationProperty(int id, string programmaticName)
{
AutomationProperty =
CreateAutomationIdentifier<AutomationProperty>(
id, programmaticName);
}
public AutomationProperty AutomationProperty { get; }
}
// From https://docs.microsoft.com/en-us/windows/desktop/winauto/uiauto-heading-level-identifiers
public enum HeadingLevel
{
HeadingLevel_None = 80050,
HeadingLevel1 = 80051,
HeadingLevel2 = 80052,
HeadingLevel3 = 80053,
HeadingLevel4 = 80054,
HeadingLevel5 = 80055,
HeadingLevel6 = 80056,
HeadingLevel7 = 80057,
HeadingLevel8 = 80058,
HeadingLevel9 = 80059
}
In the MainPage.xaml, add the following property setter in the Window resources.
<!-- By default, all HeadingTextBlocks have a heading level of None. -->
<Window.Resources>
<Style TargetType="local:HeadingTextBlock">
<Style.Setters>
<Setter Property="local:CustomAutomationProperties.HeadingLevel"
Value="HeadingLevel_None" />
</Style.Setters>
</Style>
</Window.Resources>
And having done that, add a HeadingTextBlock wherever you’d like a TextBlock to be exposed with a UIA HeadingLevel property. For one of my tests, I added the following:
<StackPanel Margin="20">
<!-- Todo: Localize this... -->
<local:HeadingTextBlock x:Name="AppHeading"
Text="The WPF Heading Experiment" FontWeight="ExtraBold" />
<TextBlock
Text="This is an experiment into enabling heading navigation in a WPF app." />
<local:HeadingTextBlock x:Name="WarningHeading"
Text="Warning" FontWeight="Bold" Margin="0 10 0 0" />
<TextBlock
Text="This is only an experiment, and would need careful consideration before using this approach in a shipping app." />
<local:HeadingTextBlock x:Name="EncouragementHeading"
Text="Encouragement" FontWeight="Bold" Margin="0 10 0 0" />
<TextBlock Text="Mind you, it does seem to work pretty well in tests so far." />
</StackPanel>
Note that in the above XAML, I added some FontWeight setting usage to have the heading information conveyed visually for the benefit of my sighted customers. Jeff raised the interesting idea here that perhaps triggers could be added to the style for HeadingTextBlock, such that such things as the FontWeight might be set automatically based on the value of the HeadingLevel property. That does sound tempting!
The final step is to set the heading level however you feel appropriate on the HeadingTextBlocks in the app. In my case, I added the following after the call to InitializeComponent() in the MainWindow constructor.
CustomAutomationProperties.SetHeadingLevel(
AppHeading, HeadingLevel.HeadingLevel1);
CustomAutomationProperties.SetHeadingLevel(
WarningHeading, HeadingLevel.HeadingLevel2);
CustomAutomationProperties.SetHeadingLevel(
EncouragementHeading, HeadingLevel.HeadingLevel2);
The Results
Having added the above code, I ran the test app. As expected, some heading-related information was conveyed visually.
Given that I’d never point the Narrator screen reader at an app before verifying the UIA representation first, I next ran the Accessibility Insights for Windows tool.
The screenshot below shows the UIA HeadingLevel property for the “Warning” heading. The property has a value of 80052, which as listed at Heading Level Identifiers means HeadingLevel2, as expected. I can also use the tool to verify the heading level properties of the other headings, and verify that text that’s not in a heading has a value of HeadingLevel_None.
Figure 1: The Accessibility Insights for Windows tool reporting that some bold text in a WPF app is exposed through UIA with a HeadingLevel property of HeadingLevel2.
Having verified that the UIA representation is as expected, I could then point the Narrator screen reader at the app, and verify that Narrator could access the heading information in the app.
The screenshot below shows Narrator listing the headings that it found in the app. As expected, it lists the main app title, and the two app subtitles.
Figure 2: The Narrator screen reader listing all the headings found in a WPF app. Narrator lists one heading of HeadingLevel1, and two headings of HeadingLevel2.
Now that I know Narrator can access the headings as expected, I can use Narrator’s heading navigation feature to jump between headings. And once I’ve reached a heading I’m interested in, I can press CapsLock+R to have the content relating to that heading announced.
Summary
If it’s practical to add robust support for functionality which enables your customers to leverage your products more efficiently, then I’d strongly recommend you consider what options you may have to achieve that.
Please do consider whether the code in this article might help you deliver a more efficient experience for more of your customers.
And I must say thanks again to Jeff, the .NET expert, who provided the original Reflection code on which my code is based.
Guy
P.S. The Win32 Experiment
Given that it was so much fun experimenting with the WPF app, I couldn't stop wondering if it'd be practical to add support for heading level navigation to a Win32 app too. After all, my snippet at Win32: A screen reader isn't announcing an update to a status string or error string shows how to add the UIA LiveSetting property to a control, so maybe similar action could be taken to add the UIA HeadingLevel property to a control. Worth an experiment at least...
Step 1: Create a new Win32 app in Visual Studio.
Step 2: Add some labels to the Help dialog that comes with the app.
Step 3: Add the following to the top of the C++ file which creates and destroys the Help dialog.
#include <initguid.h>
#include "objbase.h"
#include "uiautomation.h"
IAccPropServices* _pAccPropServices = NULL;
Step 4: Add an array of control ids for the controls you want to be headings, along with their associated heading level.
// Build up an array of labels to be turned into headings.
struct LabelHeadingData
{
int ControlId;
int HeadingLevel;
};
LabelHeadingData labelHeadingData[] = {
IDC_APPHEADING, HeadingLevel1,
IDC_WARNINGHEADING, HeadingLevel2,
IDC_ENCOURAGEMENTHEADING, HeadingLevel2
};
Step 5: Add this code where the control UI is created, (in the WM_INITDIALOG handler).
HRESULT hr = CoCreateInstance(
CLSID_AccPropServices,
nullptr,
CLSCTX_INPROC,
IID_PPV_ARGS(&_pAccPropServices));
if (SUCCEEDED(hr))
{
VARIANT var;
var.vt = VT_I4;
for (int i = 0; i < ARRAYSIZE(labelHeadingData); i++)
{
var.lVal = labelHeadingData[i].HeadingLevel;
// Todo: Handle errors.
hr = _pAccPropServices->SetHwndProp(
GetDlgItem(hDlg, labelHeadingData[i].ControlId),
OBJID_CLIENT,
CHILDID_SELF,
HeadingLevel_Property_GUID,
var);
}
}
Step 6: Add this code where the UI is destroyed, (in the WM_DESTROY handler).
if (_pAccPropServices != nullptr)
{
MSAAPROPID props[] = { HeadingLevel_Property_GUID };
for (int i = 0; i < ARRAYSIZE(labelHeadingData); i++)
{
// Todo: Handle errors.
HRESULT hr = _pAccPropServices->ClearHwndProps(
GetDlgItem(hDlg, labelHeadingData[i].ControlId),
OBJID_CLIENT,
CHILDID_SELF,
props,
ARRAYSIZE(props));
}
_pAccPropServices->Release();
_pAccPropServices = NULL;
}
The Win32 Result
And hey presto, that's all it took to add use of heading levels to the Win32 app. I could then run the app and use the Accessibility Insights for Windows tool to verify that the three labels of interest all exposed the expected UIA HeadingLevel property values.
Having verified that the UIA representation is as expected, I then used the Narrator screen reader to move back and forth through the Win32 app UI using heading navigation.
The screenshot below shows Narrator listing the headings that exist in the Win32 app UI.
Figure 3: The Narrator screen reader listing all the headings found in a Win32 app. Narrator lists one heading of HeadingLevel1, and two headings of HeadingLevel2.