C# WinForm 写一个六(多)边形菜单

背景

闲来无事逛群聊,看到一网友发来这么一个需求:

714bb06c59153c3c7faaa1d69478c2f9.png

纯色图下面是一张六边形布局的背景图层,具体图片背景内容不便透露,所以这里采用色块形式模拟,ZF类的项目都喜欢这种给人新奇的东西,这每一个色块,都是一个可以点击进入的菜单。

分析

乍一看这图,就是三个正六边形嵌套。所以就根据公式 2 * Math.PI / 6计算出每个6边形每个顶点偏移角度,再根据公式计算出每个顶点的坐标位置。算法如下:

private static Point[] CalculateHexagonVertices(int sideLength, int offset = 0)
{
    Point[] vertices = new Point[6];
    double angle = 2 * Math.PI / 6;
    for (int i = 0; i < 6; i++)
    {
        int x = offset + (int)(sideLength * Math.Cos(i * angle));
        int y = offset + (int)(sideLength * Math.Sin(i * angle));
        vertices[i] = new Point(x, y);
    }
    return vertices;
}

首先根据 sideLength 边长,计算出最外层正六边形6个顶点的位置。注意这个时候因为顶点是从 (0,0) 计算的,所以这里要加入 offset 设置顶点位置,让每一顶点平移指定的像素。再计算中间那个小正六边形(包括蓝色间隙)的顶点位置。最后再计算最中间不包括间隙的紫色小正六边形顶点位置。这时候有人可能要问中间那些色块的位置怎么确定,这是个好问题,仔细看就知道了,这每一块都是一个等腰梯形,而且每个等腰梯形,都可以根据最外层的大正六边形中间那个小正六边形(包括蓝色间隙)顶点位置计算出来。

实现

先定义一个梯形类:

internal sealed class Trapezoid
{
    public Point TopLeft { get; set; }
    public Point TopRight { get; set; }
    public Point BottomLeft { get; set; }
    public Point BottomRight { get; set; }
    public Point[] Points
    {
        get
        {
            return new Point[] { TopLeft, TopRight, BottomRight, BottomLeft };
        }
    }
    public Trapezoid(Point topLeft, Point topRight, Point bottomLeft, Point bottomRight)
    {
        TopLeft = topLeft;
        TopRight = topRight;
        BottomLeft = bottomLeft;
        BottomRight = bottomRight;
    }
}

然后通过外层的两个正六边形顶点位置计算梯形位置,演示代码如下:

private static Trapezoid[] CalculateTrapezoids(Point[] hexagonVertices, Point[] smallHexagonVertices)
{
    Trapezoid[] trapezoids = new Trapezoid[6];
    for (int i = 0; i < 6; i++)
    {
        Point topLeft = hexagonVertices[i];
        Point topRight = hexagonVertices[(i + 1) % 6];
        Point bottomLeft = smallHexagonVertices[i];
        Point bottomRight = smallHexagonVertices[(i + 1) % 6];
        trapezoids[i] = new Trapezoid(topLeft, topRight, bottomLeft, bottomRight);
    }
    return trapezoids;
}

我写了一个继承自 PictureBox 的控件类,重写了 OnPaint 方法,实现了以上色块的展示。演示代码如下:

protected override void OnPaint(PaintEventArgs e)
{
    base.OnPaint(e);
    var g = e.Graphics;
    //centerHexagon 是中间最小的不包括间隙的紫色的六边形顶点位置
    g.FillPolygon(new SolidBrush(Color.FromArgb(180, Color.Red)), centerHexagon);
    for (int i = 0; i < trapezoids.Length; i++)
    {
        //trapezoids 是6个梯形位置
        var trapezoid = trapezoids[i];
        g.FillPolygon(new SolidBrush(Color.FromArgb(10 * (i + 1), Color.Yellow)), trapezoid.Points);
    }
}

需要实现鼠标点击事件,我们需要定义一个事件,记录鼠标当前位置,然后在 OnMouseClick 方法中检测鼠标位置是否在某个梯形或者最中间位置,然后触发事件。演示代码如下:

//0-6 每个梯形位置,-1 中间位置
//          4 
//     3         5
//         -1
//     2         0
//          1
/// <summary>
/// 菜单点击事件处理
/// </summary>
public event Action<object, int> OnMenuClicked;
/// <summary>
/// 鼠标当前位置
/// </summary>
private Point? mouseHoverLocation = null;
/// <summary>
/// 鼠标滑过的时候,重绘界面,然后设置鼠标位置
/// </summary>
/// <param name="e"></param>
protected override void OnMouseMove(MouseEventArgs e)
{
    base.OnMouseMove(e);
    mouseHoverLocation = e.Location;
    Invalidate();
}
/// <summary>
/// 鼠标移除
/// </summary>
/// <param name="e"></param>
protected override void OnMouseLeave(EventArgs e)
{
    base.OnMouseLeave(e);
    mouseHoverLocation = null;
}
/// <summary>
/// 菜单点击事件
/// </summary>
/// <param name="e"></param>
protected override void OnMouseClick(MouseEventArgs e)
{
    var mouseLocation = e.Location;
    //检测鼠标是否在中间的六边形中:
    if (IsPointInPolygon(mouseLocation, centerHexagon))
    {
        OnMenuClicked?.Invoke(this, -1);
        return;
    }
    //检测是否在某个梯形内部
    for (int i = 0; i < trapezoids.Length; i++)
    {
        var trapezoid = trapezoids[i];
        if (IsPointInTrapezoid(mouseLocation, trapezoid))
        {
            OnMenuClicked?.Invoke(this, i);
            return;
        }
    }
}

里面有一个 IsPointInPolygon 方法,用于检测某一点是否在某个多边形内,这里的算法抄袭了某N上的代码,如下:

/// <summary>
/// 判断点是否在多边形内.
/// 来源:https://blog.csdn.net/xxdddail/article/details/49093635
/// ----------原理----------
/// 注意到如果从P作水平向左的射线的话,如果P在多边形内部,那么这条射线与多边形的交点必为奇数,
/// 如果P在多边形外部,则交点个数必为偶数(0也在内)。
/// </summary>
/// <param name="checkPoint">要判断的点</param>
/// <param name="polygonPoints">多边形的顶点</param>
/// <returns></returns>
private static bool IsPointInPolygon(Point checkPoint, Point[] polygonPoints)
{
    bool inside = false;
    int pointCount = polygonPoints.Length;
    Point p1, p2;
    for (int i = 0, j = pointCount - 1; i < pointCount; j = i, i++)//第一个点和最后一个点作为第一条线,之后是第一个点和第二个点作为第二条线,之后是第二个点与第三个点,第三个点与第四个点...
    {
        p1 = polygonPoints[i];
        p2 = polygonPoints[j];
        if (checkPoint.Y < p2.Y)
        {//p2在射线之上
            if (p1.Y <= checkPoint.Y)
            {//p1正好在射线中或者射线下方
                if ((checkPoint.Y - p1.Y) * (p2.X - p1.X) > (checkPoint.X - p1.X) * (p2.Y - p1.Y))//斜率判断,在P1和P2之间且在P1P2右侧
                {
                    //射线与多边形交点为奇数时则在多边形之内,若为偶数个交点时则在多边形之外。
                    //由于inside初始值为false,即交点数为零。所以当有第一个交点时,则必为奇数,则在内部,此时为inside=(!inside)
                    //所以当有第二个交点时,则必为偶数,则在外部,此时为inside=(!inside)
                    inside = (!inside);
                }
            }
        }
        else if (checkPoint.Y < p1.Y)
        {
            //p2正好在射线中或者在射线下方,p1在射线上
            if ((checkPoint.Y - p1.Y) * (p2.X - p1.X) < (checkPoint.X - p1.X) * (p2.Y - p1.Y))//斜率判断,在P1和P2之间且在P1P2右侧
            {
                inside = (!inside);
            }
        }
    }
    return inside;
}

大致就是这样,再就是缩放窗体时自动计算位置的细节处理,这里先贴一下全部代码:

using System;
using System.Drawing;
using System.Windows.Forms;

namespace HexagonButton
{
    public partial class HButton : PictureBox
    {
        //0-6 每个梯形位置,-1 中间位置
        //          4 
        //     3         5
        //         -1
        //     2         0
        //          1

        /// <summary>
        /// 菜单点击事件处理
        /// </summary>
        public event Action<object, int> OnMenuClicked;

        /// <summary>
        /// 每个梯形位置
        /// </summary>
        private Trapezoid[] trapezoids = new Trapezoid[6];

        /// <summary>
        /// 中间的小正六边形位置
        /// </summary>
        private Point[] centerHexagon = new Point[6];

        /// <summary>
        /// 鼠标当前位置
        /// </summary>
        private Point? mouseHoverLocation = null;

        /// <summary>
        /// 鼠标滑过时的层背景
        /// </summary>
        private SolidBrush mouseHoverLayerBrush = new SolidBrush(Color.FromArgb(50, Color.White));

        public HButton()
        {
            InitializeComponent();
            DoubleBuffered = true;
        }

        /// <summary>
        /// 缩放窗体时(调用),自动修正位置
        /// </summary>
        /// <param name="formWidth"></param>
        /// <param name="formHeight"></param>
        public void ResetSizeByForm(int formWidth, int formHeight)
        {
            var hHeight = (int)(formHeight * 0.8);
            var hWidth = hHeight;

            var hLeft = (formWidth - hWidth) / 2;
            var hTop = (formHeight - hHeight) / 2;

            this.Location = new Point(hLeft, hTop);
            this.Width = hWidth;
            this.Height = hHeight;
        }

        private void InitHexagonMenus()
        {
            this.BackColor = Color.Transparent;
            //this.Image = Properties.Resources.button_bg;
            this.SizeMode = PictureBoxSizeMode.Zoom;
            this.Cursor = Cursors.Hand;

            //计算图片缩放级别
            //var scale = Properties.Resources.button_bg.Width / this.Width;
            //计算原始图片高度和宽度之差,因为该背景非正六边形,所以计算一下宽度和高度之差,用以计算正确得位置
            //var diffOfImageSize = (Properties.Resources.button_bg.Width - Properties.Resources.button_bg.Height) / scale;
            var diffOfImageSize = 0;

            var sideWidth = (this.Width - diffOfImageSize) / 2;
            var offset = this.Width / 2;

            //计算最外层大六边形顶点位置
            var big = CalculateHexagonVertices((this.Width + diffOfImageSize / 2) / 2, offset);

            //计算内部小六边形顶点位置
            var small = CalculateHexagonVertices(sideWidth / 2, offset);

            //计算两个六边形相交之后,形成得六边形环,分割为6个等腰梯形,用以检测点击事件
            trapezoids = CalculateTrapezoids(big, small);

            //计算内部小的正六边形,用以检测点击事件
            centerHexagon = CalculateHexagonVertices(sideWidth / 2 - 20, offset);
        }

        /// <summary>
        /// 鼠标滑过的时候,重绘界面,然后设置鼠标位置
        /// </summary>
        /// <param name="e"></param>
        protected override void OnMouseMove(MouseEventArgs e)
        {
            base.OnMouseMove(e);
            mouseHoverLocation = e.Location;
            Invalidate();
        }

        /// <summary>
        /// 鼠标移除
        /// </summary>
        /// <param name="e"></param>
        protected override void OnMouseLeave(EventArgs e)
        {
            base.OnMouseLeave(e);
            mouseHoverLocation = null;
        }

        /// <summary>
        /// 菜单点击事件
        /// </summary>
        /// <param name="e"></param>
        protected override void OnMouseClick(MouseEventArgs e)
        {
            var mouseLocation = e.Location;
            //检测鼠标是否在中间的六边形中:
            if (IsPointInPolygon(mouseLocation, centerHexagon))
            {
                OnMenuClicked?.Invoke(this, -1);
                return;
            }
            //检测是否在某个梯形内部
            for (int i = 0; i < trapezoids.Length; i++)
            {
                var trapezoid = trapezoids[i];
                if (IsPointInTrapezoid(mouseLocation, trapezoid))
                {
                    OnMenuClicked?.Invoke(this, i);
                    return;
                }
            }
        }

        protected override void OnPaint(PaintEventArgs e)
        {
            base.OnPaint(e);
            var g = e.Graphics;
#if DEBUG
            //以下的代码可以直接删除,这里是作为标识多边形位置
            g.FillPolygon(new SolidBrush(Color.FromArgb(180, Color.Red)), centerHexagon);
            for (int i = 0; i < trapezoids.Length; i++)
            {
                var trapezoid = trapezoids[i];
                g.FillPolygon(new SolidBrush(Color.FromArgb(10 * (i + 1), Color.Yellow)), trapezoid.Points);
            }
#endif
            if (mouseHoverLocation == null)
            {
                return;
            }
            //检测鼠标是否在中间的六边形中:
            if (IsPointInPolygon(mouseHoverLocation.Value, centerHexagon))
            {
                g.FillPolygon(mouseHoverLayerBrush, centerHexagon);
                return;
            }
            //检测是否在某个梯形内部
            for (int i = 0; i < trapezoids.Length; i++)
            {
                var trapezoid = trapezoids[i];
                if (IsPointInTrapezoid(mouseHoverLocation.Value, trapezoid))
                {
                    g.FillPolygon(mouseHoverLayerBrush, trapezoid.Points);
                    return;
                }
            }
        }

        /// <summary>
        /// 计算正六边形的顶点坐标
        /// </summary>
        /// <param name="sideLength"></param>
        /// <param name="offset"></param>
        /// <returns></returns>
        private static Point[] CalculateHexagonVertices(int sideLength, int offset = 0)
        {
            Point[] vertices = new Point[6];
            double angle = 2 * Math.PI / 6;

            for (int i = 0; i < 6; i++)
            {
                int x = offset + (int)(sideLength * Math.Cos(i * angle));
                int y = offset + (int)(sideLength * Math.Sin(i * angle));
                vertices[i] = new Point(x, y);
            }

            return vertices;
        }

        /// <summary>
        /// 计算每个梯形的坐标
        /// </summary>
        /// <param name="hexagonVertices"></param>
        /// <param name="smallHexagonVertices"></param>
        /// <returns></returns>
        private static Trapezoid[] CalculateTrapezoids(Point[] hexagonVertices, Point[] smallHexagonVertices)
        {
            Trapezoid[] trapezoids = new Trapezoid[6];
            for (int i = 0; i < 6; i++)
            {
                Point topLeft = hexagonVertices[i];
                Point topRight = hexagonVertices[(i + 1) % 6];
                Point bottomLeft = smallHexagonVertices[i];
                Point bottomRight = smallHexagonVertices[(i + 1) % 6];
                trapezoids[i] = new Trapezoid(topLeft, topRight, bottomLeft, bottomRight)
                {
                    Index = i
                };
            }
            return trapezoids;
        }

        /// <summary>
        /// 判断点是否在梯形内
        /// </summary>
        /// <param name="checkPoint"></param>
        /// <param name="trapezoid"></param>
        /// <returns></returns>
        private static bool IsPointInTrapezoid(Point checkPoint, Trapezoid trapezoid)
        {
            return IsPointInPolygon(checkPoint, trapezoid.Points);
        }

        /// <summary>
        /// 判断点是否在多边形内.
        /// 来源:https://blog.csdn.net/xxdddail/article/details/49093635
        /// ----------原理----------
        /// 注意到如果从P作水平向左的射线的话,如果P在多边形内部,那么这条射线与多边形的交点必为奇数,
        /// 如果P在多边形外部,则交点个数必为偶数(0也在内)。
        /// </summary>
        /// <param name="checkPoint">要判断的点</param>
        /// <param name="polygonPoints">多边形的顶点</param>
        /// <returns></returns>
        private static bool IsPointInPolygon(Point checkPoint, Point[] polygonPoints)
        {
            bool inside = false;
            int pointCount = polygonPoints.Length;
            Point p1, p2;
            for (int i = 0, j = pointCount - 1; i < pointCount; j = i, i++)//第一个点和最后一个点作为第一条线,之后是第一个点和第二个点作为第二条线,之后是第二个点与第三个点,第三个点与第四个点...
            {
                p1 = polygonPoints[i];
                p2 = polygonPoints[j];
                if (checkPoint.Y < p2.Y)
                {//p2在射线之上
                    if (p1.Y <= checkPoint.Y)
                    {//p1正好在射线中或者射线下方
                        if ((checkPoint.Y - p1.Y) * (p2.X - p1.X) > (checkPoint.X - p1.X) * (p2.Y - p1.Y))//斜率判断,在P1和P2之间且在P1P2右侧
                        {
                            //射线与多边形交点为奇数时则在多边形之内,若为偶数个交点时则在多边形之外。
                            //由于inside初始值为false,即交点数为零。所以当有第一个交点时,则必为奇数,则在内部,此时为inside=(!inside)
                            //所以当有第二个交点时,则必为偶数,则在外部,此时为inside=(!inside)
                            inside = (!inside);
                        }
                    }
                }
                else if (checkPoint.Y < p1.Y)
                {
                    //p2正好在射线中或者在射线下方,p1在射线上
                    if ((checkPoint.Y - p1.Y) * (p2.X - p1.X) < (checkPoint.X - p1.X) * (p2.Y - p1.Y))//斜率判断,在P1和P2之间且在P1P2右侧
                    {
                        inside = (!inside);
                    }
                }
            }
            return inside;
        }

        /// <summary>
        /// 当窗体改变时,自动计算大小
        /// </summary>
        /// <param name="e"></param>
        protected override void OnClientSizeChanged(EventArgs e)
        {
            base.OnClientSizeChanged(e);
            InitHexagonMenus();
        }
    }

    /// <summary>
    /// 梯形类
    /// </summary>
    internal sealed class Trapezoid
    {
        public int Index { get; set; }
        public Point TopLeft { get; set; }
        public Point TopRight { get; set; }
        public Point BottomLeft { get; set; }
        public Point BottomRight { get; set; }

        public Point[] Points
        {
            get
            {
                return new Point[] { TopLeft, TopRight, BottomRight, BottomLeft };
            }
        }

        public Trapezoid(Point topLeft, Point topRight, Point bottomLeft, Point bottomRight)
        {
            TopLeft = topLeft;
            TopRight = topRight;
            BottomLeft = bottomLeft;
            BottomRight = bottomRight;
        }

        public override string ToString()
        {
            return $"Trapezoid {{ Index={Index}, TopLeft={TopLeft}, TopRight={TopRight}, BottomLeft={BottomLeft}, BottomRight={BottomRight} }}";
        }
    }
}

窗体代码:

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Windows.Forms;

namespace HexagonButton
{
    public partial class Form1 : Form
    {
        private HButton HButton;
        public Form1()
        {
            InitializeComponent();
            this.WindowState = FormWindowState.Maximized;
            //这个窗体里面把那个纯色的背景图放进去
            this.BackColor = Color.FromArgb(9, 56, 128);
        }

        protected override void OnSizeChanged(EventArgs e)
        {
            base.OnSizeChanged(e);
            if (HButton == null)
            {
                HButton = new HButton();
                HButton.OnMenuClicked += (s, index) =>
                {
                    MessageBox.Show($"点击了菜单:#{index}");
                };
                this.Controls.Add(HButton);
            }
            else
            {
                HButton.ResetSizeByForm(this.Width, this.Height);
            }
        }
    }
}

就是在窗体缩放时,把六边形菜单动态加进去。

结局

代码发给网友之后,网友表示很满意,然后经过我的指导,然后自由发挥改成了7边形,然后我又扩展了下面这一版,更加智能了。截一些图片在这里:

85be0367e5fbaf2e9d2a1e03c2c9d627.png cc520156f072da4917a90a489e03d3aa.png

最终版演示 DEMO:0c4d2c3b9f8b3d47e8b4e332edd2e56b.png

做着做着,就一发不可收拾了,做的越来越顺眼了... 最后,该菜单通过增加了一些配置,使得可以自定义边数,是否包含中间菜单等功能,功能更加强大了一些。该项目已全部开源,开源地址:https://github.com/mrhuo/polymenuNUGET 安装:

Install-Package MrHuo.PolyMenu -Version 1.0.23.913
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值