Introduction
This article's project is a slight replica of the Windows 8.1 apps screen, displaying a list of applications accessible from the Start menu, allowing filtering of the list using a search text box, and enabling the launching of those apps.
Background
The project makes use of the MVVM pattern, Unity is used for dependency injection, and Rx is used to maintain a responsive user interface when generating the list of apps.
Models
The project contains a single class that is used as a model. That class is the AppFile
class .
public class AppFile
{
public string Name { get; set; }
public Icon Icon { get; set; }
public string Path { get; set; }
}
Public Class AppFile
Property Name As String
Property Icon As Icon
Property Path As String
End Class
Every item displayed in the apps list is an AppFile
object.
Services
The StartMenuService
class contains a function that generates and returns a collection of AppFile
objects. The class implements the IAppService
interface.
public interface IAppsService
{
IEnumerable<AppFile> GetApps();
void LaunchApp(string path);
}
Public Interface IAppsService
Function GetApps() As IEnumerable(Of AppFile)
Sub LaunchApp(ByVal path As String)
End Interface
public class StartMenuService : IAppsService
{
private string sharedDir =
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu), "Programs");
private string userDir =
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.StartMenu), "Programs");
public IEnumerable<AppFile> GetApps()
{
var sharedFiles = Directory.EnumerateFiles(sharedDir, "*", SearchOption.AllDirectories).
Where((f) => Path.GetExtension(f) == ".lnk");
var userFiles = Directory.EnumerateFiles(userDir, "*", SearchOption.AllDirectories).
Where((f) => Path.GetExtension(f) == ".lnk");
var files = userFiles.Concat(sharedFiles).Select((f) => new FileInfo(f)).Distinct(new FileInfoComparer());
var apps = files.Select((f) => new AppFile
{
Name = Path.GetFileNameWithoutExtension(f.FullName),
Path = GetTargetPath(f.FullName),
Icon = GetTargetIcon(GetTargetPath(f.FullName))
});
apps = apps.Where((t) => t.Path != string.Empty && t.Icon != null && t.Name.Contains("install") != true &&
Path.GetExtension(t.Path).Contains(".exe", StringComparison.CurrentCultureIgnoreCase));
return apps;
}
public void LaunchApp(string path)
{
if (!String.IsNullOrEmpty(path)) { Process.Start(path); }
}
private string GetTargetPath(string lnk)
{
WshShell ws = new WshShell();
IWshShortcut shortcut;
string target;
try
{
shortcut = (IWshShortcut)ws.CreateShortcut(lnk);
target = shortcut.TargetPath;
}
catch (Exception) { target = null; }
return target;
}
private Icon GetTargetIcon(string target)
{
Icon ico;
if (target != null)
{
try { ico = Icon.ExtractAssociatedIcon(target); }
catch (Exception) { ico = null; }
}
else { ico = null; }
return ico;
}
}
Public Class StartMenuService
Implements IAppsService
Private sharedDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu), "Programs")
Private userDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.StartMenu), "Programs")
Public Function GetApps() As IEnumerable(Of AppFile) Implements IAppsService.GetApps
Dim sharedFiles = Directory.EnumerateFiles(sharedDir, "*", SearchOption.AllDirectories).
Where(Function(f) Path.GetExtension(f) = ".lnk")
Dim userFiles = Directory.EnumerateFiles(userDir, "*", SearchOption.AllDirectories).
Where(Function(f) Path.GetExtension(f) = ".lnk")
Dim files = userFiles.Concat(sharedFiles).Select(Function(f) New FileInfo(f)).Distinct(New FileInfoComparer)
Dim apps = files.Select(Function(f) New AppFile With {
.Name = Path.GetFileNameWithoutExtension(f.FullName),
.Path = GetTargetPath(f.FullName),
.Icon = GetTargetIcon(GetTargetPath(f.FullName))})
apps = apps.Where(Function(t) t.Path <> String.Empty AndAlso t.Icon IsNot Nothing AndAlso
t.Name.Contains("install") <> True AndAlso
Path.GetExtension(t.Path).Contains(".exe", StringComparison.CurrentCultureIgnoreCase))
Return apps
End Function
Public Sub LaunchApp(path As String) Implements IAppsService.LaunchApp
If Not String.IsNullOrEmpty(path) Then
Process.Start(path)
End If
End Sub
Private Function GetTargetPath(ByVal lnk As String) As String
Dim ws As New WshShell
Dim shortcut As IWshShortcut
Dim target As String
Try
shortcut = CType(ws.CreateShortcut(lnk), IWshShortcut)
target = shortcut.TargetPath
Catch ex As Exception
target = Nothing
End Try
Return target
End Function
Private Function GetTargetIcon(ByVal target As String) As Icon
Dim ico As Icon
If target IsNot Nothing Then
Try
ico = Icon.ExtractAssociatedIcon(target)
Catch ex As Exception
ico = Nothing
End Try
Else
ico = Nothing
End If
Return ico
End Function
End Class
The GetApps()
method returns an IEnumerable<AppFile>
after enumerating the files in the shared and user's Start menu folders. The GetTargetPath()
function gets the path of an executable associated with a .lnk file while the GetTargetIcon()
function is used to extract the icon of an executable.
ViewModel
There's only one View Model in the project, AppsViewModel
, and it has a single dependency that will be injected through constuctor injection. It is in one of the methods in this class that Rx is used to iterate through a collection of AppFile
objects and return results that are added to an ObservableCollection<AppFile>
.
public class AppsViewModel : ViewModelBase
{
private ObservableCollection<AppFile> _apps;
public ObservableCollection<AppFile> Apps
{
get { return _apps; }
}
private ICommand _startAppCommand;
public ICommand StartAppCommand
{
get
{
if (_startAppCommand == null) { _startAppCommand = new RelayCommand(LaunchApp); }
return _startAppCommand;
}
}
private ICommand _getAppsCommand;
public ICommand GetAppsCommand
{
get
{
if (_getAppsCommand == null) { _getAppsCommand = new RelayCommand(GetApps); }
return _getAppsCommand;
}
}
private string _filter = string.Empty;
public string Filter
{
set
{
_filter = value;
if (appsView != null) { appsView.Refresh(); }
}
}
private IAppsService appsService;
private ICollectionView appsView;
public AppsViewModel(IAppsService service)
{
appsService = service;
_apps = new ObservableCollection<AppFile>();
appsView = CollectionViewSource.GetDefaultView(Apps);
appsView.Filter = (f) => { return (f as AppFile).Name.Contains(_filter, StringComparison.CurrentCultureIgnoreCase); };
}
private void LaunchApp(object o)
{
appsService.LaunchApp((o as AppFile).Path);
}
private void GetApps(object o)
{
if (_apps.Count > 0) { _apps.Clear(); };
var ob = appsService.GetApps().ToObservable().SubscribeOn(Scheduler.Default).ObserveOn(SynchronizationContext.Current);
ob.Subscribe((f) => _apps.Add(f));
}
}
Public Class AppsViewModel
Inherits ViewModelBase
Private _apps As ObservableCollection(Of AppFile)
Public ReadOnly Property Apps As ObservableCollection(Of AppFile)
Get
Return _apps
End Get
End Property
Private _startAppCommand As ICommand
Public ReadOnly Property StartAppCommand As ICommand
Get
If _startAppCommand Is Nothing Then
_startAppCommand = New RelayCommand(AddressOf StartApp)
End If
Return _startAppCommand
End Get
End Property
Private _getAppsCommand As ICommand
Public ReadOnly Property GetAppsCommand As ICommand
Get
If _getAppsCommand Is Nothing Then
_getAppsCommand = New RelayCommand(AddressOf GetApps)
End If
Return _getAppsCommand
End Get
End Property
Private _filter As String = String.Empty
Public WriteOnly Property Filter As String
Set(value As String)
_filter = value
If appsView IsNot Nothing Then
appsView.Refresh()
End If
End Set
End Property
Private appsService As IAppsService
Private appsView As ICollectionView
Public Sub New(service As IAppsService)
appsService = service
_apps = New ObservableCollection(Of AppFile)
appsView = CollectionViewSource.GetDefaultView(_apps)
appsView.Filter = Function(f) CType(f, AppFile).Name.Contains(_filter, StringComparison.CurrentCultureIgnoreCase)
End Sub
Private Sub StartApp(ByVal o As Object)
Dim path = CType(o, AppFile).Path
appsService.LaunchApp(path)
End Sub
Private Sub GetApps(ByVal o As Object)
If _apps.Count > 0 Then
_apps.Clear()
End If
Dim ob = appsService.GetApps().ToObservable().SubscribeOn(Scheduler.Default).ObserveOn(SynchronizationContext.Current)
ob.Subscribe(Sub(f) _apps.Add(f))
End Sub
End Class
Notice the use of Rx extension methods in the GetApps()
method. The IEnumerable<AppFile>
returned by the GetApps()
method of the service is converted to an IObservable<AppFile>
using Reactive Extension's ToObservable()
method. The iteration of the collection is set to be done on a background thread, by passing Scheduler.Default
to the SubscribeOn()
extension method and the results of the iteration are set to be received on the Dispatcher
by passing the current synchronization context to the ObserveOn()
extension method. This will ensure that an item is received immeadiately it is available while maintaining a responsive UI. Finally the Subcribe()
method is called and passed a delegate where results are added to an ObservableCollection<AppFile>
.
View Model Locator
In the ViewModelLocator
class Unity is used to register dependencies and resolve instances.
class ViewModelLocator
{
private UnityContainer container;
public ViewModelLocator()
{
container = new UnityContainer();
container.RegisterType<IAppsService, StartMenuService>();
}
public AppsViewModel AppsVM
{
get { return container.Resolve<AppsViewModel>(); }
}
}
Public Class ViewModelLocator
Private container As UnityContainer
Public Sub New()
container = New UnityContainer
container.RegisterType(Of IAppsService, StartMenuService)()
End Sub
Public ReadOnly Property AppsVM As AppsViewModel
Get
Return container.Resolve(Of AppsViewModel)()
End Get
End Property
End Class
The view-model locator is declared as an application level resource.
<utils:ViewModelLocator x:Key="VMLocator"/>
View
I've made use of MahApps.Metro to give the application's window a Metro/Modern feel. The list of apps is displayed in an ItemsControl
and a TextBox
, with a custom style, is used for specifying the filter criteria.
<Controls:MetroWindow xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:Controls="clr-namespace:MahApps.Metro.Controls;assembly=MahApps.Metro"
xmlns:utils="clr-namespace:WPF_Apps_Screen_CS.Utils"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
mc:Ignorable="d" x:Class="WPF_Apps_Screen_CS.MainWindow"
Title="MainWindow" Height="603" Width="987" ShowTitleBar="False"
WindowStartupLocation="CenterScreen" EnableDWMDropShadow="True"
PreviewKeyDown="MainWindow_PreviewKeyDown">
<Controls:MetroWindow.Background>
<ImageBrush ImageSource="Images/DarkWood.jpg"/>
</Controls:MetroWindow.Background>
<Controls:MetroWindow.DataContext>
<Binding Source="{StaticResource VMLocator}" Path="AppsVM"/>
</Controls:MetroWindow.DataContext>
<i:Interaction.Triggers>
<i:EventTrigger>
<i:InvokeCommandAction Command="{Binding GetAppsCommand, Mode=OneWay}"/>
</i:EventTrigger>
</i:Interaction.Triggers>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="90"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock HorizontalAlignment="Left" Margin="50,0,0,0" TextWrapping="Wrap" Text="Apps"
VerticalAlignment="Bottom" Height="70" Foreground="White" FontSize="48"
FontFamily="Segoe UI Light"/>
<ScrollViewer Grid.Row="1" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
<i:Interaction.Behaviors>
<utils:MouseWheelScrollBehavior/>
</i:Interaction.Behaviors>
<ItemsControl ItemsPanel="{StaticResource TilesPanel}" ItemsSource="{Binding Apps}"
ItemTemplate="{StaticResource TileTemplate}" Margin="48,15,0,15"/>
</ScrollViewer>
<TextBox x:Name="FilterTextBox" HorizontalAlignment="Right" VerticalAlignment="Bottom" Margin="0,0,20,18"
TextWrapping="Wrap" Width="178" FontSize="14" FontFamily="Segoe UI" Height="26"
Style="{DynamicResource FilterTextBoxStyle}"
Text="{Binding Filter, Mode=OneWayToSource, UpdateSourceTrigger=PropertyChanged}"/>
</Grid>
</Controls:MetroWindow>
The DataTemplate
that is used to set the ItemTemplate
property of the ItemsControl
is defined in the App.xaml / Application.xaml file.
<DataTemplate x:Key="TilesDataTemplate">
<DataTemplate.Resources>
<Storyboard x:Key="ShowTileBackground">
<DoubleAnimationUsingKeyFrames Storyboard.TargetProperty="(UIElement.Opacity)"
Storyboard.TargetName="TileBackroundRct">
<EasingDoubleKeyFrame KeyTime="0" Value="0"/>
<EasingDoubleKeyFrame KeyTime="0:0:0.2" Value="1"/>
</DoubleAnimationUsingKeyFrames>
</Storyboard>
<Storyboard x:Key="HideTileBackground">
<DoubleAnimationUsingKeyFrames Storyboard.TargetProperty="(UIElement.Opacity)"
Storyboard.TargetName="TileBackroundRct">
<SplineDoubleKeyFrame KeyTime="0" Value="1"/>
<SplineDoubleKeyFrame KeyTime="0:0:0.2" Value="0"/>
</DoubleAnimationUsingKeyFrames>
</Storyboard>
<Storyboard x:Key="TileBounce">
<DoubleAnimationUsingKeyFrames
Storyboard.TargetProperty="(UIElement.RenderTransform).(TransformGroup.Children)[0].(ScaleTransform.ScaleX)"
Storyboard.TargetName="TileGrid">
<EasingDoubleKeyFrame KeyTime="0" Value="1"/>
<EasingDoubleKeyFrame KeyTime="0:0:0.1" Value="0.867"/>
<EasingDoubleKeyFrame KeyTime="0:0:0.2" Value="1"/>
</DoubleAnimationUsingKeyFrames>
<DoubleAnimationUsingKeyFrames
Storyboard.TargetProperty="(UIElement.RenderTransform).(TransformGroup.Children)[0].(ScaleTransform.ScaleY)"
Storyboard.TargetName="TileGrid">
<EasingDoubleKeyFrame KeyTime="0" Value="1"/>
<EasingDoubleKeyFrame KeyTime="0:0:0.1" Value="0.867"/>
<EasingDoubleKeyFrame KeyTime="0:0:0.2" Value="1"/>
</DoubleAnimationUsingKeyFrames>
</Storyboard>
</DataTemplate.Resources>
<Grid x:Name="TileGrid" Width="210" Height="50" Margin="0,0,15,10" RenderTransformOrigin="0.5,0.5">
<Grid.RenderTransform>
<TransformGroup>
<ScaleTransform/>
<SkewTransform/>
<RotateTransform/>
<TranslateTransform/>
</TransformGroup>
</Grid.RenderTransform>
<Rectangle x:Name="TileBackroundRct" Fill="#FF1F85BF" Height="Auto" Stroke="#FF2BA5EC" Width="Auto" Opacity="0"/>
<StackPanel Margin="5" Orientation="Horizontal">
<Border BorderBrush="#FF454F57" BorderThickness="1" HorizontalAlignment="Left"
Height="40" Width="40" Background="#FF2E3942">
<Image Margin="5" Source="{Binding Icon, Converter={StaticResource IconBitmapSourceConverter}}"/>
</Border>
<TextBlock Text="{Binding Name}" HorizontalAlignment="Left" TextWrapping="Wrap" Width="145"
TextTrimming="CharacterEllipsis" Foreground="#FFE4E4E4" FontSize="14" Margin="10,0,5,0"/>
</StackPanel>
<Button x:Name="TileButton" Command="{Binding DataContext.StartAppCommand,
RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type Window}}}"
CommandParameter="{Binding}" Opacity="0"/>
</Grid>
<DataTemplate.Triggers>
<EventTrigger RoutedEvent="ButtonBase.Click" SourceName="TileButton">
<BeginStoryboard x:Name="TileBounce_BeginStoryboard" Storyboard="{StaticResource TileBounce}"/>
</EventTrigger>
<EventTrigger RoutedEvent="Mouse.MouseEnter">
<BeginStoryboard Storyboard="{StaticResource ShowTileBackground}"/>
</EventTrigger>
<EventTrigger RoutedEvent="Mouse.MouseLeave">
<BeginStoryboard x:Name="HideTileBackground_BeginStoryboard" Storyboard="{StaticResource HideTileBackground}"/>
</EventTrigger>
</DataTemplate.Triggers>
</DataTemplate>
The Storyboard
s were created in Expression Blend and are used to animate some template elements in response to mouse events.
The binding for the Source
property of the Image
control uses a converter to convert an Icon
to a BitmapSource
. To do this the converter uses a custom extension method.
public static class Extensions
{
public static BitmapSource ToBitmapSource(this Icon ico)
{
IntPtr hIcon = ico.Handle;
BitmapSource bmpSrc = null;
try
{
bmpSrc = Imaging.CreateBitmapSourceFromHIcon(hIcon, Int32Rect.Empty, BitmapSizeOptions.FromEmptyOptions());
}
catch (Exception)
{
bmpSrc = null;
}
return bmpSrc;
}
...
}
Module Extensions
<Extension()>
Function ToBitmapSource(ByVal ico As Icon) As BitmapSource
Dim hIcon As IntPtr = ico.Handle
Dim bmpSrc As BitmapSource
Try
bmpSrc = Imaging.CreateBitmapSourceFromHIcon(hIcon, Int32Rect.Empty, BitmapSizeOptions.FromEmptyOptions())
Catch ex As Exception
bmpSrc = Nothing
End Try
Return bmpSrc
End Function
...
End Module
Horizontal Mouse Wheel Scrolling
The ScrollViewer
by default doesn't support horizontal mouse wheel scrolling but this is enabled using a custom behavior.
public class MouseWheelScrollBehavior: Behavior<ScrollViewer>
{
protected override void OnAttached()
{
base.OnAttached();
AssociatedObject.PreviewMouseWheel += AssociatedObject_PreviewMouseWheel;
}
protected override void OnDetaching()
{
base.OnDetaching();
AssociatedObject.PreviewMouseWheel -= AssociatedObject_PreviewMouseWheel;
}
protected void AssociatedObject_PreviewMouseWheel(object sender, MouseWheelEventArgs e)
{
if (e.Delta > 0)
{
AssociatedObject.LineLeft();
e.Handled = true;
}
else
{
AssociatedObject.LineRight();
e.Handled = true;
}
}
}
Public Class MouseWheelScrollBehavior
Inherits Behavior(Of ScrollViewer)
Protected Overrides Sub OnAttached()
MyBase.OnAttached()
AddHandler AssociatedObject.PreviewMouseWheel, AddressOf AssociatedObject_PreviewMouseWheel
End Sub
Protected Overrides Sub OnDetaching()
MyBase.OnDetaching()
AddHandler AssociatedObject.PreviewMouseWheel, AddressOf AssociatedObject_PreviewMouseWheel
End Sub
Private Sub AssociatedObject_PreviewMouseWheel(sender As Object, e As MouseWheelEventArgs)
If e.Delta > 0 Then
AssociatedObject.LineLeft()
e.Handled = True
Else
AssociatedObject.LineRight()
e.Handled = True
End If
End Sub
End Class
The behavior handles the ScrollViewer
's PreviewMouseWheel
event calling either its LineLeft()
or LineRight()
method depending on the mouse wheel's delta value.
Directing all Keystrokes to the TextBox
To ensure key presses are directed to the textbox, keyboard focus is set on the textbox when a key is pressed.
public partial class MainWindow : MetroWindow
{
...
private void MainWindow_PreviewKeyDown(object sender, KeyEventArgs e)
{
if (FilterTextBox.IsKeyboardFocused != true)
{
Keyboard.Focus(FilterTextBox);
}
}
}
Class MainWindow
Private Sub MainWindow_PreviewKeyDown(sender As Object, e As KeyEventArgs) Handles Me.PreviewKeyDown
If FilterTextBox.IsKeyboardFocused <> True Then
Keyboard.Focus(FilterTextBox)
End If
End Sub
End Class
Conclusion
That's it. I hope you have learnt something useful from this article. If you want to know more about Rx I recommend you check out Lee Campell's free online book, Introduction to Rx.
History
- 14th Jan 2015: Initial post,
- 8th April 2016: Update