C# WinForms Arbitrarily Large CheckBox

Recently, I needed (well, wanted) a CheckBox that was the same size as some large controls on a form. Not the deepest, most impactful thing I’ve ever done, but, in the interest of avoiding working on a different project, definitely a good time waster!

CheckBoxes in WinForms really want to be a specific size, and that size is quite small (13×13 pixels), and there aren’t really any good quick workarounds for this.

This looks very silly

I really needed to create a custom control (a derivation of CheckBox), but there are some slightly tricky issues–notably drawing the check mark when the control is checked.

If you just want the code, you can grab it at the bottom of the page. Free to use as is, no warranties, etc. One warning: I only implemented the features I needed, so if you need, say, a 3-way check or some other extended capability, you’re on your own. Also, make sure you turn off AutoSize!

I started by deriving from CheckBox:

public class ExpandableCheckBox : CheckBox

and in the constructor telling WinForms that this was owner-drawn, etc.:

public ExpandableCheckBox()
{
  SetStyle(ControlStyles.UserPaint, true);
  SetStyle(ControlStyles.AllPaintingInWmPaint, true);
  SetStyle(ControlStyles.OptimizedDoubleBuffer, true);
  SetStyle(ControlStyles.SupportsTransparentBackColor, true);
  SetStyle(ControlStyles.ResizeRedraw, true);
}

The bulk of the interesting stuff is in the OnPaintMethod. Here’s the start:

protected override void OnPaint(PaintEventArgs e)
{
  base.OnPaint(e);

  if (BackColor != Color.Transparent)
    e.Graphics.Clear(BackColor);

  int boxThickness = ExpandBorderThickness ? Math.Max(1, Height / 13) : 1;
  Rectangle checkRect = new Rectangle(boxThickness - 1, boxThickness - 1, 
    Math.Min(Height, Width) - boxThickness - boxThickness, 
    Height - boxThickness - boxThickness);

  Color interiorColor = Enabled ? 
    (_mouseDown ? _mouseDownBackground : SystemColors.Window) :
    SystemColors.Control;
  Rectangle interiorRect = new Rectangle(
    checkRect.Left + boxThickness - 1, 
    checkRect.Top + boxThickness - 1, 
    checkRect.Width - boxThickness - boxThickness + 2, 
    checkRect.Height - boxThickness - boxThickness + 2);
  using (Brush interiorBrush = new SolidBrush(interiorColor))
  {
    e.Graphics.FillRectangle(interiorBrush, checkRect);
  }

  Color boxColor = Enabled ? 
    (_mouseOver || Focused ? SystemColors.Highlight : ForeColor) : 
    SystemColors.ControlDark;
  using (Pen boxPen = new Pen(boxColor, boxThickness))
  {
    e.Graphics.DrawRectangle(boxPen, checkRect);
  }

Often when you are overriding a control you don’t want to call base.OnPaint(), but it is important if the control might be stacked with docking and parent containers, or you will get weird paint/clipping artifacts. Beyond that, we are just clearing the background for the entire control, painting the background area for the checkbox and then painting the checkbox itself. The colors will vary depending on whether the control has focused, the mouse is down over the control, etc.

Most of these colors are just various SystemColors values. The one exception is the Mouse Down background color, which is probably calculated some way, but I’m not sure how. For my purposes, I just hard-coded it (204,228,247) but if I cared enough, I’d probably render a regular small checkbox to a bitmap and grab the color from there (okay, I started doing that, but decided it wasn’t worth the hassle). Oh, one other thing, I added a property called ExpandBorderThickness. If true, then the checkbox border grows with the size of the checkbox. This looked good when the checkbox was by itself, but not so great when there were other controls around.

The fun part was drawing the check mark. I looked into using the built-in rendering, but just expanding the image (looked terrible) and using the WingDings font to create the check (but it didn’t look like other checkboxes and it is very tricky to get the size correct). Instead, I ended up just looking at the lines drawn by the original small control and calculated the points based on the the size of the larger checkbox.

if (Checked)
{
  float thickness = (float)interiorRect.Width / 11f;
  Color checkColor = Enabled ? (_mouseOver ? SystemColors.Highlight : ForeColor) : SystemColors.ControlDark;
  using (Pen boxPen = new Pen(checkColor, thickness))
  {
    float tailStartX = (float)interiorRect.Left + 
      (float)interiorRect.Width * 2f / 11f;
    float tailStartY = (float)interiorRect.Top + 
      (float)interiorRect.Height / 2f;
    float tailEndX = (float)interiorRect.Left + 
      (float)interiorRect.Width * 4f / 11f;
    float tailEndY = (float)interiorRect.Bottom - 
      (float)interiorRect.Height * 3f / 11f;
    float mainEndX = (float)interiorRect.Right - 
      (float)interiorRect.Width * 2f / 11f;
    float mainEndY = (float)interiorRect.Top + 
      (float)interiorRect.Height * 3f / 11f;

    double angle = Math.Atan2(tailEndY - tailStartY,
      tailEndX - tailStartX) * 180 / Math.PI;

    e.Graphics.SmoothingMode = 
      System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
    e.Graphics.DrawLine(boxPen, tailStartX, tailStartY, 
      tailEndX + (float)Math.Sin(angle) * thickness / 2, 
      tailEndY + (float)Math.Cos(angle) * thickness / 2);
    e.Graphics.DrawLine(boxPen, tailEndX, tailEndY, mainEndX, mainEndY);
  }
}

The interior of the original checkbox is 11 pixels, so I just take where each of the points is and scale them up appropriately. However, if you are paying attention, you may see a little bit of weirdness–all the stuff with Math.Atan2 and Sine/Cosine. To see why that is there, here’s what the check looks like without it.

Ick. The problem is how DrawLine works when the Pen is thicker than one pixel. The line goes precisely to the point specified, just drawn the specified width, orthogonal to the point. We need to make the line extend a little bit further. This required me doing some mathing, which is not necessarily a good thing, so I was quite proud of getting this working more-or-less first try (okay, second try–first time I had the Sine and Cosine backwards :-)! The Math.Atan2 is calculating the angle of the line, then we just extend it half the thickness of the line in the appropriate direction.

The paint method also draws the text as appropriate. Nothing too fancy, so I haven’t pulled that out.

I also had to override a bunch of mouse messages: OnMouseEnter(), OnMouseLeave(), OnMouseDown() and OnMouseUp(). These just set flags to tell the painting code whether the mouse is over the control, is pushed down, etc. I won’t show them all, but here’s the code for OnMouseDown():

protected override void OnMouseDown(MouseEventArgs mevent)
{
  base.OnMouseDown(mevent);
  _mouseDown = true;
  Capture = true;
  Invalidate();
}

For OnMouseDown, we set the flag and also have to Capture the mouse so that we will get the matching OnMouseUp method. The Invalidate(), of course, is to make the control redraw itself. Other than a few details you can see for yourself in the code, that’s pretty much it.

The code

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

namespace CheckboxPlay
{
	public class ExpandableCheckBox : CheckBox
	{
		// ------------------------------------------------------------------------
		// Fields

		// TODO: Should create a temporary checkbox to read proper system color. Color is <i>probably</i> calculated based on another color.
		private static Color _mouseDownBackground = Color.FromArgb(204, 228, 247);

		private bool _mouseOver = false;
		private bool _mouseDown = false;
		private bool _expandBorderThickness = false;

		// ------------------------------------------------------------------------
		// Construction/Initialization

		/// Constructor</summary>
		public ExpandableCheckBox()
		{
			SetStyle(ControlStyles.UserPaint, true);
			SetStyle(ControlStyles.AllPaintingInWmPaint, true);
			SetStyle(ControlStyles.OptimizedDoubleBuffer, true);
			SetStyle(ControlStyles.SupportsTransparentBackColor, true);
			SetStyle(ControlStyles.ResizeRedraw, true);
		}

		// ------------------------------------------------------------------------
		// Properties


		/// <summary>If true, the outer border will expand relative to the size of the checkbox. Otherwise it will be a single pixel wide.</summary>
		[Browsable(true)]
		[Category("Appearance")]
		[Description("If true, the outer border will expand relative to the size of the checkbox. Otherwise it will be a single pixel wide.")]
		public bool ExpandBorderThickness 
		{
			get { return _expandBorderThickness; }
			set { _expandBorderThickness = value; Invalidate(); }
		}

		/// <summary>This is done to hide focus rectangle</summary>
		/// <remarks>The checkbox will be blue if it has focus, and the dashed-line looks ugly</remarks>
		protected override bool ShowFocusCues
		{
			get { return false; }
		}

		// ------------------------------------------------------------------------
		// Methods

		protected override void OnPaint(PaintEventArgs e)
		{
			// Have to call this when handling stacked controls or will get strange artifacts
			// (Clipping and background setup)
			base.OnPaint(e);

			// Fill in the background
			if (BackColor != Color.Transparent)
				e.Graphics.Clear(BackColor);

			// The checkbox will be the full height of the space and the same width (or as wide as possible), the thickness will be proportional to the 
			// scaled-up size (unless ExpandThickness is false, in which case box will always be one pixel).
			int boxThickness = ExpandBorderThickness ? Math.Max(1, Height / 13) : 1;
			Rectangle checkRect = new Rectangle(boxThickness - 1, boxThickness - 1, Math.Min(Height, Width) - boxThickness - boxThickness, Height - boxThickness - boxThickness);

			// Draw the inside of the check. We do this first so that the checkbox can just sit on top and we can avoid worrying
			// about the thickness
			Color interiorColor = Enabled ? (_mouseDown ? _mouseDownBackground : SystemColors.Window) : SystemColors.Control;
			Rectangle interiorRect = new Rectangle(checkRect.Left + boxThickness - 1, checkRect.Top + boxThickness - 1, checkRect.Width - boxThickness - boxThickness + 2, checkRect.Height - boxThickness - boxThickness + 2);
			using (Brush interiorBrush = new SolidBrush(interiorColor))
			{
				e.Graphics.FillRectangle(interiorBrush, checkRect);
			}

			// Will draw the box in the highlight color if mouse over or if we have focus
			Color boxColor = Enabled ? (_mouseOver || Focused ? SystemColors.Highlight : ForeColor) : SystemColors.ControlDark;
			using (Pen boxPen = new Pen(boxColor, boxThickness))
			{
				e.Graphics.DrawRectangle(boxPen, checkRect);
			}


			// Draw the checkmark if appropriate
			if (Checked)
			{
				// Scale the line thickness based on the size of the box
				float thickness = (float)interiorRect.Width / 11f;
				Color checkColor = Enabled ? (_mouseOver ? SystemColors.Highlight : ForeColor) : SystemColors.ControlDark; // Can't just use boxColor because of focus
				using (Pen boxPen = new Pen(checkColor, thickness))
				{
					float tailStartX = (float)interiorRect.Left + (float)interiorRect.Width * 2f / 11f;
					float tailStartY = (float)interiorRect.Top + (float)interiorRect.Height / 2f;
					float tailEndX = (float)interiorRect.Left + (float)interiorRect.Width * 4f / 11f;
					float tailEndY = (float)interiorRect.Bottom - (float)interiorRect.Height * 3f / 11f;
					float mainEndX = (float)interiorRect.Right - (float)interiorRect.Width * 2f / 11f;
					float mainEndY = (float)interiorRect.Top + (float)interiorRect.Height * 3f / 11f;

					// As the shape gets bigger, the thickness of the pen will cause a gap at the bottom of the check.
					// This figures out the angle of the tale (relative to the X axis) so that we can extend the line
					// the extra little bit required to fill in the gap
					double angle = Math.Atan2(tailEndY - tailStartY, tailEndX - tailStartX) * 180 / Math.PI;

					e.Graphics.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
					e.Graphics.DrawLine(boxPen, tailStartX, tailStartY, tailEndX + (float)Math.Sin(angle) * thickness / 2, tailEndY + (float)Math.Cos(angle) * thickness / 2);
					//e.Graphics.DrawLine(boxPen, tailStartX, tailStartY, tailEndX, tailEndY);
					e.Graphics.DrawLine(boxPen, tailEndX, tailEndY, mainEndX, mainEndY);
				}
			}

			// Draw Text
			if (!string.IsNullOrEmpty(Text))
			{
				Color textColor = Enabled ? ForeColor : SystemColors.ControlDark;
				StringFormat format = new StringFormat(StringFormatFlags.NoWrap)
				{
					Alignment = GetHorStringAlignFromContentAlignment(this.TextAlign),
					LineAlignment = GetVerStringAlignFromContentAlignment(this.TextAlign),
					Trimming = AutoEllipsis ? StringTrimming.EllipsisCharacter : StringTrimming.None
				};

				Rectangle textRect = new Rectangle(checkRect.Right + boxThickness + 2, 0, this.Width - checkRect.Right - boxThickness - 2, this.Height);
				//e.Graphics.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.HighQuality;
				using (Brush textBrush = new SolidBrush(textColor))
				{
					e.Graphics.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAlias;
					e.Graphics.DrawString(Text, Font, textBrush, textRect, format);
				}
			}
		}

		protected override void OnTextChanged(EventArgs e)
		{
			base.OnTextChanged(e);
			Invalidate();
		}

		protected override void OnMouseEnter(EventArgs eventargs)
		{
			base.OnMouseEnter(eventargs);
			_mouseOver = true;
			Invalidate();
		}

		protected override void OnMouseLeave(EventArgs eventargs)
		{
			base.OnMouseLeave(eventargs);
			_mouseOver = false;
			Invalidate();
		}

		protected override void OnMouseDown(MouseEventArgs mevent)
		{
			base.OnMouseDown(mevent);
			_mouseDown = true;
			Capture = true;
			Invalidate();
		}

		protected override void OnMouseUp(MouseEventArgs mevent)
		{
			base.OnMouseUp(mevent);
			_mouseDown = false;
			Capture = false;
			Invalidate();
		}

		public static StringAlignment GetHorStringAlignFromContentAlignment(ContentAlignment textAlign)
		{
			StringAlignment result = StringAlignment.Near;
			switch(textAlign) 
			{
				case ContentAlignment.TopLeft:
				case ContentAlignment.MiddleLeft:
				case ContentAlignment.BottomLeft:
					result = StringAlignment.Near;
					break;

				case ContentAlignment.TopCenter:
				case ContentAlignment.MiddleCenter:
				case ContentAlignment.BottomCenter:
					result = StringAlignment.Center;
					break;

				case ContentAlignment.TopRight:
				case ContentAlignment.MiddleRight:
				case ContentAlignment.BottomRight:
					result = StringAlignment.Far;
					break;
			}

			return result;
		}

		public static StringAlignment GetVerStringAlignFromContentAlignment(ContentAlignment textAlign)
		{
			StringAlignment result = StringAlignment.Center;
			switch (textAlign)
			{
				case ContentAlignment.TopLeft:
				case ContentAlignment.TopCenter:
				case ContentAlignment.TopRight:
					result = StringAlignment.Near;
					break;

				case ContentAlignment.MiddleLeft:
				case ContentAlignment.MiddleCenter:
				case ContentAlignment.MiddleRight:
					result = StringAlignment.Center;
					break;

				case ContentAlignment.BottomLeft:
				case ContentAlignment.BottomCenter:
				case ContentAlignment.BottomRight:
					result = StringAlignment.Far;
					break;
			}

			return result;
		}
	}
}

3 Comments on “C# WinForms Arbitrarily Large CheckBox

Leave a Reply

Your email address will not be published. Required fields are marked *

*