Правильное использование модели представления WPF

Я учу себя WPF. Мое окно имеет два поля со списком: один для категорий и один для подкатегорий. Когда выбор категории изменится, я хочу, чтобы список подкатегорий обновился до тех, которые находятся в выбранной категории.

Я создал простой класс представления для обоих полей со списком. мой SubcategoryView конструктор класса берет ссылку на мой CategoryView класс и присоединяет обработчик события, когда выбор категории изменяется.

public class SubcategoryView : INotifyPropertyChanged
{
    protected CategoryView CategoryView;

    public SubcategoryView(CategoryView categoryView)
    {
        CategoryView = categoryView;
        CategoryView.PropertyChanged += CategoryView_PropertyChanged;
    }

    private void CategoryView_PropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        if (e.PropertyName == "SelectedItem")
        {
            _itemsSource = null;
        }
    }

    private ObservableCollection<TextValuePair> _itemsSource;
    public ObservableCollection<TextValuePair> ItemsSource
    {
        get
        {
            if (_itemsSource == null)
            {
                // Populate _itemsSource
            }
            return _itemsSource;
        }
    }
}

Я назначаю свой DataContextвот так.

cboCategory.DataContext = new CategoryView();
cboSubcategory.DataContext = new SubcategoryView(cboCategory.DataContext as CategoryView);

Проблема в том, что выбор нового элемента в поле со списком моей категории не приводит к повторному заполнению подкатегорий (хотя я подтвердил, что PropertyChanged обработчик вызывается).

Как правильно создать список для повторного заполнения?

Кроме того, я приветствую любые другие комментарии об этом подходе. Вместо того, чтобы передать мой CategoryView для конструктора, лучше ли это как-то декларативно указать в XAML?

2 ответа

Решение

Вот как мы это делаем в рабочем коде.

Каждая категория знает, каковы ее подкатегории. Если они поступают из базы данных или файла на диске, метод базы данных / веб-службы / средство чтения файлов / что угодно будет возвращать классы, подобные этим, и вы создадите соответствующие модели представления. Модель представления понимает структуру информации, но не знает и не заботится о реальном содержании; кто-то другой отвечает за это.

Обратите внимание, что все это очень декларативно: единственный цикл - это тот, который подделывает демонстрационные объекты. Никаких обработчиков событий, ничего в codebehind, кроме создания модели представления и указания ей заполнять себя поддельными данными. В реальной жизни вы часто пишете обработчики событий для особых случаев (например, перетаскивание). Нет ничего не MVVMish в размещении специфичной для вида логики в коде; вот для чего это. Но этот случай слишком тривиален, чтобы это было необходимо. У нас есть ряд .xaml.cs файлы, которые годами находились в TFS точно так, как их создал мастер.

Свойства viewmodel много шаблонного. У меня есть фрагменты ( украдите их здесь) для генерации, с # регионами и всем остальным. Другие люди копируют и вставляют.

Обычно вы помещаете каждый класс viewmodel в отдельный файл, но это пример кода.

Это написано для C#6. Если у вас более ранняя версия, мы можем изменить ее в соответствии с вашими требованиями, дайте мне знать.

Наконец, есть случаи, когда имеет смысл подумать о том, чтобы один комбинированный список (или любой другой) фильтровал другую большую коллекцию элементов, а не перемещался по дереву. Это может иметь мало смысла в этом в этом иерархическом формате, особенно если отношение "категория":"подкатегория" не одно-многим.

В этом случае у нас будет коллекция "категорий" и коллекция всех "подкатегорий", оба в качестве свойств основной модели представления. Затем мы будем использовать выбор категории для фильтрации коллекции подкатегорий, обычно через CollectionViewSource, Но вы также можете предоставить viewmodel полный список всех "подкатегорий" в паре с общедоступной ReadOnlyObservableCollection называется что-то вроде FilteredSubCategories, который вы привязали бы ко второму списку. Когда изменяется выбор категории, вы снова заполняете FilteredSubCategories основанный на SelectedCategory,

Суть в том, чтобы написать модели представления, которые отражают семантику ваших данных, а затем написать представления, которые позволяют пользователю видеть то, что ему нужно видеть, и делать то, что ему нужно делать. Модели представлений не должны знать, что представления существуют; они просто выставляют информацию и команды. Часто удобно иметь возможность писать несколько представлений, которые отображают одну и ту же модель представления по-разному или с разным уровнем детализации, поэтому думайте о модели представления как о нейтральном раскрытии любой информации о себе, которую кто-то может захотеть использовать. Применяются обычные правила факторинга: пара как можно свободнее (но не более свободно) и т. Д.

ComboDemoViewModels.cs

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading.Tasks;

namespace ComboDemo.ViewModels
{
    public class ViewModelBase : INotifyPropertyChanged
    {
        #region INotifyPropertyChanged
        public event PropertyChangedEventHandler PropertyChanged;

        protected void OnPropertyChanged([CallerMemberName] String propName = null)
        {
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propName));
        }
        #endregion INotifyPropertyChanged
    }

    public class ComboDemoViewModel : ViewModelBase
    {
        //  In practice this would probably have a public (or maybe protected) setter 
        //  that raised PropertyChanged just like the other properties below. 
        public ObservableCollection<CategoryViewModel> Categories { get; } 
            = new ObservableCollection<CategoryViewModel>();

        #region SelectedCategory Property
        private CategoryViewModel _selectedCategory = default(CategoryViewModel);
        public CategoryViewModel SelectedCategory
        {
            get { return _selectedCategory; }
            set
            {
                if (value != _selectedCategory)
                {
                    _selectedCategory = value;
                    OnPropertyChanged();
                }
            }
        }
        #endregion SelectedCategory Property

        public void Populate()
        {
            #region Fake Data
            foreach (var x in Enumerable.Range(0, 5))
            {
                var ctg = new ViewModels.CategoryViewModel($"Category {x}");

                Categories.Add(ctg);

                foreach (var y in Enumerable.Range(0, 5))
                {
                    ctg.SubCategories.Add(new ViewModels.SubCategoryViewModel($"Sub-Category {x}/{y}"));
                }
            }
            #endregion Fake Data
        }
    }

    public class CategoryViewModel : ViewModelBase
    {
        public CategoryViewModel(String name)
        {
            Name = name;
        }

        public ObservableCollection<SubCategoryViewModel> SubCategories { get; } 
            = new ObservableCollection<SubCategoryViewModel>();

        #region Name Property
        private String _name = default(String);
        public String Name
        {
            get { return _name; }
            set
            {
                if (value != _name)
                {
                    _name = value;
                    OnPropertyChanged();
                }
            }
        }
        #endregion Name Property

        //  You could put this on the main viewmodel instead if you wanted to, but this way, 
        //  when the user returns to a category, his last selection is still there. 
        #region SelectedSubCategory Property
        private SubCategoryViewModel _selectedSubCategory = default(SubCategoryViewModel);
        public SubCategoryViewModel SelectedSubCategory
        {
            get { return _selectedSubCategory; }
            set
            {
                if (value != _selectedSubCategory)
                {
                    _selectedSubCategory = value;
                    OnPropertyChanged();
                }
            }
        }
        #endregion SelectedSubCategory Property
    }

    public class SubCategoryViewModel : ViewModelBase
    {
        public SubCategoryViewModel(String name)
        {
            Name = name;
        }

        #region Name Property
        private String _name = default(String);
        public String Name
        {
            get { return _name; }
            set
            {
                if (value != _name)
                {
                    _name = value;
                    OnPropertyChanged();
                }
            }
        }
        #endregion Name Property
    }
}

MainWindow.xaml

<Window 
    x:Class="ComboDemo.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    xmlns:local="clr-namespace:ComboDemo"
    mc:Ignorable="d"
    Title="MainWindow" Height="350" Width="525">
    <Grid>
        <StackPanel Orientation="Vertical" Margin="4">
            <StackPanel Orientation="Horizontal">
                <Label>Categories</Label>
                <ComboBox 
                    x:Name="CategorySelector"
                    ItemsSource="{Binding Categories}"
                    SelectedItem="{Binding SelectedCategory}"
                    DisplayMemberPath="Name"
                    MinWidth="200"
                    />
            </StackPanel>
            <StackPanel Orientation="Horizontal" Margin="20,4,4,4">
                <Label>Sub-Categories</Label>
                <ComboBox 
                    ItemsSource="{Binding SelectedCategory.SubCategories}"
                    SelectedItem="{Binding SelectedCategory.SelectedSubCategory}"
                    DisplayMemberPath="Name"
                    MinWidth="200"
                    />
            </StackPanel>
        </StackPanel>
    </Grid>
</Window>

MainWindow.xaml.cs

using System.Windows;

namespace ComboDemo
{
    /// <summary>
    /// Interaction logic for MainWindow.xaml
    /// </summary>
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();

            var vm = new ViewModels.ComboDemoViewModel();

            vm.Populate();

            DataContext = vm;
        }
    }
}

Дополнительный кредит

Вот другая версия MainWindow.xaml, которая демонстрирует, как вы можете показать одну и ту же модель представления двумя разными способами. Обратите внимание, что при выборе категории в одном списке это обновляет SelectedCategory который затем отражается в другом списке, и то же самое верно для SelectedCategory.SelectedSubCategory,

<Window 
    x:Class="ComboDemo.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    xmlns:local="clr-namespace:ComboDemo"
    xmlns:vm="clr-namespace:ComboDemo.ViewModels"
    mc:Ignorable="d"
    Title="MainWindow" Height="350" Width="525"
    >
    <Window.Resources>
        <DataTemplate x:Key="DataTemplateExample" DataType="{x:Type vm:ComboDemoViewModel}">
            <ListBox
                ItemsSource="{Binding Categories}"
                SelectedItem="{Binding SelectedCategory}"
                >
                <ListBox.ItemTemplate>
                    <DataTemplate DataType="{x:Type vm:CategoryViewModel}">
                        <StackPanel Orientation="Horizontal" Margin="2">
                            <Label Width="120" Content="{Binding Name}" />
                            <ComboBox 
                                ItemsSource="{Binding SubCategories}"
                                SelectedItem="{Binding SelectedSubCategory}"
                                DisplayMemberPath="Name"
                                MinWidth="120"
                                />
                        </StackPanel>
                    </DataTemplate>
                </ListBox.ItemTemplate>
            </ListBox>
        </DataTemplate>
    </Window.Resources>

    <Grid>
        <StackPanel Orientation="Vertical" Margin="4">
            <StackPanel Orientation="Horizontal">
                <Label>Categories</Label>
                <ComboBox 
                    x:Name="CategorySelector"
                    ItemsSource="{Binding Categories}"
                    SelectedItem="{Binding SelectedCategory}"
                    DisplayMemberPath="Name"
                    MinWidth="200"
                    />
            </StackPanel>
            <StackPanel Orientation="Horizontal" Margin="20,4,4,4">
                <Label>
                    <TextBlock Text="{Binding SelectedCategory.Name, StringFormat='Sub-Categories in {0}:', FallbackValue='Sub-Categories:'}"/>
                </Label>
                <ComboBox 
                    ItemsSource="{Binding SelectedCategory.SubCategories}"
                    SelectedItem="{Binding SelectedCategory.SelectedSubCategory}"
                    DisplayMemberPath="Name"
                    MinWidth="200"
                    />
            </StackPanel>

            <GroupBox Header="Another View of the Same Thing" Margin="4">
                <!-- 
                Plain {Binding} just passes along the DataContext, so the 
                Content of this ContentControl will be the main viewmodel.
                -->
                <ContentControl
                    ContentTemplate="{StaticResource DataTemplateExample}"
                    Content="{Binding}"
                    />
            </GroupBox>
        </StackPanel>
    </Grid>
</Window>

Использование одной модели представления в этом случае действительно проще, как упоминалось в комментариях. Например, я буду использовать только строки для элементов комбинированного списка.

Чтобы продемонстрировать правильное использование модели представления, мы будем отслеживать изменения категории посредством привязки, а не события пользовательского интерфейса. Итак, кроме ObservableCollectionвам нужно SelectedCategory имущество.

View-модель:

public class CommonViewModel : BindableBase
{
    private string selectedCategory;

    public string SelectedCategory
    {
        get { return this.selectedCategory; }
        set
        {
            if (this.SetProperty(ref this.selectedCategory, value))
            {
                if (value.Equals("Category1"))
                {
                    this.SubCategories.Clear();
                    this.SubCategories.Add("Category1 Sub1");
                    this.SubCategories.Add("Category1 Sub2");
                }

                if (value.Equals("Category2"))
                {
                    this.SubCategories.Clear();
                    this.SubCategories.Add("Category2 Sub1");
                    this.SubCategories.Add("Category2 Sub2");
                }
            }
        }
    }

    public ObservableCollection<string> Categories { get; set; } = new ObservableCollection<string> { "Category1", "Category2" };

    public ObservableCollection<string> SubCategories { get; set; } = new ObservableCollection<string>();
}

куда SetProperty это реализация INotifyPropertyChanged,

Когда вы выбираете категорию, установщик SelectedCategory свойство срабатывает, и вы можете заполнить элементы подкатегории в зависимости от выбранного значения категории. Не заменяйте сам объект коллекции! Вы должны очистить существующие элементы, а затем добавить новые.

В xaml, кроме ItemsSource для обоих полей со списком вам нужно будет связать SelectedItem для категории со списком.

XAML:

<StackPanel x:Name="Wrapper">
    <ComboBox ItemsSource="{Binding Categories}" SelectedItem="{Binding SelectedCategory, Mode=OneWayToSource}" />
    <ComboBox ItemsSource="{Binding SubCategories}" />
</StackPanel>

Затем просто присвойте view-модель контексту данных оболочки:

Wrapper.DataContext = new CommonViewModel();

И код для BindableBase:

using System.ComponentModel;
using System.Runtime.CompilerServices;

public abstract class BindableBase : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;

    protected bool SetProperty<T>(ref T storage, T value, [CallerMemberName] string propertyName = null)
    {
        if (Equals(storage, value))
        {
            return false;
        }

        storage = value;
        this.OnPropertyChanged(propertyName);
        return true;
    }

    protected void OnPropertyChanged([CallerMemberName] string propertyName = null)
    {
        this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }
}
Другие вопросы по тегам