Based on a few comments (both directly on the blog, as well as internal email), I have decided to post a third entry to further develop our Gel Buttons. (Not that I am trying to belabor the topic, but I think there are some important things to learn from our simple little buttons.)

First of all, the implementation in parts 1 and 2 did include some pixel offsets. Let's look at the effect that this decision has. First, the radius of the ellipses (circles) forming the corners of the rounded rectanges were always exactly 5 pixels. The highight was always positioned 2 pixels from the edge of the button. The outline was always 1 pixel wide. So, while the button itself, and the highlight, would always fill the entirety of the bounding rectangle of the control, as the control became larger (or the DPI became higher), the corners would look less rounded, the highlight would get closer and closer to the edge of the control, and the single pixel outline would look less and less significant.

(Note that, as with most discussions of pixels with Windows Forms, we are discussing logical pixels. If, for example, you were to modify the graphics object using g.ScaleTransform(2.0F, 2.0F); then the outline would be 2 pixels wide.)

We can truly leverage our vector graphics platform here by setting these values as a percentage of the control size, as we are already doing with the width and height of both the background rectangle and the highlight rectangle. So, I have modified our code to do exactly that, enabling a very smooth scaling to any size.

What are some of the things to watch for when doing this? First of all, you have to determine what exactly you are going to use as the basis for your scaling. I have chosen to select the smaller of either width or height. Basing our scaling on one or the other can cause unfortunate consequences when a consumer decides to create either an unusually wide or unusually tall button. Basing it on the smaller value allows the button to behave as we expect when the button is scaled proportionately, while also affording expected behavior when scaling non-proportionately.

Another interesting consequence comes with the outline. At 1 pixel wide, we are certain that this pixel will fit inside of our container. Once that number begins to grow, then the center of this thick line becomes the bounds of our rectangle, which trims the edges disproportionately from the rounded corners (which have more pixels to spare until they reach the bounds of the container). We can fix that by setting the alignment of the pen to PenAlignment.Inset, which uses our outer boundary as the outer boundary of our drawing, rather than the center.

Now that we have addressed scaling more thoroughly, let's address another important issue: accessibility. Since we are deriving our control from Button, we inherit a number of accessibility features for free. Our control exposes AccessibleDescription, AccessibleName, AccessibleRole, TabIndex, Text, and Font Size properties. Consequently, our button will be compatible with screen readers and other features that are designed to help those who depend on these types of software.

What about those who are not using a mouse, either out of necessity or by choice? We were not providing a particularly good experience to them - we only drew our selection highlights and click effects in response to mouse events. So, let's add some keyboard handlers to have our buttons highlight when they have the focus, return to normal when they lose focus, and appear pressed when the user presses the space bar. This improves the experience with the buttons even more.

Finally, let's consider colors. Since we are allowing the developer to specify the color of the buttons (or accept the default colors), rather than taking these colors from the operating system, we are not responding to the user's preferences regarding which colors help them see better. If they have selected a high contrast color scheme, our gel buttons still appear using the colors the developer specified. What can we do about this?

One option is to start with system colors, which will detect the user's preferences. However, system colors don't provide us with two coordinated gradient values. We can overcome this by starting with a single system color, looking at that system color, and then modifying the brightness to obtain a light and dark version of that shade. Unforunately, that is not as straightforward as it seems. Although you can get hue, saturation, and brightness values from an instance of the Color structure, there is no FromHsb method that would allow us to construct a new Color structure using a variation of the brightness value. This work must be done by us. If we choose to go that route, then we also need to decide which system color should be the basis for our gradient. ButtonFace seems the logical choice, but generally buttons are not as colorful as what we are trying to create here. Perhaps SystemColors.ActiveCaption?

The other option is to specifically check to see if the user has specified a high contrast color scheme. Fortunately, the System.Windows.Forms.SystemInformation class provides a HighContrast property that allows us to check to see if the user has selected this option, and if so to respond accordingly. We can check this variable and custom render our own choices for high contrast colors. This is the option I have implemented here.

After addressing these issues, there are a couple of additional features that I believe are worth implementing. First is the notion of the default button. If you look at many dialog boxes, you will see some indication of which button is the default button - the button that will respond immediately if you press the enter key. We would like to offer a similar visual cue in our button class, which we have done here using a subtle white highlight around the edge of the button (using a PathGradientBrush).

Another important feature is visually indicating when a button is disabled. As it was, a disabled button would respond to mouse and keyboard events in exactly the same way that an enabled button would. Obviously, announcing "hey, you can click me!" when, in fact, you can't, provides a negative user experience.

With default buttons, once again we would like to respect the decision of the developer regarding colors, and simply modify these colors to present them as a grayscale of that color. Fortunately, grayscale is an edge case that doesn't require any sophisticated transformations. We can simply look at the brightness of the color we are using, and construct a new Color structure using that brightness for red, green and blue. (Note that GetBrightness returns a value from 0 to 1, so we need to multiply by 255 first.)

Let's take a look at the results:

Windows Forms Gel Buttons - Third Revision

Finally, let's take a look at the code:

namespace GelButtons {

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

  class GelButton : Button {

    #region Fields

    private Color gradientTop = Color.FromArgb(255, 44, 85, 177);
    private Color gradientBottom = Color.FromArgb(255, 153, 198, 241);
    private Color paintGradientTop;
    private Color paintGradientBottom;
    private Color paintForeColor;
    private Rectangle buttonRect;
    private Rectangle highlightRect;
    private int rectCornerRadius;
    private float rectOutlineWidth;
    private int highlightRectOffset;
    private int defaultHighlightOffset;
    private int highlightAlphaTop = 255;
    private int highlightAlphaBottom;
    private Timer animateButtonHighlightedTimer = new Timer();
    private Timer animateResumeNormalTimer = new Timer();
    private bool increasingAlpha;

    #endregion

    #region Properties

    [Category("Appearance")]
    [Description("The color to use for the top portion of the gradient fill of the component.")]
    [DefaultValue(typeof(Color), "0x2C55B1")]
    public Color GradientTop {
      get {
        return gradientTop;
      }
      set {
        gradientTop = value;
        SetPaintColors();
        Invalidate();
      }
    }

    [Category("Appearance")]
    [Description("The color to use for the bottom portion of the gradient fill of the component.")]
    [DefaultValue(typeof(Color), "0x99C6F1")]
    public Color GradientBottom {
      get {
        return gradientBottom;
      }
      set {
        gradientBottom = value;
        SetPaintColors();
        Invalidate();
      }
    }

    public override Color ForeColor {
      get {
        return base.ForeColor;
      }
      set {
        base.ForeColor = value;
        SetPaintColors();
        Invalidate();
      }
    }

    #endregion

    #region Initialization and Modification

    protected override void OnCreateControl() {
      SuspendLayout();
      SetControlSizes();
      SetPaintColors();
      InitializeTimers();
      base.OnCreateControl();
      ResumeLayout();
    }

    protected override void OnResize(EventArgs e) {
      SetControlSizes();
      this.Invalidate();
      base.OnResize(e);
    }

    private void SetControlSizes() {
      int scalingDividend = Math.Min(ClientRectangle.Width, ClientRectangle.Height);
      buttonRect = new Rectangle(ClientRectangle.X, ClientRectangle.Y, 
ClientRectangle.Width - 1, ClientRectangle.Height - 1);
      rectCornerRadius = Math.Max(1, scalingDividend / 10);
      rectOutlineWidth = Math.Max(1, scalingDividend / 50);
      highlightRect = new Rectangle(ClientRectangle.X, ClientRectangle.Y, 
ClientRectangle.Width - 1, (ClientRectangle.Height - 1) / 2);
      highlightRectOffset = Math.Max(1, scalingDividend / 35);
      defaultHighlightOffset = Math.Max(1, scalingDividend / 35);
    }

    protected override void OnEnabledChanged(EventArgs e) {
      if (!Enabled) {
        animateButtonHighlightedTimer.Stop();
        animateResumeNormalTimer.Stop();
      }
      SetPaintColors();
      Invalidate();
      base.OnEnabledChanged(e);
    }

    private void SetPaintColors() {
      if (Enabled) {
        if (SystemInformation.HighContrast) {
          paintGradientTop = Color.Black;
          paintGradientBottom = Color.Black;
          paintForeColor = Color.White;
        } else {
          paintGradientTop = gradientTop;
          paintGradientBottom = gradientBottom;
          paintForeColor = ForeColor;
        }
      } else {
        if (SystemInformation.HighContrast) {
          paintGradientTop = Color.Gray;
          paintGradientBottom = Color.White;
          paintForeColor = Color.Black;
        } else {
          int grayscaleColorTop = (int)(gradientTop.GetBrightness() * 255);
          paintGradientTop = Color.FromArgb(grayscaleColorTop, 
grayscaleColorTop, grayscaleColorTop);
          int grayscaleGradientBottom = (int)(gradientBottom.GetBrightness() * 255);
          paintGradientBottom = Color.FromArgb(grayscaleGradientBottom, 
grayscaleGradientBottom, grayscaleGradientBottom);
          int grayscaleForeColor = (int)(ForeColor.GetBrightness() * 255);
          if (grayscaleForeColor > 255 / 2) {
            grayscaleForeColor -= 60;
          } else {
            grayscaleForeColor += 60;
          }
          paintForeColor = Color.FromArgb(grayscaleForeColor, grayscaleForeColor, grayscaleForeColor);
        }
      }
    }

    private void InitializeTimers() {
      animateButtonHighlightedTimer.Interval = 20;
      animateButtonHighlightedTimer.Tick += new EventHandler(animateButtonHighlightedTimer_Tick);
      animateResumeNormalTimer.Interval = 5;
      animateResumeNormalTimer.Tick += new EventHandler(animateResumeNormalTimer_Tick);
    }

    #endregion

    #region Custom Painting

    protected override void OnPaint(PaintEventArgs pevent) {
      Graphics g = pevent.Graphics;
      ButtonRenderer.DrawParentBackground(g, ClientRectangle, this);
      // Paint the outer rounded rectangle
      g.SmoothingMode = SmoothingMode.AntiAlias;
      using (GraphicsPath outerPath = RoundedRectangle(buttonRect, rectCornerRadius, 0)) {
        using (LinearGradientBrush outerBrush = new LinearGradientBrush(buttonRect, 
paintGradientTop, paintGradientBottom, LinearGradientMode.Vertical)) {
          g.FillPath(outerBrush, outerPath);
        }
        using (Pen outlinePen = new Pen(paintGradientTop, rectOutlineWidth)) {
          outlinePen.Alignment = PenAlignment.Inset;
          g.DrawPath(outlinePen, outerPath);
        }
      }
      // If this is the default button, paint an additional highlight
      if (IsDefault) {
        using (GraphicsPath defaultPath = new GraphicsPath()) {
          defaultPath.AddPath(RoundedRectangle(buttonRect, rectCornerRadius, 0), false);
          defaultPath.AddPath(RoundedRectangle(buttonRect, rectCornerRadius, defaultHighlightOffset), false);
          using (PathGradientBrush defaultBrush = new PathGradientBrush(defaultPath)) {
            defaultBrush.CenterColor = Color.FromArgb(50, Color.White);
            defaultBrush.SurroundColors = new Color[] { Color.FromArgb(100, Color.White) };
            g.FillPath(defaultBrush, defaultPath);
          }
        }
      }
      // Paint the gel highlight
      using (GraphicsPath innerPath = RoundedRectangle(highlightRect, rectCornerRadius, highlightRectOffset)) {
        using (LinearGradientBrush innerBrush = new LinearGradientBrush(highlightRect, 
Color.FromArgb(highlightAlphaTop, Color.White),
Color.FromArgb(highlightAlphaBottom, Color.White), LinearGradientMode.Vertical)) {
          g.FillPath(innerBrush, innerPath);
        }
      }
      // Paint the text
      TextRenderer.DrawText(g, Text, Font, buttonRect, paintForeColor, Color.Transparent, 
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
    }

    private static GraphicsPath RoundedRectangle(Rectangle boundingRect, int cornerRadius, int margin) {
      GraphicsPath roundedRect = new GraphicsPath();
      roundedRect.AddArc(boundingRect.X + margin, boundingRect.Y + margin, cornerRadius * 2, 
cornerRadius * 2, 180, 90);
      roundedRect.AddArc(boundingRect.X + boundingRect.Width - margin - cornerRadius * 2, 
boundingRect.Y + margin, cornerRadius * 2, cornerRadius * 2, 270, 90);
      roundedRect.AddArc(boundingRect.X + boundingRect.Width - margin - cornerRadius * 2, 
boundingRect.Y + boundingRect.Height - margin - cornerRadius * 2, cornerRadius * 2, cornerRadius * 2, 0, 90);
      roundedRect.AddArc(boundingRect.X + margin, 
boundingRect.Y + boundingRect.Height - margin - cornerRadius * 2, 
cornerRadius * 2, cornerRadius * 2, 90, 90);
      roundedRect.AddLine(boundingRect.X + margin, 
boundingRect.Y + boundingRect.Height - margin - cornerRadius * 2, 
boundingRect.X + margin, boundingRect.Y + margin + cornerRadius);
      roundedRect.CloseFigure();
      return roundedRect;
    }

    #endregion

    #region Mouse and Keyboard Interaction

    protected override void OnMouseEnter(EventArgs e) {
      HighlightButton();
      base.OnMouseEnter(e);
    }

    protected override void OnGotFocus(EventArgs e) {
      HighlightButton();
      base.OnGotFocus(e);
    }

    private void HighlightButton() {
      if (Enabled) {
        animateResumeNormalTimer.Stop();
        animateButtonHighlightedTimer.Start();
      }
    }

    private void animateButtonHighlightedTimer_Tick(object sender, EventArgs e) {
      if (increasingAlpha) {
        if (100 <= highlightAlphaBottom) {
          increasingAlpha = false;
        } else {
          highlightAlphaBottom += 5;
        }
      } else {
        if (0 >= highlightAlphaBottom) {
          increasingAlpha = true;
        } else {
          highlightAlphaBottom -= 5;
        }
      }
      Invalidate();
    }

    protected override void OnMouseLeave(EventArgs e) {
      ResumeNormalButton();
      base.OnMouseLeave(e);
    }

    protected override void OnLostFocus(EventArgs e) {
      ResumeNormalButton();
      base.OnLostFocus(e);
    }

    private void ResumeNormalButton() {
      if (Enabled) {
        animateButtonHighlightedTimer.Stop();
        animateResumeNormalTimer.Start();
      }
    }

    private void animateResumeNormalTimer_Tick(object sender, EventArgs e) {
      bool modified = false;
      if (highlightAlphaBottom > 0) {
        highlightAlphaBottom -= 5;
        modified = true;
      }
      if (highlightAlphaTop < 255) {
        highlightAlphaTop += 5;
        modified = true;
      }
      if (!modified) {
        animateResumeNormalTimer.Stop();
      }
      Invalidate();
    }

    protected override void OnMouseDown(MouseEventArgs mevent) {
      PressButton();
      base.OnMouseDown(mevent);
    }

    protected override void OnKeyDown(KeyEventArgs kevent) {
      if (kevent.KeyCode == Keys.Space || kevent.KeyCode == Keys.Return) {
        PressButton();
      }
      base.OnKeyDown(kevent);
    }

    private void PressButton() {
      if (Enabled) {
        animateButtonHighlightedTimer.Stop();
        animateResumeNormalTimer.Stop();
        highlightRect.Location = new Point(0, ClientRectangle.Height / 2);
        highlightAlphaTop = 0;
        highlightAlphaBottom = 200;
        Invalidate();
      }
    }

    protected override void OnMouseUp(MouseEventArgs mevent) {
      ReleaseButton();
      if (DisplayRectangle.Contains(mevent.Location)) {
        HighlightButton();
      }
      base.OnMouseUp(mevent);
    }

    protected override void OnKeyUp(KeyEventArgs kevent) {
      if (kevent.KeyCode == Keys.Space || kevent.KeyCode == Keys.Return) {
        ReleaseButton();
        if (IsDefault) {
          HighlightButton();
        }
      }
      base.OnKeyUp(kevent);
    }

    protected override void OnMouseMove(MouseEventArgs mevent) {
      if (Enabled && (mevent.Button & MouseButtons.Left) == MouseButtons.Left && 
!ClientRectangle.Contains(mevent.Location)) {
        ReleaseButton();
      }
      base.OnMouseMove(mevent);
    }

    private void ReleaseButton() {
      if (Enabled) {
        highlightRect.Location = new Point(0, 0);
        highlightAlphaTop = 255;
        highlightAlphaBottom = 0;
      }
    }

    #endregion

  }
}

Next, let's move on to something other than our illustrious buttons!