Wednesday 10 March 2010

Flexibox – A Silverlight alternative to Lightbox

Introduction

Flexibox is an alternative to lightbox, displaying multiple resolutions of an image without needing a popup overlay. Flexibox shows how a Silverlight app can resize itself with a page.

Great photos need to displayed big to be best appreciated, but smaller image sizes work best within a block of text, or as a collection of thumbnails. To solve this design dilemma thumbnail images often contain a link to view a larger version of the same image. If this takes the user to another page, then this is inconvenient as the user has to go back to the original page to continue.

A popular alternative is to use a JavaScript library, such as Lightbox, to display an overlay image on the current page. This is very impressive the first few times one uses it. However the overlay hides the rest of the page. The distraction and time taken acts as a disincentive for users to see the larger version of the image.

Flexibox displays a single image within a HTML page in the same way as an <img> tag. Flexibox can change the image it displays to one of a different size and automatically resizes itself within the page. This enables a user to go from a thumbnail to a large resolution image without needing to leave the page.

The Flexibox appears on a web page just like any thumbnail image, but has an enlarge button overlaid on the top right to indicate to the user there is a larger version.

When the user clicks on the enlarge button the Flexibox is enlarged instantly causing the surrounding page elements to re-formatted to flow around the now larger control.

To see a Flexibox live go to http://ithinkly.com/demo/flexibox/

 

The Code

The code demonstrates a number of useful Silverlight techniques:
Passing parameters from the HTML page to the Silverlight
Converting CSS color strings into Silverlight Color structures
Loading content relative to the host page
Asynchronous loading and caching of images
Resizing a Silverlight control within a page

For what seems to be a very simple application Flexibox contains a lot more code than can be covered here, so only the techniques listed above are described. The full source code is  provided, so you can study all the details in full.
The project was developed with VS 2010 targeting Silverlight 3.
The HTML

The first thing to study is the way the Flexibox control HTML differs from the standard.

<div style="margin-right: 8px; margin-bottom: 3px;float: left;">
        <object data="data:application/x-silverlight-2," type="application/x-silverlight-2" width="240px" height="161px">
  <param name="source" value="ClientBin/FlexiBox.xap"/>
  <param name="onError" value="onSilverlightError" />
  <param name="background" value="white" />
  <param name="minRuntimeVersion" value="3.0.40818.0" />
  <param name="autoUpgrade" value="true" />
  <param name="initParams" value="border=2,border_color=#444,preload=true,mode=small,thumb_url=images/s1_100.jpg,medium_url=images/s1_500.jpg,large_url=images/s1_1024.jpg"  />
  <a href="http://go.microsoft.com/fwlink/?LinkID=149156&v=3.0.40818.0" style="text-decoration:none">
               <img src="http://go.microsoft.com/fwlink/?LinkId=161376" alt="Get Microsoft Silverlight" style="border-style:none"/>
  </a>
</object><iframe id="_sl_historyFrame" style="visibility:hidden;height:0px;width:0px;border:0px"></iframe></div>


The object tag is given a fixed width and height in pixels. These attributes will be later changed from within Silverlight.

The div tag surrounding the object tag uses an in-line style to set the margin and float the control to the left of the surrounding text. You can use almost any style in here, except for  width or height. Also do not set an id or class style for the div tag, as the default Visual Studio generated page will do.



The initParams param tag contains the parameters to be passed to the Silverlight control. The example above sets the width and color of the border and passes in four images. The image URLs are relative to the page, just as one would use in an img tag. The mode is set to small so that the small image will be the first one displayed.



The Flexibox XAML






There is only one control in the application and that is defined as follows.



<Border BorderBrush="{Binding Path=BorderBrush}" BorderThickness="{Binding Path=BorderThickness}" Name="border1"  >
    <Grid x:Name="LayoutRoot" >
        <Image  Name="image1" Stretch="None" Source="{Binding Path=DisplayedImage}" />
        <controlsToolkit:BusyIndicator Height="59" HorizontalAlignment="Center"  Name="busyIndicator1" VerticalAlignment="Center" Width="151" IsBusy="{Binding Path=IsBusy}" BusyContent="{Binding Path=BusyStatus}" DisplayAfter="00:00:02.1000000" IsEnabled="True" />
        <StackPanel Height="25" HorizontalAlignment="Right" Margin="0,2,2,0" Name="stackPanelButtons" VerticalAlignment="Top"  Orientation="Horizontal">
            <Button Style="{StaticResource ButtonIcon}" Click="Contract_Button_Click" Name="ContractButton" RenderTransform="{StaticResource ButtonBottomLeft}" Visibility="{Binding Path=ContractVisible}" />
            <Button Style="{StaticResource ButtonIcon}" Click="Expand_Button_Click" Name="ExpandButton"  RenderTransform="{StaticResource ButtonTopRight}" Visibility="{Binding Path=ExpandVisible}" />
        </StackPanel>
    </Grid>
</Border>


As you would expect there is a Border, an Image control and a Busy Indicator for when  loading the first image.

The StackPanel is used to align the two buttons, which only become visible when they can be used. The buttons are simply re-styled. See the styles.xaml file for details.


All the dynamic elements are controlled by data binding to properties of the model.



The Code behind file



public partial class MainPage : UserControl
{
    FlexiBoxViewModel viewModel = new FlexiBoxViewModel();
    public MainPage()
    {
        InitializeComponent();
        DataContext = viewModel;
    }
    private void Contract_Button_Click(object sender, RoutedEventArgs e)
    {
        viewModel.Contract();
    }
    private void Expand_Button_Click(object sender, RoutedEventArgs e)
    {
        viewModel.Expand();
    }
}


As can be seen all the interesting stuff is in the viewModel. Handlers for the button clicks are required as we are using Silverlight 3. In 4 we can get rid of these and use commands directly within the XAML.



The App.xaml.cs file contains only the generated code, so no need to look at that.



Reading the parameters from the HTML page



foreach (string key in Application.Current.Host.InitParams.Keys)
    ParseExternalParam(key, Application.Current.Host.InitParams[key]);


The Application Host contains a dictionary of all the parameters passed into the control, which can be processed one by one.



private void ParseExternalParam(string key, string value)
{
    try
    {
    ...
        else if (String.Compare("thumb_url", key, StringComparison.InvariantCultureIgnoreCase) == 0)
        {
            images[(int)FlexiImageModel.Size.thumb].Url = value;
        }
        else if (String.Compare("border", key, StringComparison.InvariantCultureIgnoreCase) == 0)
        {
            borderSize = Double.Parse(value);
        }
        else if (String.Compare("border_color", key, StringComparison.InvariantCultureIgnoreCase) == 0)
        {
            borderColor = value.ToColor();
        }
    }
    catch
    {
        // contain any exceptions thrown by invalid params
    }
}


Each parameter key is checked for a match and then the value is converted into the appropriate type and stored for later use.

As elsewhere in the code exceptions are caught and safely ignored.   If this is not done then entering an invalid number for the border parameter would cause a nasty error message to be displayed to the user.


This may be what you want, in which case remove the try catches, but I preferred the app to fail quietly and continue in the same way XAML does when it finds errors.



Unlike the full version of .Net there is no built in way of converting a string representation of a color into a color object in Silverlight.

Many people have had to tackle this in the last two years and there are plenty of examples on the web, but most just do hex strings in one format.


I wanted a color string converter that could handle all the CSS color formats. e.g. '#FFFFFF', '#FFF', 'white'.



So I wrote the following utility class.



public static class TWSilverlightUtilities
{
    // Converts a CSS style string into Color structure. Returns black if the input is invalid.
    // The color string to convert. May be in one of the following formats; #FFFFFFFF, #FFFFFF, #FFF, white.
    public static Color ToColor(this string str)
    {
        Color rv = Color.FromArgb(0xFF,0,0,0);
        try
        {
            rv = str.ToColorEx();
        }
        catch
        {
        }
        return rv;
    }
    public static Color ToColorEx(this string str)
    {
        // empty string check
        if ((str == null) || (str.Length == 0))
            throw new Exception("empty color string");
        // This is the only way to access the colors that XAML knows about!
        String xamlString = "<Canvas xmlns=\"http://schemas.microsoft.com/winfx/2006/xaml/presentation\" Background=\"" + str + "\"/>";
        Canvas c = (Canvas)System.Windows.Markup.XamlReader.Load(xamlString);
        SolidColorBrush brush = (SolidColorBrush)c.Background;
        return brush.Color;
    }
}


I really wanted to write this as an extension method of Color, as it would then match the ToString method nicely.

Unfortunately Color is a struct and extension methods do not work properly on them as the 'this' parameter can only be passed by value and not by reference, which makes it useless for structs. Also you cannot define a static extension method, only instance one. Both serious omissions in the language in my view.



Loading content relative to the host page






The URLs of the images to display could be given as absolute or relative to the web page. The Silverlight control may be hosted in a completely different location so the correct address must be used, which is obtained from HtmlPage.Document.DocumentUri. Its then a matter of stripping off the page name, query and fragment and appending the relative image URL, to give us the absolute URL of the image that is required.



private Uri GetImageUri(FlexiImageModel.Size size)
{
    Uri imgUri = new Uri(images[(int)size].Url, UriKind.RelativeOrAbsolute);
    if (imgUri.IsAbsoluteUri)
        return imgUri;
    // Make an absolute URL relative to the document we are in
    UriBuilder pageUri = new UriBuilder(HtmlPage.Document.DocumentUri);
    pageUri.Fragment = null;
    pageUri.Query = null;
    int n = pageUri.Path.LastIndexOf('/');
    if (n > 0)
        pageUri.Path = pageUri.Path.Substring(0, n + 1);
    return new Uri(pageUri.Uri, imgUri);
}


Asynchronous loading and caching of images



Each Flexibox can hold up to four images; thumb, small, medium and large. These can either be loaded one at a time on  request, or all pre-loaded.

The pre-load feature gives the user instant access to the different sized images, but uses more band width, so each option has its uses.


When pre-loading the most important thing is to load the image to be initially shown first. Only when that is download should the other images be downloaded and cached.


It is possible that the user may click on the expand button while the next image is being downloaded and the code needs to cope with that.


The caching of the images is plain C# and not a Silverlight technique, so it is not explained in detail here, but is in the attached source code.



private void RetrieveImage(FlexiImageModel.Size size)
{
    try
    {
        // Dont start a second retrieve of the same image
        if (!images[(int)size].IsRetrieving)
        {
            Uri uri = GetImageUri(size);
            images[(int)size].IsRetrieving = true;
            WebClient webClient = new WebClient();
            webClient.OpenReadCompleted += new OpenReadCompletedEventHandler(webClient_OpenReadCompleted);
            webClient.OpenReadAsync(uri, size);
        }
    }
    catch (Exception ex)
    {
        images[(int)size].IsError = true;
    }
}


To download an image firstly get its full URL as described above and then use a WebClient to start the download asynchronously, so as not to block the user interface.

When starting the operation we pass in the size of the image, so we know which image has been downloaded when it completes.



void webClient_OpenReadCompleted(object sender, OpenReadCompletedEventArgs e)
{
    FlexiImageModel.Size size = newSizeMode;
    if (e.UserState is FlexiImageModel.Size)
        size = (FlexiImageModel.Size)e.UserState;
    try
    {
        if ((e.Cancelled) || (e.Error != null))
            throw new Exception("Error downloading image");
        BitmapImage bitmap = new BitmapImage();
        bitmap.SetSource(e.Result);
        e.Result.Close();
        images[(int)size].Bitmap = bitmap;
        images[(int)size].IsError = false;
    }
    catch
    {
        images[(int)size].IsError = true;
        BusyStatus = "Error loading image";
    }
    images[(int)size].IsRetrieving = false;
    LoadImage();
    RetrieveNextImage();
}


WebClient has several options on how to receive the downloaded data. By using OpenReadAsync it provides an open stream in e.Result, which is perfect to set as the source for the new BitmapImage object.

Of course things can go wrong and any errors are caught and the image marked as an error in the cache.



Once the image has been cached Flexibox is ready to load it into the user control and then start the download of the next image.



Loading an image into the user interface



private void LoadImage()
{
    if ( ((imageLoaded) && (sizeMode == newSizeMode)) ||
        (images[(int)newSizeMode].IsError) || (!images[(int)newSizeMode].IsLoaded))
        return; // nothing to do
    sizeMode = newSizeMode;
    imageLoaded = true;
    ContractVisible = (sizeMode != NextSmallerImage(sizeMode)) ? Visibility.Visible : Visibility.Collapsed;
    ExpandVisible = (sizeMode != NextBiggerImage(sizeMode)) ? Visibility.Visible : Visibility.Collapsed;
    ResizeControl();
    NotifyPropertyChanged("DisplayedImage");
    IsBusy = false;
}


 



The method first checks for errors and if the required image is already loaded, before proceeding with the rest of the operation.



The Expand and Contract buttons need to be turned on or off depending on whether there is a bigger or smaller image available.



public Visibility ExpandVisible { get { return expandVisible; } set { expandVisible = value; NotifyPropertyChanged("ExpandVisible"); } }
public Visibility ContractVisible { get { return contractVisible; } set { contractVisible = value; NotifyPropertyChanged("ContractVisible"); } }


This is done by setting the two public properties that the XAML defined buttons are bound to.



public BitmapImage DisplayedImage { get { return images[(int)sizeMode].Bitmap; } }


The XAML defined image is bound to the  DisplayedImage property, which retrieves the image directly from the cache. However the image will not be updated as it needs to be told that the selected image has changed. This is done with the NotifyPropertyChanged call at the exact time required, which is after the control has first been resized to fit the new image.



Resizing a Silverlight control within a page






Now for the final technique and the one that this control is really all about, resizing a Silverlight control within a page.

I want to give full credit for this technique to Charles Petzold.



private void ResizeControl()
{
    if (!images[(int)sizeMode].IsLoaded)
        return;
    double height = Math.Max(20,(2 * borderSize) + images[(int)sizeMode].Bitmap.PixelHeight);
    double width = Math.Max(20,(2 * borderSize) + images[(int)sizeMode].Bitmap.PixelWidth);
    if (controllWidth != width)
    {
        controllWidth = width;
        SetDimensionPixelValue("width", controllWidth);
    }
    if (controllHeight != height)
    {
        controllHeight = height;
        SetDimensionPixelValue("height", controllHeight);
    }
}


Each bitmap image exposes its dimensions so we can calculate the new width and height of the control by adding these to the border thickness.

It is then a matter of setting the width and height using this method.



void SetDimensionPixelValue(string style, double value)
{
    HtmlPage.Plugin.SetAttribute(style, ((int)Math.Round(value)).ToString() + "px");
}


HtmlPage.Plugin provides access to the HtmlElement of the object tag. It is then just a case of setting either the width or the height attribute to a text definition of the measurment.

In order for this to work it is important that the page does not contain additional width and height settings that may affect the control within the page, or one of its div containers. This is done by default using styles in the generated code.



Conclusion






As well as explaining a number of basic Silverlight techniques, I am hoping that the control itself shows how Silverlight can be used as an integral part of a web page design.



Source code



Try it live

Tuesday 9 March 2010

Silverlight Hex color string and CSS style color string to C# Color structure converter

Unlike the full version of .Net there is no built in way of converting a string representation of a color into a color object in Silverlight.

There are many reasons you might need this bit of functionality,  the obvious one being able to pass a color parameter into a Silverlight app from it hosted web page.

Many people have had to tackle this in the last two years and there are plenty of examples on the web, but most just do hex strings in one format.

I wanted a color string converter that could handle all the CSS color formats. e.g. #FFFFFF, #FFF, white.

So the C# code below is a bit longer than most of the examples on the web, but it does a lot more......

Examples of how to use

Color c = TWSilverlightUtilities.ColorFromString("FFAABBCCDD");
Color c = TWSilverlightUtilities.ColorFromString("#F29");
Color c = TWSilverlightUtilities.ColorFromString("AliceBlue");


    public static class TWSilverlightUtilities
    {
        /// <summary>
        /// Converts a CSS style string into Color structure. Returns black if the input is invalid.
        /// </summary>
        /// <param name="value">The color string to convert. May be in one of the following formats; #FFFFFFFF, #FFFFFF, #FFF, white. The # is optional.</param>
        /// <returns></returns>        
        public static Color ColorFromString(string value)
        {
            Color rv = new Color();
            try
            {
                rv = ColorFromStringEx(value);
            }
            catch
            {
            }
            return rv;
        }
        /// <summary>
        /// Converts a CSS style string into Color structure. Throws an exception if the input is invalid.
        /// </summary>
        /// <param name="value">The color string to convert. May be in one of the following formats; #FFFFFFFF, #FFFFFF, #FFF, white. The # is optional.</param>
        /// <returns></returns>                
        public static Color ColorFromStringEx(string value)
        {
            Color rv = new Color();
            // empty string check
            if ((value == null) || (value.Length == 0))
                throw new Exception("empty color string");
            // determine the format, try standard one first
            Match m1 = Regex.Match(value, @"^#?([A-F\d]{2})([A-F\d]{2})([A-F\d]{2})([A-F\d]{2})?", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
            if ((m1.Success) && (m1.Groups.Count == 5))
            {
                if (m1.Groups[4].Value.Length > 0) // includes alpha channel
                    rv = Color.FromArgb(Byte.Parse(m1.Groups[1].Value, Globalization.NumberStyles.HexNumber),
                                Byte.Parse(m1.Groups[2].Value, Globalization.NumberStyles.HexNumber),
                                Byte.Parse(m1.Groups[3].Value, Globalization.NumberStyles.HexNumber),
                                Byte.Parse(m1.Groups[4].Value, Globalization.NumberStyles.HexNumber));
                else // colors only
                    rv = Color.FromArgb(0xFF,
                                Byte.Parse(m1.Groups[1].Value, Globalization.NumberStyles.HexNumber),
                                Byte.Parse(m1.Groups[2].Value, Globalization.NumberStyles.HexNumber),
                                Byte.Parse(m1.Groups[3].Value, Globalization.NumberStyles.HexNumber));
            }
            else
            {
                // try the CSS 3 char format next
                Match m2 = Regex.Match(value, @"^#?([A-F\d])([A-F\d])([A-F\d])$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
                if ((m2.Success) && (m2.Groups.Count == 4))
                {
                    byte r = Byte.Parse(m2.Groups[1].Value, Globalization.NumberStyles.HexNumber);
                    r += (byte)(r << 4);
                    byte g = Byte.Parse(m2.Groups[2].Value, Globalization.NumberStyles.HexNumber);
                    g += (byte)(g << 4);
                    byte b = Byte.Parse(m2.Groups[3].Value, Globalization.NumberStyles.HexNumber);
                    b += (byte)(b << 4);
                    rv = Color.FromArgb(0xFF,r,g,b);
                }
                else
                {
                    // This is the only way to access the colors that XAML knows about!
                    String xamlString = "<Canvas xmlns=\"http://schemas.microsoft.com/winfx/2006/xaml/presentation\" Background=\"" + value + "\"/>";
                    Canvas c = (Canvas)System.Windows.Markup.XamlReader.Load(xamlString);
                    SolidColorBrush brush = (SolidColorBrush)c.Background;
                    rv = brush.Color;
                }
            }
            return rv;
        }
    }

Tuesday 15 December 2009

ASP.NET RegularExpressionValidator cannot do a case insensitive match

ASP.NET has its own way of doing things that can be completely different from everything else. Most of the time I can live with this but now and again it is really annoying and I came across one of those times this week.

The task I wanted to achieve was to validate an email field to see check if the user had entered a valid email. Its an obvious job for a regular expression and I had some javascript code from another project that did the job.

Here is the expression.....

/^(("[\w-\s]+")|([\w-]+(?:\.[\w-]+)*)|("[\w-\s]+")([\w-]+(?:\.[\w-]+)*))(@((?:[\w-]+\.)*\w[\w-]{0,66})\.([a-z]{2,6}(?:\.[a-z]{2})?)$)|(@\[?((25[0-5]\.|2[0-4][0-9]\.|1[0-9]{2}\.|[0-9]{1,2}\.))((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[0-9]{1,2})\.){2}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[0-9]{1,2})\]?$)/i

Regular expressions are reasonably standard across different platforms so I did not think I would have a problem to use this in ASP.NET.

The RegularExpressionValidator is designed for the job, but it does not accept the '/i' at the end, which sets the regular expression to be case insensitive. Instead there is a Microsoft syntax '?i:' .

(?i:(^(("[\w-\s]+")|([\w-]+(?:\.[\w-]+)*)|("[\w-\s]+")([\w-]+(?:\.[\w-]+)*))(@((?:[\w-]+\.)*\w[\w-]{0,66})\.([a-z]{2,6}(?:\.[a-z]{2})?)$)|(@\[?((25[0-5]\.|2[0-4][0-9]\.|1[0-9]{2}\.|[0-9]{1,2}\.))((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[0-9]{1,2})\.){2}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[0-9]{1,2})\]?$)))

Using the above expression  RegularExpressionValidator will check for a valid case insensitive email address on the server side.

However  RegularExpressionValidator also checks for a valid email on the client side by generating javascript code to do the job. Unfortunately the above expression will generate an error as it is not compatible with javascript!

Why they could not make ASP.Net regular expressions compatible with the javascript standard I dont know.

In looking for a solution I came across this one, where the guy uses Javascript to do a search and replace on regular expression text when the page loads. It should work, but what a terrible cludge.

http://reflectedthought.com/thecoder/archive/2007/11/25/regularexpressionvalidator_ignore_case.aspx

In my case it was possible to rewrite the regular expression so it could be used on both the client and server sides. Next time I may not be so lucky.

So the validator to check for valid email addresses is as follows.

<asp:RegularExpressionValidator ID="emailRegularExpressionValidator1" runat="server"
    ErrorMessage="A valid email address must be supplied"
    ControlToValidate="person_email"
    ValidationExpression='^(("[\w-\s]+")|([\w-]+(?:\.[\w-]+)*)|("[\w-\s]+")([\w-]+(?:\.[\w-]+)*))(@((?:[\w-]+\.)*\w[\w-]{0,66})\.([a-zA-Z]{2,6}(?:\.[a-zA-Z]{2})?)$)|(@\[?((25[0-5]\.|2[0-4][0-9]\.|1[0-9]{2}\.|[0-9]{1,2}\.))((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[0-9]{1,2})\.){2}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[0-9]{1,2})\]?$)'></asp:RegularExpressionValidator>

Thursday 5 November 2009

ZoomBoxPanel, adding a fade out subsidiary slider control: Part 4

Introduction

This is the fourth in a series of articles that show you how to build a powerful WPF custom control to use instead of the standard WPF Viewbox class.

The first article, 'ZoomBoxPanel implementing a choice of page view zoom modes in WPF: Part 1', introduced the core functionality of the ZoomBoxPanel, its ability to resize its contents into a number of different zoom modes, such as 'Fit Page' and 'Fit Width'.
The second article 'ZoomBoxPanel: add custom commands to a WPF control: Part 2' extended ZoomBoxPanel to support both standard and custom commands to zoom and rotate its contents.
The third article 'ZoomBoxPanel, adding full mouse support and animation to a WPF custom control: Part 3'  extended ZoomBoxPanel to support the use of the mouse and the mouse wheel in different modes and in addition animation was added for zoom transitions.

This article adds a new subsidiary slider control to control the ZoomBoxPanel. The slider is designed to sit on top of the visual content and fade into the background when not in use, similar to the sliders in Google Earth and Visual Studio.

Subsidiary controls are a useful technique to add an optional user interface to a control that has no visible interface, like ZoomBoxPanel. Floating above the content, with the ability to fade into the background when not being used, these subsidiary controls give an application a modern look and feel.

Using the ZoomBoxSlider


The ZoomBoxSlider is a custom control that is designed to work with the ZoomBoxPanel.
It displays the current zoom scale as a percentage and has buttons to increase and decrease the zoom. In between those is a slider that can be used to control the zoom.
At the bottom is a button that sets the zoom mode to 'Page View'

While the control is being used it is displayed fully opaque. When the mouse is moved away from the control it will gradually fade into the background. As can be seen in the sequence above.
The ZoomBoxSlider makes use of the applications style for the buttons and slider. The example uses the default XP windows style.
The following screenshot show the same control used in an real application that has its own skinned interface.

<zb:ZoomBoxSlider Margin="10" Grid.Row="2" Grid.Column="0"
          HorizontalAlignment="Right" VerticalAlignment="Top"
          ZoomBox="{Binding ElementName=zoomBox}"  />


This XAML snippet  adds a  ZoomBoxSlider to the demo window. The first five properties place it in the top right corner in the third row of the grid beneath the toolbar; all standard stuff.

The only special thing that needs to be done is to bind the ZoomBoxPanel instance to the ZoomBox property of the Slider. This links the Slider to the ZoomBoxPanel and potentially allows any number of ZoomBoxPanels and Sliders to be placed on the same window.



The  ZoomBoxSlider Template





The ZoomBoxSlider is a lookless control. Its functionality is defined in a C# class while its user interface is defined in an XAML template. This allows the look of the control to be changed without affecting its core functionality.


This is the template for the  ZoomBoxSlider.



<Style TargetType="{x:Type zb:ZoomBoxSlider}">
    <Setter Property="SnapsToDevicePixels" Value="true"/>
    <Setter Property="BorderBrush" Value="{StaticResource ZB_DefaultBorderBrush}"/>
    <Setter Property="Background" Value="{StaticResource ZB_DefaultBackgroundBrush}"/>
    <Setter Property="Foreground" Value="{StaticResource ZB_DefaultForegroundBrush}"/>
    <Setter Property="BorderThickness" Value="1"/>
    <Setter Property="MinHeight" Value="120"/>
    <Setter Property="MinWidth" Value="40"/>
    <Setter Property="ContOpacity" Value="1.0"/>
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="{x:Type zb:ZoomBoxSlider}">
                <Border Grid.Column="1" Width="{TemplateBinding Width}" Height="{TemplateBinding Height}" BorderBrush="{TemplateBinding BorderBrush}"
                    BorderThickness="{TemplateBinding BorderThickness}"
                    Margin="0">
                    <Grid >
                        <Grid.RowDefinitions>
                            <RowDefinition Height="13" />
                            <RowDefinition Height="10" />
                            <RowDefinition />
                            <RowDefinition Height="10" />
                            <RowDefinition Height="20" />
                        </Grid.RowDefinitions>
                        <Grid Name="PART_outerGrid" Background="{TemplateBinding Background}" Grid.RowSpan="5" 
                                Opacity="{Binding Path=ContOpacity,Mode=OneWay,RelativeSource={RelativeSource TemplatedParent}}" />
                        <TextBlock Name="PART_DisplayText" 
                                   Text="{Binding Path=Zoom,Mode=OneWay,RelativeSource={RelativeSource TemplatedParent},Converter={StaticResource zoomBoxSliderDisplayConverter}}" 
                                   TextAlignment="Center" FontSize="9" VerticalAlignment="Center" 
                                   Opacity="{Binding Path=ContOpacity,Mode=OneWay,RelativeSource={RelativeSource TemplatedParent}}" />
                        <Slider Grid.Row="2"  Name="PART_Slider"  TickPlacement="Both" Orientation="Vertical" 
                                Maximum="{Binding Path=MaxZoomTick,Mode=OneWay,RelativeSource={RelativeSource TemplatedParent}}" 
                                Minimum="{Binding Path=MinZoomTick,Mode=OneWay,RelativeSource={RelativeSource TemplatedParent}}" 
                                Value="{Binding Path=ZoomTick,Mode=TwoWay,RelativeSource={RelativeSource TemplatedParent}}"
                                LargeChange="10" 
                                SmallChange="1" 
                                TickFrequency="10" 
                                MinHeight="100" 
                                HorizontalAlignment="Center" 
                                Opacity="{Binding Path=ContOpacity,Mode=OneWay,RelativeSource={RelativeSource TemplatedParent}}" />
                        <Button Grid.Row="4"  Name="PART_FitPageButton" Command="Zoom" HorizontalContentAlignment="Center" VerticalContentAlignment="Center" Opacity="{Binding Path=ContOpacity,Mode=OneWay,RelativeSource={RelativeSource TemplatedParent}}">
                            <Grid>
                                <Canvas Height="12" Width="18">
                                    <Rectangle Canvas.Left="3" Canvas.Top="3" Height="6" Width="12" Stroke="{StaticResource ZB_LightBackgroundHighlight}" Fill="{StaticResource ZB_LightBackgroundHighlight}" />
                                    <Rectangle Canvas.Left="1" Canvas.Top="1" Height="2" Width="2" Stroke="{StaticResource ZB_LightBackgroundHighlight}" Fill="{StaticResource ZB_LightBackgroundHighlight}" />
                                    <Rectangle Canvas.Left="1" Canvas.Top="9" Height="2" Width="2" Stroke="{StaticResource ZB_LightBackgroundHighlight}" Fill="{StaticResource ZB_LightBackgroundHighlight}" />
                                    <Rectangle Canvas.Left="15" Canvas.Top="9" Height="2" Width="2" Stroke="{StaticResource ZB_LightBackgroundHighlight}" Fill="{StaticResource ZB_LightBackgroundHighlight}" />
                                    <Rectangle Canvas.Left="15" Canvas.Top="1" Height="2" Width="2" Stroke="{StaticResource ZB_LightBackgroundHighlight}" Fill="{StaticResource ZB_LightBackgroundHighlight}" />
                                </Canvas>
                            </Grid>
                        </Button>
                        <Button Grid.Row="1"  Name="PART_ZoomInButton" Command="IncreaseZoom" HorizontalContentAlignment="Center" VerticalContentAlignment="Center" Opacity="{Binding Path=ContOpacity,Mode=OneWay,RelativeSource={RelativeSource TemplatedParent}}"></Button>
                        <Button Grid.Row="3"  Name="PART_ZoomOutButton" Command="DecreaseZoom" HorizontalContentAlignment="Center" VerticalContentAlignment="Center" Opacity="{Binding Path=ContOpacity,Mode=OneWay,RelativeSource={RelativeSource TemplatedParent}}"></Button>
                    </Grid>
                </Border>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>


As this is not a beginners article I will not explain all this, except to point out some key features.



The Opacity of the controls within the template is bound to the dependency property ContOpacity of the parent object.

Other template control properties are also bound to dependency properties of the parent, such as the slider's position is bound to the ZoomTick dependency property.


By using binding in this way the parent class can alter the appearance of the control indirectly by simply changing the values on it own properties.



To make it easier to customize the control without the effort of re-writing the template, I placed the color choices in separate definitions.



<LinearGradientBrush x:Key="ZB_LightBackgroundHighlight" StartPoint="0,0" EndPoint="0,1">
    <GradientBrush.GradientStops>
        <GradientStopCollection>
            <GradientStop Color="#D0D0D0" Offset="0.0"/>
            <GradientStop Color="#A5A5A5" Offset="1.0"/>
        </GradientStopCollection>
    </GradientBrush.GradientStops>
</LinearGradientBrush>
<SolidColorBrush x:Key="BS_DefaultBorderBrush_dark" Color="#FF7D7D7D" />
<SolidColorBrush x:Key="ZB_DefaultBorderBrush" Color="#FF909090" />
<SolidColorBrush x:Key="ZB_DefaultBackgroundBrush" Color="#FFD0D0D0" />
<SolidColorBrush x:Key="ZB_DefaultForegroundBrush" Color="#FF000000" />


At the top of the control the current zoom percentage is displayed in the TextBlock 'PART_DisplayText'.

This is bound to the Zoom property of the parent which is a double and which will contain values with many digits after the decimal point.


In order to show a rounded percentage figure a custom converter is used.



<zb:ZoomBoxSliderDisplayConverter x:Key="zoomBoxSliderDisplayConverter"/>


The converter itself is a small class written in C#.



public class ZoomBoxSliderDisplayConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
    {
        double? d = value as double?;
        if (d != null)
            return String.Format("{0:0.}%", d);
        return "0%";
    }
    public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
    {
        throw new NotSupportedException("unexpected Convertback");
    }
}


The  ZoomBoxSlider Code






The ZoomBoxSlider class comprises of about 230 lines of C# code, which is a lot of code for a straight forward control like this.

There is a fair amount of interesting functionality in there though and of course WPF and dependency properties in particular, require a lot of verbose code.



Dependency Properties






The class defines the following dependencies, most of which are used in the user interface template shown above.



        private static DependencyProperty ContOpacityProperty;
        private static DependencyProperty ZoomTickProperty;
        private static DependencyProperty MinZoomTickProperty;
        private static DependencyProperty MaxZoomTickProperty;
        private static DependencyProperty ZoomProperty;
        private static DependencyProperty ZoomBoxProperty;
        private static DependencyProperty TargetElementProperty;


ContOpacity is used to set the opacity of the control itself. Using a dependency property makes it easy to apply animation to cause the control to gradually fade away.



The ZoomTick, MinZoomTick and MaxZoomTick are used to set the value and the range of the slider control. 
It would be possible to bind the Slider to the Zoom property, however that would not produce the expected result for the user.


This is because each change in the zoom should be the same relative and not absolute increase. 
So the derived ZoomTick property is used instead to bind to the Slider's value.


ZoomTick is the ratio of the log values of the distance between the zoom value and the minimum zoom and the distance between the zoom value and the maximum zoom.



The following method performs the calculation and it is probably easier to understand than the explanation above.



private void calcZoomTick()
{
    double logMin = Math.Log10(MinZoom);
    double logMax = Math.Log10(MaxZoom);
    double logZoom = Math.Log10(Zoom);
    if (logMax <= logMin)
        logMax = logMin + 0.01;
    double perc = (logZoom - logMin) / (logMax - logMin);
    ZoomTick = (perc * (MaxZoomTick - MinZoomTick)) + MinZoomTick;
}


The slider is a two way link so a change to the ZoomTick must be translated to a change in the Zoom, which is done by this method.



private void calcZoomFromTick()
{
    double logMin = Math.Log10(MinZoom);
    double logMax = Math.Log10(MaxZoom);
    if (logMax <= logMin)
        logMax = logMin + 0.01;
    double perc = (ZoomTick - MinZoomTick) / (MaxZoomTick - MinZoomTick);
    double logZoom = (perc * (logMax - logMin)) + logMin;
    Zoom = Math.Pow(10.0, logZoom);
    Point panelPoint = new Point(ActualWidth / 2, ActualHeight / 2);
    ApplyZoomCommand(panelPoint);
}


The ZoomBox dependency property provides the link between the slider and the control it acts upon.



ZoomBoxProperty = DependencyProperty.Register(
    "ZoomBox", typeof(ZoomBoxPanel), typeof(ZoomBoxSlider),
    new FrameworkPropertyMetadata(null, PropertyChanged_ZoomBox),
    new ValidateValueCallback(ZoomBoxSlider.ValidateIsZoomBox));


The type of the property is obviously ZoomBoxPanel and the validate function simply checks the assigned control is in fact a ZoomBoxPanel



private static bool ValidateIsZoomBox(object value)
{
    return (value == null) || (value is ZoomBoxPanel);
}


A null value is treated as a valid value. This is to allow the Slider to be used in the design process, without it complaining about not having a valid ZoomBox before the designer has a chance to define it.



The Internal Bindings






One of the reasons for the amount of code in the ZoomBoxSlider is replication of functionality.

The ZoomBoxSlider contains a Zoom dependency property that must be kept in sync with the Zoom dependency property of the linked ZoomBoxPanel.


It would be possible to bind the TextBlock in the template directly to the Zoom property in the ZoomBoxPanel. The same could be done with the other properties.


However that would make the control less robust and potentially cause confusion during the design process. So extra C# code is justified to make life easier for the designer.



When the ZoomBox property is set then the following method is called which binds the Sliders properties to the matching properties on the ZoomBoxPanel.



void ZoomBoxChangeEvent()
{
    if (ZoomBox != null)
    {
        Binding binding;
        binding = new Binding();
        binding.Source = ZoomBox;
        binding.Path = new PropertyPath("Zoom");
        binding.Mode = BindingMode.OneWay;
        BindingOperations.SetBinding(this, ZoomProperty, binding);
        binding = new Binding();
        binding.Source = ZoomBox;
        binding.Path = new PropertyPath("ZoomTick");
        binding.Mode = BindingMode.TwoWay;
        BindingOperations.SetBinding(this, ZoomTickProperty, binding);
        binding = new Binding();
        binding.Source = ZoomBox;
        binding.Path = new PropertyPath("MinZoomTick");
        binding.Mode = BindingMode.OneWay;
        BindingOperations.SetBinding(this, MinZoomTickProperty, binding);
        binding = new Binding();
        binding.Source = ZoomBox;
        binding.Path = new PropertyPath("MaxZoomTick");
        binding.Mode = BindingMode.OneWay;
        BindingOperations.SetBinding(this, MaxZoomTickProperty, binding);
    }
}


The buttons in the Slider's template are linked to the control using WPF Commands.

Three commands are configured Zoom, IncreaseZoom and DecreaseZoom.



private void SetUpCommands()
{
    // Set up command bindings.
    CommandBinding binding = new CommandBinding(NavigationCommands.Zoom,
            ZoomCommand_Executed, ZoomCommand_CanExecute);
    this.CommandBindings.Add(binding);
    binding = new CommandBinding(NavigationCommands.IncreaseZoom,
            IncreaseZoomCommand_Executed, IncreaseZoomCommand_CanExecute);
    this.CommandBindings.Add(binding);
    binding = new CommandBinding(NavigationCommands.DecreaseZoom,
            DecreaseZoomCommand_Executed, DecreaseZoomCommand_CanExecute);
    this.CommandBindings.Add(binding);
}


The IncreaseZoom and DecreaseZoom commands are passed straight on to the ZoomBoxPanel.



private void IncreaseZoomCommand_Executed(object sender, ExecutedRoutedEventArgs e)
{
    if (ZoomBox != null)
        NavigationCommands.IncreaseZoom.Execute(null,ZoomBox);
    FocusOnTarget();
}
private void DecreaseZoomCommand_Executed(object sender, ExecutedRoutedEventArgs e)
{
    if (ZoomBox != null)
        NavigationCommands.DecreaseZoom.Execute(null,ZoomBox);
    FocusOnTarget();
}


The Zoom command has no set meaning. The slider interprets this command to be change the zoom mode to FitPage.



private void ZoomCommand_Executed(object sender, ExecutedRoutedEventArgs e)
{
    if (ZoomBox != null)
        ZoomBox.ZoomMode = ZoomBoxPanel.eZoomMode.FitPage;
    FocusOnTarget();
}


Fade away animation



The slider is designed to gradually fade away when it is not being used as explained at the start of this article.



There are five basic requirements for this functionality:




  1. If the mouse is over the control don't fade away.



  2. Don't fade away immediately the mouse has left the control, wait a while. That way the user will not be distracted by the control fading away and the fade away will occur when the users attention is elsewhere.



  3. When the mouse is over the control it should fade in and this should start immediately and happen much faster than the fade out.



  4. The timings and the faded opacity should all be configurable.



  5. It is not just the Slider that could benefit from this functionality.



The final requirement is met by placing the functionality into a separate class from which the slider control inherits.



public class ZoomBoxSlider : TWWPFUtilityLib.TWFadeAwayControl
public class TWFadeAwayControl : Control


The TWFadeAwayControl class defines the following dependency properties:



 



private static DependencyProperty ContOpacityProperty;
private static DependencyProperty FadeAwayDurationProperty;
private static DependencyProperty FadeInDurationProperty;
private static DependencyProperty FadeAwayDelayProperty;
private static DependencyProperty FadedAwayOpacityProperty;


The ContOpacity property contains the current opacity to be used in the template.

The next three properties define the durations in milliseconds and the last property defines the final faded out opacity. It is this property that will most commonly be adjusted at design time.


The following defaults are used for these properties.



private const double DEFAULT_FADEAWAYDURATION = 2000;
private const double DEFAULT_FADEINDURATION   = 450;
private const double DEFAULT_FADEAWAYDELAY    = 3000;
private const double DEFAULT_FADEDAWAYOPACITY = 0.25;


The Fade Away functionality relies on the following three member variables.



bool isVisualActive = true;


If true then the control is being used and is set to full opacity.



private bool mouseIsOver = false;


Records whether the mouse is currently over the control



private double timeSinceMouseOut = 0;


Records the total time since the mouse left the control.



Once an instance has been created and the template applied the control sets its visual state to inactive and starts up its timer.



public override void OnApplyTemplate()
{
    base.OnApplyTemplate();
    setControlVisualActive(false, false);
    SetUpTimer();
}


A DispatcherTimer is a useful class that saves us the trouble of writing multiple threads. The instance method  dispatcherTimer_Tick is called every half a second.



private void SetUpTimer()
{
    dispatcherTimer = new System.Windows.Threading.DispatcherTimer();
    dispatcherTimer.Tick += new EventHandler(dispatcherTimer_Tick);
    dispatcherTimer.Interval = new TimeSpan(0, 0, 0, 0, TICK_DURATION);
    dispatcherTimer.Start();
}


When the mouse is moved into the control then the following method is called.



protected override void OnMouseEnter(MouseEventArgs e)
{
    mouseIsOver = true;
    setControlVisualActive(true, true);
    base.OnMouseEnter(e);
}


The visual state of the control is set to active, if it is not already. The opacity of the control is increased to 1.



protected override void OnMouseLeave(MouseEventArgs e)
{
    mouseIsOver = false;
    timeSinceMouseOut = 0;
    base.OnMouseLeave(e);
}


When the mouse leaves the control the mouseIsOver flag is set to false and the time record is set to zero. So nothing happens right away.



private void dispatcherTimer_Tick(object sender, EventArgs e)
{
    timeSinceMouseOut += TICK_DURATION;
    if ((!mouseIsOver) && (isVisualActive) && (timeSinceMouseOut >= FadeAwayDelay))
    {
        setControlVisualActive(false, true);
    }
}


When the timer method is called the first thing it does is to add 500 milliseconds to the time record.

If the mouse has left the control and it is still active and there has been a three second delay then the control is set to inactive and the control fades away.



This is the method that sets the state of the control, with or animation.



private void setControlVisualActive(bool isOn, bool annimate)
{
    if (isVisualActive != isOn)
    {
        isVisualActive = isOn;
        if (!annimate)
        {
            ContOpacity = isVisualActive ? 1.0 : FadedAwayOpacity;
        }
        else
        {
            double dur = isVisualActive ? FadeInDuration : FadeAwayDuration;
            double toVal = isVisualActive ? 1.0 : FadedAwayOpacity;
            DoubleAnimation da = new DoubleAnimation(toVal, TimeSpan.FromMilliseconds(dur));
            BeginAnimation(ContOpacityProperty, da, HandoffBehavior.SnapshotAndReplace);
        }
    }
}


The non-animated option is the easiest to understand as it simply sets the ContOpacity property to either fully opaque or to the faded opacity set by the designer.



The animated option does the same job but uses a DoubleAnimation instance to do the work in the specified time.

The only thing of interest is the SnapshotAndReplace option. This is required because animations take time and the user may interrupt an animation before it is completed.


For example the control could be half way through a fade operation with an opacity of 0.6. At that moment the user move the mouse into the control and the event creates a new animation to increase the opacity to 1.


What we want to happen is for the opacity to stop decreasing at 0.6 and then rise from that value to 1. That is exactly what the  SnapshotAndReplace options does. There are other options, including the default one, that produce different effects, none of which are desirable in the case.



One last trick





There is one last issue we need to address. At present the Slider only becomes active when the mouse moves over it. However the user can alter the zoom by other means, such as by the mouse wheel, or with  the menu. While the zoom is changing it looks good if the Slider becomes active.


To do this we need a couple of extra methods.



private static void PropertyChanged_Zoom(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
    ZoomBoxSlider z = d as ZoomBoxSlider;
    if (z != null)
        z.FadeAway = false;
}


This method is included in the Sliders Zoom dependency property definition to be called whenever the Zoom changes value. It is a static method so it needs to cast an instance and it then sets  FadeAway to false in order to make the control active again.



public bool FadeAway
{
    get { return !isVisualActive; }
    set { timeSinceMouseOut = 0;  setControlVisualActive(!value, true); }
}


The TWFadeAwayControl class implelents the FadeAway property as an easy way for others to manually trigger a change of status. If the control is set to active and the mouse is not over the control then it will begin to fade after a delay, just as if the mouse left the control.



Conclusion



This article concludes the series on developing a generic custom control that can be used in different WPF projects.

The main principle behind the design of the control  and the main theme of the articles, is that it is desirable to put as much of the complexity into the code as possible in order to keep the XAML design process as simple as possible.



Download


The source code attached to this article is complete and functional and released under the Microsoft Public License.



Exe file


Full Source code - VS2008