Building on some of the cute tricks of using a Viewport3D as a two-dimensional surface, we can actually devise a fully-bindable System.Windows.Controls.ItemsControl that renders to a Viewport3D instead of a Panel. It turns out to all be quite remarkably simple:
- Define a class, ItemsControl3D, that will be our magic Viewport3D-holding ItemsControl.
- Build a ControlTemplate that contains a Viewport3D with a named ModelVisual3D whose children will be modified as the ItemsControl sees fit.
- Override GetContainerForItemOverride() to provide instances of ItemsControl3DItem as the “container” for the individual items in the ItemsControl:
- Make the “container” item a zero-size, completely non-visible FrameworkElement.
- Create an ItemsControl3DItem.Geometry property; this Geometry3D object will be used to populate the ModelVisual3D in our Viewport3D.
- I additionally chose to implement container recycling (in some early drafts of ItemsControl3D, it cut processor usage down 25%).
First, the static constructor:
1 2 3 4 5 6 7 8 9 10 11 12 | static ItemsControl3D() { DefaultStyleKeyProperty.OverrideMetadata( typeof(ItemsControl3D), new FrameworkPropertyMetadata(typeof(ItemsControl3D))); ItemsPanelProperty.OverrideMetadata( typeof(ItemsControl3D), new FrameworkPropertyMetadata( new ItemsPanelTemplate( new FrameworkElementFactory(typeof(ItemsControl3D.ItemsPanel))))); } |
The first line should be familiar to those in the audience who have authored custom controls before; it merely indicates that somewhere, WPF should expect to find:
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 | <<b>Style x:Key="{x:Type ThreeDee:ItemsControl3D}"</b> TargetType="{x:Type ThreeDee:ItemsControl3D}"> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="{x:Type ThreeDee:ItemsControl3D}"> <Border> <Grid> <Viewport3D Name="PART_Viewport"> <Viewport3D.Camera> <OrthographicCamera Position="0,0,1" LookDirection="0,0,-1" UpDirection="0,1,0" Width="{Binding Path=ActualWidth, ElementName=PART_Viewport}"/> </Viewport3D.Camera> <ModelVisual3D> <ModelVisual3D.Content> <AmbientLight Color="White"> </ModelVisual3D.Content> </ModelVisual3D> <ModelVisual3D x:Name="PART_SceneRoot"/> </Viewport3D> <ItemsPresenter/> </Grid> </Border> </ControlTemplate> </Setter.Value> </Setter> </Style> |
The line after that (the overriding of the ItemsPanel metadata) indicates that for all ItemsControl3D, the default Panel presenting children for the panel should be ItemsControl3D.ItemsPanel. This is an inner class that we’ve defined that is specially crafted to hold the child elements of the ItemsControl.
In the style, we’ve given one of the ModelVisual3D children a name (PART_SceneRoot); that’s because in OnApplyTemplate(), we’re going to look for it and use that as the place to hold the 3D objects that we generate.
We override a trio of methods in order to perform basic container housekeeping. GetContainerForItemOverride() either creates a new container or reuses an existing one; ClearContainerForItemOverride(…) adds an unused ItemsControl3DItem back to the pool; IsItemsItsOwnContainerOverride(…) is useful to override if you wanted to manually create and add ItemsControl3DItem objects to the ItemsControl3D.
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 | private readonly Stack<ItemsControl3DItem> _unusedContainers = new Stack<ItemsControl3DItem>(); protected override DependencyObject GetContainerForItemOverride() { if (_unusedContainers.Count == 0) { return new ItemsControl3DItem(); } else { return _unusedContainers.Pop(); } } protected override void ClearContainerForItemOverride( DependencyObject element, object item) { _unusedContainers.Push((ItemsControl3DItem)element); } protected override bool IsItemItsOwnContainerOverride(object item) { return (item is ItemsControl3DItem); } |
Lastly, the actual “panel” that the ItemsControl thinks is doing the work:
1 2 3 4 5 6 7 8 9 10 11 12 13 | private new sealed class ItemsPanel : Panel { protected override Size MeasureOverride(Size availableSize) { var ic3d = (ItemsControl3D)GetItemsOwner(this); if (ic3d != null) { <b>ic3d.UpdateViewportChildren(InternalChildren);</b> } return Size.Empty; } } |
And that’s all the panel needs to do. The magic method call is actually the property accessor Panel.InternalChildren—internal code in Panel works together with ItemsControl in order to derive the appropriate children (this is ultimately what will cause GetContainerForItemOverride() and other methods to be called).
Lastly, the private method UpdateViewportChildren in ItemsControl3D:
private void UpdateViewportChildren(UIElementCollection children) { if (_sceneRoot == null) return; _sceneRoot.Children.Clear(); foreach (ItemsControl3DItem item in children) { var m = item.Model; if (m != null) { _sceneRoot.Children.Add(m); } } }
And in case you were wondering, ItemsControl3DItem at a high level:
1 2 3 4 5 6 7 8 | public class ItemsControl3DItem : FrameworkElement { public double X { get; set; } public double Y { get; set; } public Brush Background { get; set; } public Geometry3D Geometry { get; set; } public ModelVisual3D Model { get; } } |
The properties of ItemsControl3DItem (X, Y, Background, Geometry) are all used to determine the Model property.
You can use an ItemsControl3D in XAML as easily as any other subclass of ItemsControl:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | <ThreeDee:ItemsControl3D ItemsSource="{Binding ...}"> <ItemsControl.ItemContainerStyle> <Style TargetType="{x:Type ThreeDee:ItemsControl3DItem}"> <Setter Property="X" Value="{Binding Year}"/> <Setter Property="Y" Value="{Binding Profit}"/> <Setter Property="Background" Value="Blue"/> </Style> </ItemsControl.ItemContainerStyle> <!-- you could also hardcode children in the control just like ListBoxItems in a ListBox or ListViewItems in a ListView --> <!-- <font color="#555555"><ThreeDee:ItemsControl3DItem X="5" Y="6" Background="Blue"/> <ThreeDee:ItemsControl3DItem X="2" Y="3" Background="Red"/></font> --> </ThreeDee:ItemsControl3D> |
It should be noted that this exact technique can be used to generate full-blown three-dimensional visualizations with nothing more than basic ItemsControl-style bindings. Coupled with an abstract data model, you’ve got yourself a pretty canvas to paint with, and it’s pretty responsive to updates as well and doesn’t blow a hole through your CPU either. The sample app updates all of the values in a collection of 1,000 points, ten times a second, while using less than 10% of my two-year-old MacBook’s CPU.
The Sample Code: ItemsControl3D.zip
—DKT
Sept 24: Fixed the problems with the download. Of course, since the AssemblyInfo.cs file was missing, the [assembly:ThemeInfo(ResourceDictionaryLocation.SourceAssembly)] tag was missing too—that caused the default template that I defined for ItemsControl3D to not be found. This is fixed now.