Introduction
Using Silverlight 2.0 and C#/VB.NET, we will build an image rotator that has a useful set of basic features and is easy to setup and deploy.
Features
- Can link to local images deployed with an application, or to images hosted elsewhere on the Internet.
- Image paths and attributes stored in an XML file for easy changes without the need to rebuild the application.
- Auto play can be toggled on/off.
- Duration that each image is shown is adjustable.
- Image transition effects can be turned on/off.
- Image number, last and next buttons can be hidden/displayed.
- Can pause auto-play when the number button is selected, restarted when another button is selected.
- Image border can be displayed/hidden.
- Image border width and color/opacity can be changed.
- All configuration changes can be made without recompiling the ImageRotator.
Using the code
The main emphasis I put on building this image viewer was to make it easy to implement on a site (HTML or ASP.NET) and yet contain a handful of useful features. I also wanted to make sure images could be changed, removed, and added without having to do a rebuild of the application or stop/start the web server. Since I abstracted a lot of the configuration complexities away, the amount of code got a little lengthy for what I would consider a simple application. For that reason, I will not go into the details of each method that makes up this control. Instead, I will give a brief overview of how it works, and then I will spend more time on the few items that caused me the most problems, along with hopefully good deployment instructions for those who don’t care how it works, but just want to use it.
Overview
This application is divided into two projects. The first is the website project (ImageRotator.Web) and the other is the Silverlight project (ImageRotator). The website project has a folder called ClientBin which the Silverlight application has access to at runtime. This is where we will store the actual images and the SlideShowImages.xml file.
The website also contains the two web pages which use the ImageRotator
control. The first is ImageRotatorTestPage.aspx. This page contains not just the control, but also some controls to change the attributes of the ImageRotator
and refresh the control so you can see the changes. Obviously, this is for demo purposes, and most likely, your page would not contain these other controls. The code-behind of this page also shows how you can load the required InitParams
from the web.config's appSettings
or from the web page control values on page refresh and pass them into the ImageRotator
control. The second page containing the Silverlight control is ImageRotatorTestPage.html. This page does not contain all the controls to change InitParams
and refresh the control at runtime. It does, however, show another example of how to load the InitParams
using a param
tag with the name of “initParams
” and a coma delimited list of name/value pairs.
Please note that the ImageRotator
requires that these parameters all be passed in. There are other methods of passing in InitParams
that I haven’t shown such as passing through a URL string. I’ll leave these other methods for you to investigate.
Moving onto the second project, ImageRotator, we have the classes that make up the Silverlight control and gives it the functionality. I’ll give a brief description of each of these files.
- SlideShowImage.cs: This class represents an image and contains the properties related to an individual image.
- PictureAlbum.cs: This class contains a collection
SlideShowImages
and those properties common to all the images in the collection. This class also has the asynchronous call which loads the images from the SlideShowImages.xml file into the collection and raises the pictureAlbumLoadedHandler
to notify listeners that the picture album has been loaded.
- PictureAlbumEventArgs.cs: This class contains the support code for custom events which fire when the picture album has finished asynchronously loading the image collection to be used by the
ImageRotator
from the SlideShowImages.xml file contained on the website.
- App.xaml.cs: When the application starts, the
Application_Startup
event is fired. This is where we verify all the parameters required by ImageRotator
are being passed in. We are not doing any robust checking of the parameters, but simply verifying that the correct number is passed. Should any of the parameters not be correctly formatted, we will throw an exception later.
- Page.xaml: Contains some of the markup for the
ImageRotator
control, the rest of which is created dynamically as the control is loaded. Here, however, you can see the image border which wraps the entire image. Also, the imageStackPanel
which will have image controls dynamically added to it during runtime (1 per image). Finally, the navigationStackPanel
can also be seen here which contains polygon (arrow) buttons, and the numberedItemsStackPanel
which is a container for the numbered buttons which are also added at runtime.
<UserControl x:Class="ImageRotator_CSharp.Page"
xmlns=http://schemas.microsoft.com/winfx/2006/xaml/presentation
xmlns:x=http://schemas.microsoft.com/winfx/2006/xaml
xmlns :local="clr-namespace:ImageRotator_CSharp"
x:Name="imageRotatorControl">
<Border x:Name="imageBorder" Cursor="Hand">
<Canvas x:Name="layoutRoot" Background="White" VerticalAlignment="Stretch"
HorizontalAlignment="Stretch" Margin="0">
<StackPanel x:Name="imageStackPanel" />
<Border x:Name="navigationBorder" Background="White" Opacity=".5"
CornerRadius="10" BorderBrush="White" BorderThickness="0"
Canvas.Left="10"
Canvas.Top="325">
<StackPanel x:Name="navigationStackPanel" Orientation="Horizontal"
Margin="2,1,2,1" >
<Polygon x:Name="arrowLeft"
MouseLeftButtonDown="LeftArrow_MouseLeftButtonDown"
MouseEnter="Arrow_MouseEnter"
MouseLeave="Arrow_MouseLeave"
Points="0,6,10,0,10,12"
Fill="Black"
VerticalAlignment="Center" Margin="2,2,8,2" />
<StackPanel x:Name="numberedItemsStackPanel"
Orientation="Horizontal" />
<Polygon x:Name="arrowRight"
MouseLeftButtonDown="RightArrow_MouseLeftButtonDown"
MouseEnter="Arrow_MouseEnter"
MouseLeave="Arrow_MouseLeave"
Points="0,0,10,6,0,12"
Fill="Black"
VerticalAlignment="Center" Margin="2" />
</StackPanel>
</Border>
</Canvas>
</Border>
</UserControl>
Page.xml.cs: This is where all the events, event handlers, and methods are contained that bring the ImageRotator
and its functionality to life. Again, I won't cover all the methods here, but feel free to download and review this code yourself. I've tried to add good comments to everything; however, if you have any questions, feel free to contact me. I will cover those areas which caused the biggest hurdles during the development of this application.
Changing images without a rebuild
When I initially built the application, I placed the images in an image directory inside the ImageRotator Silverlight library. This worked fine except for one thing. When the application is called, this library is compiled into a .xap file and placed in the website's ClientBin folder. This includes everything in that project, even the images. Through a little research, however, I discovered the true magic of the website's ClientBin folder. The bottom line is, files can be stored in this directory and accessed at runtime by the Silverlight control. So, I created an images subdirectory, and placed a few of the images I wanted to display in this directory. I also placed the SlideShowImages.xml file here. This file contains the images you want as part of your deployment along with the properties of each.
="1.0" ="utf-8"
-->
<PictureAlbum ImageWidth="480" ImageHeight="360">
<SlideShowImage>
<ImageUri>../Images/a.jpg</ImageUri>
<RedirectLink>http://www.tasteofstepford.com</RedirectLink>
<Order>1</Order>
<DisplayImage>False</DisplayImage>
</SlideShowImage>
<SlideShowImage>
<ImageUri>../Images/b.jpg</ImageUri>
<RedirectLink>http://www.tasteofstepford.com</RedirectLink>
<Order>2</Order>
<DisplayImage>True</DisplayImage>
</SlideShowImage>
<SlideShowImage>
<ImageUri>../Images/c.jpg</ImageUri>
<RedirectLink>http://www.tasteofstepford.com</RedirectLink>
<Order>3</Order>
<DisplayImage>True</DisplayImage>
</SlideShowImage>
</PictureAlbum>
Passing configuration/setup values from the website into the Silverlight application
Step #1: The first step is actually passing the name/value pairs from the website. This can be accomplished in several different ways. The way I passed them from my ImageRotatorTestPage.aspx page is through the code-behind. I built a string of name value pairs (get the initial load values from web.config and refresh the values from the page controls). I then set the InitParams
string into the ImageRotator
instance's InitParamaters
property.
protected void refresh_Click(object sender, EventArgs e)
{
string browserWindowOptions = "resizable=1|scrollbars=1|menubar=1|status=1|"
browserWindowOptions += "toolbar=1|titlebar=1|width=900|height=725|left=5|top=5";
string initParams = "autoPlay=" + autoPlay.Checked.ToString();
initParams += ",autoPlayInterval=" + autoPlayInterval.Text;
initParams += ",numberedNavigation=" + numberedNavigation.Checked.ToString();
initParams += ",arrowNavigation=" + arrowNavigation.Checked.ToString();
initParams += ",stopStartAutoPlay=" + stopStartAutoPlay.Checked.ToString();
initParams += ",animation=" + animation.Checked.ToString();
initParams += ",border=" + border.Checked.ToString();
initParams += ",borderThickness=" + borderThickness.SelectedValue;
initParams += ",argb=" + a.Text + "|" + r.Text +
"|" + g.Text + "|" + b.Text;
initParams += ",imageRotatorDiv=" + imageRotatorDiv;
initParams += ",browserWindowOptions=" + browserWindowOptions;
imageRotator.InitParameters = initParams;
}
Note that two of InitParams
' name/value pairs contain a pipe delimited list. I had initially used commas to delimit, but this messed up the delimiters of initParams
. To get around this, I encoded the commas. However, I found this hard to read and maintain. In the end, I decided to use a pipe as a delimiter. Since the ImageRotatorTestPage.html has no code-behind, I had to find another method for passing InitParams
from it. As it turns out, this can be accomplished with the param
tag having its name
attribute set to initParams
and its value
attribute set to a comma delimited list of attribute=value pairs.
<object data="data:application/x-silverlight-2,"
type="application/x-silverlight-2" width="100%" height="100%">
<param name="source" value="ClientBin/ImageRotator_CSharp.xap"/>
<param name="onerror" value="onSilverlightError" />
<param name="background" value="white" />
<param name="minRuntimeVersion" value="2.0.31005.0" />
<param name="autoUpgrade" value="true" />
-->
<param name="initParams"
value="autoPlay=true,
autoPlayInterval=7,
numberedNavigation=true,
arrowNavigation=true,
stopStartAutoPlay=true,
animation=true,
border=true,
borderThickness=2,
imageRotatorDiv=silverlightControlHost,
browserWindowOptions=resizable=1|scrollbars=1|menubar=1|
status=1|toolbar=1|titlebar=1|width=1000|height=725|left=5|top=5,
argb=255|0|0|0" />
-->
<a href=http://go.microsoft.com/fwlink/?LinkID=124807 style="text-decoration: none;">
<img src=http://go.microsoft.com/fwlink/?LinkId=108181
alt="Get Silverlight" style="border-style: none"/>
</a>
</object>
Step #2: The parameters must be passed into the Silverlight page (Page.xaml). This is not done directly, and it is not setup to pass InitParams
by default. They must be passed into the application class (App.xaml.cs) first, which then passes them on to whichever user control you want (Page.xaml.cs, in my case). More specifically, every time the Silverlight application is loaded (initially, or by page refresh), the App.xaml.cs default constructor (App()
) is called. This wires up the Application_Startup
event which is then called. By default, this method simply calls the default constructor inside Page.xaml.cs. What we need it to do is to call an overloaded constructor (which we will need to create) that takes in the InitParams
as an argument. Here's a look at the updated Application_Startup
method calling the overloaded Page
class constructor.
private void Application_Startup(object sender, StartupEventArgs e)
{
if (e.InitParams.Count == 11)
this.RootVisual = new Page(e.InitParams);
else
{
string exc = "Must pass the required InitParams " +
"to the Page.xaml.cs constructor"
throw new Exception(exc);
}
}
Step #3: Here is a look at the overloaded constructor which accepts the InitParams
and places the values into some private member variables for later use.
public Page(IDictionary<string, > parameters)
{
InitializeComponent();
foreach (var item in parameters)
{
switch (item.Key)
{
case "autoPlay":
enableAutoPlay = Convert.ToBoolean(item.Value);
break;
case "autoPlayInterval":
autoPlayIntervalSeconds = Convert.ToInt32(item.Value);
break;
case "numberedNavigation":
displayNumberedButtons = Convert.ToBoolean(item.Value);
break;
case "arrowNavigation":
displayArrows = Convert.ToBoolean(item.Value);
break;
case "stopStartAutoPlay":
buttonsStopStartAutoPlay = Convert.ToBoolean(item.Value);
break;
case "animation":
enableAnimations = Convert.ToBoolean(item.Value);
break;
case "border":
displayImageBorder = Convert.ToBoolean(item.Value);
break;
case "borderThickness":
imageBorderThickness = Convert.ToInt32(item.Value);
break;
case "argb":
argbBorderColor = item.Value.ToString().Replace("|", ",");
break;
case "imageRotatorDiv":
imageRotatorDiv = item.Value.ToString();
break;
case "browserWindowOptions":
browserWindowOptions = item.Value.ToString().Replace("|", ",");
break;
}
}
LoadPictureAlbum();
}
Asynchronously load the XML file from the website into the instance of the PictureAlbum class and notify the custom event listener when completed
There are two important things to note here. First, we want to asynchronously go to the website and get the SlideShowImages.xml file. This XML file contains a <SlideShowImage>
element for each picture. Each SlideShowImage
element contains several elements which identify attributes of that image. I chose to use elements for these instead of attributes because I want this application to be easy to use for non-developers as well, and I feel elements are easier to read than attributes for them (and me for that matter). We need this in order to load up the PictureAlbum
class which contains a collection SlideShowImages
used to drive the ImageRotator
. We also need to let the ImageRotator
know this process is done so that it can then use the SlideShowImages
instance to dynamically create the images and buttons on the control itself.
This was probably were I spent the most of my time. My first thought was to use a wait-handle of some sort to block the calling thread from my Page.xaml.cs class until the XML file was loaded, then signal that it had completed so the image controls could be created. Depending on how I created a new thread, and using wait-handles, I came across different problems. Either the wait-handle would not appear to block, or it would get to the blocking control and spin forever without ever reaching my callback method to signal. Trust me when I say, I tried everything. Then, I did a little research (which I should have done much earlier), and discovered the problem. There is no issue with using WaitHandle
s or CallBackMethod
s or ManualResetEvent
's with Silverlight. However, you cannot block the main UI thread in a Silverlight application. My alternative approach, which worked with little effort, was to create a custom event that gets fired when I'm done loading the XML file.
First, in the PictureAlbum
class, I created a delegate for my asynchronous call, and a custom event to be raised when it was completed.
public delegate void PictureAlbumLoaded(object sender, PictureAlbumLoadedEventArgs a);
public event PictureAlbumLoaded pictureAlbumLoaded;
Next, I created a method (LoadPictureAlbumXMLFile
) which gets called by the page class during its construction. This method creates an instance of WebClient
which we will use to get the XML file across the wire via its DownloadStringAsync
method. We will also wire up to the WebClient
instance's DownloadStringCompleted
event. This method takes a callback method as a parameter which I have given it (XMLFileLoaded
). Once the WebClient
has completed its asynchronous call to get the file, it will fire its DownloadStringCompleted
event. In our case, this will cause XMLFileLoaded
to be called. This method takes the XML file which has been returned as a string, and parses it into an XDocument
. We then use a little LINQ to XML to get the image details out and into the PictureAlbum
's collection of images. Finally, the pictureAlbumLoaded
event is fired to notify any listeners of the success or failure of this method call.
public void LoadPictureAlbumXMLFile()
{
WebClient xmlClient = new WebClient();
xmlClient.DownloadStringCompleted +=
new DownloadStringCompletedEventHandler(XMLFileLoaded);
xmlClient.DownloadStringAsync(new Uri("SlideShowImages.xml",
UriKind.RelativeOrAbsolute));
}
private void XMLFileLoaded(object sender, DownloadStringCompletedEventArgs e)
{
if (e.Error == null)
{
Int32 buttonNumber = 0;
slideShowImages = new List<SlideShowImage>();
PictureAlbumLoadedEventArgs pictureAlbumLoadedCompletedSuccessfully = null;
try
{
xdoc = XDocument.Parse(e.Result);
imageWidth = Convert.ToDouble(xdoc.Element("PictureAlbum")
.Attribute("ImageWidth").Value);
imageHeight = Convert.ToDouble(xdoc.Element("PictureAlbum")
.Attribute("ImageHeight").Value);
foreach (XElement item in xdoc.Elements("PictureAlbum")
.Elements("SlideShowImage"))
{
if (item.Element("DisplayImage").Value.ToLower() == "true")
{
SlideShowImage slideShowImage = new SlideShowImage();
if (item.Element("ImageUri").Value.Contains("http"))
slideShowImage.ImageUri =
new Uri(item.Element("ImageUri").Value, UriKind.Absolute);
else
{
slideShowImage.ImageUri =
new Uri(item.Element("ImageUri").Value, UriKind.Relative);
}
slideShowImage.RedirectLink = item.Element("RedirectLink").Value;
slideShowImage.Order = Convert.ToInt32(item.Element("Order").Value);
slideShowImage.ButtonNumber = buttonNumber++;
slideShowImages.Add(slideShowImage);
}
}
pictureAlbumLoadedCompletedSuccessfully = new PictureAlbumLoadedEventArgs(true);
}
catch
{
pictureAlbumLoadedCompletedSuccessfully =
new PictureAlbumLoadedEventArgs(false);
}
finally
{
pictureAlbumLoaded(this, pictureAlbumLoadedCompletedSuccessfully);
}
}
}
The method that calls all of this (LoadPictureAlbum
) is in the Page
class, and it also has some asynchronous logic. It first wires up a listener to the PictureAlbumLoaded
event, passing in the PictureAlbumLoaded
method as the callback. It then calls LoadPictureAlbumXMLFile
, which, as we saw previously, will raise the PictureAlbumLoaded
listener. Once this listener is called, PictureAlbumLoaded
is called. It first checks the PictureAlbumLoadedEventArgs
to verify the load was successful, and if not, throws an exception. If the load was successful, it continues on to dynamically generate an Image control for each image
in the collection.
private void LoadPictureAlbum()
{
pictureAlbum.pictureAlbumLoaded +=
new PictureAlbum.PictureAlbumLoaded(PictureAlbumLoaded);
pictureAlbum.LoadPictureAlbumXMLFile();
}
private void PictureAlbumLoaded(object sender,
PictureAlbumLoadedEventArgs args)
{
try
{
if (args.PictureAlbumLoadedSuccessfully)
{
imageWidth = pictureAlbum.ImageWidth;
imageHeight = pictureAlbum.ImageHeight;
pictures = pictureAlbum.SlideShowImages;
imageCount = pictures.Count;
AddNewImage(0, Visibility.Visible);
imagesToLoadCount = imageCount - 1;
for (int i = 1; (i < pictures.Count); i++)
ThreadPool.QueueUserWorkItem(ProcessSlideShowImage, i.ToString());
}
else
{
string exc = "The SlideShowImages.xml file failed to load the "
exc += "PictureAlbum class successfully."
throw new Exception(exc);
}
}
finally
{
pictureAlbum.pictureAlbumLoaded -=
new PictureAlbum.PictureAlbumLoaded(PictureAlbumLoaded);
}
}
Fading out an old image and fading in a new image
This one wasn't too bad, but it did take a little trial and error. Basically, I created a method with animation (DoubleAnimation
) to fade out the current Image
control. Inside that method, I wired up the storyboard's Completed
event. When the storyboard animation completes, the image will have faded, and the event will be raised. This event is tied to a callback method which then swaps the old Image
control with the new one and then calls FadeIn
. FadeIn
is another animation (DoubleAnimation
) which, as its name implies, fades in the new Image
control.
private void FadeOut()
{
DoubleAnimation fadeOutDoubleAnimation = new DoubleAnimation();
fadeOutDoubleAnimation.From = 1;
fadeOutDoubleAnimation.To = 0;
fadeOutDoubleAnimation.Duration = new Duration(TimeSpan.FromSeconds(1));
fadeOutStoryboard = new Storyboard();
fadeOutStoryboard.Children.Add(fadeOutDoubleAnimation);
fadeOutStoryboard.Completed += new EventHandler(FadeOutStoryboard_Completed);
Storyboard.SetTarget(fadeOutDoubleAnimation,
((Image)imageStackPanel.Children.ElementAt(lastImageIndex)));
Storyboard.SetTargetProperty(fadeOutDoubleAnimation,
new PropertyPath(ListBox.OpacityProperty));
layoutRoot.Resources.Add("fadeOutStoryboard", fadeOutStoryboard);
fadeOutStoryboard.Begin();
layoutRoot.Resources.Remove("fadeOutStoryboard");
}
private void FadeOutStoryboard_Completed(object sender, EventArgs e)
{
((Image)imageStackPanel.Children.ElementAt(lastImageIndex)).Visibility =
Visibility.Collapsed;
((Image)imageStackPanel.Children.ElementAt(nextImageIndex)).Visibility =
Visibility.Visible;
lastImageIndex = nextImageIndex;
FadeIn();
if ((enableAutoPlay || buttonsStopStartAutoPlay) && leavePaused == false)
autoPlayTimer.Start();
}
private void FadeIn()
{
DoubleAnimation fadeInDoubleAnimation = new DoubleAnimation();
fadeInDoubleAnimation.From = 0;
fadeInDoubleAnimation.To = 1;
fadeInDoubleAnimation.Duration = new Duration(TimeSpan.FromSeconds(1));
fadeInStoryboard = new Storyboard();
fadeInStoryboard.Children.Add(fadeInDoubleAnimation);
Storyboard.SetTarget(fadeInDoubleAnimation,
((Image)imageStackPanel.Children.ElementAt(nextImageIndex)));
Storyboard.SetTargetProperty(fadeInDoubleAnimation,
new PropertyPath(ListBox.OpacityProperty));
layoutRoot.Resources.Add("fadeInStoryboard", fadeInStoryboard);
fadeInStoryboard.Begin();
layoutRoot.Resources.Remove("fadeInStoryboard");
}
History
- February 23, 2009 - Added source code for VB.NET.