Wednesday, June 20, 2007

Hacking in an Items property

I wanted to create a custom control with an Items property, but at present, it's hard to do that and have it work in Silverlight's XAML parser.

The workaround I’ve considered is the following: create a class which inherits from Canvas, and in the constructor, set the Visibility=Visibility.Hidden. Use the Children property of the Canvas as your Items property.

Here’s an example with a ComboBox API. (WARNING: the sample isn’t a 100% functional combobox.)
screenshot
In WPF, the one of the many ways to create a combobox is:
<ComboBox>
  <ComboBox.Items>
    <ComboBoxItem>Apples</ComboBoxItem>
    <ComboBoxItem>Oranges</ComboBoxItem>
    <ComboBoxItem>Peaches</ComboBoxItem>
    <ComboBoxItem>Pears</ComboBoxItem>
  </ComboBox.Items>
</ComboBox>


We can't quite get there in the Alpha, but using a hidden canvas, we can come close:

<sample:ComboBox>
  <sample:ComboBoxItems>
    <TextBlock>Apples</TextBlock>
    <TextBlock>Oranges</TextBlock>
    <TextBlock>Peaches</TextBlock>
    <TextBlock>Pears</TextBlock>
  </sample:ComboBoxItems>
</sample:ComboBox>


How to kinda-sorta create the “ComboBoxItems” property

What you can do is create a ComboBoxItems class, which derives from Canvas. In the constructor, set the Visibility = Collapsed (this means it will be zero sized and invisible).

public class ComboBoxItems : Canvas {
public ComboBoxItems() {
  this.Visibility = Visibility.Collapsed;
  this.Loaded += new EventHandler(ComboBoxItems_Loaded);
}
}


When the Loaded event fires on the ComboBoxItems object, you can snap the parent and cast that to a ComboBox. The child object needs to notify the parent control because on a Control, there's no public way to access the child objects.

On ComboBox, I created a property called ‘ItemsControl’, which ComboBoxItems will set in it's loaded event.

void ComboBoxItems_Loaded(object sender, EventArgs e) {
  ComboBox comboBox = this.Parent as ComboBox;
  if (comboBox != null) {
    comboBox.ItemsControl = this;
  }
}


And poof! Now the ComboBox can surf the ComboBoxItems.Children collection to do with it what it will.

When we show the dropdown, we'll shuffle over the items to the dropdown. This is what the MoveChildren function does:

internal void MoveChildren() {
  if (ItemsControl != null
  && ItemsControl.Children.Count > 0) {

    List<Visual> children =
      new List<Visual>(ItemsControl.Children.Count);
    foreach (Visual child in ItemsControl.Children) {
      children.Add(child);
    }
    foreach (Visual child in children) {
      ItemsControl.Children.Remove(child);
      _dropDownCanvas.Children.Add(child);
    }
  }
}


    Note that if you want to reparent, you need to call ComboBoxItems.Children.Remove(child) before calling SomeOtherCanvas.Children.Add(child). Just calling Add causes an exception to be raised.



Building the ComboBox itself

A combobox has three main pieces: a text area (named ‘choice’), a button (named ‘dropDownButton’) and a dropdown list (named ‘dropDown’). We can build up that control in the constructor:

public class ComboBox : Control {
public ComboBox() {
_comboCanvas = this.InitializeFromXaml(
@"<Canvas Width='120' Height='21'
  xmlns='http://schemas.microsoft.com/client/2007'
  xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'
  Background='white'>

    <Rectangle x:Name='Border'
    Stroke='#FF161D9C' StrokeThickness='1'
    Canvas.Left='0' Canvas.Top='0'
    Width='120' Height='21'/>

    <TextBlock x:Name='choice'
    Canvas.Left='2' Width='96'/>

    <Rectangle x:Name='dropDownButton'
    Fill='#FF72AFDF' Stroke='#FF161D9C'
    Canvas.Left='98' Canvas.Top='2'
    Width='20' Height='17'
    IsHitTestVisible='true' />

    <Path x:Name='dropDownArrow'
    Fill='#FF72AFDF' Stretch='Fill'
    Stroke='#FF161D9C'
    Data='M123,146 C128,153 130.25,160 134,145.75'
    Width='12' Height='9.285'
    IsHitTestVisible='false'
    Canvas.Left='102' Canvas.Top='5'/>

    <Canvas x:Name='dropDown' Visibility='Collapsed'
    Width='150' Height='100'
    Background='White'
    Canvas.Left='0' Canvas.Top='21'>

      <Rectangle x:Name='dropDownBorder'
      Stroke='#FF161D9C'
      Canvas.Left='0' Canvas.Top='0'
      Width='150' Height='100'
      IsHitTestVisible='true' />

    </Canvas>
</Canvas>") as Canvas;

…and then we can find the controls using _comboCanvas.FindName().

// find the parts we care about
_text = _comboCanvas.FindName("choice") as TextBlock;

_dropDownButton = _comboCanvas.FindName("dropDownButton") as Rectangle;
_dropDownButton.MouseLeftButtonUp += ToggleShowDropDown;

_dropDownCanvas = _comboCanvas.FindName("dropDown") as Canvas;

…and now when the dropDownButton is pushed, we toggle the visibility of the _dropDownCanvas. Before we do, we reparent the items from the ItemsControl.Children to the _dropDownCanvas using the MoveChildren function.

private void ToggleShowDropDown(object sender, MouseEventArgs e) {
  if (_dropDownCanvas.Visibility == Visibility.Visible) {
    _dropDownCanvas.Visibility = Visibility.Collapsed;
  }
  else {
  MoveChildren();
  _dropDownCanvas.Visibility = Visibility.Visible;
  }
}


    Note I wanted to reparent the ItemsControl directly to the _dropDownCanvas, but it threw an exception.


Provisos, etc. (read: why this particular sample is silly)

Besides the fact that there’s no text editing, keyboard support, mouse over effects, there’s a subtle issue of Z-Order (the way controls pile up on top of each other in the z-axis). Z-Order in Silverlight seems to be related to the order of the Children collection on the Canvas.

The dropdown may open behind other controls that are added before the combobox in the Canvas. It definitely would open behind any HTML overlays. For this reason it might be wise to use HTML controls overlaid on top instead.

But if you’re doing all silverlight stuff and perhaps you inserted the dropdown to the front of the Children collection on the main canvas of the page, this might be a good starting point for you.

Hopefully the techniques here are interesting for your own custom control.

Here's the entire source: [ combobox.cs comboboxitems.cs ]

No comments: