In my last entry, I suggested the optimization strategy of pre-rasterizing vector images in order to improve performance. With the example presented, we realized a fairly substantial performance bump - an 1,802% increase in performance.
This technique has applications beyond hand-written vector code, such as the rendering code written to create the navigation buttons. On Windows, the predominant file format to represent vector images is the metafile - either a wmf or an emf. But what exactly is a metafile? Along with headers, handles, and a palette, a metafile is a series of metafile records that map directly to GDI calls. When you display a metafile, you are essentially "playing back" these GDI calls one after the other. So, in the same way that we were able to improve the performance of our C# GDI+ calls by pre-rasterizing, we could improve the performance of our metafile rendering by pre-rasterizing.
Have we found a magic bullet? Can we really promise an increase of ~2,000% performance across the board using this technique? Unfortunately, the answer is a definitive no. As with so many techniques for improving performance, the answer is always "it depends," and you need to validate any performance work you do with measurement.
In the example we used in my last post, we were placing a series of alpha blended objects on top of each other. Since monitors don't have 256 layers of pixels with varying degrees of translucency, alpha blending must be accomplished by taking the original color and "mixing" it with the color you are placing on top of it with an alpha channel, and with our various gradient alpha channels this must be done pixel by pixel. Obviously, that's quite a bit of math, and that takes time. Radial gradient blends also tend to be computationally intensive. Pre-rasterizing our images was quite a bit of help. We were also rendering something fairly small - moving pre-rendered images around takes more time as the size of the image grows.
To try and understand these performance characteriztics better, let's begin with a metafile. I used a metafile from the Microsoft Office clip-art library of a house, which looked relatively complex but doesn't appear to use alpha channels (although I did not dissect the metafile to verify this).
Rendering a metafile from Office Clip-Art Collection
Rendering this image at 178 x 153 pixels, real-time rasterization required an average of 2.47 milliseconds, while rendering a pre-rasterized bitmap required an average of 0.35 milliseconds. While not quite as significant as our navigation buttons, that is still a 605% increase in performance. Should we pat ourselves on the back and say job well done? Not just yet...
When we compare rendering times at 1073 x 918 for this image, we observe that our real-time rendering requires an average of 12.24 milliseconds, while our pre-rasterized rendering requires an average of 19.19 milliseconds. Wait ... what happened? Our rendering speed decreased by 36%?
Yep.
Well, this tells us that the performance characteristics of this technique are related to the size of the image we are trying to render. However, with only two data points, the best choice for a trend would be linear, which we really wouldn't expect. Rather, we would expect rendering speed to be a second order polynomial, given that we are varying both width and height. This means that we need more measurements, which we can collect and graph. Let's take a look at the chart:
Real-Time vs. Pre-Rasterized Rendering Performance: Size (as proportion of original) vs. Rendering Speed (in ms)
As we predicted, with additional data points we can find a trend line for real time rendering (with an R2 value of 0.9908598) of:
y = 0.0698718 x2 + 0.0569021 x + 2.6299394
Similarly, we can find a trend line for pre-rasterized rendering (with an R2 value of 0.9995371) of:
y = 0.1357692 x2 + 0.2795874 x - 0.2465455
Using this data, we could make an informed decision. We know that our pre-rasterized rendering performs faster than our real-time rendering at small sizes, but as the size gets larger, the pre-rasterized implementation becomes slower more quickly. This data would be even better if we had samples from additional hardware - are we completely sure that hardware, drivers, and memory configuration of a single graphics card apply to everyone's graphics card? With some additional machine data, we can then make an educated decision. If we will never need to render our image above a particular size, and if that size is going to be predominantly smaller than the crossover point in our chart, then pre-rasterizing may be a good decision.
So, we don't want to just arbitrarily pre-rasterize everything. We want to understand the performance characteristics of pre-rasterizing the particular image we are rendering at the particular sizes that we reasonably expect to be drawing to, hopefully on a fairly representative selection of hardware. In some cases, pre-rasterization will be the hands-down winner. In others, it will be much closer. In still others, it will be the wrong approach. The data can help us decide.
If it's really close, then there are other factors to consider. Rendering in real time tends to make for smaller and more maintainable code. That is almost always important. There are trade-offs everywhere.
In the past few posts, I have been exploring owner-drawing of vector images to create a more compelling user interface that recreates, to a high degree of fidelity, the intentions of the designers. However, as engineers, we are implementing the entirety of the user experience, and not simply the appearance. In addition to faithfully representing the visuals, our code must deliver on other promises for a compelling user experience. One of these components is performance.
As I mentioned in my first post on creating Gel Buttons, it is faster to move memory than it is to perform computations, which is why you can render a raster image (bitmap) faster than you can render a vector image (such as the GDI+ code that we created). In the Gel Buttons example, we were including animation, which made raster images a less tantalizing choice. However, what if we don't need animations, but we would like the scalability characteristics of vector images? We can pre-rasterize our vectors once we know the size that we are rasterizing to, and all subsequent calls to render our control can leverage the speed of rendering bitmaps (moving memory).
As with many performance-related engineering decisions, here we have a trade-off between size and performance. By consuming more memory to render in advance, we can improve runtime performance. To explore this, let's use a navigation control as an example. I have created a custom control to present a navigation UI, a common idiom in applications that users are probably most familiar with from Web applications. (This idiom receives first class support in Windows Presentation Foundation, with a NavigationWindow providing this navigation UI out of the box.)
WinForms Navigation Control
(Once again, I am not presenting this particular design as a model of good design, but I am instead interested in the engineering techniques that we can use to represent such a button.)
What I have created includes several layers of painting. There is a linear gradient background behind both buttons. Each button has a radial gradient, and there are several alpha-blended gradients on top of this button, designed both to (hopefully) make it look kind of cool and glassy.
For this example, unlike our gel buttons, I have implemented 4 distinct states: normal, mouse over, mouse down, and disabled. (For the time being, I am not addressing accessibility issues.) Because I do not have animations, there are only 16 possible states for the entire control (4 states for the left button times 4 states for the right button). Let's take a look at the rendering code for this button:
private void DrawBackground(Graphics g) { using (GraphicsPath p = new GraphicsPath()) { p.FillMode = FillMode.Winding; p.AddEllipse(0, 0, Width / 2, Height - 1); p.AddEllipse(Width / 2, 0, Width / 2 - 1, Height - 1); p.AddRectangle(new Rectangle(this.Width / 4, this.Height / 4, this.Width / 2, this.Height / 2)); using (LinearGradientBrush b = new LinearGradientBrush(ClientRectangle, backgroundTop, backgroundBottom, LinearGradientMode.Vertical)) { g.FillPath(b, p); } } } private void DrawButton(Graphics g, Rectangle boundingRectangle, ArrowDirection direction, ButtonState state) { // Draw the button using (GraphicsPath buttonPath = new GraphicsPath()) { buttonPath.AddEllipse(boundingRectangle); using (PathGradientBrush buttonBrush = new PathGradientBrush(buttonPath)) { switch (state) { case ButtonState.Normal: buttonBrush.CenterColor = buttonInnerColorNormal; buttonBrush.SurroundColors = new Color[] { buttonOuterColorNormal }; break; case ButtonState.MouseOver: buttonBrush.CenterColor = buttonInnerColorMouseOver; buttonBrush.SurroundColors = new Color[] { buttonOuterColorMouseOver }; break; case ButtonState.MouseDown: buttonBrush.CenterColor = buttonInnerColorMouseDown; buttonBrush.SurroundColors = new Color[] { buttonOuterColorMouseDown }; break; case ButtonState.Disabled: buttonBrush.CenterColor = buttonInnerColorDisabled; buttonBrush.SurroundColors = new Color[] { buttonOuterColorDisabled }; break; } g.FillPath(buttonBrush, buttonPath); } } // Draw the halo using (GraphicsPath haloPath = new GraphicsPath()) { haloPath.AddEllipse(boundingRectangle); haloRectangle.Offset(boundingRectangle.Location); haloPath.AddEllipse(haloRectangle); haloRectangle.Offset(-boundingRectangle.X, -boundingRectangle.Y); using (PathGradientBrush haloBrush = new PathGradientBrush(haloPath)) { haloBrush.CenterColor = Color.FromArgb(200, Color.White); haloBrush.SurroundColors = new Color[] { Color.FromArgb(0, Color.White) }; g.FillPath(haloBrush, haloPath); } } if (ButtonState.MouseDown != state) { // Draw the moon using (GraphicsPath moonBottomPath = new GraphicsPath()) { moonRectangle.Offset(boundingRectangle.Location); moonBottomPath.AddEllipse(moonRectangle); moonRectangle.Offset(-boundingRectangle.X, -boundingRectangle.Y); using (Region moonRegion = new Region(moonBottomPath)) { using (GraphicsPath moonTopPath = new GraphicsPath()) { moonTopPath.AddEllipse(boundingRectangle); moonRegion.Complement(moonTopPath); using (SolidBrush moonBrush = new SolidBrush( Color.FromArgb(30, Color.White))) { g.FillRegion(moonBrush, moonRegion); } } } } // Draw the top reflection using (GraphicsPath topReflectionPath = new GraphicsPath()) { topReflectionRectangle.Offset(boundingRectangle.Location); topReflectionPath.AddEllipse(topReflectionRectangle); topReflectionRectangle.Offset(-boundingRectangle.X, -boundingRectangle.Y); using (LinearGradientBrush topReflectionBrush = new LinearGradientBrush(topReflectionPath.GetBounds(), Color.FromArgb(200, Color.White), Color.FromArgb(0, Color.White), LinearGradientMode.Vertical)) { g.FillPath(topReflectionBrush, topReflectionPath); } } // Draw the bottom reflection using (GraphicsPath bottomReflectionPath = new GraphicsPath()) { bottomReflectionRectangle.Offset(boundingRectangle.Location); bottomReflectionPath.AddEllipse(bottomReflectionRectangle); bottomReflectionRectangle.Offset(-boundingRectangle.X, -boundingRectangle.Y); using (LinearGradientBrush bottomReflectionBrush = new LinearGradientBrush(bottomReflectionPath.GetBounds(), Color.FromArgb(0, Color.White), Color.FromArgb(50, Color.White), LinearGradientMode.Vertical)) { g.FillPath(bottomReflectionBrush, bottomReflectionPath); } } } // Draw the arrow arrowTop.Offset(boundingRectangle.Location); arrowBottom.Offset(boundingRectangle.Location); arrowLeft.Offset(boundingRectangle.Location); arrowRight.Offset(boundingRectangle.Location); Point[] arrowHeadPoints; if (ArrowDirection.Left == direction) { arrowHeadPoints = new Point[] { arrowTop , arrowLeft, arrowBottom }; } else { arrowHeadPoints = new Point[] { arrowTop, arrowRight, arrowBottom }; } Point[] arrowLinePoints = new Point[] { arrowLeft, arrowRight }; Color arrowColor = ButtonState.MouseDown == state ? Color.Gray : Color.White; using (SolidBrush arrowBrush = new SolidBrush(arrowColor)) { using (Pen arrowPen = new Pen(arrowBrush, Height / 10)) { arrowPen.LineJoin = LineJoin.Round; arrowPen.StartCap = LineCap.Round; arrowPen.EndCap = LineCap.Round; g.DrawLines(arrowPen, arrowHeadPoints); g.DrawLines(arrowPen, arrowLinePoints); } } arrowTop.Offset(-boundingRectangle.X, -boundingRectangle.Y); arrowBottom.Offset(-boundingRectangle.X, -boundingRectangle.Y); arrowLeft.Offset(-boundingRectangle.X, -boundingRectangle.Y); arrowRight.Offset(-boundingRectangle.X, -boundingRectangle.Y); }
To improve performance somewhat, I have tried to factor out a great deal of the mathematical computations used to describe the vectors we are drawing, although there is still more work that could be done to leverage this particular performance optimization technique. (I opted for simplicity of a single method to render a button, which means that I require some additional offsets.) However, the point is that I only need to perform these mathematical calculations if the control is resized - the results will be the same with every subsequent call to the OnPaint method.
Following the technique I used previously, we can simply call these drawing methods when our OnPaint method is called, like so:
protected override void OnPaint(PaintEventArgs e) { Graphics g = e.Graphics; g.SmoothingMode = SmoothingMode.HighQuality; g.PixelOffsetMode = PixelOffsetMode.HighQuality; ButtonRenderer.DrawParentBackground(g, ClientRectangle, this); DrawBackground(g); DrawButton(g, leftButtonRectangle, ArrowDirection.Left, leftButtonState); DrawButton(g, rightButtonRectangle, ArrowDirection.Right, rightButtonState); }
Now, if I instrument this code, I can manipulate the control and get a sense of how well it is performing as currently implemented. On my Tablet PC, the average time spent in OnPaint while using the real-time rasterization is 8.18ms. Since the rods in your eye have a period of integration of 100ms, and the cones have a period of integration of 10 to 15ms, this does not seem unreasonable. However, we would be wise to account for the fact that we are very unlikely to have a form where the only object we needed to paint was a navigation control - our control should provide some additional headroom to render the rest of the objects on the page, Furthermore, we may be implementing something more complex than a gel button with 11 distinct objects in order to faithfully represent the designers' intentions. Finally, keep in mind that those 8 ms represent time that your application isn't doing any meaningful business processing.
Knowing that there are only 16 distinct states which we will ever need to render, we can easily rasterize these states in advance, and use the much faster approach of moving memory to render these images. Here is some code to render these states in advance (which we can call when we create the control, and also in response to a resize event):
private void CreateImages() { DisposeImages(); for (int row = 0; row < 4; row++) { for (int col = 0; col < 4; col++) { buttonImages[row, col] = new Bitmap(Width, Height, System.Drawing.Imaging.PixelFormat.Format32bppArgb); using (Graphics g = Graphics.FromImage(buttonImages[row, col])) { g.SmoothingMode = SmoothingMode.HighQuality; g.PixelOffsetMode = PixelOffsetMode.HighQuality; ButtonRenderer.DrawParentBackground(g, ClientRectangle, this); DrawBackground(g); DrawButton(g, leftButtonRectangle, ArrowDirection.Left, (ButtonState)row); DrawButton(g, rightButtonRectangle, ArrowDirection.Right, (ButtonState)col); } } } }
Now that we have the images pre-rendered, the OnPaint method can be used to simply paint our pre-rendered images:
protected override void OnPaint(PaintEventArgs e) { Graphics g = e.Graphics; g.DrawImage(buttonImages[(int)leftButtonState, (int)rightButtonState], ClientRectangle); }
Again, if we instrument this code, we can determine how well this method performs. On my Tablet PC, the average time spent in the OnPaint method was reduced to 0.43ms. We were able to improve the performance of our rendering code by an order of magnitude! (An 1,802% increase - not bad.)
Of course, this method is not without its shortcomings. Every time that you create or resize the control, you have to wait for all 16 permutations to render before you can show anything with this naive implementation. A better approach might be to render the image for a state only when that state exists. You also have to set aside enough memory to hold all of these rendered images, which you did not have to do while rendering real-time. However, if you are implementing custom drawing and looking to make a significant increase in run-time performance, this is a technique worth considering.
In a comment to my final post on gel buttons, the orginality and creativity of my design was, shall we say, called into question. Obviously, I have miscommunicated my intentions here, as evidenced by the tone and directness of the feedback.
I am not a designer. I am not trained to be one, nor do I have any focussed inate talent in that domain. Therefore, my purpose was not to propose the notion of a gel button as being an exciting new design innovation. This is not a new design recommendation for implementing buttons from either me or my employer.
What is the point, then, of demonstrating how to create buttons that look like this, if I am not recommending that you create buttons that look like this?
I am not attempting to recommend a solution to a UI design problem, but instead to convey some approaches to implementing a solution to a UI design problem that somebody has provided. I believe that being able to represent, with great fidelity, the designs created by those who actually do have design talent will become an important part of the software engineering discipline. Often, implementation of an application is determined by what can be created quickly in the development tool's form designer, with the majority of the engineering effort devoted to implementing the functionality behind this UI. In many cases, this is completely appropriate. However, as application complexity continues to increase, it becomes more and more important to faithfully represent the intentions of the designers who mock up the application, as these designs will explicity target making the application either easier or more enjoyable to use. Close enough is becoming more and more insufficient as complexity, as well as the quality and quantity of competition, increases.
This is not limited to "bling" applications, such as Windows Media Player, or any number of other consumer-focussed applications. My tax software, for example, renders its own buttons. (It uses the increasingly popular "two-tone" buttons.) It increases the feel of quality. Even for non-commercial line-of-business appications, there are many problems where the approximations provided by the built-in controls are not quite accurate enough, and could be enhanced with both design and engineering talent. Usability should not be an afterthought.
Applications hopefully provide a solution to a particular problem domain. The challenge to the designer is to understand the core concepts that define the solution space, and present UI that provide a solution for this problem domain in the most intuitive way possible. The challenge to the engineer is to implement the designed solution with the maximum fidelity possible.
The new Microsoft Office system user interface
Brad Weed, the Product Design Manager of the Office Design Group, describes the relationship between the design displine and the engineering discipline in the Office team (he was a guest writer on Jensen Harris' fabulous An Office User Interface Blog):
So, essentially, my point is that making great designs come to life is important to maximize the usability and experience of your applications. This is a valuable engineering discipline. My example of gel buttons was somewhat arbitrary (coming directly from a link I read in another blog) and implemented just to start giving a taste of how to approach these problems from an engineering perspective.
Of course, that's not the only reason I delve into the implementation of user interface and experience.
I love pixels.
In the end, it's all about raw pixels. If there is one component of my computer that I am going to over-spend on, it is the monitor. Why? That's what I have to look at all day. It feels better to me to use something that has a sense of being well designed, and I want to deliver that sense of satisfaction to people who use the software that I create or help my customers create. (Incidentally, while the initial Beta 1 release of Office 2007 was "neat", the design of the Beta 1 refresh makes it finally feel satisfying. I love good design.)
I am hoping to share my joy of pixels, and to hopefully incite some respect for investment in faithfully reproducing design. In the end, the engineering goal is to bring static images and interactive prototypes to life, honoring both the intent of the design and the limitations of the real world. Design is finally coming to prominence at Microsoft. Windows Vista and Office 2007 are two products that, to me, clearly demonstrate the value of excellent design, and they are just the beginning. Coming soon, we also have the Windows Presentation Foundation, comlete with tools for designers to implement this UI code. However, even with the power provided by the Expression Interactive Designer ("Sparkle"), engineering talent for implementing fantastic UI was still required. (Check out the dynamic SmoothMove control custom implemented for the Flickr Browser.) These new products will emphasize the workflow between designer and developer, so we will no longer be limited to passing Photoshop images and Flash animations back and forth - we can finally collaborate.
Working in concert with great designers, we can create some fantastic user experiences on top of powerful products. I'll leave the design work to those more talented than I am, and stay behind the scenes writing the code to push pixels around.
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.)
g.ScaleTransform(2.0F, 2.0F);
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.
PenAlignment.Inset
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!
In our last episode, we went through the process of creating the static rendering of a gel button using Windows Forms. (The code I provided was developed using the .NET Framework 2.0, although at least one person has pointed out that it doesn't take much work to port this code to the 1.x version of the framework.) At the end of this, I mentioned that we could have achieved the same effect without having to write any code at all using a static image. I want to clarify this statement, as there could potentially be a difference.
What we have created is a vector drawing, that will smoothly scale to any size that you want to render a button. If you choose to implement this as a static image, not all image representations are vector representations. In fact, most of the most familiar image types are raster image types, such as bmp, jpg, gif, or png. If you needed to resize the image, you would be forced to resort to raster techniques, such as bicubic, bilinear, or nearest neighbor interpolation, in order to stretch the image to the target size - this pretty much always makes it look worse than the original image. In addition, your application would need to allocate space to house this raster image as a resource, increasing the size of your binary.
Not all image types are raster images, however. Windows Metafiles (wmf) and Enhanced Windows Metafiles (emf) are both vector image types. We could potentially have implemented this static image as an emf. In fact, if we had, our application would have virtually the same characteristics. You can think of emf files as being, at some level, a markup language for GDI+ drawing such as what we are doing here. If you enumerate through a metafile, you will see a series of records such as EMREXTCREATEPEN, EMRGRADIENTFILL, and EMRFILLRGN - each of these records maps directly to GDI+ commands, in exactly the same way that our System.Drawing code maps to GDI+ commands. So, from a general standpoint, it is possible to represent a vector image as a metafile rather than in code as we have done here and achieve fundamentally equivalent results in terms of both file size and drawing performance.
Of course, when I refer to metafiles as being like a markup language, there are some key differences between it and a true markup language, such as how you may use the XAML markup language to render an image in the Windows Presentation Foundation. First, metafiles are procedural - they execute from top to bottom, and do not include a notion of nesting other than by the order of commands executed. Second, they are not presented in a human readable form. They are specifically encoded to a binary format.
Some of you may be considering putting together a metafile generator to capture some of the great drawings that you may have implemented in code. A single file certainly is much easier to distribute than a block of code - and this is convenient packaging. This is not as straightforward as you might hope, as the .NET Framework documentation tells us.
Of course, that does not mean that we can't do it, but the fact that it is not obvious and ubiquitous probably explains why more software doesn't provide metafile output today. (For example, you can create a vector drawing in using the January 2006 CTP of Microsoft Expression Graphic Designer, but you can't save your drawing as a metafile.) We may come back to this in a later entry.
Of course, now that we have talked around our static vector image for a while, I think it's about time that we brought our button to life. Static images of buttons are only good for screen shots, and are not that interesting in the real world! As we said before, we would like our button to be suitably pliant, as well as taking advantage of the smart client platform. This is the result we are aiming for:
Windows Forms Gel Buttons : Second Revision
First, let's consider how we want to convey our pliancy, and how we could implement that in code. What I have chosen to do here is to manipulate the highlight, rather than manipulating any of the colors on the button. This makes it easy to support any background color that my consumer may use. Regardless of whether the button is the default blue I am using, or if it has been manipulated to be red, green, or any other color, I am always using a white highlight.
What do we want to do with that highlight? When you mouse over the button, a common approach is to make it lighter. This is fairly straightforward, as we merely need to increase the alpha channel for the bottom portion of the highlight from 0 to a higher value, allowing more of the white to shine through. However, since we have complete control of the graphics platform (a major advantage of the smart client), why limit ourselves to merely rendering a second, slightly brighter, static image? We can instead use animation to make our button pulsate, conveying the sense of pliancy through more than a single image change.
Animation will become easier with the Windows Presentation Foundation, but we can achieve the same effect today. What is animation but a series of static images that change smoothly over time? This is how movies and TV work. But how do we add a third time dimension to our code? Timers are one way to do that, and this is what I have chosen to do here.
Note that there are several options to choose from for a Timer object, with instances to choose from in the System.Threading, System.Timers, and System.Windows.Forms namespaces. I won't go into the details of when to choose which timer here (there is an introduction in MSDN Magazine for those who are interested), but I have chosen the timer object in the System.Windows.Forms namespace. If the primary UI thread is busy doing something else (handling the remainder of the windows messages coming in to the application), then losing a beat in my animation is not going to be my primary concern. Since I am doing UI work, I would need to invoke onto the primary UI thread anyway. Of course, if you unnecessarily block the primary UI thread with synchronous IO or network calls, then I will have yet another visual representation of this design deficiency.
Using this timer, I can gradually pulsate my button - adjusting the alpha of the bottom of the highlight. You can modify the properties of the timer to either speed this up or slow it down according to your tastes.
I used a second timer to return to normal, so that moving away from a button smoothly animates back to normal, rather than instantly and jarringly returning to its normal rendering state. Once I have reached that state, then the timer tick event can turn the timer off itself. Fairly straightforward.
Finally, we handle the pressed state. If I have a light coming from above in a button sticking up, then it serves to reason that this light would strike the bottom portion of that button when it is depressed, although that light would be somewhat less strong. So, here I merely moved the highlight to the bottom of the screen, reversed the order of the alpha manipulation, and modified the final alpha values to be less bright overall.
That being said, 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 { private Color gradientTop = Color.FromArgb(255, 44, 85, 177); private Color gradientBottom = Color.FromArgb(255, 153, 198, 241); private Rectangle buttonRect; private int highlightAlphaTop = 255; private int highlightAlphaBottom; private Rectangle highlightRect; private Timer animateMouseOverTimer = new Timer(); private Timer animateResumeNormalTimer = new Timer(); private bool increasingAlpha; [Category("Appearance"), Description("The color to use for the top portion of the gradient fill of the component.")] public Color GradientTop { get { return gradientTop; } set { gradientTop = value; Invalidate(); } } [Category("Appearance"), Description("The color to use for the bottom portion of the gradient fill of the component.")] public Color GradientBottom { get { return gradientBottom; } set { gradientBottom = value; Invalidate(); } } protected override void OnCreateControl() { SuspendLayout(); base.OnCreateControl(); buttonRect = new Rectangle(ClientRectangle.X, ClientRectangle.Y, ClientRectangle.Width - 1, ClientRectangle.Height - 1); highlightRect = new Rectangle(ClientRectangle.X, ClientRectangle.Y, ClientRectangle.Width - 1, ClientRectangle.Height / 2 - 1); animateMouseOverTimer.Interval = 20; animateMouseOverTimer.Tick += new EventHandler(animateMouseOverTimer_Tick); animateResumeNormalTimer.Interval = 5; animateResumeNormalTimer.Tick += new EventHandler(animateResumeNormalTimer_Tick); ResumeLayout(); } protected override void OnPaint(PaintEventArgs pevent) { Graphics g = pevent.Graphics; // Fill the background ButtonRenderer.DrawParentBackground(g, ClientRectangle, this); // Paint the outer rounded rectangle g.SmoothingMode = SmoothingMode.AntiAlias; using (GraphicsPath outerPath = RoundedRectangle(buttonRect, 5, 0)) { using (LinearGradientBrush outerBrush = new LinearGradientBrush(buttonRect, gradientTop, gradientBottom, LinearGradientMode.Vertical)) { g.FillPath(outerBrush, outerPath); } using (Pen outlinePen = new Pen(gradientTop)) { g.DrawPath(outlinePen, outerPath); } } // Paint the highlight rounded rectangle using (GraphicsPath innerPath = RoundedRectangle(highlightRect, 5, 2)) { 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, ForeColor, 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.CloseFigure(); return roundedRect; } protected override void OnMouseEnter(EventArgs e) { animateResumeNormalTimer.Stop(); animateMouseOverTimer.Start(); base.OnMouseEnter(e); } protected override void OnMouseLeave(EventArgs e) { animateMouseOverTimer.Stop(); animateResumeNormalTimer.Start(); base.OnMouseLeave(e); } protected override void OnMouseDown(MouseEventArgs mevent) { animateMouseOverTimer.Stop(); animateResumeNormalTimer.Stop(); highlightRect.Location = new Point(0, ClientRectangle.Height / 2); highlightAlphaTop = 0; highlightAlphaBottom = 200; Invalidate(); base.OnMouseDown(mevent); } protected override void OnMouseUp(MouseEventArgs mevent) { highlightRect.Location = new Point(0, 0); highlightAlphaTop = 255; highlightAlphaBottom = 0; if (DisplayRectangle.Contains(mevent.Location)) { animateMouseOverTimer.Start(); } base.OnMouseUp(mevent); } protected override void OnMouseMove(MouseEventArgs mevent) { if ((mevent.Button & MouseButtons.Left) == MouseButtons.Left && !ClientRectangle.Contains(mevent.Location)) { OnMouseUp(mevent); } base.OnMouseMove(mevent); } private void animateMouseOverTimer_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(); } 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(); } } }
So, in the end we have a gel button class that looks interesting (again, depending on your tastes), and actually exhibits pliancy to the user. It looks and behaves like a button - something that you can click.
Of course, you already have that behavior in the default buttons, so this example is really only useful if you want to create a button to specifically support a design theme for your application. Otherwise, visual styles in Windows XP and above will already provide you with this functionality, and you don't need to write any code to achieve it.
Windows, however, does not provide a control for every possible scenario. While you could use the base toolset to create just about any UI, you may very well want to optimize your UI by using a UI control that isn't provided in the base toolkit, and you don't want to have to compromise to fit your needs into this toolset. That's where custom controls become really interesting, and it is where we will go next.