Introduction
After watching some BUILD2011 videos, I couldn't help wondering whether it was possible for me to replicate the Win 8 start screen in WPF. The sample app, WPF Metro, displays tiles that enable the user to access the applications available from the start menu in Win XP or Win 7. It is a more toned-down version of the Win 8 start screen.
Requirements
To view the source files and run the demo, you require:
- At least 15" of screen real-estate
- Visual Studio 2010
WPF Metro
When you run WPF Metro, you will be presented with a series of tiles. Double-click on a tile to open the related application.
To change the app's skin, move your cursor to the bottom edge of the application, to activate the 'Edge UI', and click on one of the colored buttons.
Quick-Jump
If you don't want to pan to a tiles group by swiping, you can press a key to pan a particular tiles group into view, e.g., to pan the 'M' tiles group into view press the M key.
Design and Layout
I designed WPF Metro in Expression Blend. The primary layout containers are MainCanvas
and MetroStackPanel
.
MetroStackPanel
is a child element of MainCanvas
. Tiles are added to a WrapPanel
which is then added to MetroStackPanel
. A tile in WPF Metro is a UserControl
named Tile
which is made up of a TextBlock
and an Image
control for displaying an application's icon.
The Code
To display the relevant tiles, WPF Metro extracts the icons of the .exe files that the shortcuts in the Programs folder of the Start menu link to. The tiles are then displayed in alphabetical order by adding Tile
controls to a WrapPanel
which is eventually added to MetroStackPanel
.
The code for the Tile
UserControl
looks like this:
Partial Public Class Tile
Public Property ExecutablePath() As String
Private Sub Tile_PreviewMouseDoubleClick(ByVal sender As Object, _
ByVal e As System.Windows.Input.MouseButtonEventArgs) _
Handles Me.PreviewMouseDoubleClick
Try
Process.Start(ExecutablePath)
Catch ex As Win32Exception
Exit Sub
End Try
End Sub
End Class
In the MainWindow
Initialized
event handler, we do the following;
Private Sub MainWindow_Initialized(ByVal sender As Object, _
ByVal e As System.EventArgs) Handles Me.Initialized
timer = New DispatcherTimer
anim = New DoubleAnimationUsingKeyFrames
anim.Duration = TimeSpan.FromMilliseconds(1800)
timer.Interval = New TimeSpan(0, 0, 0, 0, 1000)
AddHandler timer.Tick, AddressOf timer_Tick
End Sub
In the MainWindow
Loaded
event handler, we set the skin for the application and display tiles:
Private Sub MainWindow_Loaded(ByVal sender As Object, _
ByVal e As System.Windows.RoutedEventArgs) Handles Me.Loaded
Dim metro As New Metrolizer
metro.DisplayTiles(MetroStackPanel)
Dim path As String = My.Settings.SkinPath
Dim newDictionary As New ResourceDictionary()
newDictionary.Source = New Uri(path, UriKind.Relative)
Me.Resources.MergedDictionaries.Clear()
Me.Resources.MergedDictionaries.Add(newDictionary)
End Sub
The Metrolizer
class containing the DisplayTiles()
method looks as follows:
Imports System.Collections.Generic
Public Class Metrolizer
Private wrapPanelX As Double = 0
Public Sub DisplayTiles(ByRef metroStackPanel As StackPanel)
Dim alphabet() As String = {"a", "b", "c", "d", "e", "f", "g", "h", "i", _
"j", "k", "l", "m", "n", "o", "p", "q", "r", _
"s", "t", "u", "v", "w", "x", "y", "z"}
Dim numbers() As String = {"1", "2", "3", "4", "5", "6", "7", "8", "9", "0"}
Dim di As Dictionary(Of String, String()) = New IconsAndPaths().GetIconsAndPaths()
For Each s As String In alphabet
Dim letter As String = s
Dim coll = di.Where(Function(k) k.Key.StartsWith(letter, True, Nothing))
If (coll.Count > 0) Then
AddTiles(coll, metroStackPanel, letter)
End If
Next
For Each s As String In numbers
Dim letter As String = s
Dim coll = di.Where(Function(k) k.Key.StartsWith(letter, True, Nothing))
If (coll.Count > 0) Then
AddTiles(coll, metroStackPanel, letter)
End If
Next
End Sub
Private Sub AddTiles(ByVal coll As IEnumerable_
(Of KeyValuePair(Of String, String())), _
ByRef metroStackPanel As StackPanel, ByVal letter As String)
Dim tileWrapPanel As New WrapPanel
tileWrapPanel.Orientation = Orientation.Vertical
tileWrapPanel.Margin = New Thickness(0, 0, 20, 0)
tileWrapPanel.Height = (110 * 3) + (6 * 3)
For Each kvp As KeyValuePair(Of String, String()) In coll
Dim newTile As New Tile
newTile.ExecutablePath = kvp.Value(1)
newTile.TileIcon.Source = New BitmapImage_
(New Uri(kvp.Value(0), UriKind.Absolute))
newTile.TileTxtBlck.Text = kvp.Key
newTile.Margin = New Thickness(0, 0, 6, 6)
tileWrapPanel.Children.Add(newTile)
Next
WrapPanelLocation(letter, tileWrapPanel)
metroStackPanel.Children.Add(tileWrapPanel)
End Sub
Private Sub WrapPanelLocation(ByVal letter As String, _
ByVal tileWrapPanel As WrapPanel)
If (WrapPanelDi.Count = 0) Then
WrapPanelDi.Add(letter, 0)
Else
WrapPanelDi.Add(letter, wrapPanelX)
End If
If (tileWrapPanel.Children.Count <= 3) Then
wrapPanelX += ((110 + 6) + 18)
Else
Dim numberOfColumns As Double = _
Math.Ceiling(tileWrapPanel.Children.Count / 3)
Dim x As Double = (numberOfColumns * 110) + (numberOfColumns * 6) + 18
wrapPanelX += x
End If
End Sub
End Class
The GetIconsAndPaths()
method in the class IconsAndPaths
finds the location of .exe files that .lnk files are linked to and extracts their icons. The IconsAndPaths
class looks like this:
Imports System.IO
Imports System.Drawing
Imports IWshRuntimeLibrary
Public Class IconsAndPaths
Public IconsPathsDi As New Dictionary(Of String, String())
Public Function GetIconsAndpaths() As Dictionary(Of String, String())
Dim path As String = Environment.GetFolderPath_
(Environment.SpecialFolder.CommonStartMenu) & _
"\Programs"
Dim startMenuProgDir As New DirectoryInfo(path)
If (startMenuProgDir.Exists <> True) Then
Dim dirPath As String = Environment.GetFolderPath_
(Environment.SpecialFolder.StartMenu) & _
"\Programs"
startMenuProgDir = New DirectoryInfo(dirPath)
End If
Dim shell As New WshShell
CreateIconsDirectory()
For Each fi As FileInfo In startMenuProgDir.GetFiles
If (fi.Extension = ".lnk") Then
Dim nameLength As Integer = fi.Name.Length - 4
Dim displayName As String = fi.Name.Substring(0, nameLength)
Dim link As IWshShortcut = CType(shell.CreateShortcut(fi.FullName), _
IWshShortcut)
Dim potentialExePath As String = link.TargetPath
Dim potentialExe As New FileInfo(potentialExePath)
If (potentialExe.Extension = ".exe") Then
Dim tileIconPath As String = Environment.CurrentDirectory & _
"\WPF Metro Icons\" & _
displayName & ".png"
Try
Dim ico As System.Drawing.Icon = _
System.Drawing.Icon.ExtractAssociatedIcon(potentialExePath)
ico.ToBitmap().Save(tileIconPath, Imaging.ImageFormat.Png)
AddToDictionary(displayName, tileIconPath, potentialExePath)
Catch ex As FileNotFoundException
Exit Try
End Try
End If
End If
Next
For Each di As DirectoryInfo In startMenuProgDir.GetDirectories()
For Each fi As FileInfo In di.GetFiles()
If (fi.Extension = ".lnk") Then
Dim nameLength As Integer = fi.Name.Length - 4
Dim displayName As String = fi.Name.Substring(0, nameLength)
If (displayName.Contains("install") <> True) Then
Dim link As IWshShortcut = CType(shell.CreateShortcut_
(fi.FullName), _
IWshShortcut)
Dim potentialExePath As String = link.TargetPath
If (potentialExePath.Contains(".exe")) Then
Dim tileIconPath As String = _
Environment.CurrentDirectory & _
"\WPF Metro Icons\" & _
displayName & ".png"
Try
Dim ico As Icon = _
Icon.ExtractAssociatedIcon(potentialExePath)
ico.ToBitmap().Save(tileIconPath, _
Imaging.ImageFormat.Png)
CheckMSOfficeApps(displayName, tileIconPath)
AddToDictionary(displayName, tileIconPath, _
potentialExePath)
Catch ex As FileNotFoundException
Exit Try
Catch ex As ArgumentException
Exit Try
End Try
End If
End If
End If
Next
Next
Return IconsPathsDi
End Function
Private Sub AddToDictionary(ByVal displayName As String, _
ByVal tileIconPath As String, _
ByVal exePath As String)
If Not IconsPathsDi.ContainsKey(displayName) Then
IconsPathsDi.Add(displayName, New String() {tileIconPath, exePath})
End If
End Sub
Private Sub CheckMSOfficeApps(ByVal app As String, ByVal tileIconPath As String)
If (app.Contains("Microsoft Office Access")) Then
AddToDictionary(app, tileIconPath, "MSACCESS.EXE")
ElseIf (app.Contains("Microsoft Office Excel")) Then
AddToDictionary(app, tileIconPath, "EXCEL.EXE")
ElseIf (app.Contains("Microsoft Office InfoPath")) Then
AddToDictionary(app, tileIconPath, "INFOPATH.EXE")
ElseIf (app.Contains("Microsoft Office OneNote")) Then
AddToDictionary(app, tileIconPath, "ONENOTEM.EXE")
ElseIf (app.Contains("Microsoft Office Outlook")) Then
AddToDictionary(app, tileIconPath, "OUTLOOK.EXE")
ElseIf (app.Contains("Microsoft Office PowerPoint")) Then
AddToDictionary(app, tileIconPath, "POWERPNT.EXE")
ElseIf (app.Contains("Microsoft Office Publisher")) Then
AddToDictionary(app, tileIconPath, "MSPUB.EXE")
ElseIf (app.Contains("Microsoft Office Word")) Then
AddToDictionary(app, tileIconPath, "WINWORD.EXE")
Else
Exit Sub
End If
End Sub
Private Sub CreateIconsDirectory()
Dim dir As String = Environment.CurrentDirectory & "\WPF Metro Icons"
If (Directory.Exists(dir)) Then
Dim di As New DirectoryInfo(dir)
For Each fi As FileInfo In di.GetFiles
fi.Delete()
Next
Else
Directory.CreateDirectory(dir)
End If
End Sub
End Class
Scrolling of the tiles is done through the use of the PreviewMouseLeftButtonDown
and PreviewMouseLeftButtonUp
events of MainCanvas
.
Private Sub MainCanvas_PreviewMouseLeftButtonDown(ByVal sender As Object, _
ByVal e As System.Windows.Input.MouseButtonEventArgs) _
Handles MainCanvas.PreviewMouseLeftButtonDown
initMouseX = e.GetPosition(MainCanvas).X
x = Canvas.GetLeft(MetroStackPanel)
End Sub
Private Sub MainCanvas_PreviewMouseLeftButtonUp(ByVal sender As Object, _
ByVal e As System.Windows.Input.MouseButtonEventArgs) _
Handles MainCanvas.PreviewMouseLeftButtonUp
finalMouseX = e.GetPosition(MainCanvas).X
Dim diff As Double = Math.Abs(finalMouseX - initMouseX)
If (diff > 5) Then
If (finalMouseX < initMouseX) Then
newX = x - (diff * 2)
ElseIf (finalMouseX > initMouseX) Then
newX = x + (diff * 2)
End If
anim.KeyFrames.Add(New SplineDoubleKeyFrame(newX, _
KeyTime.FromTimeSpan(TimeSpan.FromSeconds(1)), _
New KeySpline(0.161, 0.079, 0.008, 1)))
anim.FillBehavior = FillBehavior.HoldEnd
MetroStackPanel.BeginAnimation(Canvas.LeftProperty, anim)
anim.KeyFrames.Clear()
timer.Start()
End If
End Sub
The Tick
event handler of the DispatcherTimer
object, timer
, checks whether the StackPanel
is out of view and restores it to a suitable position:
Private Sub timer_Tick(ByVal sender As Object, ByVal e As EventArgs)
Dim mspWidth As Double = MetroStackPanel.ActualWidth
If (newX > 200) Then
anim.KeyFrames.Add(New SplineDoubleKeyFrame(45, _
KeyTime.FromTimeSpan(TimeSpan.FromSeconds(1)), _
New KeySpline(0.161, 0.079, 0.008, 1)))
anim.FillBehavior = FillBehavior.HoldEnd
MetroStackPanel.BeginAnimation(Canvas.LeftProperty, anim)
anim.KeyFrames.Clear()
ElseIf ((newX + mspWidth) < 500) Then
Dim widthX As Double = 500 - (newX + mspWidth)
Dim shiftX As Double = newX + widthX
anim.KeyFrames.Add(New SplineDoubleKeyFrame(shiftX, _
KeyTime.FromTimeSpan(TimeSpan.FromSeconds(1)), _
New KeySpline(0.161, 0.079, 0.008, 1)))
anim.FillBehavior = FillBehavior.HoldEnd
MetroStackPanel.BeginAnimation(Canvas.LeftProperty, anim)
anim.KeyFrames.Clear()
End If
timer.Stop()
End Sub
If you are interested in how I went about skinning the app, refer to my WPF skinning article here.
Quick-Jump
Panning to a particular tiles group is done by calling the ShiftStackPanel()
method defined in Module QuickJumper
.
Imports System.Windows.Media.Animation
Module QuickJumper
Public WrapPanelDi As New Dictionary(Of String, Double)
Public Sub ShiftStackPanel(ByRef letter As String, _
ByRef metroStackPanel As StackPanel)
If WrapPanelDi.ContainsKey(letter.ToLower()) Then
Dim doubleAnim As New DoubleAnimationUsingKeyFrames()
Dim newX As Double = WrapPanelDi(letter.ToLower())
doubleAnim.Duration = TimeSpan.FromMilliseconds(1800)
doubleAnim.KeyFrames.Add(New SplineDoubleKeyFrame(-newX, _
KeyTime.FromTimeSpan(TimeSpan.FromSeconds(1)), _
New KeySpline(0.161, 0.079, 0.008, 1)))
doubleAnim.FillBehavior = FillBehavior.HoldEnd
metroStackPanel.BeginAnimation(Canvas.LeftProperty, doubleAnim)
doubleAnim.KeyFrames.Clear()
End If
End Sub
End Module
Keys and values are added to WrapPanelDi
in the WrapPanelLocation()
method defined in class Metrolizer
.
Conclusion
I hope that you picked up something useful from this article. I initially did not like the Metro UI, but I think it's a great fit on Win8, considering that cut-off text is less numerous because of the generous screen real-estate on devices running the OS. I think that Win8, or whatever they'll finally call it, will be a great success when it finally launches.
History
- 20th Sep 2011: Initial post
- 24th Sep 2011: Updated code to open Microsoft Office apps
- 29th Sep 2011: Added quick-jump feature