Click here to Skip to main content
15,867,453 members
Articles / Desktop Programming / WPF

HexGrid

Rate me:
Please Sign up or sign in to vote.
4.93/5 (40 votes)
12 Jul 2017CPOL6 min read 29.5K   504   41   5
WPF HexGrid Panel

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

C#
/// <summary>
/// Hexagonal content control
/// </summary>
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:

C#
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:

XML
<converters:HexClipConverter x:Key="ClipConverter"/>

<!--HexItem-->
<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:

C#
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:

C#
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):

XML
<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 three dependency properties which affect arrange:

C#
#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

C#
protected override Size MeasureOverride(Size availableSize)
{
    double w = availableSize.Width;
    double h = availableSize.Height;

    // if there is Infinity size dimension
    if (Double.IsInfinity(w) || Double.IsInfinity(h))
    {
        // determine maximum desired size
        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;
        }

        // multiply maximum size to RowCount and ColumnCount to get total size
        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

C#
protected override Size ArrangeOverride(Size finalSize)
{
    // determine if there is empty space at grid borders
    bool first, last;
    HasShift(out first, out last);

    // compute final hex size
    Size hexSize = GetHexSize(finalSize);

    // compute arrange line sizes
    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;
    }

    // arrange elements
    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 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:

C#
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.

C#
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).

C#
<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>

Circles

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.

C#
<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>

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.

C#
<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>

HexMenu

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.

Image 8

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.

C#
/// <summary>
/// Returns adjacent hex
/// </summary>
/// <param name="size">Board dimensions</param>
/// <param name="origin">Current hex coordinates</param>
/// <param name="dir">Direction</param>
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.

C#
/// <summary>
/// Returns all adjacent hexes
/// </summary>
/// <param name="size">Board dimensions</param>
/// <param name="origin">Current hex coordinates</param>
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.

C#
/// <summary>
/// Returns all hexes around current hex which meet provided criteria
/// </summary>
/// <param name="size">Board dimensions</param>
/// <param name="origin">Current hex coordinates</param>
/// <param name="predicate">Search criteria</param>
/// <returns></returns>
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.

C#
/// <summary>
/// Returns all hexes in the requested direction from current hex to board border
/// </summary>
/// <param name="size">Board dimensions</param>
/// <param name="origin">Current hex coordinates</param>
/// <param name="dir">Direction</param>
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

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Russian Federation Russian Federation

Comments and Discussions

 
Questionhow difficult to change it to Windows Form user control? Pin
Southmountain2-Feb-23 18:58
Southmountain2-Feb-23 18:58 
QuestionHexboard HexItem background after the item is selected Pin
ledpup13-Feb-18 2:36
ledpup13-Feb-18 2:36 
AnswerRe: Hexboard HexItem background after the item is selected Pin
ledpup14-Feb-18 0:29
ledpup14-Feb-18 0:29 
AnswerRe: Hexboard HexItem background after the item is selected Pin
Alexander Sharykin17-Feb-18 5:49
Alexander Sharykin17-Feb-18 5:49 
AnswerRe: Hexboard HexItem background after the item is selected Pin
Alexander Sharykin17-Feb-18 5:57
Alexander Sharykin17-Feb-18 5:57 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.