This article describe an obsoluted usercontrol which was released in 2009, it demonstrates how to construct FileExplorer
controls
included
DirectoryTree
and
FileList
,
using
Model-View-ViewModel (MVVM) pattern, and is no longer being maintained. A
newer version of the explorer control is described in this article
here in codeproject.
Introduction
After
C#
FileExplorer,
VB.Net
ExplorerTree, you may think it's easier to write an explorer tree
in
WPF. But in fact
it's not, and thats why this article takes so many pages.
Although
WPF is supposed to allow you
to construct custom interface with minium effort,
WPF does
not even
provide the basic functionality of ListView
in .Net2.0 :
- ViewMode? Small Icon? What is that?
- Multi-Select? Sure! all you need is to press <shift> and
select each item.
- Virtual ListView? ImageList? Rename? sure, write your own.
But anyone who run a WPF application
should know WPF let you to
customize everything, but you wont know these shortcoming until you are
going implement one. There are a number of solutions available on
various
web
sites, I combined these ideas
as well as my code
and present you my finhsied FileExplorer
controls.
Further more, as these controls are written in
Model-View-ViewModel
(MVVM)
pattern,
so
this article is also a brief tutorial for how to create the controls
using the pattern.
Features :
- Shell
- List directories and files (start from Desktop)
- Context
menu
- Rename inside the control
- Drag and Drop support to and from other application
- Monitor file system so automatically refresh when
file system is changed
- Performance
- Sub-items are loaded in background
- Lookup directory in DirectoryTree
in background
- Construction of ListViewItems
and
TreeViewItems are virtualized
- DirectoryTree
- FileList
Index
How to use?
Although the internal part is
MVVM, communication between the
UserControls are through Dependency properties, e.g.
<TextBox Text="{Binding SelectedDirectory, ElementName=dirTree,
Mode=TwoWay, Converter={StaticResource ets}}" Grid.ColumnSpan="2" />
-->
<uc:DirectoryTree Grid.Column="0" Grid.Row="1" x:Name="dirTree" >
<uc:DirectoryTree.SelectedDirectory>
<uc:Ex />
</uc:DirectoryTree.SelectedDirectory>
</uc:DirectoryTree>
<uc:FileList Grid.Column="1" Grid.Row="1" ViewMode="vmIcon"
CurrentDirectory="{Binding SelectedDirectory, ElementName=dirTree, Mode=TwoWay}" />
DirectoryInfoEx
(Component)
Please keep in mind that the UserControls
associated with this article is based on
System.IO.DirectoryInfoEx,
not
System.IO.DirectoryInfo,
it's
a
custom
dotNet2.0
component
which
uses
IShellFolder2
to list shell items, and provide similar syntax as
DirectoryInfo
,
the
current
version
(0.17)
does
the
following :
- List Shell Items (sync/async)
- File System Operations (sync/async), e.g. Create, Copy, Move ,
Delete, Rename
- Display Shell Context Menu, allow Insert/Delete/Disable Menu Items
- Monitor File System Changes
- Obtain
File
Properties
from
Shell
The
DirectoryInfoEx
on this page may not be the
most update, please check
it's
page for update.
DirectoryTree (TreeView)
- DirectoryTree
- RootDirectory
- SelectedDirectory
You can set the
RootDirectory
and
SelectedDirectory
using the
ExExtension
.
Ex
is
a
MarkupExtension
, which return the
appropriate
FileSystemInfoEx
,
one can specify the FullName via
the
extension
as
well
e.g.
:
<uc:DirectoryTree Grid.Column="0" Grid.Row="1" x:Name="dirTree" >
<uc:DirectoryTree.RootDirectory>
<uc:Ex FullName="::{20D04FE0-3AEA-1069-A2D8-08002B30309D}" />
-->
</uc:DirectoryTree.RootDirectory>
<uc:DirectoryTree.SelectedDirectory>
<uc:Ex FullName="C:\Temp" />
</uc:DirectoryTree.SelectedDirectory>
</uc:DirectoryTree>
FileList (ListView)
- FileList
- SelectedEntries
- CurrentDirectory
- ViewMode
- ViewSize
- SortBy (0.2)
- SortDirection (0.2)
Unlike normal
ListView
, this
FileList
support
multi-select
by
dragging,
for
more
information
you
can take a look to
this
article.
You can get the highlight
count when the
FileList
is
dragging,
using
the
an attached
property named
SelectionHelper.HighlightCount
,
e.g.
:
(Statusbar not
included in this publish)
<uc:Statusbar Grid.Column="0" Grid.Row="4" Grid.ColumnSpan="3" x:Name="sbar"
FileCount="{Binding RootModel.CurrentDirectoryModel.FileCount, ElementName=fileList1}"
DirectoryCount="{Binding RootModel.CurrentDirectoryModel.DirectoryCount,
ElementName=fileList1}"
HighlightCount="{Binding Path=(uc:SelectionHelper.HighlightCount),
ElementName=fileList1}"
SelectedEntries="{Binding SelectedEntries, ElementName=fileList1}" />
SelectedEntries
is oneway currently, it can
return the items selected on the file list, so you can bind it with
StatusBar
or other
UserControl
.
(0.2) You can change the item sequence using SortBy
and SortDirection
property, or it can be changed by double-click the header
of GridView :
<uc:FileList SortBy="sortByLastWriteTime" SortDirection="Descending" />
ViewMode
and
ViewSize
both represent how
the
file list represent the items, the modes and it's size as follows.
<uc:FileList ViewMode="vmLargeIcon" ViewSize="80" />
public enum ViewMode : int
{
vmTile = 13,
vmGrid = 14,
vmList = 15,
vmSmallIcon = 16,
vmIcon = 48,
vmLargeIcon = 80,
vmExtraLargeIcon = 120
}
If one set the
ViewSize
to
any value between 13 to 120, the filelist will
apply the view automatically.
Noted that TileView
is not
implemented yet.
The Design
The UserControls are developed using
Model-View-ViewModel
approach (MVVM), I first learned about this approach from
Dan
Crevier's Blog, but now you can find a
simplified
explanation
here. Using
MVVM improve
the application responsivenss, as most work can be threaded (and able
to update back to UI).
My Implementation included the followings :
- Model
- Represent a
DirectoryInfoEx
object
- ViewModel - 2 kinds
- Model of a
DirectoryTree
/ FileList
- Model of a
DirectoryTreeItem
/ FileListItem
/ FileListCurrentDirectory
(which
embedded a
Model)
- View
- The
DirectoryTree
/ FileList
itself,
no
code-behind
except
DependencyProperties
and some EventHandlers
These UserControls uses
Sacha Barber's
Cinch MVVM framework. I am not sure if it's the best framework I
can get, as I haven't tested many of them, but it reduce a lot of my
work load. My only complaints is that his article is lack of a central
index, every time I wish to lookup for a description for a class I have
to look through 5 articles.
Unlike most
MVVM projects
these are UserControls instead
of
Windows, I have to write a
number of DependencyProperties to
interface
between
the
UserControls,
and because of this,
performance may suffer, but I found it simplier to divide a large
projects into smaller managable pieces.
Model
- Cinch.ValidatingObjet
- Cinch.EditableValidatingObjet
- ExModel
- FileModel
- DirectoryModel
- DriveModel (0.3)
FileSystemInfoEx
properties rarely change. so actually
FileSystemInfoEx
itself can be a model, but to support rename
I added another layer :
ExModel
.
Cinch
contains two classes for implementing Model,
ValidatingObject
and
it's derived class named
EditableValidatingObject,
the
difference
is
that
EditableValidatingObject
have
transaction
support,
using
BeginEdit()
/ CancelEdit() and EndEdit() methods. To support
this, your properties have to be implemented as DataWrapper
class.
Because the only one field is required to support rename, I implement
ExModel as
ValidatingObject
instead of
EditableValidatingObject
.
ExModel (abstract)
ExModel
contains a
FileSystemInfoEx
entry
(accessable
using EmbeddedEntry property).
It is an abstract class, use
FileModel
and
DirectoryModel
and
DriveModel
(0.3)
which is
inherited from
ExModel
instead.
Name is one of it's important property to support rename :
static PropertyChangedEventArgs nameChangeArgs =
ObservableHelper.CreateArgs<ExModel>(x => x.Name);
public string Name
{
get { return _name; }
set
{
if (!String.IsNullOrEmpty(_name) && _name != value)
{
string newName = PathEx.Combine(PathEx.GetDirectoryName(FullName), value);
FileSystemInfoEx entry = EmbeddedEntry;
string origName = _name;
string origFullName = _fullName;
_name = value;
_fullName = newName;
try
{
IOTools.Rename(entry.FullName, PathEx.GetFileName(_fullName));
FullName = newName;
Label = EmbeddedEntry.Label;
}
catch (Exception ex)
{
MessageBox.Show(ex.Message, "Rename failed");
_name = origName;
_fullName = origFullName;
return;
}
}
else _name = value;
NotifyPropertyChanged(nameChangeArgs);
}
}
As you see, it will revert back to original if the rename process
failed,
otherwise update the internal _name field and call
NotifyPropertyChanged
()
method
so
the
UI
is
notified
about the changes.
ViewModel
- Cinch.ViewModelBase
- ExViewModel
- DirectoryViewModel
- CurrentDirectoryViewModel (FileList)
- HierarchyViewModel
- DirectoryTreeViewItemViewModel (TreeViewItem)
- FileListItemViewModel (ListViewItem)
- RootModelBase
- DirectoryTreeViewModel (DirectoryTree which is a
TreeView)
- FileListViewModel (FileList which is a ListView)
All my ViewModels are
inherited
from
Cinch.ViewModelBase.
ViewModelBase,
as
a ViewModel communicating
with View, contains some
Window life-cycle related commands and method (e.g.
CloseCommand and OnWindowClose() method), but as the
file explorer
components are not Window most
of
them
are
not
used.
Instead,
I shall develop my own set of commands, like RefreshCommand for
FileList. There are a number of
unimplemented commands that I can think of, like
Cut/CopyPaste/SelectAllCommand for
FileList, if you need them
immediately you may need to write them yourself. e.g.
private SimpleCommand _refreshCommand = new SimpleCommand
{
CanExecuteDelegate = x => true,
ExecuteDelegate = x => Refresh()
};
public SimpleCommand RefreshCommand { get { return _refreshCommand; } }
Then the command is usable by binding from the UI, e.g. :
<anotherControl RefreshCommand="{Binding RootModel.RefreshCommand,
ElementName=fileList1}" />
ExViewModel (abstract)
ExViewModel are a base class for all List/TreeViewItem,
it
contains
a
FileModel or DirectoryModel in it, which can be
accessed by a readonly property
named EmbeddedModel.
Each ExViewModel is for
representing one FileInfoEx or
DirectoryInfoEx only, to
represent another FileSystemInfoEx entry
one
would
have
to
create another ExModel
and ExViewModel.
Similarly, DirectoryViewModel and
HierarchyViewModel have EmbeddedDirectoryModel. So, if one
want to access the contained Directory, one can use
EmbeddedDirectoryModel.EmbeddedDirectory
property.
RootModelBase (abstract)
RootModelBase is a base class
for DirectoryTreeViewModel and
FileListViewModel, which is
the ViewModel of the whole UserControl, it
contains an event named OnProgress,
which
is
listened
by
the
UserControl. When UserConstrol receive this event, it
will raise
again as DependencyEvent,
which will bubble up until asked to stop (args.Handled = true;).
public static readonly RoutedEvent ProgressEvent = ProgressRoutedEventArgs.ProgressEvent.AddOwner(typeof(DirectoryTree));
...
RootModel.OnProgress += (ProgressEventHandler)delegate(object sender, ProgressEventArgs e)
{
this.Dispatcher.BeginInvoke(DispatcherPriority.ApplicationIdle, new ThreadStart(delegate
{
RaiseEvent(new ProgressRoutedEventArgs(ProgressEvent, e));
}));
};
I am not sure if this will cause memory leak, e.g. not GC when the
UserControl is free. If so, I will have to implement
WeakEvent.
FileList - FileListViewModel,
CurrentDirectoryViewModel and
FileListItemViewModel
Actually both ViewModel should
be
combined
if
FileListViewModel
not
inherited from RootModelBase,
and CurrentDirectoryViewModel is
not
inherited
from
ExViewModel,
but as they do, I have to leave them as two
separate class. One FileList can
have
one
FileListViewModel only,
but it may have multiple CurrentDirectoryViewModels
(when changing directory).
FileListViewModel
(RootModelBase)
- RootModelBase
- FileListViewModel
- RefreshCommand (which calls
CurrentDirectoryModel.Refresh())
- CurrentDirectory
- CurrentDirectoryModel
- IsLoading
- SortBy (0.2)
- SortDirection (0.2)
FileListViewModel
is a middle
layer between
CurrentDirectoryModel
(which changes regularly) and the
FileList
. In
FileListViewModel,
CurrentDirectory and CurrentDirectoryModel links to each
other, if you
change one of them it will change the another, the reason to have both
properties is that, CurrentDirectory
is for outside the FileList
(e.g.
from DirectoryTree, or user
code), CurrentDirectoryModel
is for
internal use as ViewModel.
(0.2) SortBy and SortDirection enable sort by calling
CurrentDirectoryModel.ChangeSortMethod() method, which change the sort
to CustomSort :
public void ChangeSortMethod(ExComparer.SortCriteria sortBy, ListSortDirection sortDirection)
{
ListCollectionView dataView = (ListCollectionView)(CollectionViewSource.GetDefaultView(_subEntries.View));
dataView.SortDescriptions.Clear(); dataView.CustomSort = null;
ExComparer.SortDirectionType direction = sortDirection == ListSortDirection.Ascending ?
ExComparer.SortDirectionType.sortAssending : ExComparer.SortDirectionType.sortDescending;
dataView.CustomSort = new ExModelComparer(sortBy, direction); }
In this case, although the
CollectionViewSource
discussed in
CurrentDirectoryViewModel
is
still
used,
it's
sorting
method
is overrided by my own implementation.
Most ViewModel has
IsLoading
property, which will be
later bound to UI, so when it's true, the UI shows loading animation.
CurrentDirectoryViewModel
(ExViewModel)
- ExViewModel
- DirectoryViewModel
- CurrentDirectoryModel
- RefreshCommand
- ListFiles, ListDirectories
- IsLoading
- Filter
- BgWorker (bgWorker_LoadSubEntries
and
bgWorker_FilterSubEntries)
- FileCount, DirectoryCount
- HasSubEntries
- SubEntries, SubEntriesInternal
CurrentDirectoryViewModel
responsible
for
listing
the
contents
of
current directory, to do this, it contains a
Cinch.BackgroundTaskManager
named bgWorker_LoadSubEntries,
BackgroundTaskManager
as it's name implies, it allows running a task in background,
using the
RunBackgroundTask() method,
the advantage of using this class is that
you can specify the task and how to update back to ViewModel in one
place.
bgWorker_LoadSubEntries = new BackgroundTaskManager<List<FileListViewItemViewModel>>(
() =>
{
IsLoading = true;
return getEntries();
},
(result) =>
{
updateSubEntries(result);
IsLoading = false;
});
The first section is
TaskFunc
(Func<FileListViewItemViewModel>),
which
does
the
time-consuming
work, and the second section is
CompleteAction (Action<FileListViewItemViewModel>),
which
update the UI
(run in UI thread). Noted that only the first section is run in
background.
The fiest section return the result of getEntries()
methods, getEntries()
is
a
method
with
a linq command, the _cachedSubEntries
is
used again when Filter needed.
private List<FileListViewItemViewModel> getEntries()
{
var retVal = from entry in EmbeddedDirectoryModel.EmbeddedDirectoryEntry.EnumerateFileSystemInfos()
where (entry is IDirectoryInfoExA && ListDirectories) || (entry is IFileInfoExA && ListFiles)
select new FileListViewItemViewModel(_rootModel, ExAModel.FromExAEntry(entry)); ;
_cachedSubEntries = retVal.ToArray();
return new List<FileListViewItemViewModel>(_cachedSubEntries);
}
Action<List<FileListViewItemViewModel>> updateSubEntries =
(result) =>
{
List<FileListViewItemViewModel> delList = new List<FileListViewItemViewModel>(SubEntriesInternal.ToArray());
List<FileListViewItemViewModel> addList = new List<FileListViewItemViewModel>();
foreach (FileListViewItemViewModel model in result)
if (delList.Contains(model))
delList.Remove(model);
else addList.Add(model);
foreach (FileListViewItemViewModel model in delList)
SubEntriesInternal.Remove(model);
foreach (FileListViewItemViewModel model in addList)
SubEntriesInternal.Add(model);
DirectoryCount = (uint)(from model in SubEntriesInternal where
model.EmbeddedModel is DirectoryModel select model).Count();
FileCount = (uint)(SubEntriesInternal.Count - DirectoryCount);
HasSubEntries = SubEntriesInternal.Count > 0;
};
Basically the second section is to identify the difference of result
and the output
(SubEntriesInternal), and
changes the ObservableCollection.
Instead
of
replacing
the
ObservableCollection completely, this reduce
the overhead needed to destroy and create of ListViewItem in the UI
side, and most importantly, it allow the FileList to maintain the
selection as long as possible.
FileList support Filter, when
the property is changed,
bgWorker_FilterSubEntries will
be run in background, and change the
SubEntriesInternal when
completed, it's similar as
bgWorker_LoadSubEntries,
except it uses filterEntries()
method
(as below)
public List<FileListViewItemViewModel> filterEntries()
{
if (_cachedSubEntries == null)
_cachedSubEntries = getEntries().ToArray();
var retVal = from entry in _cachedSubEntries where (String.IsNullOrEmpty(Filter) ||
IOTools.MatchFileMask(entry.Name, Filter + "*"))
select entry;
return new List<FileListViewItemViewModel>(retVal);
}
So why it's called SubEntriesInternal
instead of SubEntries?
It's
because there's another layer, SubEntries
is a
CollectionViewSource,
which
can
group
or
sort
the
SubEntriesInternal
without
changing
it
(e.g.
if
I want to sort SubEntriesInternal
directly I will have to do a
lot of work, Deleting and Inserting, you cant call Array.Sort() method
on ObservableCollection), SubEntriesInternal is sorted
using it's IsDirectory
property, then it's FullName property.
_subEntries = new CollectionViewSource();
_subEntries.Source = SubEntriesInternal;
_subEntries.SortDescriptions.Add(new SortDescription("IsDirectory", ListSortDirection.Descending));
_subEntries.SortDescriptions.Add(new SortDescription("FullName", ListSortDirection.Ascending));
Because it's
CollectionViewSource,
when
Binding
ItemsSource,
one
have
to
bind
it's
SubEntries.View
property
insteading
of the SubEntries property
directly. (btw, you can still bind to SubEntries.Source
as well)
Lastly, there's a FileSystemWatcherEx, which refresh the
FileList when
there's a change :
FileSystemWatcherEx watcher = new FileSystemWatcherEx(model.EmbeddedDirectoryEntry);
var handler = (FileSystemEventHandlerEx)delegate(object sender, FileSystemEventArgsEx args)
{
if (args.FullPath.Equals(model.FullName))
Refresh();
};
var renameHandler = (RenameEventHandlerEx)delegate(object sender, RenameEventArgsEx args)
{
if (args.OldFullPath.Equals(model.FullName))
Refresh();
};
watcher.OnChanged += handler;
watcher.OnCreated += handler;
watcher.OnDeleted += handler;
watcher.OnRenamed += renameHandler;
FileListItemViewModel
(ExViewModel)
ExpandCommand
run the
selected item (via Process.Start()
method) or
change the
current directory (via _rootModel.CurrentDirectory)
depend
what
is
selected.
If the selected file is a link (*.lnk) it uses
VBaccelerator's
ShellLink to find the linked item, then run the file or change
directory based on the linked item.
if (PathEx.GetExtension(entry.Name).ToLower() == ".lnk")
using (ShellLink sl = new ShellLink(entry.FullName))
{
string linkPath = sl.Target;
if (DirectoryEx.Exists(linkPath) && sl.Arguments == "")
_rootModel.CurrentDirectory = FileSystemInfoEx.FromString(linkPath) as DirectoryInfoEx;
else Run(linkPath, sl.Arguments);
}
But how to hook the ExpandCommand?
ListViewItem itself does not
have DoubleClickCommand, you
can :
1. either monitor the MouseDoubleClickEvent
and do the action when raised :
#region ExpandHandler
this.AddHandler(ListViewItem.MouseDoubleClickEvent, (RoutedEventHandler)delegate(object sender, RoutedEventArgs e)
{
DependencyObject lvItem = getListViewItem(e.OriginalSource as DependencyObject);
if (lvItem != null)
{
FileListViewItemViewModel model =
(FileListViewItemViewModel)ItemContainerGenerator.ItemFromContainer(lvItem);
if (model != null)
model.Expand();
}
});
#endregion
2. or you can create the DoubleClickCommand
your own, and hook them together (more elegant)
<Style x:Key="{x:Type ListViewItem}" TargetType="{x:Type ListViewItem}" >
<Setter Property="uc:CommandProvider.DoubleClickCommand" Value="{Binding ExpandCommand}" />
</Style>
CommandProvider
is a FrameworkElement which
have
an
attached
property named DoubleClickCommand,
and
when
it's
set,
CommandProvider will
hook to
the control's MouseLeftButtonDown event,
in
this
case,
the ListViewItem.
It
will invoke the command when it receive a double click (clickcount =
2) event.
In normal case it should be called this way :
<Label uc:CommandProvider.DoubleClickCommand="{Binding ExpandCommand}" />
Beside DoubleClickCommand, it
also included other commands like Click,
RightClick, EnterPress, Prev and TreeViewSelectionChanged as well.
IsSelected is binded with ListViewItem.IsSelected, it have no
use currently,
but it can be used by the FileListViewModel
to change the item's
selected state.
It's intended for allowing FileList to setting the selected items
externally (just like DirectoryTree).
DirectoryTree -
DirectoryTreeViewModel and
DirectoryTreeItemViewModel
TreeView can be interpreted
as multi-level ListView, that
means unlike
WindowsForms you cannot get a TreeViewItem
by using
listView1.Items[0].Items[1], you cannot use TreeView's
ItemsContainerGenerator to get the
ListViewItem either, because
each level has it's own
ItemsContainerGenerator, you
have to use ListViewItem.Parent's ItemsContainerGenerator..
HierarchyViewModel (ExViewModel)
One of the basic functionality of the DirectoryTree
is to display the
selected directory properly, in WPF, it's easy to obtain the selected
value in TreeView, but changing it is another story. In earlier
version I uses
DaWanderer's
method which navigate through the TreeViewItem layer by layer to
find the
required item, and change it's IsSelected property, but this hog the
UIThread, and this disallow
Virtualizing TreeView to be used, because
if the item is not shown, it's not created, and if it's not created, it
cannot be selected. This
method is used when I develop the control using MVP
(Model-View-Presenter) pattern.
Until one day I read
Josh
Smith's
article mentioned how to support selection in MVVM, the
problem solved itself. Basically it's to have IsExpanded and
IsSelected in the ViewModel
(in my case, HierarchyViewModel),
and
bind
the
TreeViewItem's IsExpanded and IsSelected to it. The issue of MVP
pattern is the lack of another layer (ViewModel) i.e. I cannot add
IsExpanded/IsSelected to FileSystemInfoEx.
<Style x:Key="{x:Type local:DirectoryTree}" TargetType="{x:Type local:DirectoryTree}"
BasedOn="{StaticResource {x:Type TreeView}}">
<Setter Property="ItemTemplate" Value="{StaticResource TreeItemTemplate}" />
<Setter Property="ItemContainerStyle">
<Setter.Value>
<Style TargetType="{x:Type TreeViewItem}">
<Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}" />
<Setter Property="IsSelected" Value="{Binding IsSelected, Mode=TwoWay}" />
<Setter Property="FontWeight" Value="Normal" />
</Setter.Value>
</Setter>
</Style>
So if you change the IsExpanded/IsSelected field in the ViewModel, it
will affect the ListViewItem.
DirectoryTreeViewModel
(RootModelBase)
- RootModelBase
- DirectoryTreeViewModel
- BgWorker_FindChild
- RootDirectoryModelList
- RootDirectory
- SelectedDirectoryModel
- SelectedDirectory
- IsLoading
Similar to FileList, there
are linked property : SelectedDirectory
and
SelectedDirectoryModel
property, so changing one will change the
other as well. The another pair is RootDirectory and
RootDirectoryModelList, except it's
a List, because TreeView.Items property accept a
List only.
DirectoryTree also have a FileSystemWatcherEx to refresh
the tree when
needed.
One important feature is when SelectedDirectory
is set externally, it
will update the Selected Item in DirectoryTree,
this
is
done
in
background using the BgWorker_FindChild
(which is a
Cinch.BackgroundTaskManager),
as
follows
:
bgWorker_findChild = new BackgroundTaskManager<DirectoryTreeItemViewModel>(
() =>
{
IsLoading = true;
DirectoryTreeItemViewModel lookingUpModel = _selectedDirectoryModel;
Func<bool> cancelNow = () => {
bool cont = lookingUpModel != null && lookingUpModel.Equals(_selectedDirectoryModel);
return !cont;
};
DirectoryTreeItemViewModel newSelectedModel = null;
{
DirectoryInfoEx newSelectedDir = _selectedDirectory;
if (newSelectedDir != null)
{
foreach (DirectoryTreeItemViewModel rootModel in _rootDirectoryModelList)
{
newSelectedModel = rootModel.LookupChild(newSelectedDir, cancelNow);
if (newSelectedModel != null)
return newSelectedModel;
}
}
}
return _rootDirectoryModelList.Count == 0 ? null : _rootDirectoryModelList[0];
},
(result) =>
{
if (result != null)
{
if (result.Equals(_selectedDirectoryModel))
result.IsSelected = true;
}
IsLoading = false;
});
DirectoryTreeItemViewModel.LookupChild() method return the lookup
DirectoryTreeItemViewModel, or
it's parent's model if it cannot be found (which is rare). It
will expand if it's not
already expanded, because the expand is not run in UIThread it wont
make your
application freeze.
The cancelCheck will
cancel the iteration if the SelectedDirectory
is changed again when the lookup is working, it's pointless to
continue because another BgWorker_FindChild
is already started.
public DirectoryTreeItemViewModel LookupChild(DirectoryInfoEx directory, Func<bool> cancelCheck)
{
if (cancelCheck != null && cancelCheck())
return null;
if (Parent != null)
Parent.IsExpanded = true;
if (directory == null)
return null;
if (!IsLoaded) {
IsLoading = true;
SubDirectories = getDirectories();
HasSubDirectories = SubDirectories.Count > 0;
IsLoading = false;
}
foreach (DirectoryTreeItemViewModel subDirModel in SubDirectories)
{
if (!subDirModel.Equals(dummyNode)) {
DirectoryInfoEx subDir = subDirModel.EmbeddedDirectoryModel.EmbeddedDirectoryEntry;
if (directory.Equals(subDir))
return subDirModel;
else if (IOTools.HasParent(directory, subDir))
return subDirModel.LookupChild(directory, cancelCheck);
}
}
return null;
}
DirectoryTreeItemViewModel
(HierarchyViewModel)
- ExViewModel
- HierarchyViewModel
- DirectoryTreeItemViewModel
- RefreshCommand
- SubDirectories
- HasSubDirectories
- IsLoading
- bgWorker_loadSub
DirectoryTreeItemViewModel contains
a
Cinch.BackgroundTaskManager
(bgWorker_loadSub), It's
similar to CurrentDirectoryViewModel
except it
loads subdirectory only instead of both subdirectory and files.
To make the application even more responsive some developer may want to
list the sub-items asynchronously, it's now possible as
DirectoryInfoEx
0.17 included DirectoryInfoEx.EnumerateDirectories() method (as
well as EnumerateFiles() and EnumerateFileSystemInfos()
methods), which return a IEnumerable
instead of a List,
like the
method
with same name in DirectoryInfo in .Net 4.0, , In this case
you may use a Linq query
instead of
Cinch.BackgroundTaskManager.
I
havent implement this yet.
Like FileList, DirectoryTree also contains a FileSystemWatcherEx which
montior the Desktop directory and it's sub-folders (instead of creating
a watcher for each directory). When there's a
change, it will call RootDirectoryModelList[0]'s BroadcastChange()
method, which will iterate through all created folders.
internal void BroadcastChange(string parseName, WatcherChangeTypesEx changeType)
{
if (IsLoaded) foreach (DirectoryTreeItemViewModel subItem in SubDirectories)
subItem.BroadcastChange(parseName, changeType);
switch (changeType)
{
case WatcherChangeTypesEx.Created:
case WatcherChangeTypesEx.Deleted:
if (EmbeddedDirectoryModel.FullName.Equals(PathEx.GetDirectoryName(parseName)))
Refresh();
break;
default:
if (EmbeddedDirectoryModel.FullName.Equals(parseName))
Refresh();
break;
}
}
View
Most of the View I created are inherited from UserControl, but not
FileList and DirectoryTree. The major
reason is that these class
exposed a lot of properties thats required by other class, if I inherit
from UserControl I will have to re-write many DependencyProperties. The
another
reason is that I have a DragDropHelper
which have to be plugged to
TreeView/ListView only.
Both FileList and DirectoryTree have some code-behind, as it's easier
to code that way. To make it easier to be edit by Expression, true MVVM
project shouldnt have any View, and MVVM components should be connected
via ViewModel instead of Dependency properties.
DragDropHelper work with DirectoryInfoEx related controls only, you can
enable the support by adding a few lines in xaml :
<ListView x:Class="QuickZip.IO.PIDL.UserControls.FileList"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:uc="http://www.quickzip.org/UserControls"
xmlns:local="clr-namespace:QuickZip.IO.PIDL.UserControls"
SelectionMode="Extended"
VirtualizingStackPanel.IsVirtualizing="True" //Virtual File List support.
VirtualizingStackPanel.VirtualizationMode="Recycling"
uc:SelectionHelper.EnableSelection="True" //Enable Multi-Select by dragging.
local:DragDropHelperEx.EnableDrag="True" //Hook drag related events (drag-FROM file list)
local:DragDropHelperEx.EnableDrop="True" //Hook drop related events (drop-TO file list)
local:DragDropHelperEx.ConfirmDrop="False" //Display an ugly confirm dialog when drop
local:DragDropHelperEx.CurrentDirectory=
"{Binding RootModel.CurrentDirectory, RelativeSource={RelativeSource self}}"
//Where do the files DROPPED to?
local:DragDropHelperEx.Converter=
"{Binding ModelToExConverter, RelativeSource={RelativeSource self}}"
//Connected to FileList.ModelToExConverter,
//which convert ExAViewModel to FileSystemInfoEx
/>
OT: How to create custom control in
ClassLibrary?
The FileList and DirectoryTree class are created
using UserControl template
(Add...\User
Control), then rename the UserControl
to TreeView/ListView. This will generate both
.cs and .xaml file.
The another way is to
- Create a new Class (Add
\ Class),
- Change it so it's inherited from TreeView/ListView.
- If you want to style it, Add a Style
in Themes\Generic.xaml,
<Style x:Key="{x:Type local:YourControl}" TargetType="{x:Type local:YourControl}"
BasedOn="{x:Type local:ListViewBase}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type local:YourControl}">
<ItemsPresenter />
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
If you need a specific ControlTemplate, lets say ListView, just google
"ControlTemplate ListView" and you can find the
template from msdn.
- Update AssemblyInfo.cs,
add
the
following
: (which make it load your generic.xaml, you
only have to do it once per Class Library)
[assembly: ThemeInfo(
ResourceDictionaryLocation.ExternalAssembly, ResourceDictionaryLocation.SourceAssembly )]
- You have to override the default style too :
static YourControl()
{
DefaultStyleKeyProperty.OverrideMetadata(typeof(YourControl), new FrameworkPropertyMetadata(typeof(YourControl)));
}
protected override DependencyObject GetContainerForItemOverride()
{
return new YourControlItem();
}
Common in DirectoryTree/FileList
- DirectoryTree/FileList
- RootModel
- ProgressEvent
- IsEditing (Attached property)
RootModel is the top level RootModelBase (which is ViewModel), which
provide
properties for the
UserControls to bind. As it's MVVM
RootModel is also the
DataContext, eg:
DataContext = RootModel = new FileListViewModel();
Bcause the ViewModel is DataContext, binding them become
very easy :
<Style x:Key="{x:Type local:FileList}" TargetType="{x:Type local:FileList}"
BasedOn="{StaticResource {x:Type ListBox}}" >
<Setter Property="ItemsSource"
Value="{Binding CurrentDirectoryModel.SubEntries.View}" />
<Setter Property="View" Value="{StaticResource GridView}" />
</Style>
ListView.ItemsSource is bound
to
RootModel.CurrentDirectoryModel.SubEntries.View.
(remember,
it's
CollectionViewSource)
ContextMenu handling is done in
UserControl level, DirectoryInfoEx has a static class named
ContextMenuWrapper, which can
generate the shell context menu for specified entries, all you need is
to provide the entries (in FileSystemInfoEx)
and
the
coordinate
(in System.Drawing.Point):
_cmw = new ContextMenuWrapper();
this.AddHandler(TreeViewItem.MouseRightButtonUpEvent, new MouseButtonEventHandler(
(MouseButtonEventHandler)delegate(object sender, MouseButtonEventArgs args)
{
if (SelectedValue is FileListViewItemViewModel)
{
var selectedItems = (from FileListViewItemViewModel model in SelectedItems
select model.EmbeddedModel.EmbeddedEntry).ToArray();
Point pt = this.PointToScreen(args.GetPosition(this));
string command = _cmw.Popup(selectedItems, new System.Drawing.Point((int)pt.X, (int)pt.Y));
switch (command)
{
case "rename":
if (this.SelectedValue != null)
SetIsEditing(ItemContainerGenerator.ContainerFromItem(this.SelectedValue), true);
break;
case "refresh":
RootModel.CurrentDirectoryModel.Refresh();
break;
}
}
}));
ProgressEvent is ProgressRoutedEventArgs.ProgressEvent,
which
is
a
RoutedEvent, it is raised if RootModel.OnProgress event is
raised by the
ViewModel.
IsEditing is an attached property, when
rename is issued (from the
Shell Context Menu) in FileList, it will set IsEditing of the specified
ListViewItem to true. On
the another side, the
ListViewItem.IsEditing is
bound to it's enclosed EditBox.
The following sample is taken from DirectoryTree,
as
it
looks simplier :
<HierarchicalDataTemplate x:Key="TreeItemTemplate" DataType="{x:Type vm:DirectoryTreeItemViewModel}" ItemsSource="{Binding SubDirectories}">
<StackPanel Orientation="Horizontal" x:Name="itemRoot">
<Image x:Name="img" Source="{Binding Converter={StaticResource amti}}" Width="16"/>
<uc:EditBox x:Name="eb" Margin="5,0" DisplayValue="{Binding EmbeddedModel.Label}"
ActualValue="{Binding EmbeddedModel.Name, Mode=TwoWay}"
IsEditable="{Binding EmbeddedModel.IsEditable}"
IsEditing="{Binding Path=(local:DirectoryTree.IsEditing),
RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type TreeViewItem}}, Mode=TwoWay}"
/>
<Grid Width="50" Margin="15, 5, 0, 5" Visibility="{Binding IsLoading, Converter={StaticResource btv}}">
<ProgressBar IsIndeterminate="True" />
<TextBlock Text="Loading" FontSize="6" TextAlignment="Center" />
</Grid>
</StackPanel>
-->
</HierarchicalDataTemplate>
EditBox is a replacement of TextBox + Label, which display the Label
when not IsEditing, and TextBox (EditBoxAdorner) when IsEditing, it's a
technique
learned from ATC
Avalon
Team, but the EditBox
used in this project is a rewrite one,
with the following changes :
-
No longer bound to ListView
-
Has two value instead of one,
which is required because a FileSystemInfoEx item's label
and
name may be different.
The converter (amti) is ExModelToIconConverter
which, similar to FileNameToIconConverter,
is
a
IValueConverter
can
convert
ExModel/ExViewModel
to
ImageSource.
FileList (ListView)
- FileList
- SelectedEntries
- CurrentDirectory
- ViewMode
- ViewSize
- IsLoading
- RefreshCommand
- View
- FileListLookupBoxAdorner
Most of the properties here are explained in the ViewModel, except
these are DependencyProperties,
those
are
bound
to the RootModel's
related
properties.
FileList can have different
Views,
a
View
can define ListView's Orientation, ItemContainerStyle, ItemTemplate,
HorizontalContentAlignment and whatever Listview's properties in one
place, if you look at VirtualWrapPanelView.cs
you can find the following :
public class VirutalWrapPanelView : ViewBase
{
public static readonly DependencyProperty ItemContainerStyleProperty =
ItemsControl.ItemContainerStyleProperty.AddOwner(typeof(VirutalWrapPanelView));
public Style ItemContainerStyle
{
get { return (Style)GetValue(ItemContainerStyleProperty); }
set { SetValue(ItemContainerStyleProperty, value); }
}
}
then in VirtualWrapPanelView.xaml,
you
can
find the property is bound to the ListView
<Style x:Key="{ComponentResourceKey TypeInTargetAssembly={x:Type uc:VirutalWrapPanelView},
ResourceId=virtualWrapPanelViewDSK}"
TargetType="{x:Type ListView}" BasedOn="{StaticResource {x:Type ListBox}}">
...
<Setter Property="ItemContainerStyle"
Value="{Binding (ListView.View).ItemContainerStyle,
RelativeSource={RelativeSource Self}}"/>
....
</Style>
To support multiple
Views,
It's
a
good
idea
to
construct
View
instead of setting each
properties individually.
There are 7
ViewModes, so you may
think
there are 6
Views
in
FileList
(and
TileView
is
not
implemented implemented in 0.3), actually there are just
VirtualWrapPanelView
and GridView.
There are only 3 different
Views
derived
from
VirtualWrapPanelView,
which
is
SmallIconView, ListView and
IconView. The following is IconView, which is used by 3
similar ViewModes (vmIcon, vmLargeIcon and vmExtraLargeIcon).
<uc:VirutalWrapPanelView x:Key="IconView" ItemHeight="..." ItemWidth="..." HorizontalContentAlignment="Left" >
<uc:VirutalWrapPanelView.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Vertical">
<Image x:Name="img" HorizontalAlignment="Center" Stretch="Fill" Source="..."
Height="{Binding RelativeSource={RelativeSource AncestorType=local:FileList}, Path=ViewSize}"
Width="{Binding RelativeSource={RelativeSource AncestorType=local:FileList}, Path=ViewSize}" />
<uc:EditBox x:Name="eb" Margin="5,0" ... />
</StackPanel>
</DataTemplate>
</uc:VirutalWrapPanelView.ItemTemplate>
</uc:VirutalWrapPanelView>
Once you completed the
View,
changing
ViewMode
is
just
one
line of code:
this.View = (ViewBase)(this.TryFindResource(IconView));
When you use the FileList,
remember leave some space below the control
(not necessary to be empty), because thats where the
FileListLookupAdorner placed
in. FileListLookupAdorner
is used to filter listed element by name, remember
CurrentDirectoryViewModel has a
property named Filter which
use runs a
Cinch.BackgroundTaskManager
to filter the data. FileListLookupAdorner
can be bring up by pressing any key in file list.
It's harder to make the control as adorner, as I have to write the code
in cs instead of xaml, the advantage is that
- it's separate from your main control, so you can reuse it in
other controls you made,
- as all the unrelated logic code, like close the adorner when user
press the x button, is placed on the FileListLookupAdorner.cs
instead of the FileList.cs.
- the FileListLookupAdorner is
shown
on
the
AdornerLayer,
which usually is the topmost, and it wont interfere other parts of the
main control.
I originally wanted to bind them together, but not sure why it's not
working, so I monitor the
changes and update when the text of the adorner is changed.
DependencyPropertyDescriptor descriptor = DependencyPropertyDescriptor.FromProperty
(FileListLookupBoxAdorner.TextProperty, typeof(FileListLookupBoxAdorner));
descriptor.AddValueChanged
(_lookupAdorner, new EventHandler(delegate {
RootModel.CurrentDirectoryModel.Filter = _lookupAdorner.Text; }));
DirectoryTree (TreeView)
- DirectoryTree
- AutoCollapse (0.4)
- RootDirectory
- SelectedDirectory
- SelectedDirectoryPath
- LoadingAdorner
LoadingAdorner is shown when RootModel's BgWorker_FindChild (see
DirectoryTreeViewModel) is
working (or IsLoading equals
to true).
The DataTemplate of DirectoryTree is HierarchicalDataTemplate, which is
a DataTemplate that allow
you to set the ItemsSource
(or subItems). The complete template can be found above in
"Common in DirectoryTree/FileList" section,
<HierarchicalDataTemplate ...>
<Grid Width="50" Margin="15, 5, 0, 5" Visibility="{Binding IsLoading, Converter={StaticResource btv}}">
<ProgressBar IsIndeterminate="True" />
<TextBlock Text="Loading" FontSize="6" TextAlignment="Center" />
</Grid>
</HierarchicalDataTemplate>
I just want to point out there are two IsLoading property:
- IsLoading above is
linked to DirectoryTreeItemViewModel
(which shown when loading a specific subdirectory),
- LoadingAdorner one is
linked to DirectoryTreeViewModel
(which shown when looking up for a specific DirectoryTreeItemViewModel)
When an item is Selected, by setting HierarchyViewModel
.IsSelected
property, which linked to TreeViewItem.IsSelected
dependency
property, the
DirectoryTree will try to bring it to view.
this.AddHandler(TreeViewItem.SelectedEvent, new RoutedEventHandler(
(RoutedEventHandler)delegate(object obj, RoutedEventArgs args)
{
if (SelectedValue is DirectoryTreeItemViewModel)
{
DirectoryTreeItemViewModel selectedModel = SelectedValue as DirectoryTreeItemViewModel;
SelectedDirectory = selectedModel.EmbeddedDirectoryModel.EmbeddedDirectoryEntry;
if (args.OriginalSource is TreeViewItem)
(args.OriginalSource as TreeViewItem).BringIntoView();
_lastSelectedContainer = (args.OriginalSource as TreeViewItem);
}
}));
_lastSelectedConiner is later
use when user try to rename an item, it's too hard to get the container
(I meant TreeViewItem, not it's model) from TreeView.
switch (command)
{
case "rename":
if (this.SelectedValue != null)
{
if (_lastSelectedContainer != null)
SetIsEditing(_lastSelectedContainer, true); }
break;
}
Other Components
There are a number of components developed for the FileList and
DirectoryTree:
- Components
- Converters
- UserControls
EditBox
- a
replacement of TextBox + Label, which display the Label
when not IsEditing, and TextBox (EditBoxAdorner) when IsEditing, more
information here.
EditBoxAdorner
-
the TextBox part of the EditBox
LoadingAdorner
- a
circular animation that shows when DirectoryTree
is
finding child.
SelectionAdorner
-
display a rectangle when user is dragging
on FileList.
Conclusion
I have described how did I constructed the FileList and DirectoryTree
using the MVVM pattern, compared with NO pattern or MVP pattern,
using MVVM pattern have these advantages
:
The photo above shows the other UserControls
that I developed using the similar method described above, These are
not included in this article.
If
you
just go through my
torture
article you should be able to create the controls yourself.
Lets say I want to construct the Toolbar using MVVM, I would
- Construct and Style a ToolbarBase that have nothing to do with
MVVM, that included ToolbarBase and ToolbarItem
- Make sure it's working without MVVM, using a simple WPF
application
- Construct the UserControl
(e.g. Toolbar), which
contains or inherited from ToolbarBase
- Construct Models and ViewModels
- Construct an abstract class named ToolbarItemModel
- Which represent a ToolbarItem,
you
may
want to have a ToolbarSubItem
as well.
- Construct a ToolbarViewModel
(RootModel or DataContext), which will host a
number of ToolbarItemModel
(in ObservableCollection),
and method to poll them in background.
- Construct custom ToolbarViewModels
(e.g. ViewModeViewModel, to
change ViewMode, see the
right side)
- Construct View
- Hook them together, done.
If you found this article useful, please vote for the article. If
you found anything thats not correct, please tell me your
thoughts. Thank you
References
History
- 2 May 2010 - Inital version 0.1
- 4 May 2010 - 0.2 - screenshot
- Added FileList.SortBy and SortDirection property.
- Changed GridView selection behavior / Template
- Fixed FileList not showing GridView Header.
- 6 May 2010 - 0.3 - screenshot
- Added TileView
- Added GridView.Type header
- Added DriveModel, all DirectoryModel is now constructed using
ExModel.FromExEntry() method.
- Added GridViewHeader
(for sorting) for every ViewModel.
- 13 May 2010 - 0.4
- Fixed Small Image Icon not shared by all instance.
- Enabled BugTrap
support in app.xaml.cs. Fixed all warning messages.
- Added DirectoryTree.AutoCollapse,
collapse unrelated directory when changed externally.
- Fixed FileList scrolling :
- scroll based on a property (SmallChanges), instead of
10pt.
- scroll horizontally if
Orientation equals Vertical (e.g. ListView)
- 28 May 2010 - 0.5 - screenshot
- Fixed a bug related to expand wrong
directory when expand via double click on file list.
- Fixed a crash in DriveModel (Drive
not found)
- Added FileList Select(), SelectAll(),
UnselectAll() and Focus() method.
- 18 Jun 2010 - 0.6 - screenshot
- Updated DirectoryTree Style so it match the style of FileList.
- Added a wide range of Commands (in SimpleRoutedCommand format, 6 for FileList, 2 for DirectoryTree and 6 for both), can be accessed by
- calling FileList/DirectoryTree.Commands (In separate class to reduce the complexity of main control).
- most of those commands are bound with a RoutedUICommands, like ApplicationCommands.SelectAll.
- shortcut keys (e.g. F2 for rename)
See FileList/DirectoryTree/SharedCommands.cs for details. - Updated DirectoryInfoEx to 0.18.
- 18 Jul 2010 - 0.7 - screenshot
- Fixed a bug that caused wired thumbnail render.
- Fixed W7TreeViewItem Style, which enable hot track only over the text instead of the whole line.
- Fixed Virtual FileListItem retain selection state (IsSelected = true) after SelectAllCommand and User selecting another item (via Selection Helper).
- Fixed click on GridView Header recognize as drag start.
- For GridView, only support selection if drag occur inside the first column
- Drop operations now use WorkEx, which support custom progress dialog implementation and run in separate thread.
- OverwriteMode changes in DirectoryInfoEx 0.19. (see below)
- 13 May 2012 - 0.8 -
- Fixed Windows 7 related crashes:
- ExIconConverter no longer link with SysImageList, as it's shared among different user controls. (but that means no more Extra large and Jumbo icons)
- A number of dlls thats required is reside in another directory.
- QuickZip.IO.PIDL library is updated.