Monthly Archives: Grudzień 2012

Kolekcje w WPF – sortowanie, grupowanie, filtrowanie oraz nawigacja

2012-12-27

Poprzez mechanizm wiązania danych WPF pozwala w łatwy sposób prezentować zawartość kolekcji obiektów. Jeżeli jednak oprócz samego wyświetlania elementów zależy nam na ich sortowaniu, grupowaniu, filtrowaniu czy nawigacji po nich, standardowe możliwości kolekcji okażą się niewystarczające. W takiej sytuacji idealnym rozwiązaniem będzie użycie widoku kolekcji. CollectionView jest swojego rodzaju wraperem na kolekcję umożliwiającym wykonanie powyższych operacji. Co więcej wszystkie te operacje nie mają wpływu na zbiór źródłowy, co z kolei pozwala na utworzenie kilku niezależnych widoków dla danej kolekcji.

Tworzenie widoku kolekcji

Załóżmy, że mamy następującą klasę:

public class Person
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public int Age { get; set; }
}

Poniższy kod tworzy kolekcję obiektów Person oraz widok dla tej kolekcji:

ObservableCollection<Person> Persons = new ObservableCollection<Person>();

//Pobranie domyślnego widoku kolekcji (każda kolekcja posiada widok domyślny)
ICollectionView PersonsView = CollectionViewSource.GetDefaultView(Persons);

//Utworzenie nowego widoku kolekcji
ICollectionView NewPersonsView = new ListCollectionView(Persons);

Widok możemy zastosować dla dowolnej kolekcji implementującej interfejs IEnumerable (kolekcja źródłowa dostępna jest poprzez właściwość SourceCollection widoku). Obiekt będący widokiem może być następnie użyty jako źródło danych w kontrolkach WPF. Jeżeli kolekcja implementuje interfejs INotifyCollectionChanged wszystkie dokonywane w niej zmiany (dodanie, usunięcie, zmiana elementu) będą automatycznie uwzględniane zarówno w widoku jak i powiązanych z nim kontrolkach. Jeżeli w danym momencie chcemy wymusić odświeżenie widoku możemy użyć metody Refresh.

W zależności od rodzaju kolekcji źródłowej metoda CollectionViewSource.GetDefaultView zwraca obiekt:

  • CollectionView – dla kolekcji implementującej jedynie IEnumerable
  • ListCollectionView – dla kolekcji implementującej IList
  • BindingListCollectionView – dla kolekcji implementującej IBindingListView lub IBindingList

Powyższe klasy mogą być obsługiwane poprzez interfejs ICollectionView. Interfejs ten implementuje klasa CollectionView, po której z kolei dziedziczą klasy ListCollectionView oraz BindingListCollectionView.

Widok kolekcji możemy również stworzyć bezpośrednio w kodzie XAML. Do tego celu służy klasa CollectionViewSource będąca w XAML-u odpowiednikiem klasy CollectionView:

<Window.Resources>
    <local:Persons x:Key="persons" />
    <CollectionViewSource x:Key="personsView" Source="{StaticResource persons}" />
</Window.Resources>

<DataGrid x:Name="dataGrid1"
          ItemsSource="{Binding Source={StaticResource personsView}}">

Nawigacja

Widok kolekcji pozwala na śledzenie aktualnie wybranego elementu oraz jego zmianę przy użyciu szeregu metod. Bieżący element dostępny jest poprzez właściwość CurrentItem, natomiast jego indeks poprzez CurrentPosition:

Person currentPerson = PersonsView.CurrentItem as Person;
int currentPosition = PersonsView.CurrentPosition;

W celu synchronizacji aktualnie wybranego elementu pomiędzy widokiem kolekcji a kontrolką, w kontrolce należy ustawić właściwość IsSynchronizedWithCurrentItem:

<DataGrid ItemsSource="{Binding PersonsView}" IsSynchronizedWithCurrentItem="True">

W przypadku zmiany aktualnie wybranego elementu, na widoku kolekcji wywoływane jest zdarzenie CurrentChanged:

PersonsView.CurrentChanged += PersonsViewCurrentChanged;

private void PersonsViewCurrentChanged(object sender, EventArgs e)
{
    //Kod wykonywany po zmianie bieżącego elementu
}

Zmiany bieżącego elementu możemy dokonać za pomocą metod: MoveCurrentToFirst, MoveCurrentToPrevious, MoveCurrentToNext, MoveCurrentToLast, MoveCurrentToPosition, MoveCurrentTo. Oto ich użycie:

public void MoveToFirst()
{
    PersonsView.MoveCurrentToFirst();
}

public void MoveToNext()
{
    if (PersonsView.CurrentPosition < (PersonsView.Cast<Person>().Count() - 1))
        PersonsView.MoveCurrentToNext();
}

public void MoveToPrev()
{
    if (PersonsView.CurrentPosition > 0)
        PersonsView.MoveCurrentToPrevious();
}

public void MoveToLast()
{
    PersonsView.MoveCurrentToLast();
}

public void MoveToPosition(int position)
{
    PersonsView.MoveCurrentToPosition(position);
}

public void MoveTo(Person person)
{
    PersonsView.MoveCurrentTo(person);
}

Warto zaznaczyć, że w przypadku pobierania liczby wszystkich elementów widoku należy posłużyć się metodą Cast (konwertuje ona widok na obiekt IEnumerable), a następnie metodą Count. Odczyt liczby elementów z kolekcji źródłowej nie zawsze jest właściwy ponieważ widok może posiadać filtr ograniczający liczbę elementów.

Sortowanie

Do definiowania sortowania elementów widoku służy kolekcja SortDescriptions składająca się z obiektów SortDescription. Obiekty te określają właściwości po jakich następuje sortowanie oraz jego kierunek (rosnąco/malejąco):

if (PersonsView.CanSort)
{
    using (PersonsView.DeferRefresh())
    {
        PersonsView.SortDescriptions.Clear();
        PersonsView.SortDescriptions.Add(new SortDescription("LastName",
            ListSortDirection.Ascending));
        PersonsView.SortDescriptions.Add(new SortDescription("FirstName",
            ListSortDirection.Ascending));
    }
}

Metoda DeferRefresh opóźnia odświeżenie widoku do czasu zakończenia na nim wszystkich operacji.

Sortowanie zdefiniować możemy także w kodzie XAML:

<!-- xmlns:cm="clr-namespace:System.ComponentModel;assembly=WindowsBase" -->
<CollectionViewSource.SortDescriptions>
    <cm:SortDescription PropertyName="LastName" />
    <cm:SortDescription PropertyName="FirstName" />
</CollectionViewSource.SortDescriptions>

Niestety powyższy sposób sortowania ma jedną wadę – niską wydajność. Przy dużych kolekcjach jest on nieefektywny. Istnieje jednak alternatywa w postaci właściwości CustomSort widoku, do której przypisujemy własny obiekt implementujący interfejs IComparer (właściwość dostępna jest w klasie ListCollectionView):

ListCollectionView personsView = PersonsView as ListCollectionView;
personsView.CustomSort = new PersonSorter();

public class PersonSorter : IComparer
{
    public int Compare(object a, object b)
    {
        int result;
        Person personA = a as Person;
        Person personB = b as Person;
        result = personA.LastName.CompareTo(personB.LastName);
        if (result == 0)
            result = personA.FirstName.CompareTo(personB.FirstName);
        return result;
    }
}

Grupowanie

Grupowanie elementów widoku tworzymy poprzez kolekcję GroupDescriptions. Składa się ona z obiektów PropertyGroupDescription definiujących właściwości, po których wykonywane jest grupowanie:

if (PersonsView.CanGroup)
{
    using (PersonsView.DeferRefresh())
    {
        PersonsView.GroupDescriptions.Clear();
        PersonsView.GroupDescriptions.Add(new PropertyGroupDescription("LastName"));
    }
}

Podczas definiowania grupowania możemy użyć przeciążonego konstruktora PropertyGroupDescription przyjmującego jako drugi argument obiekt IValueConverter. Pozwala on na dowolną konwersję wartości właściwości przed użyciem jej w grupowaniu (możemy na przykład dla właściwości Age klasy Person stworzyć grupowanie po przedziałach wartości).

Grupowanie utworzyć możemy także w kodzie XAML:

<CollectionViewSource.GroupDescriptions>
    <PropertyGroupDescription PropertyName="LastName" />
</CollectionViewSource.GroupDescriptions>

W celu prezentacji zgrupowanych danych musimy zdefiniować odpowiedni styl wykorzystując właściwość GroupStyle kontrolki. Oto przykładowy styl:

<DataGrid.GroupStyle>
    <GroupStyle>
        <GroupStyle.HeaderTemplate>
            <DataTemplate>
                <DockPanel Background="Navy">
                    <TextBlock Text="{Binding Name}" Foreground="White"
                               Margin="30,0,0,0" Width="100"/>
                    <TextBlock Text="{Binding ItemCount}" Foreground="White"/>
                </DockPanel>
            </DataTemplate>
        </GroupStyle.HeaderTemplate>
    </GroupStyle>
</DataGrid.GroupStyle>

Właściwość GroupStyle dostępna jest w każdej kontrolce dziedziczącej po ItemsControl.

Należy pamiętać, że użycie grupowania danych oznacza wyłączenie wirtualizacji, co przy dużych kolekcjach może mieć negatywny wpływ na wydajność.

Filtrowanie

Włączenie filtrowania widoku polega na przypisaniu do jego właściwości Filter metody odpowiedzialnej za selekcję elementów. Metoda w parametrze przyjmuje element kolekcji, w wyniku zwracając wartość logiczną określającą czy dany element spełnia warunki filtrowania:

if (PersonsView.CanFilter)
    PersonsView.Filter = PersonFilter;

private bool PersonFilter(object item)
{
    Person person = item as Person;
    return person.Age >= 18;
}

W przypadku zmiany warunków filtrowania należy odświeżyć widok przy użyciu metody Refresh.

Podczas definiowania filtrowania z poziomu kodu XAML musimy posłużyć się zdarzeniem Filter obiektu CollectionViewSource:

//XAML
<CollectionViewSource x:Key="personsView" Source="{StaticResource persons}"
                      Filter="CollectionViewSource_Filter" />

//C# (Code-behind)
private void CollectionViewSource_Filter(object sender, FilterEventArgs e)
{
    Person person = e.Item as Person;
    e.Accepted = person.Age >= 18;
}

Więcej informacji można znaleźć na MSDN: CollectionViewSource, CollectionViewListCollectionView, BindingListCollectionView, How to: Group, Sort, and Filter Data in the DataGrid Control.

WPF – formatowanie danych oraz konwersja typów

2012-12-09

W poprzednim wpisie omówiłem kilka mechanizmów walidacji wprowadzanych danych jakie oferuje technologia WPF. Dzisiaj rozszerzę ten temat o kwestie związane z formatowaniem danych oraz konwersją typów. Często zdarza się sytuacja, w której prezentując użytkownikowi pewne dane chcemy określić dla nich własny format. Równie często konieczne jest skonwertowanie wartości wprowadzonej przez użytkownika na docelowy typ danych. Operacje te nie powinny być implementowane po stronie źródła danych ponieważ ściśle dotyczą warstwy prezentacyjnej aplikacji. WPF oferuje kilka ciekawych rozwiązań w tym zakresie: StringFormat, ValueConverter oraz MultiValueConverter.
Na początku przygotujmy projekt zawierający ViewModel z czterema właściwościami oraz powiązany z nim widok (View):

ViewModel:

namespace WPFConverter
{
    public class ConverterViewModel : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;

        virtual protected void OnPropertyChanged(string propertyName)
        {
            if (PropertyChanged != null)
                PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
        }

        private string firstName;
        private string lastName;
        private DateTime employmentData;
        private decimal salary;

        public string FirstName
        {
            get { return firstName; }
            set
            {
                firstName = value;
                OnPropertyChanged("FirstName");
            }
        }

        public string LastName
        {
            get { return lastName; }
            set
            {
                lastName = value;
                OnPropertyChanged("LastName");
            }
        }

        public DateTime EmploymentData
        {
            get { return employmentData; }
            set
            {
                employmentData = value;
                OnPropertyChanged("EmploymentData");
            }
        }

        public decimal Salary
        {
            get { return salary; }
            set
            {
                salary = value;
                OnPropertyChanged("Salary");
            }
        }
    }
}

View:

<Window x:Class="WPFConverter.ConverterView"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:l="clr-namespace:WPFConverter"
        Title="View" Height="214" Width="350" WindowStartupLocation="CenterScreen">
    <Window.DataContext>
        <l:ConverterViewModel
            FirstName="Jan"
            LastName="Kowalski"
            EmploymentData="2010-12-01"
            Salary="18500.5" />
    </Window.DataContext>
    <Grid Margin="0,0,2,0">
        <Label Content="Imię i nazwisko:" HorizontalAlignment="Left"
               VerticalAlignment="Top" Margin="46,32,0,0"/>
        <Label Content="Data zatrudnienia:" HorizontalAlignment="Left"
               VerticalAlignment="Top" Margin="33,76,0,0"/>
        <Label Content="Wynagrodzenie:" HorizontalAlignment="Left"
               VerticalAlignment="Top" Margin="43,116,0,0"/>
        <TextBox Height="23" HorizontalAlignment="Left" Margin="139,35,0,0"
                 Name="tbName" VerticalAlignment="Top" Width="155"
                 Text="{Binding LastName}" />
        <TextBox Height="23" HorizontalAlignment="Left" Margin="139,79,0,0"
                 Name="tbEmploymentData" VerticalAlignment="Top" Width="155"
                 Text="{Binding EmploymentData}" />
        <TextBox Height="23" HorizontalAlignment="Left" Margin="139,119,0,0"
                 Name="tbSalary" VerticalAlignment="Top" Width="93"
                 Text="{Binding Salary}" />
    </Grid>
</Window>

Convert1

Od razu widać, że sposób prezentacji danych odbiega od naszych oczekiwań. W pierwszym polu powinny wyświetlać się imię i nazwisko, data powinna być sformatowana do postaci np. RRRR-MM-DD, kwota również mogłaby wyglądać lepiej.

StringFormat

Zacznijmy od najprostszego rozwiązania czyli sformatowania kwoty przy użyciu właściwości StringFormat obiektu wiązania (Binding). Właściwość ta pozwala zdefiniować format w jakim wyświetlana będzie określona wartość. Przypisujemy do niej wyrażenie w postaci {0:X}, gdzie 0 oznacza numer argumentu, X – format dla niego:

<TextBox Height="23" HorizontalAlignment="Left" Margin="139,119,0,0"
         Name="tbSalary" VerticalAlignment="Top" Width="93"
         Text="{Binding Salary, StringFormat={}{0:N2}}" />

Dla kwoty użyty został format N2, więcej o formatowaniu na MSDN: Formatting Types

Użycie na początku {} jest wymagane jedynie w przypadku, gdy wyrażenie rozpoczyna się od znaku {.

Jeżeli chcemy wymusić formatowanie zgodne z polskimi ustawieniami wystarczy w oknie widoku dodać parametr xml:lang=”pl-PL” (można go stosować także w poszczególnych elementach).

ValueConverter

ValueConverter jest znacznie bardziej rozbudowanym narzędziem. Pozwala na konwersję danych pomiędzy dwoma typami. Aby skorzystać z tego rozwiązania musimy stworzyć klasę implementującą interfejs IValueConverter. Klasa musi posiadać dwie metody: Convert oraz ConvertBack. Pierwsza z nich odpowiada za konwersję z typu źródłowego na docelowy, druga za operację odwrotną. Metody te posiadają parametry, w których przekazywane są: wartość do konwersji, docelowy typ danych, własny parametr oraz obiekt CultureInfo. Dobrą praktyką jest oznaczenie takiej klasy atrybutem ValueConversion zawierającym informacje o typach na jakich klasa operuje (typ źródłowy, typ docelowy).
Powyższe rozwiązanie zastosujemy do pola z datą. Oto utworzona klasa DateToStringConverter:

[ValueConversion(typeof(DateTime), typeof(string))]
public class DateToStringConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter,
        CultureInfo culture)
    {
        DateTime date = (DateTime)value;
        string format = parameter.ToString();
        return date.ToString(format);
    }

    public object ConvertBack(object value, Type targetType, object parameter,
        CultureInfo culture)
    {
        DateTime result;
        if (DateTime.TryParse(value.ToString(), out result))
            return result;
        else
            return value;
    }
}

Klasa odpowiada za konwersję pomiędzy typem DateTime (właściwość ViewModel) a typem string (kontrolka widoku). W przypadku wyświetlania danych w widoku wywoływana będzie metoda Convert, do której przekazujemy parametr z formatem używanym podczas konwersji z DateTime na string. Gdy użytkownik wprowadzi wartość w kontrolce wywołana zostanie metoda ConvertBack konwertująca wartość string na DateTime.
Utworzenie obiektu DateToStringConverter w zasobach okna i użycie go w kontrolce:

<Window.Resources>
    <l:DateToStringConverter x:Key="DateToStringConverter" />
</Window.Resources>
<TextBox Height="23" HorizontalAlignment="Left" Margin="139,79,0,0"
         Name="tbEmploymentData" VerticalAlignment="Top" Width="155"
         Text="{Binding EmploymentData,
            Converter={StaticResource DateToStringConverter},
            ConverterParameter=yyyy-MM-dd}" />

lub utworzenie bezpośrednio w kontrolce:

<TextBox Height="23" HorizontalAlignment="Left" Margin="139,79,0,0"
         Name="tbEmploymentData" VerticalAlignment="Top" Width="155">
    <TextBox.Text>
        <Binding Path="EmploymentData" ConverterParameter="yyyy-MM-dd">
            <Binding.Converter>
                <l:DateToStringConverter />
            </Binding.Converter>
        </Binding>
    </TextBox.Text>
</TextBox>

Użycie obiektu ValueConverter polega na przypisaniu go do właściwości Converter obiektu Binding. Dodatkowo poprzez właściwość ConverterParameter możemy określić wartość trzeciego argumentu metod Convert i ConvertBack.

MultiValueConverter

ValueConverter pozwalał na konwertowanie pojedynczych wartości, z kolei MultiValueConverter umożliwia nam łączenie wielu wartości w jedną oraz rozbijanie pojedynczej wartości na kilka. Aby użyć tego mechanizmu musimy stworzyć klasę implementującą interfejs IMultiValueConverter. Podobnie jak przy ValueConverter klasa ta posiada metody Convert oraz ConvertBack. Różnica w stosunku do ValueConverter polega na innych parametrach oraz wynikach tych metod. W przypadku Convert przyjmowana jest tablica wartości a w wyniku zwracana pojedyncza wartość. ConvertBack z kolei przyjmuje pojedynczą wartość i zwraca tablicę.
Tego rozwiązania użyjemy w celu połączenia imienia i nazwiska w jedną wartość przy prezentacji (oddzielone spacją) oraz do ponownego ich rozbicia po wprowadzeniu nowej wartości przez użytkownika. Oto definicja klasy NameConverter:

public class NameConverter : IMultiValueConverter
{
    public object Convert(object[] values, Type targetType, object parameter,
        CultureInfo culture)
    {
        string firstName = values[0] as string;
        string lastName = values[1] as string;

        return String.Format("{0} {1}", firstName, lastName);
    }

    public object[] ConvertBack(object value, Type[] targetTypes, object parameter,
        CultureInfo culture)
    {
        string[] results = new string[2];
        char[] separator = {' '};
        string[] splitValues = ((string)value).Split(separator,
            StringSplitOptions.RemoveEmptyEntries);

        if (splitValues.Length > 0)
            results[0] = CultureInfo.CurrentCulture.TextInfo.ToTitleCase(splitValues[0]);
        if (splitValues.Length > 1)
            results[1] = CultureInfo.CurrentCulture.TextInfo.ToTitleCase(splitValues[1]);

        return results;
    }
}

Utworzenie obiektu NameConverter w zasobach okna i użycie go w kontrolce:

<Window.Resources>
    <l:NameConverter x:Key="NameConverter" />
</Window.Resources>
<TextBox Height="23" HorizontalAlignment="Left" Margin="139,35,0,0"
         Name="tbName" VerticalAlignment="Top" Width="155">
    <TextBox.Text>
        <MultiBinding Converter="{StaticResource NameConverter}">
            <Binding Path="FirstName" />
            <Binding Path="LastName" />
        </MultiBinding>
    </TextBox.Text>
</TextBox>

Aby do danej właściwości podpiąć kilka źródeł musimy użyć obiektu MultiBinding zawierającego kolekcję wiązań oraz wskazać w nim obiekt MultiValueConverter odpowiedzialny za konwersję wartości. Kolejność poszczególnych obiektów Binding determinuje kolejność wartości w tablicach przekazywanych i zwracanych w metodach Convert i ConvertBack.

W celu samej prezentacji danych zamiast obiektu MultiValueConverter moglibyśmy w obiekcie MultiBinding zdefiniować następujące formatowanie: StringFormat=”{}{0} {1}”.

Oto efekt końcowy:

Convert2

Podsumowanie

Na zakończenie kilka uwag. Jeżeli zastosujemy jednocześnie Converter oraz StringFormat to w pierwszej kolejności wywołany będzie Converter, później StringFormat.
Jeżeli w danej kontrolce mamy zdefiniowaną własną regułę walidacji (obiekt ValidationRule) to w zależności od ustawienia jej właściwości ValidationStep różna będzie kolejność wywoływania walidatora i konwertera po zmianie wartości w kontrolce. Jeżeli właściwość będzie ustawiona na „RawProposedValue” to kolejność będzie następująca: Validation -> Converter -> Property. Jeżeli ustawimy „ConvertedProposedValue” to otrzymamy kolejność: Converter -> Validation -> Property.
Oprócz pokazanych możliwości deklarowania konwerterów (zasoby okna lub kontrolka) możemy zastosować dwa inne rozwiązania. Pierwszym z nich jest utworzenie zewnętrznego zasobu z konwerterami i podpięcie go do zasobów aplikacji:

<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                    xmlns:l="clr-namespace:WPFConverter">
    <l:DateToStringConverter x:Key="DateToStringConverter" />
    <l:NameConverter x:Key="NameConverter" />
</ResourceDictionary>

<Application x:Class="WPFValidation.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             StartupUri="MainWindow.xaml">
    <Application.Resources>
        <ResourceDictionary>
            <ResourceDictionary.MergedDictionaries>
                <ResourceDictionary Source="Converters.xaml" />
            </ResourceDictionary.MergedDictionaries>
        </ResourceDictionary>
    </Application.Resources>
</Application>

Drugim sposobem jest utworzenie abstrakcyjnej klasy dziedziczącej po System.Windows.Markup.MarkupExtension, a następnie użycie jej jako klasy bazowej dla tworzonych konwerterów:

public abstract class BaseConverter : System.Windows.Markup.MarkupExtension
{
    public override object ProvideValue(IServiceProvider serviceProvider)
    {
        return this;
    }
}

Takie rozwiązanie pozwoli na bezpośrednie używanie konwerterów w kodzie XAML:

Converter={l:DateToStringConverter}

Więcej informacji można znaleźć na MSDN: BindingBase.StringFormat Property, IValueConverter InterfaceIMultiValueConverter Interface, MultiBinding Class.

WPF – walidacja danych

2012-12-01

Jedną z podstawowych kwestii przy budowie interfejsu użytkownika jest walidacja wprowadzanych danych. WPF w tym obszarze oferuje kilka mechanizmów. Dzisiaj zajmę się omówieniem trzech rozwiązań: walidacja poprzez implementację interfejsu IDataErrorInfo (DataErrorValidationRule), walidacja za pomocą wyjątków (ExceptionValidationRule) oraz walidacja przy wykorzystaniu własnych obiektów reguł (ValidationRule).
W jaki sposób działa weryfikacja wprowadzanych danych? Jeżeli kontrolka ma ustawione reguły walidacji mechanizm wiązania uruchamia je w momencie przesyłania danych z kontrolki do podpiętego źródła. Gdy reguła walidacji zwróci błąd tworzony jest obiekt ValidationError i dodawany do kolekcji Validation.Errors dostępnej jako właściwość kontrolki. Jeżeli kolekcja ta nie jest pusta właściwość Validation.HasError kontrolki ustawiana jest na true. Dodatkowo gdy wiązanie ma ustawioną właściwość NotifyOnValidationError w przypadku błędu wywołane zostanie zdarzenie Validation.Error na danej kontrolce. Możemy także dla danej reguły walidacji ustawić właściwość ValidatesOnTargetUpdated określającą czy walidacja ma być uruchamiana przy zmianie wartości źródła spoza kontrolki.
Zacznijmy od przygotowania prostego projektu zawierającego model widoku (ViewModel) z trzema właściwościami oraz widok (View) posiadający kontrolki zbindowane do tych właściwości:

ViewModel:

namespace WPFValidation
{
    public class ViewModel : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;

        virtual protected void OnPropertyChanged(string propertyName)
        {
            if (PropertyChanged != null)
                PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
        }

        private string firstName;
        private string lastName;
        private int age;

        public string FirstName
        {
            get { return firstName; }
            set
            {
                firstName = value;
                OnPropertyChanged("FirstName");
            }
        }

        public string LastName
        {
            get { return lastName; }
            set
            {
                lastName = value;
                OnPropertyChanged("LastName");
            }
        }

        public int Age
        {
            get { return age; }
            set
            {
                age = value;
                OnPropertyChanged("Age");
            }
        }
    }
}

View:

<Window x:Class="WPFValidation.View"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:l="clr-namespace:WPFValidation"
        Title="View" Height="279" Width="300" WindowStartupLocation="CenterScreen">
    <Window.DataContext>
        <l:ViewModel />
    </Window.DataContext>
    <Grid>
        <Label Content="Wiek:" HorizontalAlignment="Left"
               VerticalAlignment="Top" Margin="42,119,0,0"/>
        <Label Content="Nazwisko:" HorizontalAlignment="Left"
               VerticalAlignment="Top" Margin="18,76,0,0"/>
        <Label Content="Imię:" HorizontalAlignment="Left"
               VerticalAlignment="Top" Margin="46,33,0,0"/>
        <TextBox Height="23" HorizontalAlignment="Left" Margin="81,33,0,0"
                 Name="tbFirstName" VerticalAlignment="Top" Width="155"
                 Text="{Binding FirstName}" />
        <TextBox Height="23" HorizontalAlignment="Left" Margin="81,76,0,0"
                 Name="tbLastName" VerticalAlignment="Top" Width="155"
                 Text="{Binding LastName}" />
        <TextBox Height="23" HorizontalAlignment="Left" Margin="81,119,0,0"
                 Name="tbAge" VerticalAlignment="Top" Width="93"
                 Text="{Binding Age}" />
        <Button Content="Zapisz" Height="23" HorizontalAlignment="Left"
                Margin="105,188,0,0"
                Name="btSave" VerticalAlignment="Top" Width="75" />
    </Grid>
</Window>

View

Prezentacja błędów walidacji

Na początku opracujemy sposób prezentacji informacji o nieprawidłowych danych. Załóżmy, że w przypadku błędu walidacji dana kontrolka zostanie otoczona czerwoną ramką, obok niej pojawi się ikona błędu, natomiast informacja o błędzie prezentowana będzie po najechaniu myszą na kontrolkę lub ikonę:

walidacja

Oto implementacja powyższego rozwiązania:

<Window.Resources>
    <ControlTemplate x:Key="errorTemplate">
        <DockPanel LastChildFill="true">
            <Border Background="OrangeRed" DockPanel.Dock="right"
                    Margin="3,0,0,0" Width="20" Height="20" CornerRadius="5"
                    ToolTip="{Binding ElementName=adoner,
                        Path=AdornedElement.(Validation.Errors)[0].ErrorContent}">
                <TextBlock Text="!" VerticalAlignment="center" HorizontalAlignment="center"
                           FontWeight="Bold" Foreground="white" />
            </Border>
            <AdornedElementPlaceholder Name="adoner" VerticalAlignment="Center">
                <Border BorderBrush="OrangeRed" BorderThickness="1" />
            </AdornedElementPlaceholder>
        </DockPanel>
    </ControlTemplate>
    <Style x:Key="textBoxError" TargetType="TextBox">
        <Style.Triggers>
            <Trigger Property="Validation.HasError" Value="true">
                <Setter Property="ToolTip"
                        Value="{Binding RelativeSource={x:Static RelativeSource.Self},
                        Path=(Validation.Errors)[0].ErrorContent}"/>
            </Trigger>
        </Style.Triggers>
    </Style>
</Window.Resources>

W zasobach widoku umieszczony został obiekt ControlTemplate odpowiedzialny za utworzenie ikony błędu oraz ramki wokół kontrolki, a także styl ustawiający ToolTip kontrolki w przypadku błędu.

Walidacja poprzez implementację interfejsu IDataErrorInfo (DataErrorValidationRule)

Pierwszym mechanizmem walidacji jaki zastosujemy jest implementacja interfejsu IDataErrorInfo w modelu widoku (ViewModel). Interfejs ten wymaga metody Error (nie jest ona wykorzystywana w WPF) oraz indeksatora, do którego przekazywane są nazwy właściwości po zmianie ich wartości. Indeksator zwraca null w przypadku poprawnej walidacji lub komunikat w przypadku błędu. To rozwiązanie zastosujemy dla właściwości FirstName. Gdy wprowadzona wartość będzie pusta zgłoszony zostanie błąd:

public string Error
{
    get { return String.Empty; }
}

public string this[string fieldName]
{
    get
    {
        string result = null;
        if (fieldName == "FirstName")
        {
            if (string.IsNullOrEmpty(FirstName))
                result = "Imię nie może być puste!";
        }
        return result;
    }
}

W kontrolce odpowiedzialnej za wprowadzanie imienia musimy dodać obsługę tego typu walidacji:

<TextBox Height="23" HorizontalAlignment="Left" Margin="81,33,0,0"
         Name="tbFirstName" VerticalAlignment="Top" Width="155"
         Validation.ErrorTemplate="{StaticResource errorTemplate}"
         Style="{StaticResource textBoxError}"
         Text="{Binding FirstName, UpdateSourceTrigger=LostFocus,
            ValidatesOnDataErrors=True}" />

Pojawiły się w niej cztery nowe elementy:

  • Validation.ErrorTemplate – szablon ustawiany w przypadku błędu walidacji (przypisujemy obiekt zdefiniowany wcześniej w zasobach widoku)
  • Style – przypisanie stylu zdefiniowanego w zasobach widoku
  • UpdateSourceTrigger – opcja pozwala na określenie, w którym momencie następuje aktualizacja źródła i uruchomienie walidacji
  • ValidatesOnDataErrors – włączenie walidacji dla interfejsu IDataErrorInfo, jest to skrócona forma następującego kodu:
<Binding.ValidationRules>
    <DataErrorValidationRule />
</Binding.ValidationRules>

Należy pamiętać, że w przypadku tego rozwiązania walidacja uruchamiana jest dopiero po przypisaniu wartości do danej właściwości. Oznacza to, że jeżeli operacja ta nie powiedzie się (wystąpi np. błąd konwersji) walidacja nie zostanie uruchomiona. Ma to duże znaczenie przy typach liczbowych – gdy do kontrolki wprowadzone zostaną litery ten rodzaj walidacji nie wykryje tego faktu (w kontrolce będzie inna wartość niż we właściwości źródłowej).

Walidacja za pomocą wyjątków (ExceptionValidationRule)

Kolejnym typem walidacji jaki zastosujemy jest walidacja poprzez zgłoszenie wyjątku. Tego rozwiązania użyjemy dla właściwości LastName. W przypadku próby wprowadzenia pustego ciągu znaków zgłoszony zostanie wyjątek:

public string LastName
{
    get { return lastName; }
    set
    {
        if (string.IsNullOrEmpty(value))
            throw new ArgumentException("Nazwisko nie może być puste!");
        lastName = value;
        OnPropertyChanged("LastName");
    }
}

W kontrolce odpowiedzialnej za wprowadzanie nazwiska musimy dodać obsługę tego typu walidacji:

<TextBox Height="23" HorizontalAlignment="Left" Margin="81,76,0,0"
         Name="tbLastName" VerticalAlignment="Top" Width="155"
         Validation.ErrorTemplate="{StaticResource errorTemplate}"
         Style="{StaticResource textBoxError}"
         Text="{Binding LastName, UpdateSourceTrigger=LostFocus,
            ValidatesOnExceptions=True}" />

Pojawiły się w niej cztery nowe elementy (oprócz omówionych wcześniej):

  • Validation.ErrorTemplate – szablon ustawiany w przypadku błędu walidacji (przypisujemy obiekt zdefiniowany wcześniej w zasobach widoku)
  • Style – przypisanie stylu zdefiniowanego w zasobach widoku
  • UpdateSourceTrigger – opcja pozwala na określenie, w którym momencie następuje aktualizacja źródła i uruchomienie walidacji
  • ValidatesOnExceptions – włączenie walidacji na podstawie wyjątków, jest to skrócona forma następującego kodu:
<Binding.ValidationRules>
    <ExceptionValidationRule />
</Binding.ValidationRules>

Jeżeli chcemy za pomocą tego typu walidacji wykrywać błędy konwersji wartości pomiędzy kontrolką a właściwością źródłową musimy zastosować pewne mało eleganckie rozwiązanie. Mianowicie, mając pole prywatne typu int tworzymy do niego właściwość typu string, w której podczas przypisywania wartości sprawdzamy możliwość jej przekonwertowania na typ int i ewentualnie zgłaszamy wyjątek.

Walidacja przy wykorzystaniu własnych obiektów reguł (ValidationRule)

Ostatnim rozwiązaniem jest stworzenie własnej klasy implementującej reguły walidacji. Klasa taka musi dziedziczyć po klasie ValidationRule. Za przeprowadzenie walidacji odpowiada metoda Validate zwracająca wynik walidacji w postaci obiektu ValidationResult. W celu walidacji pól liczbowych stworzyłem klasę IntegerValidation, w której umieściłem właściwości MinValue, MaxValue oraz ErrorMessage umożliwiające sterowanie procesem walidacji:

public class IntegerValidation : ValidationRule
{
    public int? MinValue { get; set; }
    public int? MaxValue { get; set; }
    public string ErrorMessage { get; set; }

    public override ValidationResult Validate(object value, CultureInfo cultureInfo)
    {
        int i;
        if (!int.TryParse(value.ToString(), out i))
            return new ValidationResult(false, "Podana wartość nie jest liczbą!");
        else
            if (i < (MinValue ?? i) || i > (MaxValue ?? i))
                return new ValidationResult(false, ErrorMessage);
            else
                return ValidationResult.ValidResult;
    }
}

Obiektu klasy IntegerValidation użyjemy w kontrolce powiązanej z właściwością Age:

<TextBox Height="23" HorizontalAlignment="Left" Margin="81,119,0,0"
         Name="tbAge" VerticalAlignment="Top" Width="93"
         Validation.ErrorTemplate="{StaticResource errorTemplate}"
         Style="{StaticResource textBoxError}">
    <TextBox.Text>
        <Binding Path="Age" UpdateSourceTrigger="PropertyChanged" >
            <Binding.ValidationRules>
                <l:IntegerValidation ValidatesOnTargetUpdated="True"
                                     ValidationStep="RawProposedValue"
                                     MinValue="18"
                                     ErrorMessage="Osoba musi być pełnoletnia!" />
            </Binding.ValidationRules>
        </Binding>
    </TextBox.Text>
</TextBox>

Pojawiły się w niej cztery nowe elementy:

  • Validation.ErrorTemplate – szablon ustawiany w przypadku błędu walidacji (przypisujemy obiekt zdefiniowany wcześniej w zasobach widoku)
  • Style – przypisanie stylu zdefiniowanego w zasobach widoku
  • UpdateSourceTrigger – opcja pozwala na określenie, w którym momencie następuje aktualizacja źródła i uruchomienie walidacji
  • Binding.ValidationRules – użycie obiektu IntegerValidation w celu walidacji danych

Dodatkowo za pomocą właściwości ValidationStep reguły walidacji możemy określić, w którym momencie walidacja będzie uruchamiana:

  • RawProposedValue – przed konwersją wartości na docelowy typ i przed ustawieniem właściwości (parametr value metody Validate zawiera wartość przed konwersją)
  • ConvertedProposedValue – po konwersji wartości na docelowy typ, przed ustawieniem właściwości (parametr value metody Validate zawiera skonwertowaną wartość)
  • UpdatedValue – po ustawieniu właściwości (parametr value metody Validate udostępnia obiekt BindingExpression gdzie DataItem/ResolvedSource to referencja do obiektu zawierającego modyfikowaną właściwość, ResolvedSourcePropertyName to nazwa tej właściwości)
  • CommittedValue – po zatwierdzeniu wartości we właściwości (parametr value metody Validate udostępnia obiekt BindingExpression gdzie DataItem/ResolvedSource to referencja do obiektu zawierającego modyfikowaną właściwość, ResolvedSourcePropertyName to nazwa tej właściwości)

Dwie pierwsze opcje pozwalają walidować wartości przed przypisaniem ich do docelowej właściwości ale nie dają możliwości odwołania się do obiektu zawierającego modyfikowaną właściwość (ViewModel). Z kolei dwie pozostałe opcje nie pozwalają na wykrycie błędów konwersji ponieważ walidacja uruchamiana jest już po przypisaniu wartości do źródła.

Blokowanie wybranych kontrolek przy błędach walidacji

Jeżeli mamy już zaimplementowane mechanizmy walidacji danych, w prosty sposób możemy wpływać na możliwość wykonania pewnych akcji na widoku. W naszym oknie zablokujemy przycisk Zapisz w przypadku błędnie wprowadzonych danych. Do tego celu możemy użyć wyzwalaczy operujących na wielu warunkach. Na podstawie właściwości Validation.HasError poszczególnych kontrolek ustawiamy właściwość IsEnabled przycisku:

<Button Content="Zapisz" Height="23" HorizontalAlignment="Left"
        Margin="105,188,0,0" Name="btSave" VerticalAlignment="Top" Width="75">
    <Button.Style>
        <Style TargetType="Button">
            <Setter Property="IsEnabled" Value="false" />
            <Style.Triggers>
                <MultiDataTrigger>
                    <Setter Property="IsEnabled" Value="true" />
                    <MultiDataTrigger.Conditions>
                        <Condition Binding="{Binding ElementName=tbFirstName,
                            Path=(Validation.HasError)}" Value="false" />
                        <Condition Binding="{Binding ElementName=tbLastName,
                            Path=(Validation.HasError)}" Value="false" />
                        <Condition Binding="{Binding ElementName=tbAge,
                            Path=(Validation.HasError)}" Value="false" />
                    </MultiDataTrigger.Conditions>
                </MultiDataTrigger>
            </Style.Triggers>
        </Style>
    </Button.Style>
</Button>

Oto efekt końcowy:

View

Podsumowanie

Jak widać mamy sporo możliwości jeżeli chodzi o weryfikację poprawności wprowadzanych danych. Opisane przeze mnie mechanizmy pozwalają na tworzenie dowolnych kombinacji reguł walidacyjnych. Co więcej, w jednej kontrolce możemy stosować wiele reguł jednocześnie. Osobiście preferuję podejście, w którym za wykrywanie błędów konwersji lub wymuszenie formatu odpowiadają własne obiekty reguł (ValidationRule), natomiast za biznesową poprawność danych odpowiadają obiekty logiki biznesowej wykorzystywane poprzez implementację interfejsu IDataErrorInfo w modelu widoku.

Więcej informacji można znaleźć na MSDN: Validation Class, ValidationRule ClassIDataErrorInfo Interface, Binding.ValidatesOnDataErrors Property, Binding.ValidatesOnExceptions Property.