Dataniedziela, 9 sierpnia 2026 Czas07:10:12
← Aplikacje mobilne (.NET MAUI)

Lekcja 15. Wyświetlanie list danych — CollectionView, binding i ItemTemplate

Trudny / egzaminacyjny

Po co się tego uczymy?

W lekcji o kontrolkach poznałeś CollectionView jako "kontrolkę do list". Teraz czas zobaczyć, jak NAPRAWDĘ się jej używa w praktyce: skąd CollectionView bierze dane, jak wygląda pojedynczy wiersz listy, i jak sprawić, żeby lista sama odświeżała się po zmianie danych — bez ręcznego przebudowywania całego widoku. To jeden z najczęściej potrzebnych mechanizmów w prawdziwych aplikacjach mobilnych (lista produktów, kontaktów, zadań, wiadomości) i częsty temat zadań egzaminacyjnych INF.04.6.

Teoria

Wyobraź sobie, że tworzysz aplikację, w której chcesz wyświetlić listę książek, produktów albo zadań do zrobienia. Nie chcesz ręcznie tworzyć osobnej kontrolki Label dla każdej pozycji — chcesz, żeby program SAM wypełnił listę na podstawie danych. Do tego służy mechanizm złożony z czterech współpracujących elementów: model danych (klasa opisująca jedną pozycję, np. Ksiazka), kolekcja (zbiór obiektów modelu, najlepiej ObservableCollection<T>), DataTemplate (opisuje wygląd JEDNEGO elementu listy) i binding (automatyczne powiązanie danych z interfejsem).

Zwykła List<T> a ObservableCollection<T>. Zwykła List<string> świetnie nadaje się do przechowywania danych w kodzie, ale NIE odświeża automatycznie interfejsu, gdy coś się w niej zmieni (dodanie, usunięcie elementu). ObservableCollection<T> (z System.Collections.ObjectModel) działa tak samo jak lista, ale DODATKOWO informuje podpięty do niej CollectionView o każdej zmianie — dzięki temu wystarczy wywołać Ksiazki.Add(nowaKsiazka), a lista na ekranie natychmiast pokaże nową pozycję, bez żadnego ręcznego odświeżania.

Odświeżanie POJEDYNCZEJ właściwości — interfejs INotifyPropertyChanged. ObservableCollection powiadamia o dodaniu/usunięciu całego elementu, ale co, gdy zmienia się tylko JEDNO pole istniejącego obiektu (np. status "Wypożyczona" zmienia się na "Dostępna")? Do tego model musi implementować interfejs INotifyPropertyChanged — każdy set właściwości wywołuje wtedy zdarzenie PropertyChanged, informując interfejs, KTÓRA konkretnie właściwość się zmieniła, żeby odświeżyć tylko ten fragment ekranu:

public class Ksiazka : INotifyPropertyChanged
{
    private bool _wypozyczona;

    public bool Wypozyczona
    {
        get => _wypozyczona;
        set { _wypozyczona = value; OnPropertyChanged(); OnPropertyChanged(nameof(StatusOpis)); }
    }

    public string StatusOpis => Wypozyczona ? "Wypożyczona" : "Dostępna";

    public event PropertyChangedEventHandler? PropertyChanged;
    void OnPropertyChanged([CallerMemberName] string? nazwa = null)
        => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nazwa));
}

Atrybut [CallerMemberName] sprawia, że parametr nazwa automatycznie przyjmuje nazwę właściwości, z której metoda została wywołana — nie trzeba jej ręcznie wpisywać jako string (co łatwo popsuć literówką). Zwróć uwagę na StatusOpis — to właściwość WYLICZANA (bez set, tylko =>), która zależy od Wypozyczona; dlatego w setterze Wypozyczona ręcznie wywołujemy DRUGIE OnPropertyChanged(nameof(StatusOpis)), żeby interfejs wiedział, że ta zależna właściwość też się zmieniła.

CollectionView i DataTemplate. Właściwość ItemsSource wskazuje kolekcję do wyświetlenia, a CollectionView.ItemTemplate (jako DataTemplate) opisuje wygląd JEDNEGO wiersza — MAUI automatycznie "sztancuje" ten szablon dla każdego elementu kolekcji. Atrybut x:DataType="models:Ksiazka" na DataTemplate mówi kompilatorowi, jakiego typu obiektu dotyczy binding wewnątrz — dzięki temu błędy w nazwach właściwości (np. literówka w {Binding Tytuł}) są wykrywane already przy kompilacji, a nie dopiero w czasie działania aplikacji.

Odczytanie, KTÓREGO elementu dotyczy kliknięcie w wierszu. Gdy przycisk wewnątrz szablonu wiersza zostanie kliknięty, trzeba dowiedzieć się, do którego obiektu z kolekcji ten konkretny wiersz się odnosi. W MAUI robi się to przez BindingContext obiektu, który wywołał zdarzenie: var ksiazka = (sender as BindableObject)?.BindingContext as Ksiazka; — każdy wiersz listy ma swój WŁASNY BindingContext ustawiony automatycznie na odpowiadający mu element kolekcji.

Filtrowanie listy (np. przez SearchBar). Prosta i skuteczna technika: trzymaj gdzieś ORYGINALNĄ, pełną listę (np. w polu klasy), a przy każdej zmianie tekstu wyszukiwania twórz NOWĄ, przefiltrowaną kolekcję przez LINQ (.Where(...)) i podstawiaj ją do ItemsSource. Gdy pole wyszukiwania jest puste, przywracasz oryginalną listę.

Schemat

Modele/Ksiazka.cs                  Logika/BibliotekaViewModel.cs
(implementuje                       ObservableCollection<Ksiazka> Ksiazki
 INotifyPropertyChanged)                     │
        │                                    │ ItemsSource = vm.Ksiazki
        │  właściwości: Tytul,                ▼
        │  Autor, Wypozyczona,        MainPage.xaml — CollectionView
        │  StatusOpis (wyliczana)             │
        │                                     │ ItemTemplate → DataTemplate
        └─────────────────────────────────────┘   dla KAŻDEJ pozycji z kolekcji
                                                    │
                                            {Binding Tytul}, {Binding Autor}...
                                                    │
                                     klik przycisku w wierszu
                                                    ▼
                (sender as BindableObject).BindingContext as Ksiazka
                                                    │
                                     ks.Wypozyczona = !ks.Wypozyczona
                                                    ▼
                            PropertyChanged → CollectionView odświeża TYLKO ten wiersz

Przykład z życia

Aplikacja biblioteczna wyświetla listę książek z tytułem, autorem i statusem wypożyczenia, z przyciskiem "Wypożycz"/"Zwróć" przy każdej pozycji oraz paskiem wyszukiwania na górze — dokładnie tak działa lista produktów w aplikacji sklepowej, lista kontaktów w komunikatorze czy lista zadań w aplikacji do organizacji pracy. We wszystkich tych przypadkach mechanizm jest identyczny: model + kolekcja obserwowalna + szablon wiersza + binding.

MainPage.xaml

<!-- MainPage.xaml -->
<ContentPage x:Class="BibliotekaApp.MainPage"
             xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:models="clr-namespace:BibliotekaApp.Modele"
             Title="Lista książek">

    <VerticalStackLayout Padding="20">

        <SearchBar Placeholder="Szukaj książki..." TextChanged="Szukaj_TextChanged" />

        <CollectionView x:Name="cvKsiazki">
            <CollectionView.ItemTemplate>
                <DataTemplate x:DataType="models:Ksiazka">
                    <Grid ColumnDefinitions="*,Auto" Padding="12" Margin="0,4">
                        <VerticalStackLayout>
                            <Label Text="{Binding Tytul}" FontAttributes="Bold" />
                            <Label Text="{Binding Autor}" />
                            <Label Text="{Binding StatusOpis}" />
                        </VerticalStackLayout>
                        <Button Grid.Column="1"
                                Text="{Binding AkcjaTekst}"
                                Clicked="ZmienStatus_Click" />
                    </Grid>
                </DataTemplate>
            </CollectionView.ItemTemplate>
        </CollectionView>

    </VerticalStackLayout>
</ContentPage>

Modele/Ksiazka.cs + Logika/BibliotekaViewModel.cs + MainPage.xaml.cs

// Modele/Ksiazka.cs - model z powiadamianiem o zmianach
using System.ComponentModel;
using System.Runtime.CompilerServices;

namespace BibliotekaApp.Modele;

public class Ksiazka : INotifyPropertyChanged
{
    private string _tytul = "";
    private string _autor = "";
    private bool _wypozyczona;

    public string Tytul { get => _tytul; set { _tytul = value; OnPropertyChanged(); } }
    public string Autor { get => _autor; set { _autor = value; OnPropertyChanged(); } }

    public bool Wypozyczona
    {
        get => _wypozyczona;
        set
        {
            _wypozyczona = value;
            OnPropertyChanged();
            OnPropertyChanged(nameof(StatusOpis));
            OnPropertyChanged(nameof(AkcjaTekst));
        }
    }

    public string StatusOpis => Wypozyczona ? "Wypożyczona" : "Dostępna";
    public string AkcjaTekst => Wypozyczona ? "Zwróć" : "Wypożycz";

    public event PropertyChangedEventHandler? PropertyChanged;
    void OnPropertyChanged([CallerMemberName] string? nazwa = null)
        => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nazwa));
}

// ---------------------------------------------------------------------

// Logika/BibliotekaViewModel.cs - trzyma kolekcję książek
using System.Collections.ObjectModel;
using BibliotekaApp.Modele;

namespace BibliotekaApp.Logika;

public class BibliotekaViewModel
{
    public ObservableCollection<Ksiazka> Ksiazki { get; }

    public BibliotekaViewModel()
    {
        Ksiazki = new ObservableCollection<Ksiazka>
        {
            new Ksiazka { Tytul = "Hobbit", Autor = "J.R.R. Tolkien" },
            new Ksiazka { Tytul = "Dune", Autor = "Frank Herbert", Wypozyczona = true },
            new Ksiazka { Tytul = "1984", Autor = "George Orwell" }
        };
    }
}

// ---------------------------------------------------------------------

// MainPage.xaml.cs
using BibliotekaApp.Logika;
using BibliotekaApp.Modele;
using System.Collections.ObjectModel;

namespace BibliotekaApp;

public partial class MainPage : ContentPage
{
    private BibliotekaViewModel vm;
    private ObservableCollection<Ksiazka> oryginalnaLista;

    public MainPage()
    {
        InitializeComponent();
        vm = new BibliotekaViewModel();
        oryginalnaLista = vm.Ksiazki;
        cvKsiazki.ItemsSource = vm.Ksiazki;
    }

    private void ZmienStatus_Click(object sender, EventArgs e)
    {
        var ks = (sender as BindableObject)?.BindingContext as Ksiazka;
        if (ks != null) ks.Wypozyczona = !ks.Wypozyczona;
    }

    private void Szukaj_TextChanged(object sender, TextChangedEventArgs e)
    {
        string filtr = e.NewTextValue?.ToLower() ?? "";
        if (string.IsNullOrWhiteSpace(filtr))
        {
            cvKsiazki.ItemsSource = oryginalnaLista;
            return;
        }

        var wyniki = new ObservableCollection<Ksiazka>(
            oryginalnaLista.Where(k =>
                k.Tytul.ToLower().Contains(filtr) || k.Autor.ToLower().Contains(filtr)));
        cvKsiazki.ItemsSource = wyniki;
    }
}

Komentarz i wyjaśnienie kodu

Zwróć uwagę na trzy warstwy w tym przykładzie: Modele (czysta klasa danych z powiadamianiem o zmianach), Logika (klasa trzymająca kolekcję i tworząca dane startowe) i Widok (XAML + minimalny code-behind). To ta sama zasada oddzielenia odpowiedzialności co w lekcji o MVVM — tutaj rozszerzona o warstwę danych zbiorowych.

W MainPage.xaml.cs pole oryginalnaLista przechowuje PEŁNY zestaw książek, niezależnie od tego, co aktualnie pokazuje cvKsiazki.ItemsSource — dzięki temu funkcja wyszukiwania zawsze filtruje od nowa z PEŁNEJ listy, a nie z wyników poprzedniego filtrowania (co dawałoby błędne wyniki przy kasowaniu liter z wyszukiwarki).

ZmienStatus_Click pokazuje typowy wzorzec pobierania obiektu powiązanego z konkretnym wierszem listy: sender to przycisk, który wywołał zdarzenie, a jego BindingContext (rzutowany na BindableObject) to obiekt Ksiazka tego konkretnego wiersza — MAUI ustawia to automatycznie dzięki mechanizmowi DataTemplate.

Ćwiczenie samodzielne

Uruchom powyższy przykład, dodaj kilka własnych książek na starcie (w konstruktorze BibliotekaViewModel) i sprawdź w emulatorze: (1) czy kliknięcie przycisku "Wypożycz"/"Zwróć" poprawnie zmienia tylko status TEGO jednego wiersza, (2) czy wpisywanie tekstu w polu wyszukiwania filtruje listę na bieżąco, oraz (3) czy wyczyszczenie pola wyszukiwania przywraca pełną listę.

Zadania do pracy własnej

  1. Dodaj do modelu Ksiazka właściwość RokWydania (typ int?) i wyświetl ją w szablonie wiersza obok tytułu i autora.

  2. Dodaj nad listą przełącznik Switch "Pokaż tylko wypożyczone" — gdy jest włączony, ItemsSource pokazuje wyłącznie książki z Wypozyczona == true (użyj LINQ .Where(...), analogicznie do filtrowania wyszukiwarką).

  3. Zbuduj kompletny formularz "Dodaj książkę": nową stronę DodajKsiazkePage z polami Entry na Tytuł/Autora/Rok wydania oraz Switch na Wypożyczona, otwieraną przyciskiem z listy głównej (Navigation.PushAsync). Napisz klasę walidującą zwracającą krotkę (bool ok, List<string> bledy, Ksiazka? model) — jeśli dane są poprawne (tytuł i autor niepuste, min. 2 znaki; rok w zakresie 1450-2100, jeśli podany), metoda buduje i zwraca gotowy obiekt Ksiazka, który strona formularza dodaje bezpośrednio do vm.Ksiazki (kolekcja ObservableCollection automatycznie pokaże nową pozycję na liście po powrocie), a w razie błędów wyświetla je w czerwonej etykiecie pod przyciskiem "Zapisz".

Typowe błędy

Model bez INotifyPropertyChanged — jeśli klasa modelu nie implementuje tego interfejsu, zmiana pojedynczej właściwości (np. statusu wypożyczenia) NIE odświeży widoku, mimo że dane w obiekcie faktycznie się zmieniły; lista będzie wyglądać na "zamrożoną" do czasu ręcznego przeładowania całego ItemsSource.

Brak dodatkowego OnPropertyChanged(nameof(...)) dla właściwości WYLICZANYCH — jeśli StatusOpis zależy od Wypozyczona, ale setter Wypozyczona zgłasza zmianę TYLKO dla siebie, etykieta ze statusem nie odświeży się, mimo że sam przycisk (jeśli osobno obsłużony) może zadziałać poprawnie.

Filtrowanie bezpośrednio na ItemsSource zamiast na zapamiętanej oryginalnej liście — prowadzi do "znikających" wyników przy usuwaniu znaków z wyszukiwarki, bo każde kolejne filtrowanie zawęża już zawężony wcześniej zbiór.

Zapomnienie o x:DataType na DataTemplate — bez tego bindingi wewnątrz szablonu nadal zadziałają (przez refleksję w czasie działania), ale stracisz sprawdzanie literówek w nazwach właściwości na etapie kompilacji oraz nieco wydajności.

Nawiązanie do egzaminu zawodowego

To pełne, praktyczne rozwinięcie wymagań INF.04.6.2 dotyczących list i elementów UI, połączone z INF.04.4.5-6 (klasy, pola, właściwości, hermetyzacja przez get/set) — INotifyPropertyChanged to też dobre wprowadzenie do interfejsów jako mechanizmu "kontraktu" między klasami, co bezpośrednio łączy się z programowaniem obiektowym z wcześniejszego działu kursu.