Правильное использование модели представления 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));
}
}