Desktop UI

TreeView та GridView

Ієрархічні та табличні контроли для відображення складних структур даних

TreeView та GridView

Коли дані мають ієрархічну структуру — файлова система, організаційна структура компанії, меню навігації — звичайний список не підходить. Для таких сценаріїв WPF надає TreeView — контрол, що дозволяє відображати деревоподібні структури з можливістю згортання та розгортання вузлів.

У цій статті ми також розглянемо ListView з GridView — легковісну альтернативу DataGrid для табличного відображення даних без редагування, та навчимося реалізовувати Drag and Drop між контролами.

Для кого ця стаття?Ця стаття призначена для студентів, які вже знайомі з базовими контролами колекцій (ListBox, DataGrid) та розуміють концепції DataTemplate і HierarchicalDataTemplate. Якщо ви вже працювали з плоскими списками даних, ви готові до вивчення ієрархічних структур.

TreeView: дерева даних

TreeView — це контрол для відображення ієрархічних даних, де кожен елемент може мати дочірні елементи, які, у свою чергу, можуть мати свої дочірні елементи, і так далі.

Базова структура TreeView

Найпростіший спосіб створити TreeView — вручну визначити елементи в XAML:

Loading Avalonia WebAssembly...

Downloading .NET runtime (10MB)...

HierarchicalDataTemplate: прив'язка до даних

Для реальних застосунків дані зазвичай приходять з моделі. HierarchicalDataTemplate дозволяє визначити, як відображати кожен рівень ієрархії:

Спочатку створимо модель даних:

public class FileSystemItem : INotifyPropertyChanged
{
    private bool _isExpanded;
    
    public string Name { get; set; }
    public FileSystemItemType Type { get; set; }
    public ObservableCollection<FileSystemItem> Children { get; set; }
    
    public bool IsExpanded
    {
        get => _isExpanded;
        set
        {
            _isExpanded = value;
            OnPropertyChanged();
        }
    }
    
    public string Icon => Type switch
    {
        FileSystemItemType.Folder => "📁",
        FileSystemItemType.Document => "📄",
        FileSystemItemType.Image => "🖼️",
        FileSystemItemType.Audio => "🎵",
        FileSystemItemType.Video => "🎬",
        _ => "📄"
    };
    
    public FileSystemItem()
    {
        Children = new ObservableCollection<FileSystemItem>();
    }
    
    public event PropertyChangedEventHandler PropertyChanged;
    protected void OnPropertyChanged([CallerMemberName] string name = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
    }
}

public enum FileSystemItemType
{
    Folder,
    Document,
    Image,
    Audio,
    Video
}

ViewModel:

public class FileExplorerViewModel : INotifyPropertyChanged
{
    public ObservableCollection<FileSystemItem> RootItems { get; set; }
    
    public FileExplorerViewModel()
    {
        RootItems = new ObservableCollection<FileSystemItem>
        {
            new FileSystemItem
            {
                Name = "Документи",
                Type = FileSystemItemType.Folder,
                IsExpanded = true,
                Children = new ObservableCollection<FileSystemItem>
                {
                    new FileSystemItem { Name = "Звіт.docx", Type = FileSystemItemType.Document },
                    new FileSystemItem { Name = "Презентація.pptx", Type = FileSystemItemType.Document },
                    new FileSystemItem
                    {
                        Name = "Архів",
                        Type = FileSystemItemType.Folder,
                        Children = new ObservableCollection<FileSystemItem>
                        {
                            new FileSystemItem { Name = "Старий_звіт.docx", Type = FileSystemItemType.Document }
                        }
                    }
                }
            },
            new FileSystemItem
            {
                Name = "Зображення",
                Type = FileSystemItemType.Folder,
                Children = new ObservableCollection<FileSystemItem>
                {
                    new FileSystemItem { Name = "Фото1.jpg", Type = FileSystemItemType.Image },
                    new FileSystemItem { Name = "Фото2.png", Type = FileSystemItemType.Image }
                }
            }
        };
    }
    
    public event PropertyChangedEventHandler PropertyChanged;
}

XAML з HierarchicalDataTemplate:

Loading Avalonia WebAssembly...

Downloading .NET runtime (10MB)...

Ключові властивості HierarchicalDataTemplate
  • ItemsSource — прив'язка до колекції дочірніх елементів (наприклад, {Binding Children})
  • Вміст шаблону — як відображати кожен вузол
  • ItemTemplate — вкладений шаблон для дочірніх елементів (якщо структура різна на різних рівнях)

IsExpanded: контроль згортання

Властивість IsExpanded контролює, чи розгорнутий вузол:

<TreeView.ItemContainerStyle>
    <Style Selector="TreeViewItem">
        <!-- TwoWay binding для синхронізації з ViewModel -->
        <Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}" />
    </Style>
</TreeView.ItemContainerStyle>

Програмне керування:

// Розгорнути всі вузли
public void ExpandAll(FileSystemItem item)
{
    item.IsExpanded = true;
    foreach (var child in item.Children)
    {
        ExpandAll(child);
    }
}

// Згорнути всі вузли
public void CollapseAll(FileSystemItem item)
{
    item.IsExpanded = false;
    foreach (var child in item.Children)
    {
        CollapseAll(child);
    }
}

Lazy Loading: завантаження на вимогу

Для великих дерев (наприклад, файлова система з тисячами файлів) завантажуйте дочірні елементи тільки при розгортанні вузла:

public class FileSystemItem : INotifyPropertyChanged
{
    private bool _isExpanded;
    private bool _childrenLoaded;
    
    public bool IsExpanded
    {
        get => _isExpanded;
        set
        {
            _isExpanded = value;
            OnPropertyChanged();
            
            // Завантажити дочірні елементи при першому розгортанні
            if (value && !_childrenLoaded && Type == FileSystemItemType.Folder)
            {
                LoadChildren();
            }
        }
    }
    
    private void LoadChildren()
    {
        _childrenLoaded = true;
        
        // Видалити placeholder
        Children.Clear();
        
        // Завантажити реальні дочірні елементи
        try
        {
            var directoryInfo = new DirectoryInfo(FullPath);
            
            foreach (var dir in directoryInfo.GetDirectories())
            {
                Children.Add(new FileSystemItem
                {
                    Name = dir.Name,
                    FullPath = dir.FullName,
                    Type = FileSystemItemType.Folder,
                    Children = new ObservableCollection<FileSystemItem>
                    {
                        // Placeholder для показу стрілки розгортання
                        new FileSystemItem { Name = "Loading..." }
                    }
                });
            }
            
            foreach (var file in directoryInfo.GetFiles())
            {
                Children.Add(new FileSystemItem
                {
                    Name = file.Name,
                    FullPath = file.FullName,
                    Type = GetFileType(file.Extension)
                });
            }
        }
        catch (UnauthorizedAccessException)
        {
            // Немає доступу до папки
            Children.Add(new FileSystemItem { Name = "Access Denied" });
        }
    }
    
    private FileSystemItemType GetFileType(string extension)
    {
        return extension.ToLower() switch
        {
            ".jpg" or ".png" or ".gif" => FileSystemItemType.Image,
            ".mp3" or ".wav" => FileSystemItemType.Audio,
            ".mp4" or ".avi" => FileSystemItemType.Video,
            _ => FileSystemItemType.Document
        };
    }
}
Placeholder для Lazy LoadingЩоб показати стрілку розгортання біля папки (навіть якщо дочірні елементи ще не завантажені), додайте один placeholder-елемент до Children. При розгортанні замініть його реальними даними.

SelectedItem: робота з виділенням

TreeView підтримує виділення елементів:

<TreeView ItemsSource="{Binding RootItems}"
          SelectedItem="{Binding SelectedItem, Mode=TwoWay}">
    <!-- ItemTemplate -->
</TreeView>

ViewModel:

public class FileExplorerViewModel : INotifyPropertyChanged
{
    private FileSystemItem _selectedItem;
    
    public FileSystemItem SelectedItem
    {
        get => _selectedItem;
        set
        {
            _selectedItem = value;
            OnPropertyChanged();
            
            // Реакція на зміну виділення
            if (value != null)
            {
                Debug.WriteLine($"Виділено: {value.Name}");
                LoadDetails(value);
            }
        }
    }
    
    private void LoadDetails(FileSystemItem item)
    {
        // Завантажити деталі про файл/папку
        // Показати у панелі деталей
    }
}

Стилізація TreeView

Ви можете повністю налаштувати вигляд TreeView:

Loading Avalonia WebAssembly...

Downloading .NET runtime (10MB)...

ListView + GridView: табличний вигляд

ListView з GridView — це легковісна альтернатива DataGrid для відображення табличних даних без редагування.

Базовий GridView

Loading Avalonia WebAssembly...

Downloading .NET runtime (10MB)...

Avalonia vs WPF: В Avalonia немає класу ListView з GridView як у WPF. Замість цього використовується ListBox з кастомним DataTemplate для імітації колонок, або DataGrid для повноцінних табличних даних.

Порівняння: DataGrid vs ListView+GridView

ФункціяDataGridListView + GridView
Редагування inline✅ Вбудоване❌ Немає
Сортування✅ Автоматичне⚠️ Ручна реалізація
Валідація✅ Вбудована❌ Немає
Продуктивність✅ Віртуалізація✅ Віртуалізація
Складність⚠️ Середня✅ Проста
Кастомізація⚠️ Обмежена✅ Повна свобода
ВикористанняРедагування данихТільки перегляд

Коли використовувати ListView+GridView:

  • Тільки перегляд даних (без редагування)
  • Потрібен повний контроль над виглядом
  • Простіший код без зайвої функціональності
  • Легковісний інтерфейс

Коли використовувати DataGrid:

  • Потрібне редагування даних
  • Потрібне автоматичне сортування
  • Потрібна валідація
  • Табличні дані з багатьма колонками

Drag and Drop: перетягування елементів

Drag and Drop дозволяє користувачам перетягувати елементи між контролами або всередині одного контролу.

Базова реалізація Drag and Drop

Loading Avalonia WebAssembly...

Downloading .NET runtime (10MB)...

Реалізація у code-behind:

public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
        
        var sourceListBox = this.FindControl<ListBox>("SourceListBox");
        var targetListBox = this.FindControl<ListBox>("TargetListBox");
        
        // Налаштування джерела (звідки перетягуємо)
        sourceListBox.PointerPressed += (s, e) =>
        {
            if (e.GetCurrentPoint(sourceListBox).Properties.IsLeftButtonPressed)
            {
                var item = sourceListBox.SelectedItem;
                if (item != null)
                {
                    var data = new DataObject();
                    data.Set("TaskItem", item);
                    DragDrop.DoDragDrop(e, data, DragDropEffects.Move);
                }
            }
        };
        
        // Налаштування цілі (куди перетягуємо)
        targetListBox.AddHandler(DragDrop.DropEvent, (s, e) =>
        {
            var data = e.Data.Get("TaskItem");
            if (data != null)
            {
                // Видалити з джерела
                var sourceItems = (ObservableCollection<string>)sourceListBox.ItemsSource;
                sourceItems.Remove(data.ToString());
                
                // Додати до цілі
                var targetItems = (ObservableCollection<string>)targetListBox.ItemsSource;
                targetItems.Add(data.ToString());
            }
        });
        
        targetListBox.AddHandler(DragDrop.DragOverEvent, (s, e) =>
        {
            // Дозволити drop
            e.DragEffects = DragDropEffects.Move;
        });
    }
}

MVVM-friendly Drag and Drop

Для MVVM-підходу використовуйте Attached Behaviors або Commands:

public class DragDropBehavior
{
    public static readonly DependencyProperty IsDragSourceProperty =
        DependencyProperty.RegisterAttached(
            "IsDragSource",
            typeof(bool),
            typeof(DragDropBehavior),
            new PropertyMetadata(false, OnIsDragSourceChanged));
    
    public static bool GetIsDragSource(DependencyObject obj)
    {
        return (bool)obj.GetValue(IsDragSourceProperty);
    }
    
    public static void SetIsDragSource(DependencyObject obj, bool value)
    {
        obj.SetValue(IsDragSourceProperty, value);
    }
    
    private static void OnIsDragSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        if (d is ListBox listBox && (bool)e.NewValue)
        {
            listBox.PointerPressed += ListBox_PointerPressed;
        }
    }
    
    private static void ListBox_PointerPressed(object sender, PointerPressedEventArgs e)
    {
        var listBox = sender as ListBox;
        var item = listBox?.SelectedItem;
        
        if (item != null)
        {
            var data = new DataObject();
            data.Set("Item", item);
            DragDrop.DoDragDrop(e, data, DragDropEffects.Move);
        }
    }
}

Використання в XAML:

<ListBox ItemsSource="{Binding AvailableTasks}"
         local:DragDropBehavior.IsDragSource="True" />

Візуальний feedback при Drag

Для кращого UX додайте візуальний feedback:

// Зміна курсору
targetListBox.AddHandler(DragDrop.DragOverEvent, (s, e) =>
{
    if (e.Data.Contains("TaskItem"))
    {
        e.DragEffects = DragDropEffects.Move;
        // Курсор автоматично змінюється на "move"
    }
    else
    {
        e.DragEffects = DragDropEffects.None;
        // Курсор "заборонено"
    }
});

// Підсвічування цільового контролу
targetListBox.AddHandler(DragDrop.DragEnterEvent, (s, e) =>
{
    targetListBox.Background = new SolidColorBrush(Color.FromRgb(229, 231, 235));
});

targetListBox.AddHandler(DragDrop.DragLeaveEvent, (s, e) =>
{
    targetListBox.Background = Brushes.White;
});

🔵 Recap: Рекурсія та дерева даних

Для студентів, які тільки опановують програмування, важливо зрозуміти концепцію рекурсії, яка лежить в основі роботи з деревами:

Рекурсія — це коли функція викликає саму себе. Це ідеальний підхід для роботи з деревоподібними структурами:

// Рекурсивний підрахунок всіх файлів у дереві
public int CountAllFiles(FileSystemItem item)
{
    int count = 0;
    
    // Базовий випадок: якщо це файл, повертаємо 1
    if (item.Type != FileSystemItemType.Folder)
        return 1;
    
    // Рекурсивний випадок: підраховуємо файли у всіх дочірніх елементах
    foreach (var child in item.Children)
    {
        count += CountAllFiles(child);  // Виклик самої себе!
    }
    
    return count;
}

// Рекурсивний пошук файлу по імені
public FileSystemItem FindFile(FileSystemItem root, string fileName)
{
    // Базовий випадок: знайшли файл
    if (root.Name == fileName)
        return root;
    
    // Рекурсивний випадок: шукаємо у дочірніх елементах
    foreach (var child in root.Children)
    {
        var result = FindFile(child, fileName);
        if (result != null)
            return result;
    }
    
    return null;  // Не знайдено
}

Ключові концепції рекурсії:

  1. Базовий випадок — умова зупинки рекурсії (інакше нескінченний цикл)
  2. Рекурсивний випадок — виклик функції самої себе з меншою задачею
  3. Call Stack — кожен виклик зберігається в стеку, тому глибока рекурсія може призвести до StackOverflowException

Практичні завдання

Рівень 1: Файловий браузер з TreeView

Мета: Навчитися створювати ієрархічні структури даних та відображати їх через TreeView.

Завдання: Створіть простий файловий браузер, що показує структуру папок.

Вимоги:

  • Створіть клас FolderItem з властивостями: Name (string), Path (string), Children (ObservableCollection)
  • Використайте HierarchicalDataTemplate для відображення
  • Додайте іконки для папок (📁) та файлів (📄)
  • Реалізуйте IsExpanded binding для збереження стану розгортання
  • Додайте SelectedItem binding для показу шляху виділеного елемента
  • Створіть тестові дані з 3 рівнями вкладеності

Підказка:

var root = new FolderItem
{
    Name = "C:\\",
    Children = new ObservableCollection<FolderItem>
    {
        new FolderItem
        {
            Name = "Program Files",
            Children = new ObservableCollection<FolderItem>
            {
                new FolderItem { Name = "App1" },
                new FolderItem { Name = "App2" }
            }
        }
    }
};

Рівень 2: Таблиця контактів через ListView

Мета: Опанувати створення табличного вигляду через ListView з кастомним DataTemplate.

Завдання: Створіть адресну книгу з табличним відображенням контактів.

Вимоги:

  • Створіть клас Contact з властивостями:
    • Name (string)
    • Email (string)
    • Phone (string)
    • Company (string)
    • IsFavorite (bool)
  • Використайте ListBox з DataTemplate для імітації колонок
  • Додайте заголовки колонок (окремий Grid над ListBox)
  • Реалізуйте сортування по імені (кнопка в заголовку)
  • Додайте фільтр "Тільки обрані" (CheckBox)
  • Стилізуйте рядки: обрані контакти мають золоту зірку ⭐
  • Додайте hover-ефект для рядків

Додаткові виклики:

  • Пошук по імені через TextBox
  • Експорт у CSV
  • Групування по компанії

Рівень 3: Drag & Drop між TreeView та ListBox

Мета: Реалізувати складну взаємодію з Drag and Drop між різними контролами.

Завдання: Створіть систему управління проєктами з можливістю перетягування завдань.

Вимоги:

  • Ліворуч: TreeView з проєктами та їх категоріями (ієрархія)
  • Праворуч: ListBox з завданнями поточної категорії
  • Реалізуйте Drag and Drop:
    • Перетягування завдань між категоріями (TreeView → TreeView)
    • Перетягування завдань зі списку в категорію (ListBox → TreeView)
    • Перетягування завдань для зміни порядку (ListBox → ListBox)
  • Додайте візуальний feedback:
    • Підсвічування цільової категорії при наведенні
    • Зміна курсору (move/copy/none)
    • Анімація при drop
  • Реалізуйте через MVVM (без code-behind)
  • Збережіть структуру при закритті застосунку (JSON)

Структура даних:

public class Project
{
    public string Name { get; set; }
    public ObservableCollection<Category> Categories { get; set; }
}

public class Category
{
    public string Name { get; set; }
    public ObservableCollection<Task> Tasks { get; set; }
}

public class Task
{
    public string Title { get; set; }
    public string Description { get; set; }
    public TaskPriority Priority { get; set; }
    public bool IsCompleted { get; set; }
}

Додаткові виклики:

  • Підтримка Ctrl+Drag для копіювання (замість переміщення)
  • Drag and Drop файлів з Explorer у застосунок
  • Undo/Redo для операцій перетягування
  • Анімація переміщення елемента

Резюме

У цій статті ми детально розібрали роботу з ієрархічними та табличними контролами:

TreeView:

  • TreeViewItem для ручного визначення структури
  • HierarchicalDataTemplate для прив'язки до даних
  • ItemsSource вказує на колекцію дочірніх елементів
  • IsExpanded для контролю згортання/розгортання
  • Lazy Loading для великих дерев (завантаження на вимогу)
  • SelectedItem для роботи з виділенням
  • Стилізація через ItemContainerStyle

ListView + GridView:

  • Легковісна альтернатива DataGrid для перегляду даних
  • Повний контроль над виглядом через DataTemplate
  • Порівняння з DataGrid: простіший, але без редагування
  • Використання для read-only табличних даних

Drag and Drop:

  • DragDrop.DoDragDrop() для початку перетягування
  • DragEnter, DragOver, Drop події для обробки
  • DragEffects для контролю типу операції (Move, Copy, None)
  • Візуальний feedback для покращення UX
  • MVVM-friendly підхід через Attached Behaviors

Рекурсія:

  • Базовий випадок для зупинки
  • Рекурсивний випадок для обходу дерева
  • Застосування для підрахунку, пошуку, обходу

Наступні кроки: У наступній статті ми розглянемо систему меню, панелей інструментів та контекстних меню для створення повноцінного інтерфейсу застосунку.

Глосарій

Основні терміни:
  • TreeView — контрол для відображення ієрархічних даних у вигляді дерева
  • TreeViewItem — елемент дерева (вузол)
  • HierarchicalDataTemplate — шаблон для рекурсивного відображення ієрархічних даних
  • IsExpanded — властивість, що контролює розгортання вузла
  • Lazy Loading — відкладене завантаження даних на вимогу
  • ListView — контрол для відображення списків з підтримкою різних режимів
  • GridView — режим відображення ListView у вигляді таблиці
  • Drag and Drop — перетягування елементів мишею
  • DragEffects — тип операції перетягування (Move, Copy, Link, None)
  • DataObject — контейнер для даних, що передаються при Drag and Drop
  • Рекурсія — виклик функції самої себе для обробки вкладених структур
  • Call Stack — стек викликів функцій

Додаткові ресурси

📚 Microsoft Docs: TreeView

Офіційна документація по TreeView з прикладами та best practices

🎨 WPF Tutorial: HierarchicalDataTemplate

Детальний туторіал по прив'язці даних до TreeView

⚡ Drag and Drop in WPF

Повний гайд по реалізації Drag and Drop у WPF

🔧 GitHub: TreeView Examples

Офіційні приклади складних сценаріїв використання TreeView