Monday, July 30, 2007

Control Templates and UIAutomation don't get along all that well

Off and on I try and use the UIAutomation libraries from .NET 3.0 for a variety of things. Most often it is testing but not always. Since I do more unit testing than anything else I find LogicalTreeHelper, VisualTreeHelper and the AutomationPeer functionality more useful than AutomationElement.
On a recent project we were doing some work on a screen that wasn't particularly easy to get to. It took about 9 clicks. Not that bad but I'm lazy. We were doing a lot of fancy WPF stuff and we really wanted to see things live in the full application context instead of in Blend or just raw xaml. So we would run the app a lot. I got tired of doing the same clicks and occasionally making mistakes just to get the screen to show. So I chose to write a small app that would use UIAutomation to get me to the screen. Seemed simple enough. I've done plenty of work with UIAutomation and I thought it would be easy. Was I in for a suprise.
We had done something clever to a ListBox to get an button to show up at the top of the list box before the items of the list. We had used a control template to get the button in there. Of course there were at least a dozen other ways to do it but that's a different post.
So to start out I did something sane for once and pointed UISpy at the application before I jumped in and started writing code. To my suprise UISpy could not see the button! I tried the Raw view, the content view, custom views, everything. No button.
Then I put some extra code into the application so I could use VisualTreeHelper to see if it could see the button. Sure enough there it was. So what was wrong with UISpy and therefore UIAutomation?
I thought it would be good to try and reproduce the problem in a smaller more isolated way. Then I could be sure it wasn't something else we did. We had a pretty complicated application going so I wanted to reduce the number of variables. I just created a standard WPF application and put this in the cleverly named Window1.xaml

<Window x:Class="WindowsApplication1.Window1"
    Title="WindowsApplication1" Height="300" Width="300"
            <Style TargetType="{x:Type WindowsApplication1:SpecialListBox}">
                <Setter Property="Template">
                        <ControlTemplate TargetType="{x:Type WindowsApplication1:SpecialListBox}">
                                <TextBox x:Name="templateTextBox" Grid.Row="0">Can you see me now?</TextBox>
                                <StackPanel Grid.Row="1">
        <WindowsApplication1:SpecialListBox x:Name="specialList">

There is nothing special about SpecialListBox. It is just a class that derives from ListBox and doesn't override anything, yet. There was also no reason that I switched from Button to TextBox in the smaller code base.
I fired that up, pointed UISpy at it and UISpy could not see TextBox from the control template. It showed the two ListBoxItems just fine. I did some poking around and figured it out eventually.
Since this is a WPF app UIAutomation will talk to AutomationPeers exposed by each WPF element's OnCreateAutomationPeer method. AutomationPeer has a GetChildren method that UISpy and other UIAutomation code will use to navigate the control heirarchy. ListBox will return a ListBoxAutomationPeer instance when its OnCreateAutomationPeer method is called. ListBoxAutomationPeer only returns the list items from its GetChildren implementation. So it will never return the textbox I added to the control template because it doesn't know how.
That seems to put us in a situation where we can't ever get to the textbox. We could refactor and not put the TextBox in the ListBox's ControlTemplate and use a different form of composition like a UserControl. But I thought I'd see if I could get UIAutomation to place nice with the ControlTemplate.
The solution I chose to use is to create my own AutomationPeer for the ListBox. I'm already using a class derived from ListBox anyway so overriding OnCreateAutomationPeer to return my own AutomationPeer is pretty easy. For the custom AutomationPeer itself it seems to make sense to start out by deriving from ListBoxAutomationPeer and seeing if GetChildren can be overriden. It can't, but the GetChildrenCore method can, and that is good enough.
All that has to be done is to add a TextBoxAutomationPeer to the list of children and we should be good to go. SpecialListBox ends up looking like this:

    public class SpecialListBox : ListBox
        protected override AutomationPeer OnCreateAutomationPeer()
            return new SpecialListBoxAutomationPeer(this);

The fun bits are in the SpecialListBoxAutomationPeer. I used a helper class from WPFUtilities to help me get at the TextBox. All it does is make VisualTreeHelper a bit easier to use, but under the covers the work is done by VisualTreeHelper.

    public class SpecialListBoxAutomationPeer : ListBoxAutomationPeer
        public SpecialListBoxAutomationPeer(ListBox owner) : base(owner)
        protected override string GetLocalizedControlTypeCore()
            return "SpecialListBox";
        protected override List<AutomationPeer> GetChildrenCore()
            TextBox textBox = null;
            DepthFirstVisualTreeIterator iter = new DepthFirstVisualTreeIterator();
            foreach (DependencyObject dependencyObject in iter.GetNodes(Owner))
                FrameworkElement element = dependencyObject as FrameworkElement;
                if (element != null && element is TextBox && element.Name == "templateTextBox")
                    textBox = (TextBox)element;
            List<AutomationPeer> children = base.GetChildrenCore();
            TextBoxAutomationPeer button = new TextBoxAutomationPeer(textBox);
            return children;

SpecialListBoxAutomationPeer just finds the TextBox, creates an AutomationPeer for it, gets the children that would normally be returned and adds the TextBoxAutomationPeer to them.

This all ends up being a bit more work than I think should have to be done. It also requires me to change my code. I can't fix this kind of problem after the fact. I still think re-writing this to use a user control is a better solution. That way I don't have to mess with custom AutomationPeers or anything. Still, it was a good learning exercise.