wtorek, 5 października 2010

Własna kontrolka do przeglądania katalogów

Jedną z funkcjonalności Chupacabry będzie możliwość przeglądania zdjęć znajdujących się na dysku i aby to osiągnąć konieczny jest mechanizm nawigowania pomiędzy katalogami. Oczywiście można znaleźć gotowe rozwiązania zaimplementowane przez kogoś, gotowe do wykorzystania w WPF'ie, ale pomyślałem, że stworzenie takiej kontrolki samemu będzie dobrym sprawdzianem oraz ćwiczeniem z wykorzystania samego WPF'a jak i MVVM Light Toolkit w praktyce.
Od razu napiszę, że nie wymyśliłem samemu jak to ładnie połączyć, tylko oparłem się na artykule z CodeProject i jedynie uprościłem go nieco oraz dostosowałem do wykorzystania z MVVM Light Toolkit.


DirectoryTreeView
Tak właśnie nazywać się będzie ta kontrolka. Możliwości jakie chciałbym zaimplementować to przeglądanie struktury dysku twardego i zwracanie na bieżąco aktualnie wybranego katalogu, możliwość przeskoczenia do zarządanego przez użytkownika folderu oraz opcje pozwalające na niepokazywanie na przykład ukrytych katalogów. Chciałbym, żeby kontrolka była w jak największym stopniu zgodna z zasadami MVVM.
Do rzeczy. Do reprezentowania drzewiastej struktury katalogów oczywistym wyborem jest kontrolka TreeView. Oczywiście mógłbym wykorzystać jej zdarzenia i reagować na każde rozwinięcie gałęzi wczytaniem nowej listy katalogów i dodaniem do listy obiektów TreeViewItem, ale nie o to chodzi w MVVM. Dlatego idea jest następująca: stworzenie obiektu ViewModel powiązanego z plikiem XAML kontrolki, który będzie przechowywał listę innych obiektów ViewModel, z których każdy reprezentuje pojedynczą gałąź ze struktury katalogów i sam odpowiada za wczytywanie swoich podkatalogów. Może to trochę skomplikowane, ale niedługo powinno się wyjaśnić.


Model
Nie wiem czy można jedną prostą klasę nazwać modelem, ale na potrzeby podbudowania swojego programistycznego ego tak właśnie zrobię. Modelem będzie to co chcemy właściwie od tej kontrolki się dowiedzieć, czyli pełna ścieżka do katalogu oraz sama nazwa katalogu. Można to zawrzeć w takiej o to trywialnej klasie:
public class DirectoryItem
{
 public string Name { get; set; }
 public string Path { get; set; }
}
Instancja tej klasy będzie dostępna w każdym ViewModelu reprezentującym jedną gałąź.


DirectoryTreeViewModel
Właściwie powinien się on nazywać DirectoryTreeViewViewModel, ale jakoś za bardzo kłuło mnie to w oczy. Sama klasa dziedziczy z ViewModelBase, aby móc wykorzystywać MVVM Light Toolkit. Instancja tej klasy będzie przypisana do właściwości DataContext widoku kontrolki. Wspomniana wcześniej lista obiektów reprezentujących pojedynczy katalog udostępniania jest przez właściwość:
public const string RootPropertyName = "Root";
private ObservableCollection _root = null;

public ObservableCollection Root
{
 get
 {
  return _root;
 }

 set
 {
  if (_root == value)
   return;

  _root = value;

  RaisePropertyChanged(RootPropertyName);
 }
}
W konstruktorze pobierana jest lista dostępnych dysków i dla każdego z nich tworzony jest nowy obiekt DirectoryItemViewModel oraz następnie dodawany do listy. Nic szczególnego się nie dzieje.
public DirectoryTreeViewModel()
{
 Root = new ObservableCollection<directoryitemviewmodel>(getRootDirectories());
}

private IList<directoryitemviewmodel> getRootDirectories()
{
 IList<directoryitemviewmodel> result = new List<directoryitemviewmodel>();

 foreach (var s in Directory.GetLogicalDrives())
 {
  DirectoryItem item = new DirectoryItem
  {
   Name = s,
   Path = s
  };

  result.Add(new DirectoryItemViewModel(item));
 }

 return result;
}
DirectoryItemViewModel
To tu dzieje się większość magii związanej z przeglądaniem katalogów. Instancje tych obiektów będą używane jako DataContext dla obiektów TreeViewItem. Za pomocą data bindingu przekazywać będą dane do wyświetlania, a także reagować na zmiany stanu kontrolki TreeView.
Każdy ViewModel tego typu przechowuje instancję "modelu" odpowiadający aktualnemu katalogowi oraz listę jego podfolderów w postaci listy innych obiektów DirectoryItemViewModel.
private ObservableCollection _children;
private DirectoryItem _fileitem;
Na potrzeby data bindingu istotne dane udostępniane są przez właściwości:
public string Name
{
 get { return _fileitem.Name; }
}

public ObservableCollection<directoryitemviewmodel> Children
{
 get { return _children; }
}
I teraz bardzo istotna kwestia. Wczytywanie wszystkich katalogów na dysku, aby zbudować odpowiednią hierachię nie ma najmniejszego sensu. Dlatego należy zastosować coś na kształt "lazy loadingu", to znaczy wczytywania katalogów tylko wtedy, gdy zarząda tego użytkownik. Nie należy też wczytywać listy katalogów za każdym razem, gdy rozwijana będzie gałąź. Można zastosować coś co w artukule z którego korzystałem zostało określone jako "dummy object" (być może pasuje tłumaczenie tego jako manekin). Jego jedynym przeznaczeniem jest reprezentowanie nic nie znaczącej gałęzi, dzięki czemu będzie można wykryć czy gałąź była już rozwijana oraz czy posiada jakieś elementy. Poprzez stworzenie statycznej instancji będzie on wspólny dla wszystkich obiektów:
readonly static DirectoryItemViewModel DummyChild = new DirectoryItemViewModel();
Klasa posiada też dwa konstruktory, jeden prywatny tylko na potrzeby powyższej linijki kodu, a drugi inicjalizujący właściwy obiekt.
private DirectoryItemViewModel()
{ }

public DirectoryItemViewModel(DirectoryItem fileItem)
{
 _fileitem = fileItem;
 _children = new ObservableCollection<DirectoryItemViewModel>(new List<DirectoryItemViewModel>());

 _children.Add(DummyChild);            
}
Jedyne co konstruktor robi to przypisuje otrzymaną intancję modelu i tworzy listę podkatalogów z jednym elementem manekinem, symbolizującym, że gałąź przypisana do bieżącego obiektu nie została jeszcze rozwinięta.
Teraz czas na dwie istotne właściwości, które będą zbindowane do właściwości obiektu TreeViewItem, mianowicie IsSelected oraz IsExpanded. Ich znaczenia są raczej oczywiste, mają wartość true, gdy odpowiednio gałąź jest zaznaczona lub rozwinięta. Właściwość IsSelected jest zwykłą właściwością typu bool, która podczas przypisywania wartości wywołuje RaisePropertyChanged. Natomiast w IsExpanded zawarta jest obsługa wczytywania kolejnych katalogów:
public const string IsExpandedPropertyName = "IsExpanded";
private bool _isExpanded = false;

public bool IsExpanded
{
 get
 {
  return _isExpanded;
 }

 set
 {
  if (_isExpanded == value)
   return;

  _isExpanded = value;

  RaisePropertyChanged(IsExpandedPropertyName);

  if (HasDummyChildren)
  {
   _children.Remove(DummyChild);
   loadChildren();
  }
 }
}
Po uaktualnieniu wartości, sprawdzam czy czasem dany węzeł nie posiada tylko jednego potomka, którym jest właśnie nasz dummy object. Jeśli tak jest to usuwany jest on z listy podkatalogów a na jego miejsce dodawane są nowe odpowiadające strukturze aktualnego katalogu:
public bool HasDummyChildren
{
 get
 {
  return (_children.Count == 1 && _children[0] == DummyChild);
 }
}

private void loadChildren()
{
 foreach (var s in Directory.GetDirectories(_fileitem.Path))
 {
  DirectoryItem item = new DirectoryItem
  {
   Name = s.Substring(s.LastIndexOf(@"\") + 1),
   Path = s
  };
  _children.Add(new DirectoryItemViewModel(item));
 }
}
 
DirectoryTreeView.xaml.cs
Jedyną operacją jaką na razie trzeba zrobić w code behind kontrolki to przypisanie do właściwości DataContext nowej instancji odpowiedniego obiektu ViewModel:
DataContext = new ViewModel.DirectoryTreeViewModel();

DirectoryTreeView.xaml
Teraz należy odpowiednio połączyć wszystkie elementy poprzez bindingi:

 
  
   
  
 
 
  
 

Głównym źródłem elementów od którego należy zacząć jest właściwość Root z DirectoryTreeViewModel. Kolejne elementy pobierane są z właściwości Children obiektu DirectoryItemViewModel. Każdy element jest zwykłym polem tekstowym, w którym wyświetlana jest nazwa katalogu; jednoczeście istnieje możliwość ładnego dopasowania wyglądu kontrolki dodając na przykład ikonki przy każdym folderze. 
Bardzo istotny jest fragment zbindowania właściwości IsExpanded oraz IsSelected dla każdego obiektu TreeViewItem. To właśnie dzięki dwustronnemu powiązaniu wszystkie zmiany następują automatycznie bez zwracania na to uwagi. Dodatkowo jeśli dany element jest wybrany to będzie on wypisany grubszą czcionką. 

Co dalej?
W następnej kolejności chcę się zająć udostępnianiem na zewnątrz kontrolki aktualnie zaznaczonego katalogu, sprawdzaniem czy dany folder ma podkatalogi by ewentualnie nie wyświetlać strzałki do rozwijania oraz możliwością niepokazywania ukrytych katalogów.


Zaktualizowane źródła projektu dostępne są na BitBucket.

1 komentarz:

  1. Też kiedyś korzystałem z tego rozwiązania, muszę przyznać, że całkiem niezłe, chociaż wymagało kilku przeróbek do własnych potrzeb.

    OdpowiedzUsuń