A Testable Custom Tab Control
In adding some user interface tests to a .NET windows application, I stumbled upon a custom TabControl whose children were not accessible to the Microsoft CodedUI libraries. Inspect.exe (a tool for browsing the control tree of windows application) showed TabItems without content.
I was able to restore testability to the application with a minor change to the custom TabControl, and by implementing custom AutomationPeers. The complete source code is shown below, but first, a look at how the problem was created, and an explanation of the solution.
The problematic TabControl came from this post on StackOverflow: Stop TabControl From Recreating It’s Children. The TabControlEx class prevents the TabControl form unloading it’s children from memory when the user switches between TabItems. To accomplish this, the TabControl has been changed from a type of ContentPresenter to a type of Panel. The standard TabControl copies the TabItem content into it’s own SelectedContent dependency property, which then makes the controls within the TabItem visible and accessible. TabControl is a ContentPresenters whose children are replaced when the user switches between tabs. TabControlEx instead adds TabItems to the ‘Children’ collection of the Panel. When the user switches between tabs, the selected tab is made visible, and the other tabs are collapsed.
The TabItemAutomationPeer which provides automation accessibility for the contents of TabItems loads it’s child controls by referring to the TabControl. Specifically, GetChildrenCore method casts the parent of the TabItem as a TabControl, and returns the children of the TabControl.SelectedContentPresenter property. The SelectedContentPresenter property calls GetTemplateChild to return the current state of the TabControl ContentPresenter using a hard coded TemplatePart Name. When the TabItemAutomationPeer attempts to return its children from the TabControlEx, none are found. As a result, the TabItems are visible within the control tree, but they do not appear to have any contents.
In order to return testability to this control class, I have added a new SelectedContentPresenter method which uses methods already provided on the class to return the ContentPresenter for the selected TabItem. This method replicates the interaction between the TabControl and the TabItemAutomationPeer, making use of the collection of ContentPresenters contained in the TabControlEx.
I have also overridden the TabControlAutomationPeer, and the TabItemAutomationPeer classes. The TabItemAutomationPeer was only modified to refer to the TabControlEx class and use the SelectedContentPresenter method on that class. The TabControlAutomationPeer was only modified to make use of the CustomTabItemAutomationPeer class, and the TabControlEx class was also modified to load the CustomTabControlAutomationPeer by overriding the OnCreateAutomationPeer event.
// Extended TabControl which saves the displayed item so you don't get the performance hit of
// unloading and reloading the VisualTree when switching tabs
// Obtained from http://eric.burke.name/dotnetmania/2009/04/26/22.09.28
// and made a some modifications so it reuses a TabItem's ContentPresenter when doing drag/drop operations
[TemplatePart(Name = "PART_ItemsHolder", Type = typeof(Panel))]
public class TabControlEx : System.Windows.Controls.TabControl
{
// Holds all items, but only marks the current tab's item as visible
private Panel _itemsHolder = null;
// Temporaily holds deleted item in case this was a drag/drop operation
private object _deletedObject = null;
public TabControlEx()
: base()
{
// this is necessary so that we get the initial databound selected item
this.ItemContainerGenerator.StatusChanged += ItemContainerGenerator_StatusChanged;
}
/// <summary>
/// Overrides OnCreateAutomationPeer to use CustomTabControlAutomationPeer..
/// </summary>
protected override AutomationPeer OnCreateAutomationPeer()
{
return new CustomTabControlAutomationPeer(this);
}
/// <summary>
/// if containers are done, generate the selected item
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
void ItemContainerGenerator_StatusChanged(object sender, EventArgs e)
{
if (this.ItemContainerGenerator.Status == GeneratorStatus.ContainersGenerated)
{
this.ItemContainerGenerator.StatusChanged -= ItemContainerGenerator_StatusChanged;
UpdateSelectedItem();
}
}
/// <summary>
/// get the ItemsHolder and generate any children
/// </summary>
public override void OnApplyTemplate()
{
base.OnApplyTemplate();
_itemsHolder = GetTemplateChild("PART_ItemsHolder") as Panel;
UpdateSelectedItem();
}
/// <summary>
/// when the items change we remove any generated panel children and add any new ones as necessary
/// </summary>
/// <param name="e"></param>
protected override void OnItemsChanged(NotifyCollectionChangedEventArgs e)
{
base.OnItemsChanged(e);
if (_itemsHolder == null)
{
return;
}
switch (e.Action)
{
case NotifyCollectionChangedAction.Reset:
_itemsHolder.Children.Clear();
if (base.Items.Count > 0)
{
base.SelectedItem = base.Items[0];
UpdateSelectedItem();
}
break;
case NotifyCollectionChangedAction.Add:
case NotifyCollectionChangedAction.Remove:
// Search for recently deleted items caused by a Drag/Drop operation
if (e.NewItems != null && _deletedObject != null)
{
foreach (var item in e.NewItems)
{
if (_deletedObject == item)
{
// If the new item is the same as the recently deleted one (i.e. a drag/drop event)
// then cancel the deletion and reuse the ContentPresenter so it doesn't have to be
// redrawn. We do need to link the presenter to the new item though (using the Tag)
ContentPresenter cp = FindChildContentPresenter(_deletedObject);
if (cp != null)
{
int index = _itemsHolder.Children.IndexOf(cp);
(_itemsHolder.Children[index] as ContentPresenter).Tag =
(item is TabItem) ? item : (this.ItemContainerGenerator.ContainerFromItem(item));
}
_deletedObject = null;
}
}
}
if (e.OldItems != null)
{
foreach (var item in e.OldItems)
{
_deletedObject = item;
// We want to run this at a slightly later priority in case this
// is a drag/drop operation so that we can reuse the template
this.Dispatcher.BeginInvoke(DispatcherPriority.DataBind,
new Action(delegate()
{
if (_deletedObject != null)
{
ContentPresenter cp = FindChildContentPresenter(_deletedObject);
if (cp != null)
{
this._itemsHolder.Children.Remove(cp);
}
}
}
));
}
}
UpdateSelectedItem();
break;
case NotifyCollectionChangedAction.Replace:
throw new NotImplementedException("Replace not implemented yet");
}
}
/// <summary>
/// update the visible child in the ItemsHolder
/// </summary>
/// <param name="e"></param>
protected override void OnSelectionChanged(SelectionChangedEventArgs e)
{
base.OnSelectionChanged(e);
UpdateSelectedItem();
}
/// <summary>
/// generate a ContentPresenter for the selected item
/// </summary>
void UpdateSelectedItem()
{
if (_itemsHolder == null)
{
return;
}
// generate a ContentPresenter if necessary
TabItem item = GetSelectedTabItem();
if (item != null)
{
CreateChildContentPresenter(item);
}
// show the right child
foreach (ContentPresenter child in _itemsHolder.Children)
{
child.Visibility = ((child.Tag as TabItem).IsSelected) ? Visibility.Visible : Visibility.Collapsed;
}
}
/// <summary>
/// create the child ContentPresenter for the given item (could be data or a TabItem)
/// </summary>
/// <param name="item"></param>
/// <returns></returns>
ContentPresenter CreateChildContentPresenter(object item)
{
if (item == null)
{
return null;
}
ContentPresenter cp = FindChildContentPresenter(item);
if (cp != null)
{
return cp;
}
// the actual child to be added. cp.Tag is a reference to the TabItem
cp = new ContentPresenter();
cp.Content = (item is TabItem) ? (item as TabItem).Content : item;
cp.ContentTemplate = this.SelectedContentTemplate;
cp.ContentTemplateSelector = this.SelectedContentTemplateSelector;
cp.ContentStringFormat = this.SelectedContentStringFormat;
cp.Visibility = Visibility.Collapsed;
cp.Tag = (item is TabItem) ? item : (this.ItemContainerGenerator.ContainerFromItem(item));
_itemsHolder.Children.Add(cp);
return cp;
}
/// <summary>
/// Provides the content Presenter to the TabItem for UI automation.
/// </summary>
/// <returns>ContentPresenter</returns>
internal ContentPresenter SelectedContentPresenter
{
get
{
return FindChildContentPresenter(GetSelectedTabItem());
}
}
/// <summary>
/// Find the CP for the given object. data could be a TabItem or a piece of data
/// </summary>
/// <param name="data"></param>
/// <returns></returns>
ContentPresenter FindChildContentPresenter(object data)
{
if (data is TabItem)
{
data = (data as TabItem).Content;
}
if (data == null)
{
return null;
}
if (_itemsHolder == null)
{
return null;
}
foreach (ContentPresenter cp in _itemsHolder.Children)
{
if (cp.Content == data)
{
return cp;
}
}
return null;
}
/// <summary>
/// copied from TabControl; wish it were protected in that class instead of private
/// </summary>
/// <returns></returns>
protected TabItem GetSelectedTabItem()
{
object selectedItem = base.SelectedItem;
if (selectedItem == null)
{
return null;
}
if (_deletedObject == selectedItem)
{
}
TabItem item = selectedItem as TabItem;
if (item == null)
{
item = base.ItemContainerGenerator.ContainerFromIndex(base.SelectedIndex) as TabItem;
}
return item;
}
}
/// <summary>
/// Extends the TabControlAutomationPeer to use the CustomItemAutomationPeer
/// </summary>
public class CustomTabControlAutomationPeer : TabControlAutomationPeer
{
/// <summary>
/// Initializes a new instance of the <see cref="CustomTabControlAutomationPeer"/> class.
/// </summary>
public CustomTabControlAutomationPeer(TabControl owner)
: base(owner)
{
}
/// <summary>
/// overrides GetClasNamecore to display TabControlEx.
/// </summary>
protected override string GetClassNameCore()
{
return "TabControlEx";
}
/// <summary>
/// overrides CreateItemAutomationPeer to returnCustomTabItemAutomationPeer
/// </summary>
/// <returns>ItemAutomationPeer</returns>
protected override ItemAutomationPeer CreateItemAutomationPeer(object item)
{
return new CustomTabItemAutomationPeer(item, this);
}
}
/// <summary>
/// Extends the TabItemAutomation peer to add child Automation peers from the TabControlEx
/// </summary>
public class CustomTabItemAutomationPeer :TabItemAutomationPeer
{
/// <summary>
/// Initializes a new instance of the <see cref="CustomTabItemAutomationPeer"/> class.
/// </summary>
public CustomTabItemAutomationPeer(object owner, TabControlAutomationPeer tabControlAutomationPeer)
: base(owner, tabControlAutomationPeer)
{
}
/// <summary>
/// overrides GetChildrenCore to return return children from the content presenters in a TabControlEx.
/// </summary>
protected override List<AutomationPeer> GetChildrenCore()
{
List<AutomationPeer> childrenCore = base.GetChildrenCore();
TabControlEx owner = base.ItemsControlAutomationPeer.Owner as TabControlEx;
if (owner == null)
{
return childrenCore;
}
ContentPresenter selectedContentPresenter = owner.SelectedContentPresenter;
if (selectedContentPresenter == null)
{
return childrenCore;
}
List<AutomationPeer> children = new FrameworkElementAutomationPeer(selectedContentPresenter).GetChildren();
if (children == null)
{
return childrenCore;
}
if (childrenCore == null)
{
return children;
}
childrenCore.AddRange(children);
return childrenCore;
}
}
Great Article. It helped me a lot. Thanks William