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.
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:
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 the 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 HexItem
s 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 HexItem
s) in honeycomb pattern. Depending on Orientation
, the pattern looks differently.
HexGrid
declares three 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:
As you can see, vertical HexGrid
space is split in columns of equal width (W
) and rows with equal height (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 with text "First
" from HexGrid
, there will be empty gray space on the left side. To avoid this situation, yellow hexes should be positioned closer to the left. Hex "Last
" 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).
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"/>
<!--...-->
<hx:HexItem Grid.Row="4" Grid.Column="1" Background="#CC9900"/>
<hx:HexItem Grid.Row="4" Grid.Column="2" Background="#FF3300"/>
<hx:HexItem Grid.Row="4" Grid.Column="3" Background="#CC0000"/>
</hx:HexList>
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 a 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 a 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 include unusual controls layouts, graphical patterns and of course hexagonal game boards. It would be nice if you use HexGrid
in your own projects and share your results (you are welcome to post a comment with screenshots/links to source code).
History
- 8th July, 2017: Initial version