【问题标题】:Correct use of WPF view model正确使用 WPF 视图模型
【发布时间】:2016-07-18 23:56:19
【问题描述】:

我正在自学 WPF。我的窗口有两个组合框:一个用于Categories,一个用于Subcategories。当类别选择发生变化时,我希望子类别列表仅更新为所选类别中的那些。

我为这两个组合框创建了一个简单的视图类。我的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;
        }
    }
}

我这样分配我的DataContexts。

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

问题是在我的类别组合框中选择新项目不会导致子类别重新填充(即使我确认正在调用我的 PropertyChanged 处理程序)。

使列表重新填充的正确方法是什么?

另外,我欢迎任何其他有关此方法的 cmets。与其将我的 CategoryView 传递给构造函数,不如在 XAML 中以某种方式声明性地指出这一点?

【问题讨论】:

  • 在我看来,你做错了。请参阅@SreeHarshaNellore 提供的链接。您只需要一个视图(带有 2 个组合框)和一个视图模型。
  • 我会给每个类别一个子类别子类别的集合。 x:将类别组合命名为 CategorySelector。绑定其他组合的 ItemsSource="{Binding SelectedItem.SubCategories, ElementName=CategorySelector}"。鲍勃是你的叔叔。
  • @Jai 我会查看链接。但是当你说我做错了,你能清楚地说明你觉得错了什么以及为什么这似乎是个问题吗?
  • @Ed 我不确定我是否理解它是如何工作的。你能进一步澄清吗?

标签: c# .net wpf viewmodel


【解决方案1】:

这是我们在生产代码中的做法。

每个类别都知道它的子类别是什么。如果它们来自数据库或磁盘文件,则数据库/Web 服务方法/文件读取器/任何东西都会返回类似的类,并且您将创建与之匹配的视图模型。 viewmodel 理解信息的结构,但不知道也不关心实际的内容;由其他人负责。

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

viewmodel 属性是很多样板文件。我有 sn-ps (steal them here) 来生成这些,带有#regions 和所有内容。其他人复制粘贴。

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

它是为 C#6 编写的。如果您使用的是早期版本,我们可以将其更改为适合,请告诉我。

最后,在某些情况下,考虑让一个组合框(或其他)过滤另一个大型项目集合而不是导航树更有意义。以这种分层格式执行此操作几乎没有意义,尤其是在“类别”:“子类别”关系不是一对多的情况下。

在这种情况下,我们会有一个“类别”的集合和一个所有“子类别”的集合,它们都作为主视图模型的属性。然后我们将使用“类别”选择来过滤“子类别”集合,通常通过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
    }
}

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>

【讨论】:

  • 感谢您的详细回复。不幸的是,我目前专注于另一个项目,但会非常仔细地查看您发布的所有内容。
【解决方案2】:

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

为了演示视图模型的正确使用,我们将通过绑定而不是 UI 事件来跟踪 category 的变化。所以,除了ObservableCollections 你还需要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 的实现。

当你选择category时,SelectedCategory属性的setter触发,你可以根据选择的category值填充subcategory项。 不要替换集合对象本身!您应该清除现有项目,然后添加新项目。

在 xaml 中,除了两个组合框的 ItemsSource 之外,您还需要为 category 组合框绑定 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));
    }
}

【讨论】:

  • @EdPlunkett 你能详细介绍一下吗?
  • 我应该退出“这不是 MVVM”;您拥有的 Add 东西比在代码隐藏中添加 ComboBoxItems 要好得多。但是,如果每个类别都有自己的子类别的集合并且您绑定到所选类别的子类别属性,则它会使代码更简单。您可能还拥有所有子类别的一个集合,并使用 CollectionViewSource 对其进行过滤。 .
  • @EdPlunkett 完全同意你的看法。这就是为什么我添加了“例如,我将只使用字符串......” - 因为 OP 只想要简单的东西作为开始 :) 我也会使用 DelegateCommand 来跟踪更复杂版本中的项目更改(如果跟踪更改手动)。
猜你喜欢
  • 1970-01-01
  • 2012-07-10
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多