正确使用WPF视图模型

本文关键字:视图 模型 WPF | 更新日期: 2023-09-27 17:57:37

我在自学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中以某种方式声明性地指示它?

正确使用WPF视图模型

以下是我们在生产代码中如何做到这一点。

每个类别都知道它的子类别是什么。如果它们来自数据库或磁盘文件,那么数据库/webservice方法/file reader/whatthing将返回类似的类,并且您将创建匹配的视图模型。视图模型理解信息的结构,但对实际内容一无所知;其他人负责这件事。

请注意,这一切都是非常声明性的:唯一的循环是伪造演示对象的循环。没有事件处理程序,除了创建视图模型并告诉它用假数据填充自己之外,代码隐藏中什么都没有。在现实生活中,您经常会为特殊情况(例如拖放)编写事件处理程序。将视图特定的逻辑放在代码后台并没有什么非MVVMish的地方;这就是它的作用。但这个案子太琐碎了,没有必要这么做。我们有许多.xaml.cs文件,这些文件已经在TFS中连续放置多年,与向导创建它们的方式完全相同。

viewmodel属性是很多样板。我有一些片段(在这里偷)来生成这些片段,包括#区域和所有内容。其他人复制粘贴。

通常,您会将每个视图模型类放在一个单独的文件中,但这只是示例代码。

它是为C#编写的。如果您使用的是早期版本,我们可以根据需要进行更改,请告诉我。

最后,在某些情况下,考虑让一个组合框(或其他任何组合框)过滤另一个大型项目集合比浏览树更有意义。在这种层次结构中这样做几乎没有意义,特别是如果"类别":"子类别"的关系不是一对多的关系。

在这种情况下,我们将有一个"类别"集合和一个所有"子类别"的集合,这两个集合都是主视图模型的属性。然后,我们将使用"类别"选择来过滤"子类别"集合,通常通过CollectionViewSource。但你也可以给视图模型一个所有"子类别"的私有完整列表,并与一个名为FilteredSubCategories的公共ReadOnlyObservableCollection配对,你可以将其绑定到第二个组合框。当"类别"选择发生更改时,将根据SelectedCategory重新填充FilteredSubCategories

底线是编写反映数据语义的视图模型,然后编写视图,让用户看到他需要看到的内容并做他需要做的事情。视图模型不应该意识到视图的存在;它们只是公开信息和命令。编写多个视图以不同的方式或不同的细节级别显示同一个视图模型通常很方便,所以可以将视图模型视为中性地暴露任何人可能想要使用的关于它自己的信息。通常的保理规则适用:尽可能松散地耦合(但不能更松散),等等

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
    }
}

主窗口.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>

主窗口.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>

如注释中所述,在这种情况下使用单视图模型确实更简单。例如,我只将字符串用于组合框项目。

为了演示视图模型的正确使用,我们将通过绑定而不是UI事件来跟踪类别的更改。因此,除了ObservableCollection,您还需要SelectedCategory属性。

视图模型:

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>();
}

其中SetPropertyINotifyPropertyChanged的实现。

当您选择类别时,SelectedCategory属性的设置器会触发,您可以根据所选类别值填充子标签不要替换集合对象本身您应该清除现有项目,然后添加新项目。

在xaml中,除了两个组合框的ItemsSource之外,还需要为类别组合框绑定SelectedItem

XAML:

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

然后只需将视图模型分配给包装器的数据上下文:

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));
    }
}