HexGrid

0
67

Introduction

WPF panels arrange elements in rectangular areas and most WPF elements have rectangular shape. HexGrid project started as an attempt to create a custom shaped control and evolved into a hexagonal control and a panel which can arrange them.

HexGrid board

HexItem

HexItem is a simple ContentControl with a hexagonal shape. It has only one additional property Orientation (Horizontal or Vertical) which determines the form of a hexagon:

HexItem

public class HexItem : ListBoxItem
{ static HexItem() { DefaultStyleKeyProperty.OverrideMetadata(typeof(HexItem), new FrameworkPropertyMetadata(typeof(HexItem))); } public static readonly DependencyProperty OrientationProperty = HexGrid.OrientationProperty.AddOwner(typeof(HexItem)); public Orientation Orientation { get { return (Orientation) GetValue(OrientationProperty); } set { SetValue(OrientationProperty, value); } }
}

HexItem is derived from ListBoxItem because it gives selection support (IsSelected property) out-of-box.

Orientation property is declared in HexGrid class and HexItem shares the property definition. Here is Orientation DP code:

public static readonly DependencyProperty OrientationProperty = DependencyProperty.RegisterAttached("Orientation", typeof(Orientation), typeof(HexGrid), new FrameworkPropertyMetadata(Orientation.Horizontal, FrameworkPropertyMetadataOptions.AffectsMeasure | FrameworkPropertyMetadataOptions.AffectsArrange | FrameworkPropertyMetadataOptions.Inherits)); public static void SetOrientation(DependencyObject element, Orientation value)
{ element.SetValue(OrientationProperty, value);
} public static Orientation GetOrientation(DependencyObject element)
{ return (Orientation)element.GetValue(OrientationProperty);
}

As you can see it has FrameworkPropertyMetadataOptions.Inherits attribute which means that user can set Orientation value for HexGrid and all HexItems inside that HexGrid will get the same value via DP value inheritance (HexGrid and nested HexItems should have the same Orientation because they won’t make a nice honeycomb pattern otherwise).

HexItem hexagonal shape is configurated in a template in Generic.xaml. Template consists of two Grids with hexagonal Clip geometry (one (“hexBorder”) represents a border, another (“hexContent”) is a background cover) with a ContentPresenter:

<converters:HexClipConverter x:Key="ClipConverter"/> 
<Style TargetType="{x:Type local:HexItem}"> <Setter Property="Background" Value="CornflowerBlue"/> <Setter Property="BorderBrush" Value="Black"/> <Setter Property="BorderThickness" Value="4"/> <Setter Property="HorizontalContentAlignment" Value="Center"/> <Setter Property="VerticalContentAlignment" Value="Center"/> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="local:HexItem"> <Grid Name="hexBorder" Background="{TemplateBinding BorderBrush}"> <Grid.Clip> <MultiBinding Converter="{StaticResource ClipConverter}"> <Binding Path="ActualWidth" ElementName="hexBorder"/> <Binding Path="ActualHeight" ElementName="hexBorder"/> <Binding Path="Orientation" RelativeSource="{RelativeSource TemplatedParent}"/> </MultiBinding> </Grid.Clip> <Grid Name="hexContent"
                          Background="{TemplateBinding Background}"
                          Margin="{TemplateBinding BorderThickness}" VerticalAlignment="Stretch" HorizontalAlignment="Stretch"> <Grid.Clip> <MultiBinding Converter="{StaticResource ClipConverter}"> <Binding Path="ActualWidth" ElementName="hexContent"/> <Binding Path="ActualHeight" ElementName="hexContent"/> <Binding Path="Orientation" RelativeSource="{RelativeSource TemplatedParent}"/> </MultiBinding> </Grid.Clip> <ContentPresenter VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
                                          HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
                                          ClipToBounds="True"

                                          Margin="{TemplateBinding Padding}"
                                          Content="{TemplateBinding Content}"
                                          ContentTemplate="{TemplateBinding ContentTemplate}"/> </Grid> </Grid> <ControlTemplate.Triggers> <Trigger Property="IsSelected" Value="True"> <Setter Property="BorderBrush" Value="Gold"/> </Trigger> </ControlTemplate.Triggers> </ControlTemplate> </Setter.Value> </Setter>
</Style>

Hexagonal geometry is created by HexClipConverter based on element dimensions and orientation:

public class HexClipConverter: IMultiValueConverter
{ public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) { double w = (double)values[0]; double h = (double)values[1]; Orientation o = (Orientation) values[2]; if (w <= 0 || h <= 0) return null; PathFigure figure = o == Orientation.Horizontal ? new PathFigure { StartPoint = new Point(0, h*0.5), Segments = { new LineSegment {Point = new Point(w*0.25, 0)}, new LineSegment {Point = new Point(w*0.75, 0)}, new LineSegment {Point = new Point(w, h*0.5)}, new LineSegment {Point = new Point(w*0.75, h)}, new LineSegment {Point = new Point(w*0.25, h)}, } } : new PathFigure { StartPoint = new Point(w*0.5, 0), Segments = { new LineSegment {Point = new Point(w, h*0.25)}, new LineSegment {Point = new Point(w, h*0.75)}, new LineSegment {Point = new Point(w*0.5, h)}, new LineSegment {Point = new Point(0, h*0.75)}, new LineSegment {Point = new Point(0, h*0.25)}, } }; return new PathGeometry { Figures = { figure } }; } public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) { throw new NotImplementedException(); }

HexList

HexList is a Selector ItemsControl derived from ListBox (with selection support out-of-box). It overrides item container type and creates HexItems instead of ListBoxItems:

protected override bool IsItemItsOwnContainerOverride(object item)
{ return (item is HexItem);
} protected override DependencyObject GetContainerForItemOverride()
{ return new HexItem();
}

HexList uses HexGrid as default ItemsPanel (set in HexList style in Generic.xaml)

<Setter Property="ItemsPanel"> <Setter.Value> <ItemsPanelTemplate> <local:HexGrid ColumnCount="{Binding Path=ColumnCount, RelativeSource={RelativeSource AncestorType=ListBox}}"
                           RowCount="{Binding Path=RowCount, RelativeSource={RelativeSource AncestorType=ListBox}}"
                           Background="{Binding Path=Background, RelativeSource={RelativeSource AncestorType=ListBox}}"/> </ItemsPanelTemplate> </Setter.Value>
</Setter>

Similar to HexItem, HexList shares with HexGrid definition of Orientation, RowCount and ColumnCount dependency properties. ItemsPanel RowCount and ColumnCount properties are bound to HexList properties and users can set them only for HexList without repeating ItemsPanelTemplate.

HexGrid

HexGrid is a WPF Panel designed to arrange elements (primarily hexagonal HexItems) in honeycomb pattern. Depending on Orientation the pattern looks differently.

HexGrid declares 3 dependency properties which affect arrange:

#region Orientation
public static readonly DependencyProperty OrientationProperty = DependencyProperty.RegisterAttached("Orientation", typeof(Orientation), typeof(HexGrid), new FrameworkPropertyMetadata(Orientation.Horizontal, FrameworkPropertyMetadataOptions.AffectsMeasure | FrameworkPropertyMetadataOptions.AffectsArrange | FrameworkPropertyMetadataOptions.Inherits)); public static void SetOrientation(DependencyObject element, Orientation value)
{ element.SetValue(OrientationProperty, value);
} public static Orientation GetOrientation(DependencyObject element)
{ return (Orientation)element.GetValue(OrientationProperty);
} public Orientation Orientation
{ get { return (Orientation) GetValue(OrientationProperty); } set { SetValue(OrientationProperty, value); }
}
#endregion

public static readonly DependencyProperty RowCountProperty = DependencyProperty.Register("RowCount", typeof (int), typeof (HexGrid), new FrameworkPropertyMetadata(1, FrameworkPropertyMetadataOptions.AffectsMeasure | FrameworkPropertyMetadataOptions.AffectsArrange), ValidateCountCallback); public int RowCount
{ get { return (int) GetValue(RowCountProperty); } set { SetValue(RowCountProperty, value); }
} public static readonly DependencyProperty ColumnCountProperty = DependencyProperty.Register("ColumnCount", typeof (int), typeof (HexGrid), new FrameworkPropertyMetadata(1, FrameworkPropertyMetadataOptions.AffectsMeasure | FrameworkPropertyMetadataOptions.AffectsArrange), ValidateCountCallback); public int ColumnCount
{ get { return (int) GetValue(ColumnCountProperty); } set { SetValue(ColumnCountProperty, value); }
} private static bool ValidateCountCallback(object value)
{ if (value is int) { int count = (int)value; return count > 0; } return false;
}

HexGrid is similar to UniformGrid. It splits available space into predefined number of rows (RowCount) and columns (ColumnCount) and each child element gets the same area size during arrange. Unlike UniformGrid it reuses Grid.Row and Grid.Column attached properties to position child elements in appropriate cells (similar to Grid).

HexGrid Measure

protected override Size MeasureOverride(Size availableSize)
{ double w = availableSize.Width; double h = availableSize.Height; if (Double.IsInfinity(w) || Double.IsInfinity(h)) { h = 0; w = 0; foreach (UIElement e in InternalChildren) { e.Measure(availableSize); var s = e.DesiredSize; if (s.Height > h) h = s.Height; if (s.Width > w) w = s.Width; } if (Orientation == Orientation.Horizontal) return new Size(w*(ColumnCount * 3 + 1)/4, h*(RowCount * 2 + 1)/2); return new Size(w*(ColumnCount * 2 + 1)/2, h*(RowCount * 3 + 1)/4); } return availableSize;
}

If at least one dimension is Infinity, HexGrid gets max height and max width of child elements and multiply them to RowCount and ColumnCount to get total size. Otherwise HexGrid uses available size.

HexGrid Arrange

protected override Size ArrangeOverride(Size finalSize)
{ bool first, last; HasShift(out first, out last); Size hexSize = GetHexSize(finalSize); double columnWidth, rowHeight; if (Orientation == Orientation.Horizontal) { rowHeight = 0.50 * hexSize.Height; columnWidth = 0.25 * hexSize.Width; } else { rowHeight = 0.25 * hexSize.Height; columnWidth = 0.50 * hexSize.Width; } UIElementCollection elements = base.InternalChildren; for (int i = 0; i < elements.Count; i++) { if (elements[i].Visibility == Visibility.Collapsed) continue; ArrangeElement(elements[i], hexSize, columnWidth, rowHeight, first); } return finalSize;
}

I will explain HexGrid arrange on the example of Vertical orientation. To simplify explanation I made a sample with visible arrange lines:

VerticalArrange

As you can see vertical HexGrid space is split in columns of equal width (W) and rows with height H or 2*H. Each hex takes 2 columns and 4 rows and also adjacent hexes from different rows overlap in 1 column and 1 row. Total number of arrange columns is ColumnCount * 2 + 1. Total number of arrange rows is RowCount * 3 + 1.

If I hide hex #1 from HexGrid there will be empty gray space on the left side. To avoid this situation brown hexes should be positioned closer to the left. Hex #2 is a similar case on the right side. During Arrange void HasShift(out bool first, out bool last) method determines if the first or last arrange column can be ignored. HasShift method for Vertical orientation:

private void HasShift(out bool first, out bool last)
{ if (Orientation == Orientation.Horizontal) HasRowShift(out first, out last); else HasColumnShift(out first, out last);
} private void HasColumnShift(out bool firstColumn, out bool lastColumn)
{ firstColumn = lastColumn = true; UIElementCollection elements = base.InternalChildren; for (int i = 0; i < elements.Count && (firstColumn || lastColumn); i++) { var e = elements[i]; if (e.Visibility == Visibility.Collapsed) continue; int row = GetRow(e); int column = GetColumn(e); int mod = row % 2; if (column == 0 && mod == 0) firstColumn = false; if (column == ColumnCount - 1 && mod == 1) lastColumn = false; }
}

GetHexSize method computes final hex size in HexGrid. GetHexSize checks if shift is possible and then splits available space into appropriate number of arrange rows and columns. Each child element will get the height of 2 arrange rows and the width of 4 arrange columns. However if that size is less than MinHeight or MinWidth for any of childs, hex size will be increased to MinHeight/MinWidth to fit them even if it means to go out of arrange bounds. When HexGrid is used as HexList ItemsPanel this will cause HexList to activate scrollbars.

private Size GetHexSize(Size gridSize)
{ double minH = 0; double minW = 0; foreach (UIElement e in InternalChildren) { var f = e as FrameworkElement; if (f != null) { if (f.MinHeight > minH) minH = f.MinHeight; if (f.MinWidth > minW) minW = f.MinWidth; } } bool first, last; HasShift(out first, out last); var possibleSize = GetPossibleSize(gridSize); double possibleW = possibleSize.Width; double possibleH = possibleSize.Height; var w = Math.Max(minW, possibleW); var h = Math.Max(minH, possibleH); return new Size(w, h);
} private Size GetPossibleSizeVertical(Size gridSize, bool first, bool last)
{ int columns = ((first ? 0 : 1) + 2*ColumnCount - (last ? 1 : 0)); double w = 2 * (gridSize.Width / columns); int rows = 1 + 3*RowCount; double h = 4 * (gridSize.Height / rows); return new Size(w, h);
}

Arrange for HexGrid with Horizontal orientation is symmetric (in formulas rows switch with columns, width switches with height).

HorizontalArrange

HexGrid examples

Circles

HexGrid is designed for hexagons but other elements can fit nicely as well (though they overlap without margin).

<hx:HexGrid Margin="20"
            Orientation="Vertical"
            RowCount="3" ColumnCount="3"> <Ellipse Grid.Row="0" Grid.Column="1" Fill="Purple"/> <Ellipse Grid.Row="0" Grid.Column="2" Fill="DarkOrange"/> <Ellipse Grid.Row="1" Grid.Column="0" Fill="Blue"/> <Ellipse Grid.Row="1" Grid.Column="1" Fill="Red"/> <Ellipse Grid.Row="1" Grid.Column="2" Fill="Yellow"/> <Ellipse Grid.Row="2" Grid.Column="1" Fill="Cyan"/> <Ellipse Grid.Row="2" Grid.Column="2" Fill="Green"/>
</hx:HexGrid>

Office color selector

This list with colors is similar to the one used in MS Word to select text color. Click on a color hex and list background will get the same color.

<hx:HexList Name="HexColors" Orientation="Vertical"
            Grid.Row="1"
            Padding="10"
            SelectedIndex="0"
            Background="{Binding Path=SelectedItem.Background, RelativeSource={RelativeSource Self}}"
            RowCount="5" ColumnCount="5"> <hx:HexItem Grid.Row="0" Grid.Column="1" Background="#006699"/> <hx:HexItem Grid.Row="0" Grid.Column="2" Background="#0033CC"/> <hx:HexItem Grid.Row="0" Grid.Column="3" Background="#3333FF"/> 

HexColorSelector

A question: should I add all colors and make HexColorSelector control? What do you think?

Hexagonal menu

A group of buttons (7 total) is arranged in hexagonal form. May be an interesting replacement for horizontal/vertical toolbars with buttons.

<hx:HexGrid Grid.Row="1" Grid.Column="1"
            RowCount="3" ColumnCount="3" Orientation="Horizontal"> <hx:HexItem Grid.Row="0" Grid.Column="1" Content="2"/> <hx:HexItem Grid.Row="1" Grid.Column="0" Content="1"/> <hx:HexItem Grid.Row="1" Grid.Column="1" Content="0" Background="Gold"/> <hx:HexItem Grid.Row="1" Grid.Column="2" Content="3"/> <hx:HexItem Grid.Row="2" Grid.Column="0" Content="6"/> <hx:HexItem Grid.Row="2" Grid.Column="1" Content="5"/> <hx:HexItem Grid.Row="2" Grid.Column="2" Content="4"/>
</hx:HexGrid>

HexGrid helper methods

Hex controls can be used to create games (in fact I’m making a few simple games as a proof of a concept). Hexagonal board often provides more options than rectangular. However it makes controller logic more complex.

On a rectangular board adjacent cells have +1/-1 delta in X or Y coordinate (diagonal cells have +1/-1 delta in X and Y coordinates). On a hexagonal board  there are 8 possible positions for two adjacent hexes (see screenshot). Only 6 are valid for each grid Orientation.

HexArrayHelper class defines methods which should help to work with hexagonal board. HexArrayHelper assumes that user has a board with dimensions size (IntSize structure with int Width and int Height properties) and current hex is located in position origin (IntPoint structure with int X and int Y properties). HexArrayHelper works with coordinates of hexes and the real data structures which represent a board and tiles may be different (e.g. the simplest is two dimensional array of any type).

Basic HexArrayHelper method is GetNextHex. Method returns coordinates of an adjacent hex or null if there is no hex in the requested direction. Result depends on board orientation (Horizontal if IsHorizontal=true and Vertical otherwise). The real work is done in GetNextHexHorizontal/GetNextHexVertical methods which check valid directions, border cases where requested direction can be out of bounds and then calculate adjacent hex coordinates.

public IntPoint? GetNextHex(IntSize size, IntPoint origin, HexDirection dir)
{ if (IsHorizontal) return GetNextHexHorizontal(size, origin, dir); return GetNextHexVertical(size, origin, dir);
}

GetNeighbours method returns all adjacent hexes.

public IEnumerable<IntPoint> GetNeighbours(IntSize size, IntPoint origin)
{ for (int index = 0; index < _directions.Length; index++) { HexDirection dir = _directions[index]; var point = GetNextHex(size, origin, dir); if (point.HasValue) yield return point.Value; }
}

GetArea method returns all hexes around current hex which meet provided criteria.

public IEnumerable<IntPoint> GetArea(IntSize size, IntPoint origin, Func<IntPoint, bool> predicate)
{ if (false == predicate(origin)) yield break; int idx = 0; var points = new List<IntPoint>(); points.Add(origin); do { IntPoint p = points[idx]; yield return p; foreach (var point in GetNeighbours(size, p).Where(predicate)) { if (points.IndexOf(point) < 0) points.Add(point); } idx++; } while (idx < points.Count);
}

GetRay method returns all hexes in the requested direction from current hex to board border.

public IEnumerable<IntPoint> GetRay(IntSize size, IntPoint origin, HexDirection dir)
{ IntPoint? next; do { next = GetNextHex(size, origin, dir); if (next != null) { yield return next.Value; origin = next.Value; } } while (next != null);
}

Conclusion

In this article I have tried to give general overview, explain most important design decisions and implementation details. For more details browse project source code on CodeProject or GitHub. 

HexGrid is my first attempt to create a custom WPF Panel. Possible HexGrid usages inlude unusual controls layouts, graphical patterns and of course hexagonal game boards.

LEAVE A REPLY