Introduction
I had to wait in today for a new washing machine to be delivered, so had a few hours to spare. So decided it might be fun to try and create a nice WPF screensaver. This article is the outcome of this.
Contents
Here is what I will be covering in this article:
Well, it looks like this when running:
And it can be configured using the configuration screen. Configuring is done just as for a normal screensaver.
The configuration screen simply allows a user to select a list of folders that should contain images. When the user closes the configuration screen, a file is written to the Environment.SpecialFolder.MyPictures
folder. This file simply contains a list of all the directories the user picked. Another thing that happens when the user closes this form is that an internal IList<FileInfo>
is updated to hold an instance of any valid image file found in these directories.
The valid files are found using the following Extension Methods that work with IEnumerable<FileInfo>
types:
public static IEnumerable<FileInfo> IsImageFile(
this IEnumerable<FileInfo> files,
Predicate<FileInfo> isMatch)
{
foreach (FileInfo file in files)
{
if (isMatch(file))
yield return file;
}
}
public static IEnumerable<FileInfo> IsImageFile(
this IEnumerable<FileInfo> files)
{
foreach (FileInfo file in files)
{
if (file.Name.EndsWith(".jpg") ||
file.Name.EndsWith(".png") ||
file.Name.EndsWith(".bmp"))
yield return file;
}
}
To put this into context, I'll show you the entire listing for the configuration screen. There is not that much code for it, so don't worry.
using System;
using System.Collections.Generic;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Shapes;
using System.IO;
using System.Windows.Forms;
namespace WPF_ScreenSaver
{
public partial class Settings : System.Windows.Window
{
#region Ctor
public Settings()
{
InitializeComponent();
this.Loaded += new RoutedEventHandler(Settings_Loaded);
this.Closing +=
new System.ComponentModel.CancelEventHandler(Settings_Closing);
}
#endregion
#region Private Methods
private void Settings_Loaded(object sender, RoutedEventArgs e)
{
String fullFileName = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.MyPictures),
Globals.TempFileName);
String line;
try
{
using (StreamReader reader = File.OpenText(fullFileName))
{
line = reader.ReadLine();
while (line != null)
{
lstFolders.Items.Add(line);
line = reader.ReadLine();
}
reader.Close();
}
}
catch (FileNotFoundException fex)
{
}
}
private void Settings_Closing
(object sender, System.ComponentModel.CancelEventArgs e)
{
DealWithLocationFile();
}
private void btnPick_Click(object sender, RoutedEventArgs e)
{
FolderBrowserDialog fd = new FolderBrowserDialog();
fd.SelectedPath = Environment.GetFolderPath
(Environment.SpecialFolder.MyPictures);
if (fd.ShowDialog() == System.Windows.Forms.DialogResult.OK)
{
if (fd.SelectedPath != String.Empty)
{
if (!lstFolders.Items.Contains(fd.SelectedPath))
lstFolders.Items.Add(fd.SelectedPath);
}
}
}
private void DealWithLocationFile()
{
String fullFileName = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.MyPictures),
Globals.TempFileName);
if (File.Exists(fullFileName))
{
File.Delete(fullFileName);
}
using (TextWriter tw = new StreamWriter(fullFileName))
{
Globals.Files.Clear();
foreach (String folderName in lstFolders.Items)
{
try
{
foreach (var file in
new DirectoryInfo(folderName).GetFiles().IsImageFile())
{
Globals.Files.Add(file);
}
tw.WriteLine(folderName);
}
catch (DirectoryNotFoundException dex)
{
}
catch (ArgumentException ax)
{
}
}
tw.Close();
}
}
#endregion
}
}
I think this code is fairly self-explanatory. The XAML for this screen is not that exciting; there are a few Templates for things like ScrollViewer
and Button
types, but this is all standard stuff. So I'll leave that as an exercise to the reader. The part within the configuration screen that makes use of the previously mentioned Extension Methods is this part:
try
{
foreach (var file in
new DirectoryInfo(folderName).GetFiles().IsImageFile())
{
Globals.Files.Add(file);
}
tw.WriteLine(folderName);
}
catch (DirectoryNotFoundException dex)
{
}
catch (ArgumentException ax)
{
}
within the DealWithLocationFile()
method shown above. You got to love Extension Methods, they make it so easy to do neat things like this.
I can claim nothing for the WPF Template, I found that on the Internet at the following URL: http://scorbs.com/2006/12/21/wpf-screen-saver-template.
Full instructions of how to use this can be found at this link, should you wish to try and create your own screensaver.
I have already discussed the configuration screen so I will not go over that again. So that really only leaves the main window, which is the actual screensaver. The basic idea is as follows:
- There is a 3D cube that has images shown on its surfaces. These images are randomly picked from a working set of images.
- The actual working set is picked by randomly picking a number of images from the total images available that were retrieved based on the directories the user picked on the configuration screen.
- If there are not enough images to create a working set, the working set is padded with a single image that is embedded in the actual assembly resources. I have chosen a nice fetching She-Hulk image for this purpose.
- A timer is used to signal that a new image should be picked for the 3D cube. This is done as follows:
- A random index is picked from the working set which is then used to show on the 3D cube.
- A counter is incremented. When the counter reaches the same size as the working set of images, a new working set of images is generated.
- There is a small area at the bottom of the screen which represents the current working set. The current image within this set will get a small
IsSelected
indicator applied to it. This area is also refreshed when a new working set is created.
I'll now show you how some of this works.
The 3D Cube
This is defined in XAML as shown below:
<Viewport3D x:Name="myViewport">
<Viewport3D.Resources>
<MeshGeometry3D x:Key="plane1"
Normals="0,-1,0 0,-1,0 0,-1,0 0,-1,0"
Positions="-0.5,0,0.5 0.5,0,-0.5 0.5,0,0.5 -0.5,0,-0.5"
TextureCoordinates="0,1 1,0 1,1 0,0"
TriangleIndices="0 1 2 1 0 3"/>
<MeshGeometry3D x:Key="plane2"
Normals="0,0,1 0,0,1 0,0,1 0,0,1"
Positions="-0.5,0,0.5 0.5,0,0.5 0.5,1,0.5 -0.5,1,0.5"
TextureCoordinates="0,1 1,1 1,0 0,0"
TriangleIndices="0 1 2 2 3 0"/>
<MeshGeometry3D x:Key="plane3"
Normals="0,0,-1 0,0,-1 0,0,-1 0,0,-1"
Positions="-0.5,0,-0.5 0.5,1,-0.5 0.5,0,-0.5 -0.5,1,-0.5"
TextureCoordinates="0,1 1,0 1,1 0,0"
TriangleIndices="0 1 2 1 0 3"/>
<MeshGeometry3D x:Key="plane4"
Normals="1,0,0 1,0,0 1,0,0 1,0,0"
Positions="0.5,0,0.5 0.5,0,-0.5 0.5,1,-0.5 0.5,1,0.5"
TextureCoordinates="0,1 1,1 1,0 0,0"
TriangleIndices="0 1 2 2 3 0"/>
<MeshGeometry3D x:Key="plane5"
Normals="-1,0,0 -1,0,0 -1,0,0 -1,0,0"
Positions="-0.5,0,0.5 -0.5,1,-0.5 -0.5,0,-0.5 -0.5,1,0.5"
TextureCoordinates="0,1 1,0 1,1 0,0"
TriangleIndices="0 1 2 1 0 3"/>
<MeshGeometry3D x:Key="plane6"
Normals="0,1,0 0,1,0 0,1,0 0,1,0"
Positions="-0.5,1,0.5 0.5,1,0.5 0.5,1,-0.5 -0.5,1,-0.5"
TextureCoordinates="0,1 1,1 1,0 0,0"
TriangleIndices="0 1 2 2 3 0"/>
</Viewport3D.Resources>
<Viewport3D.Camera>
<PerspectiveCamera x:Name="Camera"
FieldOfView="45"
FarPlaneDistance="20" LookDirection="5,-2,-3"
NearPlaneDistance="0.1" Position="-5,2,3"
UpDirection="0,1,0"/>
</Viewport3D.Camera>
<ModelVisual3D>
<ModelVisual3D.Content>
<Model3DGroup x:Name="Scene"
Transform="{DynamicResource SceneTR8}">
<AmbientLight Color="#333333" />
<DirectionalLight Color="#C0C0C0"
Direction="5,0,-1" />
<DirectionalLight Color="#C0C0C0"
Direction="1,0,-2.22045e-016" />
<DirectionalLight Color="#C0C0C0"
Direction="-1,0,-2.22045e-016" />
<DirectionalLight Color="#C0C0C0"
Direction="-2.44089e-016,0,1" />
</Model3DGroup>
</ModelVisual3D.Content>
</ModelVisual3D>
<ModelVisual3D x:Name="topModelVisual3D">
<ModelVisual3D.Transform>
<Transform3DGroup>
<TranslateTransform3D OffsetX="0"
OffsetY="0" OffsetZ="0"/>
<ScaleTransform3D ScaleX="1"
ScaleY="1" ScaleZ="1"/>
<RotateTransform3D>
<RotateTransform3D.Rotation>
<AxisAngleRotation3D Angle="1" Axis="0,1,0"/>
</RotateTransform3D.Rotation>
</RotateTransform3D>
<TranslateTransform3D OffsetX="0"
OffsetY="0" OffsetZ="0"/>
<TranslateTransform3D OffsetX="0"
OffsetY="0" OffsetZ="0"/>
</Transform3DGroup>
</ModelVisual3D.Transform>
<ModelVisual3D>
<ModelVisual3D.Content>
<DirectionalLight Color="#FFFFFFFF"
Direction="0.717509570032485,-0.687462205666443,
-0.112141574324722"/>
</ModelVisual3D.Content>
</ModelVisual3D>
-->
<Viewport2DVisual3D Geometry="{StaticResource plane1}">
<Viewport2DVisual3D.Material>
<DiffuseMaterial
Viewport2DVisual3D.IsVisualHostMaterial="True"
Brush="CornflowerBlue"/>
</Viewport2DVisual3D.Material>
<Image x:Name="img1"
Source="Images/NoImage.jpg" Stretch="Fill"/>
</Viewport2DVisual3D>
-->
<Viewport2DVisual3D Geometry="{StaticResource plane2}">
<Viewport2DVisual3D.Material>
<DiffuseMaterial
Viewport2DVisual3D.IsVisualHostMaterial="True"
Brush="CornflowerBlue"/>
</Viewport2DVisual3D.Material>
<Image x:Name="img2"
Source="Images/NoImage.jpg" Stretch="Fill"/>
</Viewport2DVisual3D>
-->
<Viewport2DVisual3D Geometry="{StaticResource plane3}">
<Viewport2DVisual3D.Material>
<DiffuseMaterial
Viewport2DVisual3D.IsVisualHostMaterial="True"
Brush="CornflowerBlue"/>
</Viewport2DVisual3D.Material>
<Image x:Name="img3"
Source="Images/NoImage.jpg" Stretch="Fill"/>
</Viewport2DVisual3D>
-->
<Viewport2DVisual3D Geometry="{StaticResource plane4}">
<Viewport2DVisual3D.Material>
<DiffuseMaterial
Viewport2DVisual3D.IsVisualHostMaterial="True"
Brush="CornflowerBlue"/>
</Viewport2DVisual3D.Material>
<Image x:Name="img4"
Source="Images/NoImage.jpg" Stretch="Fill"/>
</Viewport2DVisual3D>
-->
<Viewport2DVisual3D Geometry="{StaticResource plane5}">
<Viewport2DVisual3D.Material>
<DiffuseMaterial
Viewport2DVisual3D.IsVisualHostMaterial="True"
Brush="CornflowerBlue"/>
</Viewport2DVisual3D.Material>
<Image x:Name="img5"
Source="Images/NoImage.jpg" Stretch="Fill"/>
</Viewport2DVisual3D>
-->
<Viewport2DVisual3D Geometry="{StaticResource plane6}">
<Viewport2DVisual3D.Material>
<DiffuseMaterial
Viewport2DVisual3D.IsVisualHostMaterial="True"
Brush="CornflowerBlue"/>
</Viewport2DVisual3D.Material>
<Image x:Name="img6"
Source="Images/NoImage.jpg" Stretch="Fill"/>
</Viewport2DVisual3D>
</ModelVisual3D>
</Viewport3D>
And from there, the 3D cube is animated, using the following StoryBoard
:
<Storyboard x:Key="sbLoaded" RepeatBehavior="Forever"
AutoReverse="True" Duration="00:00:02.5000000">
<Rotation3DAnimationUsingKeyFrames BeginTime="00:00:00"
Storyboard.TargetName="topModelVisual3D"
Storyboard.TargetProperty="(Visual3D.Transform).
(Transform3DGroup.Children)[2].(RotateTransform3D.Rotation)">
<SplineRotation3DKeyFrame KeyTime="00:00:00.5000000">
<SplineRotation3DKeyFrame.Value>
<AxisAngleRotation3D Angle="46.567463442210148"
Axis="0.447213595499955,0.774596669241484,
0.44721359549996"/>
</SplineRotation3DKeyFrame.Value>
</SplineRotation3DKeyFrame>
<SplineRotation3DKeyFrame KeyTime="00:00:01">
<SplineRotation3DKeyFrame.Value>
<AxisAngleRotation3D Angle="78.477102851225609"
Axis="0.250562807085731,0.93511312653103,
0.250562807085732"/>
</SplineRotation3DKeyFrame.Value>
</SplineRotation3DKeyFrame>
<SplineRotation3DKeyFrame KeyTime="00:00:01.5000000">
<SplineRotation3DKeyFrame.Value>
<AxisAngleRotation3D Angle="180"
Axis="-6.12303176911192E-17,
2.8327492261615E-16,1"/>
</SplineRotation3DKeyFrame.Value>
</SplineRotation3DKeyFrame>
<SplineRotation3DKeyFrame KeyTime="00:00:02">
<SplineRotation3DKeyFrame.Value>
<AxisAngleRotation3D Angle="148.600285190081"
Axis="-0.678598344545847,-0.28108463771482,
-0.678598344545847"/>
</SplineRotation3DKeyFrame.Value>
</SplineRotation3DKeyFrame>
<SplineRotation3DKeyFrame KeyTime="00:00:02.5000000">
<SplineRotation3DKeyFrame.Value>
<AxisAngleRotation3D Angle="338.81717773037957"
Axis="-0.704062592219638,-0.704062592219635,
0.0926915987235715"/>
</SplineRotation3DKeyFrame.Value>
</SplineRotation3DKeyFrame>
</Rotation3DAnimationUsingKeyFrames>
<DoubleAnimationUsingKeyFrames BeginTime="00:00:00"
Storyboard.TargetName="topModelVisual3D"
Storyboard.TargetProperty="(Visual3D.Transform).
(Transform3DGroup.Children)[1].(ScaleTransform3D.ScaleX)">
<SplineDoubleKeyFrame KeyTime="00:00:00.5000000" Value="1"/>
<SplineDoubleKeyFrame KeyTime="00:00:01" Value="2"/>
<SplineDoubleKeyFrame KeyTime="00:00:01.5000000" Value="1.5"/>
<SplineDoubleKeyFrame KeyTime="00:00:02" Value="1.5"/>
<SplineDoubleKeyFrame KeyTime="00:00:02.5000000" Value="1"/>
</DoubleAnimationUsingKeyFrames>
<DoubleAnimationUsingKeyFrames BeginTime="00:00:00"
Storyboard.TargetName="topModelVisual3D"
Storyboard.TargetProperty="(Visual3D.Transform).
(Transform3DGroup.Children)[1].(ScaleTransform3D.ScaleY)">
<SplineDoubleKeyFrame KeyTime="00:00:00.5000000" Value="1"/>
<SplineDoubleKeyFrame KeyTime="00:00:01" Value="2"/>
<SplineDoubleKeyFrame KeyTime="00:00:01.5000000" Value="1.5"/>
<SplineDoubleKeyFrame KeyTime="00:00:02" Value="1.5"/>
<SplineDoubleKeyFrame KeyTime="00:00:02.5000000" Value="1"/>
</DoubleAnimationUsingKeyFrames>
<DoubleAnimationUsingKeyFrames BeginTime="00:00:00"
Storyboard.TargetName="topModelVisual3D"
Storyboard.TargetProperty="(Visual3D.Transform).
(Transform3DGroup.Children)[1].(ScaleTransform3D.ScaleZ)">
<SplineDoubleKeyFrame KeyTime="00:00:00.5000000" Value="1"/>
<SplineDoubleKeyFrame KeyTime="00:00:01" Value="2"/>
<SplineDoubleKeyFrame KeyTime="00:00:01.5000000" Value="1.5"/>
<SplineDoubleKeyFrame KeyTime="00:00:02" Value="1.5"/>
<SplineDoubleKeyFrame KeyTime="00:00:02.5000000" Value="1"/>
</DoubleAnimationUsingKeyFrames>
</Storyboard>
The Working Set of Images
The working set of images is picked as follows:
private void CreateWorkingSetOfFiles()
{
Int32 currentSetIndex = 0;
Globals.WorkingSetOfImages.Clear();
if (Globals.Files.Count > 0)
{
while (currentSetIndex < Globals.WorkingSetLimit)
{
Int32 randomIndex = rand.Next(0, Globals.Files.Count);
String imageUrl = Globals.Files[randomIndex].FullName;
if (!Globals.WorkingSetOfImages.Contains(imageUrl))
{
Globals.WorkingSetOfImages.Add(imageUrl);
currentSetIndex++;
}
}
}
else
{
for (int i = 0; i < Globals.WorkingSetLimit; i++)
{
Globals.WorkingSetOfImages.Add("Images/NoImage.jpg");
}
}
itemsCurrentImages.Items.Clear();
foreach (String imageUrl in Globals.WorkingSetOfImages)
{
SelectableImageUrl selectableImageUrl = new SelectableImageUrl();
selectableImageUrl.ImageUrl = imageUrl;
selectableImageUrl.IsSelected = false;
itemsCurrentImages.Items.Add(selectableImageUrl);
}
}
It can be seen that this is not actually using images to add to the ItemsControl
at the bottom, but rather a SelectableImageUrl
object. Let's have a look at one of these objects. They are a simple bindable object, thanks to the INotifyPropertyChanged
interface.
using System.ComponentModel;
using System;
namespace WPF_ScreenSaver
{
public class SelectableImageUrl : INotifyPropertyChanged
{
#region Data
private String imageUrl;
private Boolean isSelected;
#endregion
#region Public Properties
public String ImageUrl
{
get { return imageUrl; }
set
{
if (value == imageUrl)
return;
imageUrl = value;
this.OnPropertyChanged("ImageUrl");
}
}
public Boolean IsSelected
{
get { return isSelected; }
set
{
if (value == isSelected)
return;
isSelected = value;
this.OnPropertyChanged("IsSelected");
}
}
#endregion
#region INotifyPropertyChanged Members
public event PropertyChangedEventHandler PropertyChanged;
void OnPropertyChanged(string propertyName)
{
if (this.PropertyChanged != null)
this.PropertyChanged
(this, new PropertyChangedEventArgs(propertyName));
}
#endregion
}
}
Which means that we can create a nice XAML DataTemplate
for this type of object. So this is exactly what I do to show the currently selected one. Here is the DataTemplate
for one of these objects that are added to the ItemsControl
representing the current working window objects:
<DataTemplate DataType="{x:Type local:SelectableImageUrl}">
<Grid Background="Transparent">
<Grid.RowDefinitions>
<RowDefinition Height="15"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Rectangle x:Name="rect" Grid.Column="0"
Grid.Row="0" Fill="Transparent"
Width="10" Height="10"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
<Border Grid.Column="0"
Grid.Row="1" Margin="2"
Background="White">
<Image
Source="{Binding Path=ImageUrl}"
Width="40" Height="40"
Stretch="Fill" Margin="2"/>
</Border>
</Grid>
<DataTemplate.Triggers>
<DataTrigger
Binding="{Binding Path=IsSelected}"
Value="True">
<Setter TargetName="rect"
Property="Fill" Value="Orange" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
Generating a New Working Set of Images
As I previously stated, there is an animation timer that runs, and when it ticks, a new image from the working set is used for the 3D cube surfaces. But this timer tick also works out whether to create a new working set of images. This is shown below:
private void timer_Tick(object sender, EventArgs e)
{
Int32 randomIndex = rand.Next(0, Globals.WorkingSetOfImages.Count);
String imageUrl = Globals.WorkingSetOfImages[randomIndex];
foreach (SelectableImageUrl selectableImageUrl in itemsCurrentImages.Items)
{
if (selectableImageUrl.ImageUrl == imageUrl)
selectableImageUrl.IsSelected = true;
else
selectableImageUrl.IsSelected = false;
}
img1.Source = new BitmapImage(new Uri(imageUrl, UriKind.RelativeOrAbsolute));
img2.Source = new BitmapImage(new Uri(imageUrl, UriKind.RelativeOrAbsolute));
img3.Source = new BitmapImage(new Uri(imageUrl, UriKind.RelativeOrAbsolute));
img4.Source = new BitmapImage(new Uri(imageUrl, UriKind.RelativeOrAbsolute));
img5.Source = new BitmapImage(new Uri(imageUrl, UriKind.RelativeOrAbsolute));
img6.Source = new BitmapImage(new Uri(imageUrl, UriKind.RelativeOrAbsolute));
currentChangeCount++;
if (currentChangeCount == Globals.WorkingSetLimit)
{
CreateWorkingSetOfFiles();
currentChangeCount = 0;
}
}
All you have to do to us this at home is build the attached project in Release mode and then do the following:
- Copy the EXE produced to somewhere convenient
- Rename the EXE to SCR
- Right click the SCR file
- Select Install
That's it, you will now have a working WPF screensaver. Enjoy.
Some of you may actually have thousands of photos in your "My Pictures" folder. It was never my intention that this screensaver would need to work with thousands of images. Especially not with 5-7 megapixel camera photos, which could be very large files indeed. If you would like to use this for a screensaver in this situation, I would strongly recommend you modify the code in the part that gets all the photos for the selected directories, and store these in the global List<FileInfo>
. This is within the configuration screen logic. You could do something like, maybe take only the top 100 picked images. You could use some nice LINQ for this.
This article was more about how to go about creating a screensaver in WPF. I have about 200 PNG/JPG images (though not photos) and they load like lightning.
That's it
That's all I wanted to say this time, I hope it helps some of you. Could I just ask, if you liked this article, please vote for it.