Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / multimedia / GDI+

Media Button: An animated WMC style button control

4.85/5 (36 votes)
17 Jan 2010CPOL6 min read 70.9K   3.1K  
Swirling, swishing, pulsing, shrinking, you know the one..

MediaButton

Inception

Having recently written something that required some buttons with a bit of polish, I started thinking about writing a button class for my little Wav recorder.. My first thought was of WMC's buttons.. There are a lot of things I don't like about WMC (that dumb summary page - and why is there a delete button there?, the lack of UI options -what's up with the other two styles.. did you fire the graphics guy?, and why don't they cache the damn thumbnails?!!! geez..), but one thing they did a decent job on was the user interface. It definitely has some wow-factor when seen for the first time. So.. I opened up WMC on my dev box, and took a closer look.. after some brisk inner dialog (aww man, that's gonna take a week to get right!), and as reckless with my free time as I am famous for being, I (you), now have a media button in my war chest.

Styles

There are three distinct styles of buttons in WMC: the 'Menu' button on the main page that sends you to a category of options, the 'Media' button controls that have no animation, and the 'Custom' buttons found throughout the option pages. I'll focus this article primarily on the more complex of the three: the Menu style.

Menu Style

Before creating sprites or starting timers and so forth, I did my best to get near the visual style of the button. The WMC frame has a black outer frame 1px wide (it looks better without it), with a 2px frame drawn with a diagonal gradient. This is achieved using a BackwardDiagonal blended gradient brush drawn via a GraphicsPath:

C#
private void DrawMenuButtonBorder(Graphics g, RectangleF bounds)
{
    using (GraphicsPath borderPath = CreateRoundRectanglePath(
        g,
        bounds.X, bounds.Y,
        bounds.Width, bounds.Height,
        CornerRadius))
    {
        // top-left bottom-right -dark
        using (LinearGradientBrush borderBrush = new LinearGradientBrush(
            bounds,
            Color.FromArgb(140, Color.DarkGray),
            Color.FromArgb(140, Color.White),
            LinearGradientMode.BackwardDiagonal))
        {
            Blend blnd = new Blend();
            blnd.Positions = new float[] { 0f, .5f, 1f };
            blnd.Factors = new float[] { 1f, 0f, 1f };
            borderBrush.Blend = blnd;
            using (Pen borderPen = new Pen(borderBrush, 2f))
                g.DrawPath(borderPen, borderPath);
        }
    }
}

The menu style has a substantial drop shadow, also achieved with a gradient brush, and using a clipping region to contain the effect:

C#
private void DrawMenuButtonDropShadow(Graphics g, RectangleF bounds, 
                                      int depth, int opacity)
{
    // offset shadow dimensions
    RectangleF shadowBounds = bounds;
    shadowBounds.Inflate(1, 1);
    shadowBounds.Offset(depth, depth);
    // create a clipping region
    bounds.Inflate(1, 1);
    using (GraphicsPath clipPath = CreateRoundRectanglePath(
        g,
        bounds.X, bounds.Y,
        bounds.Width, bounds.Height,
        CornerRadius))
    {
        // clip the interior
        using (Region region = new Region(clipPath))
            g.SetClip(region, CombineMode.Exclude);
    }
    // create a graphics path
    using (GraphicsPath gp = CreateRoundRectanglePath(g, shadowBounds.X, 
           shadowBounds.Y, shadowBounds.Width, shadowBounds.Height, 8))
    {
        // draw with a path brush
        using (PathGradientBrush borderBrush = new PathGradientBrush(gp))
        {
            borderBrush.CenterColor = Color.FromArgb(opacity, Color.Black);
            borderBrush.SurroundColors = new Color[] { Color.Transparent };
            borderBrush.SetBlendTriangularShape(.5f, 1.0f);
            borderBrush.FocusScales = new PointF(.4f, .5f);
            g.FillPath(borderBrush, gp);
            g.ResetClip();
        }
    }
}

Now we have something that looks like this:

Then, we draw a nearly transparent mask within this, using a LinearGradient brush and a blend between translucent white and a nearly transparent silver, adjusting the blend and factors to achieve a tear effect near the top edge:

C#
private void DrawMenuButtonMask(Graphics g, RectangleF bounds)
{
    RectangleF maskRect = bounds;
    int offsetX = (this.ImagePadding.Left + this.ImagePadding.Right) / 2;
    int offsetY = (this.ImagePadding.Top + this.ImagePadding.Bottom) / 2;
    maskRect.Inflate(offsetX, offsetY);
    // draw using hq anti alias
    using (GraphicsMode mode = new GraphicsMode(g, SmoothingMode.AntiAlias))
    {
        // draw the drop shadow 494 210
        DrawMenuButtonDropShadow(g, maskRect, ShadowDepth, 120);
        // draw the border
        DrawMenuButtonBorder(g, maskRect);
        
        maskRect.Inflate(-1, -1);
        // create an interior path
        using (GraphicsPath gp = CreateRoundRectanglePath(g, maskRect.X, maskRect.Y,
            maskRect.Width, maskRect.Height, CornerRadius))
        {
            // fill the button with a subtle glow
            using (LinearGradientBrush fillBrush = new LinearGradientBrush(
                maskRect,
                Color.FromArgb(160, Color.White),
                Color.FromArgb(5, Color.Silver),
                75f))
            {
                Blend blnd = new Blend();
                blnd.Positions = new float[] { 0f, .1f, .2f, .3f, .4f, .5f, 1f };
                blnd.Factors = new float[] { 0f, .1f, .2f, .4f, .7f, .8f, 1f };
                fillBrush.Blend = blnd;
                g.FillPath(fillBrush, gp);
            }
        }
        // init storage
        _tSwirlStage = new SwirlStage(0);
        maskRect.Inflate(1, 1);
        _tSwirlStage.mask = Rectangle.Round(maskRect);
    }
}

This gives us the focused mask:

OK, with that out of the way, now comes the fun part (me->sarcasm). The menu button employs a four stage compound animation. The first stage has two simultaneous effects: line sprites, and an ambient glow that appears to emanate from under the button. In the first stage, the two line sprites start at (0,0), then split off in two directions: one down the left side, and another across the top. While travelling, they are also losing luminance, until about a third of the way across, they disappear. While this is happening, a textured glow pulses once across the top edge. Now, the trick, I think, with this sort of animation, is subtlety. If the animation is too busy or the effect too obvious, the observer will get it all on the first cycle and quickly lose interest. So, just as in WMC, the effects are very subdued, and require some scrutiny to get the whole effect.

Given that C# graphics leave a lot to be desired as far as speed goes (30 overloads on Graphics.Draw, no wonder it's so slow; by the way, it calls an API that only takes an integer..), we need to buffer the drawing, and create the sprites needed just once when the control loads. This control uses my StoreDc class, used to draw the control into a static buffer; a second buffer is used for the sprites, in effect, triple buffering the control, but also saving a lot of CPU time, and creating a seamless animation.

The animation state is stored in a custom type that keeps state on things like the sprite size, timer tick, and relative coordinates. This is all run from a custom timer class FadeTimer, that fires events from a synchronized timer in ascending/descending counts or a loop. I named this animation segment 'Swirl', for the swirling edge sprites. The DrawSwirl() routine is the hub, too large to simply drop on this page, but here's the segment up to the end of the first animation stage:

C#
private void DrawSwirl()
{
    if (_cButtonDc != null)
    {
        int endX = 0;
        int endY = this.Height / 2;
        float alphaline = 0;
        int offset = 0;
        Rectangle cliprect;
        Rectangle mistrect;
        Rectangle linerect;
        // copy unaltered image into buffer
        BitBlt(_cTempDc.Hdc, 0, 0, _cTempDc.Width, 
               _cTempDc.Height, _cButtonDc.Hdc, 0, 0, 0xCC0020);
        switch (_tSwirlStage.stage)
        {
            #region Stage 1 - Top/Left
            case 0:
                {
                    endX = _tSwirlStage.mask.Width / 2;
                    if (_tSwirlStage.tick == 0)
                    {
                        _tSwirlStage.linepos.X = 
                          _tSwirlStage.mask.X + (int)CornerRadius;
                        _tSwirlStage.linepos.Y = _tSwirlStage.mask.Y;
                        _ftAlphaLine = .95f;
                        _ftAlphaGlow = .45f;
                    }
                    // just in case..
                    if (endX - _tSwirlStage.linepos.X > 0)
                    {
                        // get the alpha
                        _ftAlphaLine -= .02f;
                        if (_ftAlphaLine < .4f)
                            _ftAlphaLine = .4f;
                        linerect = new Rectangle(_tSwirlStage.linepos.X, 
                          _tSwirlStage.linepos.Y, _bmpSwirlLine.Width, 
                          _bmpSwirlLine.Height);
                        // draw first sprite -horz
                        DrawMenuButtonLine(_cTempDc.Hdc, _bmpSwirlLine, 
                                           linerect, _ftAlphaLine);
                        // second sprite -vert
                        // turn down the alpha to match border color
                        alphaline = _ftAlphaLine - .1f;
                        // draw second sprite
                        linerect = new Rectangle(_tSwirlStage.linepos.Y, 
                          _tSwirlStage.linepos.X, _bmpSwirlLineVert.Width, 
                          _bmpSwirlLineVert.Height);
                        DrawMenuButtonLine(_cTempDc.Hdc, 
                          _bmpSwirlLineVert, linerect, alphaline);
                    }
                    // draw mist //
                    if (_tSwirlStage.linepos.X < endX / 3)
                    {
                        _ftAlphaGlow += .05f;
                        if (_ftAlphaGlow > .9f)
                            _ftAlphaGlow = .9f;
                    }
                    else
                    {
                        _ftAlphaGlow -= .05f;
                        if (_ftAlphaGlow < .1f)
                            _ftAlphaGlow = .1f;
                    }
                    // position
                    cliprect = _tSwirlStage.mask;
                    cliprect.Inflate(1, 1);
                    cliprect.Offset(1, 1);
                    mistrect = new Rectangle(_tSwirlStage.mask.Left, 
                      _tSwirlStage.mask.Top, _bmpSwirlGlow.Width, 
                      _bmpSwirlGlow.Height);
                    offset = (int)(ShadowDepth * .7f);
                    mistrect.Offset(-offset, -offset);
                    // draw _ftAlphaGlow
                    DrawMenuButtonMist(_cTempDc.Hdc, _bmpSwirlGlow, 
                                       cliprect, mistrect, _ftAlphaGlow);
                    // counters
                    _tSwirlStage.linepos.X++;
                    _tSwirlStage.tick++;
                    // reset
                    if (_tSwirlStage.linepos.X > (endX - _tSwirlStage.linepos.X))
                    {
                        _tSwirlStage.stage = 1;
                        _tSwirlStage.tick = 0;
                        _ftAlphaGlow = 0;
                        _ftAlphaLine = 0;
                    }
                    break;
                }
            #endregion
...

You may have noticed the routines DrawMenuButtonMist and DrawMenuButtonLine; these call to the AlphaBlend routine. Now initially, I used GdiAlphaBlend, but I found that the Graphics ColorMatrix/ImageAttribute method worked rather well..

C#
private void DrawMenuButtonLine(IntPtr destdc, Bitmap source, 
                                Rectangle bounds, float intensity)
{
    using (Graphics g = Graphics.FromHdc(destdc))
    {
        g.CompositingMode = CompositingMode.SourceOver;
        AlphaBlend(g, source, bounds, intensity);
    }
}

private void AlphaBlend(Graphics g, Bitmap bmp, 
                        Rectangle bounds, float alpha)
{
    if (alpha > 1f)
        alpha = 1f;
    else if (alpha < .01f)
        alpha = .01f;
    using (ImageAttributes ia = new ImageAttributes())
    {
        ColorMatrix cm = new ColorMatrix();
        cm.Matrix00 = 1f;
        cm.Matrix11 = 1f;
        cm.Matrix22 = 1f;
        cm.Matrix44 = 1f;
        cm.Matrix33 = alpha;
        ia.SetColorMatrix(cm);
        g.DrawImage(bmp, bounds, 0, 0, bmp.Width, 
                    bmp.Height, GraphicsUnit.Pixel, ia);
    }
}

Also note the CompositingMode change to SourceOver in the calling method.

The glow that emanates from behind the button in the top corner is rendered using a clipping region placed around the mask and the border. As I mentioned, I initially used the API AlphaBlend, and the Graphics clipping method, for some reason, did not work in that context, so I wrote an API based clipping class, ClippingRegion, which seems to work well in either instance.

The other sprite movements on the focused state are similar to the first, but this button also uses a second effect, it resizes when pressed. It also grows in size when WMC first loads. This was a problem, because it was resizing before the control became visible. You see, the Visible property is only an indication that the WS_VISIBLE style bit has been set, and not that the control has drawn itself. I posed this question on MSDN, but got no answer (you can't swing a dead cat without hitting an MVP on MSDN, but nobody knows how to determine if a control is truly visible?!). I fixed this with a simple wait timer, and give the option of using this effect with the ResizeOnLoad property, which should be assigned to the button getting the focus when the form loads. The resize effect was achieved by blitting the mask image fragment into a temporary DC, then sizing the image in stages via the FadeTimer tick count, like this:

C#
private void DrawResized()
{
    Rectangle canvas = new Rectangle(0, 0, this.Width, this.Height);
    Rectangle maskRect = canvas;
    // image axis
    int offsetX = (this.ImagePadding.Left + this.ImagePadding.Right) / 2;
    int offsetY = (this.ImagePadding.Top + this.ImagePadding.Bottom) / 2;
    maskRect.Inflate(-offsetX, -offsetY);
    // inflate by -tickcount
    int sizediff = _cResizeTimer.TickCount;
    maskRect.Inflate(-sizediff, -sizediff);
    if (_cButtonDc != null)
    {
        using (Graphics g = Graphics.FromHdc(_cButtonDc.Hdc))
        {
            using (GraphicsMode mode = 
                     new GraphicsMode(g, SmoothingMode.AntiAlias))
            {
                // set hq render
                g.InterpolationMode = InterpolationMode.HighQualityBicubic;
                // backfill
                using (Brush br = new SolidBrush(this.BackColor))
                    g.FillRectangle(br, canvas);
                // draw text in at normal size
                if (this.Text.Length != 0)
                {
                    SizeF sz = MeasureText(this.Text);
                    maskRect.Height -= (int)sz.Height + 
                             TextPadding.Top + TextPadding.Bottom;
                    Rectangle textRect = GetTextRectangle(TextAlign, canvas, 
                              new Rectangle(0, 0, (int)sz.Width, (int)sz.Height));
                    DrawText(g, this.Text, textRect);
                }
                // draw the sized image
                g.DrawImage(_bmpResize, maskRect);
            }
        }
    }
    // draw to control
    using (Graphics g = Graphics.FromHwnd(this.Handle))
    {
        BitBlt(g.GetHdc(), 0, 0, _cButtonDc.Width, 
          _cButtonDc.Height, _cButtonDc.Hdc, 0, 0, 0xCC0020);
        g.ReleaseHdc();
    }
    // don't repaint
    RECT r = new RECT(0, 0, canvas.Width, canvas.Height);
    ValidateRect(this.Handle, ref r);
}

Note the use of ValidateRect in the above code. We are telling Windows that all the painting has been done (and to leave it alone ;o).

Custom Button

Now, you didn't think that I forgot about this one; it's almost as complicated as the menu button (well, not quite..). The custom style has two animations: the pulse effect when focused (that is very subtle; when I first noticed it in WMC, I thought it was glare from the TV, or sunspots, or that 3 day old pot roast I should have just left alone..), and the 'Swish' effect when pressed, i.e., that little blob flying by.. I think the hardest part about creating those effects is in matching up the gradient textures. I am fairly sure they are using images for some of the effects, the glow under the menu button, and the flying blob seemed to have annoyed/vexed me most, but rather than drop some more boring code here, I'll give you a tip. Whilst recreating the gradients, I did not so much look at color as at shape; get the shape right, then tweak the colors. For example, when creating the ambient glow for the custom button, it's subtlety made it hard to grasp, so I used the color red instead of white to get an idea of shape and texture; that out of the way, I adjusted the color.

License

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