Saturday, August 9, 2008

Building a WPF Grid Control (Part 2 of ?)

So, last time (Building a WPF Grid Control (Part 1 of ?)) we described some of the data binding structures of our WPF grid control. I'm not going to provide implementations of those interfaces yet, instead concentrating on some of the interesting bits of the control's implementation. For the most part, building the control comes down to several bits of functionality - scrolling, rendering, mouse support, keyboard support, and data binding. We've already seen the data binding part, so next I want to concentrate on scrolling.

Building scrolling into the UI is not terribly difficult, and for the most part, the implementation can be done without actually dealing with the data binding interfaces at all. However, there are a few details that we need from the interfaces in order to have a go at scrolling - namely, we need to know how big the fixed and scrolling regions need to be. Therefore, we need a dummy implementation of IDimensionMetrics that give us some sizes to play with. Since we currently won't have fixed rows or fixed columns, and all the grid cares about for laying out the regions is the TotalSpace member of this interface, we should be able to get away with a really dumb implementation.

So, let's implement IDimensionMetrics as:

public class ReallyDumbMetrics : IDimensionMetrics
{
private double _TotalSpace;

public ReallyDumbMetrics(double totalSpace)
{
_TotalSpace = totalSpace;
}

#region IDimensionMetrics Members

public double GetSpace(int index)
{
throw new NotImplementedException();
}

public void SetSpace(int index, double space)
{
throw new NotImplementedException();
}

public double GetStart(int index)
{
throw new NotImplementedException();
}

public double TotalSpace
{
get { return _TotalSpace; }
}

public int Count
{
get { throw new NotImplementedException(); }
}

public event EventHandler SpaceChanged;

#endregion
}

I just realized that I forgot to describe the IGridBindings interface in my last post. This interface is very simple and is just a container for the various pieces of the bindings (selection info, dimensions, region render info) that allows a single point of binding for the grid control. The interface is defined as:

public interface IGridBindings
{
IRegionRenderInfo TopLeftRenderInfo { get; }
IRegionRenderInfo HScrollRenderInfo { get; }
IRegionRenderInfo VScrollRenderInfo { get; }
IRegionRenderInfo HVScrollRenderInfo { get; }

IDimensionMetrics FixedRowMetrics { get; }
IDimensionMetrics FixedColMetrics { get; }
IDimensionMetrics ScrollingRowMetrics { get; }
IDimensionMetrics ScrollingColMetrics { get; }

ISelectionInfo SelectionInfo { get; }

void Reorder(int[] oldPositions);
}

Since we'll be starting on our grid control now, it would be nice to have a IGridBindings implementation that will allow us to start working with the binding interfaces of the grid. So, let's go with a dummy implementation of GridBindings that uses our ReallyDumbMetrics implementation above. Here goes:

public class ReallyDumbGridBindings : IGridBindings
{
ReallyDumbMetrics _FixedRowMetrics;
ReallyDumbMetrics _FixedColMetrics;
ReallyDumbMetrics _ScrollingRowMetrics;
ReallyDumbMetrics _ScrollingColMetrics;

public ReallyDumbGridBindings(double fixedRowSize, double fixedColSize, double scrollRowSize, double scrollColSize)
{
_FixedRowMetrics = new ReallyDumbMetrics(fixedRowSize);
_FixedColMetrics = new ReallyDumbMetrics(fixedColSize);
_ScrollingRowMetrics = new ReallyDumbMetrics(scrollRowSize);
_ScrollingColMetrics = new ReallyDumbMetrics(scrollColSize);
}

#region IGridBindings Members

public IRegionRenderInfo TopLeftRenderInfo
{
get { throw new NotImplementedException(); }
}

public IRegionRenderInfo HScrollRenderInfo
{
get { throw new NotImplementedException(); }
}

public IRegionRenderInfo VScrollRenderInfo
{
get { throw new NotImplementedException(); }
}

public IRegionRenderInfo HVScrollRenderInfo
{
get { throw new NotImplementedException(); }
}

public IDimensionMetrics FixedRowMetrics
{
get { return _FixedRowMetrics; }
}

public IDimensionMetrics FixedColMetrics
{
get { return _FixedColMetrics; }
}

public IDimensionMetrics ScrollingRowMetrics
{
get { return _ScrollingRowMetrics; }
}

public IDimensionMetrics ScrollingColMetrics
{
get { return _ScrollingColMetrics; }
}

public ISelectionInfo SelectionInfo
{
get { throw new NotImplementedException(); }
}

public void Reorder(int[] oldPositions)
{
throw new NotImplementedException();
}

#endregion
}

Ok... I think we're ready to write some Grid code...

Region Implementation

Let's start with a simple implementation of a GridRegion base class that will provide the functionality for the four cell-containing regions in the grid UI. This class will derive from FrameworkElement just as our Grid does, and will participate in the layout system as usual (measure and arrange passes). For now, to avoid getting bogged down in the details of rendering rows and columns, we'll make it just draw big red ellipses in the regions.

Let's start with the GridRegion. We can begin with the following class:

internal class GridRegion: FrameworkElement
{
public GridRegion()
{
}
}

Now, for any FrameworkElement, we need to support Measure and Arrange layout passes. We also need to support the dimension metrics in order to obtain the sizes for our control. The dimension metrics additions to GridRegion will be:

private IDimensionMetrics _RowMetrics;
private IDimensionMetrics _ColMetrics;

private void _ReplaceMetrics(ref IDimensionMetrics target, IDimensionMetrics source)
{
if (target != null)
target.SpaceChanged -= Metrics_SpaceChanged;
target = source;
if (target != null)
target.SpaceChanged += Metrics_SpaceChanged;
}

public void SetBindings(IDimensionMetrics rowMetrics, IDimensionMetrics colMetrics)
{
_ReplaceMetrics(ref _RowMetrics, rowMetrics);
_ReplaceMetrics(ref _ColMetrics, colMetrics);
InvalidateMeasure();
InvalidateVisual();
}

void Metrics_SpaceChanged(object sender, EventArgs e)
{
InvalidateMeasure();
InvalidateVisual();
}

The basic idea here is the SetBindings method, used by the EditorGrid (that owns the GridRegion objects) to initialize the RowMetrics and ColMetrics properties. Each time the Bindings property of the EditorGrid is changed (it's the property that holds the IGridBindings interface reference) the grid will call SetBindings on each of the four regions.

We also need to support the layout and arrange passes for our control. Our container (the grid) will decide the layout of our control, all we need to do is request as much space as it is willing to give us (by returning availableSize from the MeasureOverride method as follows).

protected override Size MeasureOverride(Size availableSize)
{
return availableSize;
}

This same functionality could possibly be achieved in another way, but this was the easiest way that I found. I suspect that setting the alignment properties to 'stretch' might have worked, but it didn't seem to (or at least I don't remember it working when I thought I tried it).

Grid Implementation

Ok, we can now start implementing the grid control itself. Let's start with this class, similar to the GridRegion we just completed:

public class EditorGrid: FrameworkElement
{
GridRegion _TopLeftNonScroll;
GridRegion _HScrollRegion;
GridRegion _VScrollRegion;
GridRegion _HVScrollRegion;

public EditorGrid()
{
_TopLeftNonScroll = new GridRegion();
_HScrollRegion = new GridRegion();
_VScrollRegion = new GridRegion();
_HVScrollRegion = new GridRegion();
}

private IGridBindings _Bindings;
public IGridBindings Bindings
{
get { return _Bindings; }
set
{
if (_Bindings != value)
{
if (_Bindings != null)
{
_Bindings.FixedColMetrics.SpaceChanged -= Metrics_SpaceChanged;
_Bindings.FixedRowMetrics.SpaceChanged -= Metrics_SpaceChanged;
_Bindings.ScrollingColMetrics.SpaceChanged -= Metrics_SpaceChanged;
_Bindings.ScrollingRowMetrics.SpaceChanged -= Metrics_SpaceChanged;
}
_Bindings = value;
if (_Bindings != null)
{
_Bindings.FixedColMetrics.SpaceChanged += Metrics_SpaceChanged;
_Bindings.FixedRowMetrics.SpaceChanged += Metrics_SpaceChanged;
_Bindings.ScrollingColMetrics.SpaceChanged += Metrics_SpaceChanged;
_Bindings.ScrollingRowMetrics.SpaceChanged += Metrics_SpaceChanged;
}
_TopLeftNonScroll.SetBindings(_Bindings.FixedRowMetrics, _Bindings.FixedColMetrics);
_HScrollRegion.SetBindings(_Bindings.FixedRowMetrics, _Bindings.ScrollingColMetrics);
_VScrollRegion.SetBindings(_Bindings.ScrollingRowMetrics, _Bindings.FixedColMetrics);
_HVScrollRegion.SetBindings(_Bindings.ScrollingRowMetrics, _Bindings.ScrollingColMetrics);

InvalidateMeasure();
InvalidateVisual();
}
}
}

void Metrics_SpaceChanged(object sender, EventArgs e)
{
InvalidateMeasure();
InvalidateVisual();
}
}

As you can see, the bindings on the regions are set to different metrics depending on their locations in the grid. The TopLeftNonScroll region uses 'Fixed' metrics for both rows and columns, the HScrollRegion uses 'Scrolling' for columns and 'Fixed' for rows, and so on.

We now need to discuss the layout of the children of the editor grid. We also need to add scrollbars and the other non-cellular regions. For the moment, we'll ignore the other regions and the scrollbars. Let's just get the cellular regions in place first. In order to do all the layout stuff, we'll create a helper class that will make things easier for us. I'll call this class LayoutMetrics and define it as follows:

internal class LayoutMetrics
{
public Rect vscroll_rect;
public Rect hscroll_rect;
public Rect hscrollR_rect;
public Rect vscrollR_rect;
public Rect hvscrollR_rect;
public Rect topleft_rect;
public Rect topright_rect;
public Rect botleft_rect;
public Rect botright_rect;

public LayoutMetrics(Size size, double vscroll_width, double hscroll_height, double fixedRowHeight, double fixedColWidth)
{
vscroll_rect =
new Rect(size.Width - vscroll_width,
fixedRowHeight,
vscroll_width,
Math.Max(0, size.Height - hscroll_height - fixedRowHeight));

hscroll_rect =
new Rect(fixedColWidth,
size.Height - hscroll_height,
Math.Max(0, size.Width - vscroll_width - fixedColWidth),
hscroll_height);

hscrollR_rect = hscroll_rect;
hscrollR_rect.Y = 0;
hscrollR_rect.Height = fixedRowHeight + 1;

vscrollR_rect = vscroll_rect;
vscrollR_rect.X = 0;
vscrollR_rect.Width = fixedColWidth + 1;

hvscrollR_rect =
new Rect(hscroll_rect.X, vscroll_rect.Y,
hscroll_rect.Width, vscroll_rect.Height);

topleft_rect =
new Rect(new Size(fixedColWidth + 1, fixedRowHeight + 1));
topright_rect =
new Rect(vscroll_rect.X, 0, vscroll_rect.Width, hscrollR_rect.Height - 1);
botleft_rect =
new Rect(0, hscroll_rect.Y, vscrollR_rect.Width - 1, hscroll_rect.Height);
botright_rect =
new Rect(vscroll_rect.X, hscroll_rect.Y, vscroll_rect.Width, hscroll_rect.Height);
}
}

The main idea of this class is to break the space occupied by the Grid into the component rectangles. For now, we'll supply some dummy values for the sizes of the horizontal and vertical scrollbars. Now, given this class, we can implement our 'measure' and 'arrange' methods for the WPF layout system. We do so (on our EditorGrid class) as follows:

protected override Size MeasureOverride(Size availableSize)
{
// for now, fake the sizes of the scroll bars just to reserve some space.
LayoutMetrics m = new LayoutMetrics(availableSize,
14, // vscroll_width
14, // hscroll_width
_Bindings.FixedRowMetrics.TotalSpace,
_Bindings.FixedColMetrics.TotalSpace);

_TopLeftNonScroll.Measure(m.topleft_rect.Size);
_HScrollRegion.Measure(m.hscrollR_rect.Size);
_VScrollRegion.Measure(m.vscrollR_rect.Size);
_HVScrollRegion.Measure(m.hvscrollR_rect.Size);

return availableSize;
}

protected override Size ArrangeOverride(Size finalSize)
{
// for now, fake the sizes of the scroll bars just to reserve some space.
LayoutMetrics m = new LayoutMetrics(finalSize,
14, // vscroll_width
14, // hscroll_width
_Bindings.FixedRowMetrics.TotalSpace,
_Bindings.FixedColMetrics.TotalSpace);

_TopLeftNonScroll.Arrange(m.topleft_rect);
_HScrollRegion.Arrange(m.hscrollR_rect);
_VScrollRegion.Arrange(m.vscrollR_rect);
_HVScrollRegion.Arrange(m.hvscrollR_rect);

return finalSize;
}

Now, we need to add visual tree support to our grid control. In order to do this, we need a few features - first, we need to add the regions to the visual tree by calling AddVisualChild on our grid visual. Second, we need to override the 'render list' method & property GetVisualChild and VisualChildrenCount respectively. The first (calling AddVisualChild) we do by adding the following lines to the constructor (after the creation of the regions):

AddVisualChild(_TopLeftNonScroll);
AddVisualChild(_HScrollRegion);
AddVisualChild(_VScrollRegion);
AddVisualChild(_HVScrollRegion);

Now that we have those lines in the constructor, we need to implement the 'rendering' functionality. The easiest way to do this is either with a VisualCollection, or since in our case we have a fixed list, just an array of Visuals. The GetVisualChild method must return visuals in the order in which they should be rendered, and we want our regions to render in the following order: HVScrollRegion, HScrollRegion, VScrollRegion, TopLeftNonScroll. We will add a field to EditorGrid class that is an array of visuals (Visual[]) called _Visuals, and initialize it in the constructor (after the four lines above) as follows:

_Visuals = new Visual[]
{
_HVScrollRegion,
_HScrollRegion,
_VScrollRegion,
_TopLeftNonScroll
};

Additionally, we need to implement the GetVisualChild method and VisualChildrenCount property as follows:

protected override Visual GetVisualChild(int index)
{
return _Visuals[index];
}

protected override int VisualChildrenCount
{
get { return _Visuals.Length; }
}

We now need to implement rendering in our GridRegion and then we'll have something we can start messing with. Here's the implementation of the OnRender method for the GridRegion control.

protected override void OnRender(DrawingContext drawingContext)
{
if (_RowMetrics == null _ColMetrics == null)
return;

double xmid = _ColMetrics.TotalSpace / 2;
double ymid = _RowMetrics.TotalSpace / 2;

drawingContext.PushClip(new RectangleGeometry(new Rect(RenderSize)));
drawingContext.DrawEllipse(Brushes.Red, null, new Point(xmid, ymid), xmid, ymid);
drawingContext.Pop();
}

Next time, we'll work on getting some scrolling features working.

2 comments:

Anonymous said...

Hi there,

Many thanks for sharing your ideas for the grid control. It has been a long time since you last post, I was wondering what happen to the project, have you given up creating you own grid control or has it moved somewhere else.

I'm moving to WPF but found very disappointing the design of the DataGrid control, I just started using my old favorite, FlexGrid for WPF.

Thanks again,

Israel

Kelly Leahy said...

Hi Israel,

I'm sorry, things got busy for me and blogging fell behind. We successfully implemented our grid control, and after a few performance missteps, we've grown very happy with its current implementation. I'm not sure when I'll get back to blogging about it, so if you have questions, feel free to post them.