取消

解决 WPF 分组的 ItemsControl 内部控件无法被 UI 自动化识别的问题

如果你试图给 WPF 的 ItemsControl 加入自动化识别,或者支持无障碍使用,会发现 ItemsControl 内的元素如果进行了分组,则只能识别到组而不能识别到元素本身。如果你正试图解决这个问题,那么本文正好能给你答案。


现象

现在,我们在 ItemsControl 的内部放几个按钮并进行分组。用自动化软件去捕获它,会发现整个 ItemsControl 会被视为一个控件(如下图上方),而我们期望的是像下图下方那样可识别到内部的每一个按钮。

ItemsControl 的自动化支持情况

这个例子的最简示例我已经开源到 GitHub 上了,感兴趣可以自行去看看:

官方推荐的解决方法(但有 bug,无效)

官方其实有一个开关 Switch.System.Windows.Controls.ItemsControlDoesNotSupportAutomation 解决这个问题。但是自 .NET Framework 4.7 开始直到 .NET 6 正式版,这个开关实际上一直都不会生效。

关于如何打开这个开关,可以查看林德熙的博客:https://blog.lindexi.com/post/WPF-Application-Compatibility-switches-list.html#switchsystemwindowscontrolsitemscontroldoesnotsupportautomation

关于这个 bug,我已经向微软官方 GitHub 仓库提了:

后面我会解释原因。但是现在我们需要换一个新的方法来解决它。

临时解决方案(在官方 bug 修掉之前是最好方案)

在你的项目中增加一个自己实现的 ItemsControl,源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
namespace Walterlv.Windows.Controls;
// The fixed version of the ItemsControl.
public class FixedItemsControl : ItemsControl
{
    protected override AutomationPeer OnCreateAutomationPeer()
    {
        return new ItemsControlWrapperAutomationPeer(this);
    }

    private sealed class ItemsControlWrapperAutomationPeer : ItemsControlAutomationPeer
    {
        public ItemsControlWrapperAutomationPeer(ItemsControl owner) : base(owner)
        {
        }

        protected override ItemAutomationPeer CreateItemAutomationPeer(object item)
        {
            return new ItemsControlItemAutomationPeer(item, this);
        }

        protected override string GetClassNameCore()
        {
            return "ItemsControl";
        }

        protected override AutomationControlType GetAutomationControlTypeCore()
        {
            return AutomationControlType.List;
        }
    }

    private class ItemsControlItemAutomationPeer : ItemAutomationPeer
    {
        public ItemsControlItemAutomationPeer(object item, ItemsControlWrapperAutomationPeer parent)
            : base(item, parent)
        { }

        protected override AutomationControlType GetAutomationControlTypeCore()
        {
            return AutomationControlType.DataItem;
        }

        protected override string GetClassNameCore()
        {
            return "ItemsControlItem";
        }
    }
}

在你项目里原本需要使用到 ItemsControl 的地方,都换成以上这个修复版的 FixedItemsControl 就可以解决问题。

官方开关不生效的原因

会出现这个原因,是因为 ItemsControl 内部元素分组后,元素会在 GroupItem 中,GroupItem 重写了 OnCreateAutomationPeer 方法并返回了 GroupItemAutomationPeer 的实例。在其 GetChhildrenCore 方法中会试图从 ItemsControl 中获取它的 ItemsControlAutomationPeer 以返回子节点。然而在这段代码中,itemsControl.CreateAutomationPeer() 始终返回 null,所以永远没有子节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// GroupItemAutomationPeer.cs
protected override List<AutomationPeer> GetChildrenCore()
{
    GroupItem owner = (GroupItem)Owner;
    ItemsControl itemsControl = ItemsControl.ItemsControlFromItemContainer(Owner);
    if (itemsControl != null)
    {
        ItemsControlAutomationPeer itemsControlAP = itemsControl.CreateAutomationPeer() as ItemsControlAutomationPeer;
        if (itemsControlAP != null)
        {
            List<AutomationPeer> children = new List<AutomationPeer>();
            // Ignore this code because in this case it will not be executed.
            return children;
        }
    }

    return null;
}

ItemsControlCreateAutomationPeer 是怎么实现的呢?直接靠 UIElement 基类来实现。可以发现,它单独对 ItemsControl 判断了我们本文一开始所说的开关。

按名称进行推测,ItemsControlDoesNotSupportAutomation 指“ItemsControl 不支持自动化”,也就是说我们需要将其设置为 false 才是让它支持自动化。但实际上这个值无论设置为 true 还是 false 都不会让自动化生效。

1
2
3
4
5
6
7
8
9
// UIElement.cs
protected virtual AutomationPeer OnCreateAutomationPeer()
{
    if (!AccessibilitySwitches.ItemsControlDoesNotSupportAutomation)
    {
        AutomationNotSupportedByDefaultField.SetValue(this, true);
    }
    return null;
}

假设设置为 true,那么上述方法直接返回 null 即不会生成自动化节点。显然不能解决问题。

假设设置为 false,那么会设置一个标识位 AutomationNotSupportedByDefaultFieldtrue

现在我们继续看与之相关的代码,即 UIElementCreateAutomationPeer 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// UIElement.cs
// Method: CreateAutomationPeer
if (!AccessibilitySwitches.ItemsControlDoesNotSupportAutomation)
{
    // work around (ItemsControl.GroupStyle doesn't show items in groups in the UIAutomation tree)
    AutomationNotSupportedByDefaultField.ClearValue(this);
    ap = OnCreateAutomationPeer();

    // if this element returns an explicit peer (even null), use
    // it.  But if it returns null by reaching the default method
    // above, give it a second chance to create a peer.
    // [This whole dance, including the UncommonField, would be
    // unnecessary once ItemsControl implements its own override
    // of OnCreateAutomationPeer.]
    if (ap == null && !AutomationNotSupportedByDefaultField.GetValue(this))
    {
        ap = OnCreateAutomationPeerInternal();
    }
}
else
{
    ap = OnCreateAutomationPeer();
}

ItemsControlDoesNotSupportAutomation 标识设为 false 时,第一个 if 将进入,OnCreateAutomationPeer 将执行,然后将按前面的代码将 AutomationNotSupportedByDefaultField 标识设置为 true。这会导致第二个 if 不满足条件而退出,从而整个方法执行完毕——没有产生任何自动化节点。

而就算将 ItemsControlDoesNotSupportAutomation 标识设为 true,进入了 elseOnCreateAutomationPeer 内部也不会返回自动化节点。

于是,这个开关完全没有生效!

官方正在解决

在我查出以上原因之后,给官方提了此问题的修复方案,可以让这个开关正常工作。

目前这个方案正在审查中。

但在官方合并之前,可以使用我在本文第二小节中提到的方案临时解决问题。

本文会经常更新,请阅读原文: https://blog.walterlv.com/post/wpf-items-control-supports-ui-automation ,以避免陈旧错误知识的误导,同时有更好的阅读体验。

知识共享许可协议

本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。欢迎转载、使用、重新发布,但务必保留文章署名 吕毅 (包含链接: https://blog.walterlv.com ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。如有任何疑问,请 与我联系 ([email protected])

登录 GitHub 账号进行评论