Blog

Creating an XNA Tree View Control

Oct 7

Written by:
Friday, October 07, 2011  RssIcon

This article is a step-by-step description of my first attempt to create a tree view control. I spent less than 4 hours of work on the control and this was the result:

image

In this article I will explain the development steps, as well as my thought process. At the end of the article you can download the source code.

Step 1: Know the DigitalRune Game UI Library

The first step is to become familiar with the DigitalRune Game UI library: documentation and blog articles, samples, source code – especially the source code of controls that have aspects similar to a tree view control. It is good to know the features and restrictions of the library. And it is also important to know the API of the other controls. The new control should be similar to use and should not confuse developers.

Needless to say that I skipped this step. But: Skipping this step is only allowed for the creators of the DigitalRune Game UI library! ;-)

Step 2: Research Tree View Controls

When I create a new control, I look for similar controls in Windows Forms and Silverlight/WPF. For the tree view control, I also looked at the Visual Studio solution explorer window – this is the tree view that most developers will know very closely. The goal is to create a new control that looks familiar for users and developers. I also try to find out how the control is implemented in WinForms, Silverlight/WPF, and any open-source libraries that I can find.

In the MSDN library I saw that a WPF TreeView control can be created in XAML like this:

<TreeView>
  <TreeViewItem Header="Employee1">
    <TreeViewItem Header="Jesper"/>
    <TreeViewItem Header="Aaberg"/>
    <TreeViewItem Header="12345"/>
  </TreeViewItem>
  <TreeViewItem Header="Employee2">
    <TreeViewItem Header="Dominik"/>
    <TreeViewItem Header="Paiha"/>
    <TreeViewItem Header="98765"/>
  </TreeViewItem>
</TreeView>

Our DigitalRune Game UI library is not as powerful as the XAML-technologies (Silverlight, WPF, Metro), but a similar structure should be possible in our library.

Step 3: The Initial Concept

Now, that my brain is full of information, I get out of the office into the fresh air, let the information sink in and develop a plan…

I will call the new control TreeView. – What a brilliant start for my design. I have the feeling this will turn out well…

The tree view control will have a collection of TreeViewItems. Each TreeViewItem must display data, therefore it could have a property “Header” of type String. But then the tree view could only display text :-(. Looking at the Visual Studio solution explorer, I realize that icons are really handy too. And what if the user wants to create a tree view that only displays images? – Our Button class has a similar problem. It solves the problem by being a ContentControl, where the Content can be any UIControl. Therefore, TreeViewItem.Header should also be of type UIControl.

A TreeViewItem also has a collection of nested Items, it can be selected, and can be expanded (child tree view items are visible) or collapsed (child tree view items are hidden).

How do we arrange the tree view items? They should obviously be stacked vertically and have an indentation on the left. And how do we organize the visual tree? (Side note: If you do not know the difference between the logical tree and the visual tree, please read the the “Visual Tree and Logical Tree” section in our documentation.) - There is no unique answer for this and I see several possible solutions. The TreeView control and the TreeViewItem could override OnMeasure() and OnArrange() to control the arrangement manually. For my first TreeView attempt, I decide to go a different route: The TreeView and the TreeViewItem will use an internal StackPanel that takes care of the arrangement. This could spare me some code. To create the indentation I use TreeViewItem.Padding property. This allows the user to define the desired indentation using the left padding value in the Theme.xml.

My TreeView and the TreeViewItem will derive from the UIControl class. Often it is easier to derive from an existing control class, but there does not seem to be suitable candidate for my new TreeView.

Step 4: Verify Concept

After I feel that I have enough information, I discuss my concept with a coworker. Only if I can explain my concept to him and if I can answer his questions, I can be sure that I have a good understanding of the problem and the aspired solution. The simple act of explaining the solution shines light on problems that haven’t been thought of before. We also discuss all the names of the public API members.

On a side note: I cannot stress enough how important it is to discuss designs in front of a whiteboard – even for the smallest teams; often even for seemingly trivial problems. The Way of the Whiteboard has definitely improved our engineering process.

And a message to everyone who writes code and APIs that should be useable by other developers: Please choose names for types, properties, methods, etc. very carefully! Code is written once – but it is read many, many times. In our company we review names in the public API very thoroughly. We discuss them until we find a name that is simple, consistent, descriptive and does not invoke the wrong connotations. We do not always succeed, but we always try.

After verifying the concept in the discussion, I have only started to understand the problem according to Donald Knuth:

“It has often been said that a person does not really understand something until he teaches it to someone else. Actually a person does not really understand something until after teaching it to a computer, i.e., express it as an algorithm." - Donald Knuth, in "American Mathematical Monthly," 81

So lets enter a new level of understanding and start coding.

(The whole process up to here took a little bit more than 1 hour.)

Step 5: Start Hacking

Project Setup

First I copy an existing sample project to have a solid starting point. I copy the InGameGuiSample, rename everything to CustomControlSample and replace the relevant GUIDs in the project file. In the code I remove everything I do not need.

Running the project this empty project displays an empty gray screen.

A New Control

Then I add a class TreeView that derives from UIControl:

public class TreeView : UIControl
{
  public TreeView()
  {
    Style = "TreeView";
  }
}

In the Theme.xml file, I define a new style:

<Style Name="TreeView" Inherits="UIControl">
  <Background>128;128;128;255</Background>
  <State Name="Default" />
  <State Name="Disabled" />
</Style>

In MyGameComponent.cs, where the UIScreen is created, I add a TreeView instance directly to the UIScreen:

var treeView = new TreeView
{
  X = 100,
  Y = 100,
  Width = 100,
  Height = 100,
};

_screen.Children.Add(treeView);

Running the Application produces this result:

image

This verifies that my setup works and the TreeView is picking up the style from the Theme.xml.

Adding TreeViewItems

Next, I add a TreeViewItem class. To both new classes I add an Items property where the user can add TreeViewItems, and the TreeViewItem gets a Header property. Whenever the Items or Header properties are changed, they should be added to the VisualChildren of the owning control. Here is the current code of the TreeView class:

public class TreeView : UIControl
{
  public NotifyingCollection<TreeViewItem> Items { get; private set; }


  public TreeView()
  {
    Style = "TreeView";

    Items = new NotifyingCollection<TreeViewItem>(false, false);
    Items.CollectionChanged += (s, e) => OnItemsChanged();
  }


  private void OnItemsChanged()
  {
    VisualChildren.Clear();

    foreach (var item in Items)
      VisualChildren.Add(item);

    InvalidateMeasure();
  }
}

 

The new TreeViewItem class:

public class TreeViewItem : UIControl
{
  public UIControl Header
  {
    get { return _header; }
    set
    {
      if (_header == value)
        return;

      _header = value;
      OnItemsChanged();
    }
  }
  private UIControl _header;


  public NotifyingCollection<TreeViewItem> Items { get; private set; }


  public TreeViewItem()
  {
    Style = "TreeViewItem";

    Items = new NotifyingCollection<TreeViewItem>(false, false);
    Items.CollectionChanged += (s, e) => OnItemsChanged();
  }


  private void OnItemsChanged()
  {
    VisualChildren.Clear();

    VisualChildren.Add(Header);

    foreach (var item in Items)
      VisualChildren.Add(item);

    InvalidateMeasure();
  }
}

The Items and the Header must be added to the VisualChildren collection to make them a part of the visual tree. Anything that is part of the visual tree will automatically receive input, will be part of the layout and will be rendered.

To test this code, I replace the code that adds the TreeView instance to the UIScreen and add some test data. (Most TreeViewItems use TextBlocks, but one uses an image for the Header.)

var treeView = new TreeView
{
  X = 10,
  Y = 10,
  Items =
    {
      new TreeViewItem
      {
        Header = new TextBlock { Text = "Item 1", },              
        Tag = "1",
      },
      new TreeViewItem
      {
        Header = new TextBlock { Text = "Item 2", },
        Tag = "2",
        Items =
        {
          new TreeViewItem
          {
            Header = new TextBlock { Text = "Item 2.1", },              
            Tag = "2.1",
          },
          new TreeViewItem
          {
            Header = new TextBlock { Text = "Item 2.2", },              
            Tag = "2.2",
            Items =
            {
              new TreeViewItem
              {
                Header = new TextBlock { Text = "Item 2.2.1", },              
                Tag = "2.2.1",
              },
              new TreeViewItem
              {
                Header = new TextBlock { Text = "Item 2.2.2", },              
                Tag = "2.2.2",
              },
            }
          },
          new TreeViewItem
          {
            Header = new TextBlock { Text = "Item 2.3", },              
            Tag = "2.3",
          },
        }
      },
      new TreeViewItem
      {
        Header = new TextBlock { Text = "Item 3 (The quick brown fox jumps over the lazy dog.)", },
        Tag = "3",
        Items =
        {
          new TreeViewItem
          {
            Header = new TextBlock { Text = "Item 3.1", },              
            Tag = "3.1",
          },
          new TreeViewItem
          {
            Header = new TextBlock { Text = "Item 3.2", },   
            Tag = "3.2",
          },
          new TreeViewItem
          {
            Header = new Image { Texture = theme.Texture, SourceRectangle = new Rectangle(214, 66, 16, 16), },
            Tag = "3.3",
          },
          new TreeViewItem
          {
            Header = new TextBlock { Text = "Item 3.4", },              
            Tag = "3.4",
          },
        }
      },
    }
};

_screen.Children.Add(treeView);

Running the project gives the following result:

image

The TreeView automatically determines its size and the TreeViewItems are rendered.

Arranging the Items

Now, we have to arrange them properly. I add a StackPanel to the TreeView class and the TreeViewItem class. When the controls are loaded, the internal panels are created and the Items and the TreeViewItem.Header are added to the panel:

private StackPanel _panel;

protected override void OnLoad()
{
  base.OnLoad();

  if (_panel == null)
  {
    _panel = new StackPanel();

    _panel.Children.Add(Header);   // (Only in TreeViewItem)
    foreach (var item in Items)
      _panel.Children.Add(item);

    VisualChildren.Add(_panel);
  }
}

The panel is added to the visual children of the TreeView/TreeViewItem. When the Header and Items change, we must now add them to the panel instead of the TreeView/TreeViewItem directly. The old OnItemsChanged() methods are replaced with this:

private void OnItemsChanged()
{
  if (_panel == null)
    return;

  _panel.Children.Clear();

  _panel.Children.Add(Header);  // (Only in TreeViewItem)
  foreach (var item in Items)
    _panel.Children.Add(item);

  InvalidateMeasure();
}

The panel will automatically add its children (Panel.Children) to its visual children collection (UIControl.VisualChildren). So the Items and Header end up in the visual tree again, but the new parent is the internal panel.

When I run the project, the items are arranged vertically. The horizontal indentation is still missing. As written above, I will use the Padding to the define the indentation. The padding can be set in a new the style for the TreeViewItem in the Theme.xml.

<Style Name="TreeViewItem" Inherits="UIControl">
  <Padding>20;0;0;0</Padding>
</Style>

Setting a Padding has no effect yet because the UIControl base class does not use this property. The padding of the TreeViewItem should be applied to the panel, which is the visual child of the TreeViewItem. I need to replace the line that creates the panel in TreeViewItem.OnLoad() with following code:

_panel = new StackPanel
{
  Margin = Padding,
};

var panelMargin = _panel.Properties.Get<Vector4F>(UIControl.MarginPropertyId);
var padding = this.Properties.Get<Vector4F>(UIControl.PaddingPropertyId);
padding.Changed += panelMargin.Change;

This code does the following: The Margin of the stack panel is initialized with the Padding of the parent TreeViewItem. If the padding is changed at runtime, the panel margin should be updated as well. Those two properties should always have the same value. This can be achieved by connecting the panel margin to the Changed event of the padding. (This is explained in more detail in the article UI Control Properties.)

Now, when I run the application, the TreeViewItems are indented. It is helpful to visualize the area of each control for debugging. For that I set a random background color in the TreeViewItem constructor:

Vector3F color = RandomHelper.Random.NextVector3F(0, 1);
Background = new Color(color.X, color.Y, color.Z, 0.4f);

Here is the current result:

image

Up to here I have spent less than 45 minutes coding. I think that is a pretty good result for a little amount of code and time!

Expanding and Collapsing TreeViewItems

Time to make it interactive. The TreeViewItems should collapse or expand when the area left of the TreeViewItem Header is clicked. I add an IsExpanded UI control property to the TreeViewItem class:

public static readonly int IsExpandedPropertyId = CreateProperty(
  typeof(TreeViewItem), "IsExpanded", GamePropertyCategories.Default, null, true,
  UIPropertyOptions.AffectsMeasure);

public bool IsExpanded
{
  get { return GetValue<bool>(IsExpandedPropertyId); }
  set { SetValue(IsExpandedPropertyId, value); }
}

For occurring code patterns like this, we use Visual Studio code snippets. (Such snippets can be quickly created using the Snippet Designer.) In this case, we have a Boolean property that is marked with AffectsMeasure because collapsing/expanding the TreeViewItem should invalidate the current control size.

In the TreeViewItem constructor I attach an event handler to the Changed event of this UI control property:

var isExpandedProperty = Properties.Get<bool>(IsExpandedPropertyId);
isExpandedProperty.Changed += OnExpandedChanged;

The event handler toggles the visibility of the child items of the TreeViewItem:

private void OnExpandedChanged(object sender, GamePropertyEventArgs<bool> eventArgs)
{
  foreach (var item in Items)
    item.IsVisible = eventArgs.NewValue;
}

To make the TreeViewItem interactive, I override the OnHandleInput() method:

protected override void OnHandleInput(InputContext context)
{
  base.OnHandleInput(context);

  if (!InputService.IsMouseOrTouchHandled && InputService.IsPressed(MouseButtons.Left, false))
  {
    var headerHeight = Header != null ? Header.ActualHeight : 0;
    if (context.MousePosition.X > ActualX && context.MousePosition.X < ActualX + Padding.X
        && context.MousePosition.Y > ActualY && context.MousePosition.Y < ActualY + headerHeight)
    {
      IsExpanded = !IsExpanded;

      InputService.IsMouseOrTouchHandled = true;
    }
  }
}

The OnHandleInput() method calls the base implementation first, and then it checks if the mouse input has not been handled yet and if the left mouse button is pressed. If the mouse button is pressed in the padding area left of the Header, then IsExpanded flag is toggled.

A quick test shows that this works; we can already expand and collapse TreeViewItems that have children! But the clickable area is still empty. We should add some images, like arrows or plus/minus symbols.

Adding Some Images

I open the UITexture.png of the BlendBlue theme in Photoshop and add two triangle symbols for the expanded/collapsed state:

UITexture

These new images are used in the TreeViewItem style in Theme.xml:

<Style Name="TreeViewItem" Inherits="UIControl">
  <Padding>20;0;0;0</Padding>
  <State Name="Default">
  </State>
  <State Name="Expanded">
    <Image Source="114;72;13;8" Margin="4;6;0;0"/>
  </State>
  <State Name="Collapsed">
    <Image Source="127;68;8;13" Margin="8;3;0;0"/>
  </State>
</Style>

In the “Default” state, no images are drawn. This should be used for TreeViewItems without child items. The downward pointing triangle is used for expanded TreeViewItems, and the other triangle is used for collapsed ones.

A normal UIControl only has a “Default” and a “Disabled” state. We need to override the VisualState property of the TreeViewItem class to add our new states:

public override string VisualState
{
  get
  {
    if (Items.Count == 0)
      return "Default";

    if (IsExpanded)
      return "Expanded";

    return "Collapsed";
  }
}

This brings us to the following intermediate result:

image

Already looks like a tree view!

Making Items Selectable

The only thing missing is the function to select a TreeViewItem and mark it selected (using a blue rectangle). The TreeView control should track the current selected item, and only one item should be selected at a time (no multi-selections at first). The TreeView class gets a new property and a new method:

public TreeViewItem SelectedItem { get; private set; }

public void SelectItem(TreeViewItem item)
{
  SelectedItem = item;

  foreach (var descendant in UIHelper.GetDescendants(this).OfType<TreeViewItem>())
    descendant.IsSelected = (descendant == item);
}

The property shows the selected TreeViewItem, and the method allows to change the selected item. This method uses a LINQ method of our UIHelper class. This method enumerates all visual children of the current control. (Check out the UIHelper class, it has several other useful LINQ methods.) The IsSelected properties of all visual descendants that are of type TreeViewItems will be updated. – The IsSelected property is a new UI control property that we add to the TreeViewItem class:

public static readonly int IsSelectedPropertyId = CreateProperty(
  typeof(TreeViewItem), "IsSelected", GamePropertyCategories.Default, null, false,
  UIPropertyOptions.AffectsRender);

public bool IsSelected
{
  get { return GetValue<bool>(IsSelectedPropertyId); }
  set { SetValue(IsSelectedPropertyId, value); }
}

Further, we add a property to the TreeViewItem that returns the TreeView for this TreeViewItem. This is done again using the visual tree and a LINQ method of the UIHelper class:

public TreeView TreeView
{
  get { return UIHelper.GetAncestors(this).OfType<TreeView>().FirstOrDefault(); }
}

If the user clicks a TreeViewItem it should get selected automatically. Therefore, we add a few lines to the TreeViewItem.OnHandleInput() method:

protected override void OnHandleInput(InputContext context)
{
  base.OnHandleInput(context);

  if (!InputService.IsMouseOrTouchHandled && InputService.IsPressed(MouseButtons.Left, false))
  {
    var headerHeight = Header != null ? Header.ActualHeight : 0;
    if (context.MousePosition.X > ActualX && context.MousePosition.X < ActualX + Padding.X
        && context.MousePosition.Y > ActualY && context.MousePosition.Y < ActualY + headerHeight)
    {
      IsExpanded = !IsExpanded;

      InputService.IsMouseOrTouchHandled = true;
    }
    else
    {
      if (Header != null && Header.IsMouseOver && TreeView != null)
        TreeView.SelectItem(this);
    }
  }
}

This is enough to add basic selection support. But not quite, we still have to draw the selection…

Rendering Selected TreeViewItems

This gives me the opportunity to demonstrate how you can extend the default UIRenderer.

The UIRenderer uses several render callback methods to draw the different kinds of controls. The TreeView and the TreeViewItem do not have a special render callback, therefore the UIRenderer falls back to the default render callback for the UIControl base class.

We can add a custom render callback that supports TreeViewItems. It should draw selected TreeViewItems with a blue background. The simplest solution is to derive a custom renderer that adds a new render callback:

internal class MyUIRenderer : UIRenderer
{
  public MyUIRenderer(Microsoft.Xna.Framework.Game game, Theme theme)
    : base(game, theme)
  {
    RenderCallbacks.Add("TreeViewItem", RenderTreeViewItem);
  }


  public void RenderTreeViewItem(UIRenderContext context)
  {
    var treeViewItem = context.Control as TreeViewItem;
    if (treeViewItem != null && treeViewItem.IsSelected && treeViewItem.Header != null)
    {
      context.RenderTransform.Draw(
        SpriteBatch, 
        WhiteTexture, 
        treeViewItem.Header.ActualBounds, 
        null,
        Color.CornflowerBlue);
    }

    RenderCallbacks["UIControl"](context);
  }
}

It is very simple: The constructor registers a new render callback. The render callback draws a blue rectangle for selected TreeViewItems. A draw method of the current RenderTransform is used. This method will draw the rectangle at the correct position for scaled, rotated or translated controls.

When we use this renderer for the UIScreen instead of the normal UIRenderer, then we get our final TreeView:

image

Conclusion

The first version of my TreeView is finished. It is not perfect and not fully tested – but for a total development time of about 4 hours (not counting the time to write this article), this is a solid result.

You can download the final source code here. This source code contains some cleanups and a few more comments. It also shows how you can react to double-clicks on tree view items.

>>> Download TreeView source code. <<<

(Do not forget to unblock the downloaded ZIP file!) We will add this sample to the other DigitalRune Game UI samples in one of the next releases.

With more time there are several more features that should be added:

  • Add game pad and keyboard support.
  • Add a TreeViewItem.HeaderStyle property that is automatically applied to the Header (similar to ContentControl.ContentStyle property).
  • Maybe try to get rid of the internal stack panels and do the measuring and arranging manually. This would be more efficient.
  • Make the selection color configurable. Currently the custom UI renderer uses a hard-coded color.
  • Maybe get rid of the custom UI renderer and try to use images in the Theme.xml to draw the selection rectangle.
  • Test the TreeView within a ScrollViewer. When a new TreeViewItem is selected, it should call UIControl.BringIntoView() to make sure that it is visible in the scroll viewer.

The goal of this article was not to create a perfect tree view control – I only wanted to give you a bunch of information that let you start implementing the custom controls that you need. If you think this article is helpful or not, please let us know in the comments below.

4 comment(s) so far...


Gravatar

Re: Creating an XNA Tree View Control

The resulting TreeView control from this blogpost works great!
I use this custom control for integrating the hierarchical profiler inside the treeview ... realtime profiling with "drilldown" into the details. nice!
But there are more uses for me like making my ragdoll editor more visually appealing and easier usable. All those bones, joint and limits scream for a tree view representation of the data - it's hard to use if you have a grid of buttons in the UI.
The TreeView should really be a standard control inside digitalrune gameui.

By Laurentius on   Sunday, October 09, 2011
Gravatar

Re: Creating an XNA Tree View Control

I found one minor glitch.
The TreeView is placed inside a scrollviewer that is inside a groupbox (to get those nice rounded edge look :-)).
When I try to set a background color (other then transparent) for the controls the background color never fits the rounded edge of the groupbox exactly.
Maybe it's a bug in the groupbox - if a background color is set it gets drawn outside the groupbox rounded frame.
How can I get rid of that?

By Laurentius on   Sunday, October 09, 2011
Gravatar

Re: Creating an XNA Tree View Control

Hi Laurentius, the Background color always fills the whole rectangular area of a control. The GroupBox control does not know that there are any rounded edges. The rounded edges are simply create by an image in the Theme.xml.
To get a GroupBox where the interior is filled with a color you have to replace the current GroupBox image (in the UITexture.png) with a new image where the interior is filled.

By HelmutG on   Monday, October 10, 2011
Gravatar

Re: Creating an XNA Tree View Control

Adding a secondary style for a "lighter" colored groupbox in the theme and using this style for displaying a colored groupbox works like a charm ...
Thanks ;-)

By Laurentius on   Monday, October 10, 2011

Your name:
Gravatar Preview
Your email:
(Optional) Email used only to show Gravatar.
Your website:
Title:
Comment:
Security Code
CAPTCHA image
Enter the code shown above in the box below
Add Comment   Cancel