Gdy formularz w aplikacji desktopowej ma pozwolić na wybór jednej wartości z listy, kontrolka ComboBox w WPF zwykle jest najprostszym i najbardziej czytelnym rozwiązaniem. Pokażę, jak poprawnie zasilić ją danymi, powiązać z modelem w MVVM, obsłużyć wybór użytkownika oraz uniknąć problemów z SelectedItem, edycją tekstu i walidacją.
Najważniejsze informacje o ComboBox w WPF w jednym miejscu
- ItemsSource służy do ładowania elementów z kolekcji, a DisplayMemberPath wskazuje tekst widoczny dla użytkownika.
- SelectedItem zwraca cały wybrany obiekt, natomiast SelectedValue pojedynczą wartość określoną przez SelectedValuePath.
- Tryb IsEditable="True" pozwala wpisywać tekst, ale wymaga osobnego podejścia do wyboru i walidacji.
- W aplikacji MVVM najlepiej wiązać kontrolkę z właściwościami ViewModelu zamiast obsługiwać wszystko w code-behind.
- Przy dużych zbiorach danych warto zastosować filtrowanie lub wyszukiwanie, ponieważ długa lista szybko staje się niewygodna.
Czym jest ComboBox i kiedy ma sens
ComboBox łączy pole wyboru z rozwijaną listą. Użytkownik widzi jedną aktualną wartość, a po otwarciu może wybrać inną. To dobry wybór wtedy, gdy lista ma kilka lub kilkanaście opcji, a wszystkie elementy da się opisać krótkim tekstem.
Sam najczęściej używam tej kontrolki do wyboru statusu, kategorii, kraju, użytkownika albo typu dokumentu. Nie stosuję jej jednak do bardzo długich list. Przy setkach lub tysiącach rekordów lepsze będzie pole wyszukiwania z autouzupełnianiem, bo przewijanie takiej listy jest zwyczajnie męczące.
Najprostsza wersja może zawierać elementy wpisane bezpośrednio w XAML:
Nowy
W trakcie
Zakończony
Takie rozwiązanie sprawdza się dla krótkiej, stałej listy. W aplikacji biznesowej dane zwykle pochodzą jednak z bazy, API albo ViewModelu, dlatego bardziej elastyczne jest użycie właściwości ItemsSource.
Jak zasilić kontrolkę danymi
Załóżmy, że aplikacja wyświetla listę kategorii. Model może wyglądać tak:
public class Category
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
}W ViewModelu przygotowuję kolekcję oraz właściwość przechowującą wybrany element:
public ObservableCollection Categories { get; } =
new()
{
new Category { Id = 1, Name = "Sprzęt" },
new Category { Id = 2, Name = "Oprogramowanie" },
new Category { Id = 3, Name = "Usługi" }
};
private Category? _selectedCategory;
public Category? SelectedCategory
{
get => _selectedCategory;
set
{
_selectedCategory = value;
OnPropertyChanged();
}
} W XAML wystarczy połączyć kolekcję z kontrolką i wskazać właściwość widoczną na liście:
DisplayMemberPath decyduje o tym, co użytkownik zobaczy. Bez niego WPF może wyświetlić nazwę typu, na przykład MyApp.Category, zamiast przyjaznego tekstu. W prostych przypadkach to wystarczy, ale przy bardziej rozbudowanym wyglądzie lepiej użyć ItemTemplate.
Template daje większą kontrolę, ale ma też konsekwencję. Jeśli kontrolka jest edytowalna, WPF może nie wiedzieć, którą właściwość obiektu zamienić na tekst. W takim przypadku przydaje się TextSearch.TextPath albo jawne powiązanie właściwości Text.
SelectedItem, SelectedValue i SelectedIndex nie oznaczają tego samego
To miejsce powoduje najwięcej nieporozumień. SelectedItem wskazuje cały obiekt z kolekcji, SelectedValue przechowuje wybraną wartość, a SelectedIndex oznacza numer elementu na liście.
| Właściwość | Co zwraca | Kiedy jej użyć |
|---|---|---|
| SelectedItem | Cały obiekt, na przykład Category | Gdy ViewModel potrzebuje wszystkich danych wybranego elementu |
| SelectedValue | Pojedynczą wartość, na przykład Id | Gdy formularz zapisuje tylko identyfikator |
| SelectedIndex | Numer pozycji na liście | Gdy liczy się kolejność, a nie dane obiektu |
Jeżeli model formularza powinien przechowywać wyłącznie identyfikator kategorii, konfiguracja może wyglądać tak:
W tym wariancie SelectedValuePath="Id" mówi WPF, którą właściwość pobrać z wybranego obiektu. To wygodne przy zapisie do bazy, ale nie daje bezpośredniego dostępu do pozostałych danych kategorii.
Nie polecam opierać logiki biznesowej na SelectedIndex, jeśli lista może zmieniać kolejność. Po dodaniu nowego elementu indeks 1 może wskazywać zupełnie inną wartość. Stabilniejszy jest identyfikator albo referencja do obiektu.
Edytowalny ComboBox i wyszukiwanie elementów
Ustawienie IsEditable="True" pozwala użytkownikowi wpisywać tekst. To nie oznacza jednak automatycznie, że kontrolka stanie się pełnoprawnym polem wyszukiwania. WPF nadal rozdziela wpisany tekst od wybranego elementu z kolekcji.
Ta konfiguracja pozwala rozpocząć wpisywanie nazwy i przejść do pasującego elementu. IsTextSearchEnabled włącza wyszukiwanie tekstowe, a TextSearch.TextPath wskazuje właściwość, po której WPF ma dopasowywać wpisy.
Trzeba odróżnić dwa scenariusze. W pierwszym użytkownik wpisuje tekst tylko po to, by szybciej znaleźć istniejący element. W drugim może wprowadzić całkowicie nową wartość. Ten drugi wariant wymaga sprawdzenia, czy tekst odpowiada rekordowi, oraz osobnej decyzji, co zrobić z wartością nieobecną na liście.
Gdy użytkownik może wpisywać dowolny tekst, wiążę go zwykle z właściwością Text:
Przy filtrowaniu kolekcji trzeba uważać, aby nie zmieniać bezpośrednio źródła danych w sposób, który utrudni zachowanie wybranego elementu. W praktyce najlepiej oddzielić pełną kolekcję od kolekcji filtrowanej i jasno ustalić, czy wybór następuje po każdym znaku, czy dopiero po zatwierdzeniu.
ComboBox w MVVM i typowe problemy z bindingiem
W aplikacji opartej na MVVM kontrolka nie musi mieć handlera SelectionChanged. Zmiana wyboru może aktualizować właściwość ViewModelu przez binding, a ViewModel może zareagować na zmianę w setterze albo za pomocą komendy.
Najczęstszy błąd polega na tym, że binding wskazuje niewłaściwą ścieżkę:
Jeśli Categories albo SelectedCategory znajduje się w innym obiekcie, WPF nie znajdzie danych. Wtedy trzeba sprawdzić DataContext, nazwę właściwości i implementację INotifyPropertyChanged. Bez powiadomienia interfejs może nie odświeżyć się po zmianie wartości w kodzie.
Drugi częsty problem pojawia się przy ustawianiu wartości początkowej. Wybrany obiekt powinien pochodzić z tej samej kolekcji, która zasila ItemsSource, albo mieć poprawnie skonfigurowane porównywanie. Samo utworzenie nowej instancji z tym samym Id nie zawsze wystarczy, ponieważ SelectedItem może porównywać referencję do obiektu.
W formularzach edycji rekordów ustawiam więc najpierw kolekcje, a dopiero później właściwości wyboru. Dzięki temu kontrolka ma już dostęp do elementów, kiedy otrzymuje wartość początkową. To drobna kolejność, ale eliminuje sporą grupę trudnych do zauważenia błędów.
Walidacja, pusta wartość i obsługa błędów
Jeżeli wybór jest wymagany, użytkownik powinien od razu widzieć, że pozostawienie pustej wartości jest niedozwolone. Nie traktuję pierwszego elementu listy jako domyślnego wyboru, jeśli może to prowadzić do przypadkowego zapisania błędnych danych.
Lepszym rozwiązaniem bywa pozycja typu „Wybierz kategorię” z pustą wartością albo właściwość nullable w ViewModelu. Podczas zapisu można wtedy sprawdzić, czy SelectedCategory lub CategoryId rzeczywiście zostało ustawione.
W większych aplikacjach warto oprzeć walidację na INotifyDataErrorInfo. Pozwala to zwracać komunikaty z ViewModelu i pokazywać je przy konkretnym polu, bez przenoszenia reguł biznesowych do widoku.
Trzeba też pamiętać, że walidacja wyboru i walidacja wpisanego tekstu to dwie różne rzeczy. Przy zwykłej liście wystarczy sprawdzić wybrany obiekt. Przy edytowalnym polu trzeba dodatkowo ocenić, czy tekst jest pusty, poprawny i zgodny z regułami aplikacji.
Wydajność i wygoda użytkownika przy większych listach
ComboBox jest wygodny, dopóki lista pozostaje krótka. Przy kilkudziesięciu elementach użytkownik nadal może sprawnie przewijać opcje, ale przy setkach rekordów lepsze będzie filtrowanie, paginacja albo osobne okno wyboru.
Nie ładuję całej tabeli klientów tylko po to, aby pokazać ją w rozwijanej liście. Zamiast tego pobieram ograniczony zestaw wyników po wpisaniu co najmniej kilku znaków. Ogranicza to ilość danych, przyspiesza interfejs i zmniejsza ryzyko wybrania niewłaściwej osoby.
Przy projektowaniu zwracam uwagę także na klawiaturę. Użytkownik powinien móc otworzyć listę klawiszem Alt+Strzałka w dół, przechodzić po elementach strzałkami i zatwierdzać wybór Enterem. Sama atrakcyjna lista nie pomoże, jeśli obsługa formularza wymaga ciągłego sięgania po mysz.
Warto ograniczyć długość tekstu w elementach. Jeżeli nazwy są podobne, można zastosować ItemTemplate z dodatkowym identyfikatorem lub krótkim opisem. Trzeba jednak zachować umiar, bo rozbudowana karta w każdej pozycji przestaje pasować do charakteru rozwijanej listy.
Co sprawdzić przed oddaniem formularza
- Czy ItemsSource wskazuje właściwą kolekcję i czy DataContext jest ustawiony?
- Czy użytkownik widzi przyjazną nazwę, a nie nazwę klasy .NET?
- Czy wybrany jest cały obiekt, identyfikator czy tylko indeks?
- Czy wartość początkowa jest ustawiana po załadowaniu kolekcji?
- Czy edytowalny wariant rozróżnia tekst wpisany przez użytkownika od istniejącego elementu?
- Czy pusta wartość i błędny wybór są poprawnie walidowane?
- Czy lista nie jest zbyt długa i czy można ją filtrować?
Moja praktyczna zasada jest prosta: dla krótkiej, zamkniętej listy używam zwykłego wyboru, dla obiektów domenowych wiążę SelectedItem, a dla dużych zbiorów projektuję wyszukiwanie zamiast klasycznego przewijania. Taki dobór rozwiązuje więcej problemów niż późniejsze poprawianie stylów i zdarzeń.
Dobry wybór kontrolki zaczyna się od modelu danych
ComboBox w WPF nie jest trudną kontrolką, ale wymaga świadomego wyboru między tekstem, obiektem i identyfikatorem. Najwięcej błędów bierze się nie z XAML, lecz z niejasnej odpowiedzi na pytanie, co dokładnie ma zostać zapisane po wyborze.
Jeżeli model i binding są zaprojektowane poprawnie, konfiguracja pozostaje krótka, walidacja jest przewidywalna, a interfejs zachowuje się dobrze także po zmianie danych. Właśnie od tego zacząłbym każdą implementację, zanim dodałbym własne style, zdarzenia albo rozbudowane filtrowanie.
