WPF MVVM Parent View/ViewModel 与 child UserControl/ViewModel 数据绑定问题

WPF MVVM Parent View/ViewModel with child UserControl/ViewModel Data Binding Issue

我一直在尝试实现一个 WPF UserControl,在几个不同的视图之间具有一些通用功能,但没有成功。 UserControl 本质上是一个带有一些上一个和下一个按钮和一个搜索过滤器的 ListBox。 Previous 和 Next 逻辑很容易复制和粘贴,但每次过滤都很痛苦,所以将其全部封装到自己的 UserControlViewModel.

中会非常好

但我已经 运行 撞墙,以将 child UserControl/ViewModel 双向绑定回 parent 虚拟机.

如果 child UserControl 没有自己的 ViewModel,这会起作用,但是我必须在代码中为该逻辑实现所有功能,即没有吸引力,但并非不可能。

我已经将其归结为一个演示项目- MRE Project - ChildVMBindingDemo

我有一个 MainWindow、MainWindowViewModel、MyListBoxControl 和一个 MyListBoxControlViewModel。

MainWindow.xaml 托管 MyListBoxControl,并在 MyListBoxControl 的代码后面将两个绑定转发到 DependencyProperty。然后后面的代码将这些值转发给 MyListBoxControlViewModel。这显然是我的问题——“流量”影响了后面的代码,在 child VM 中设置了值,而且它是单向的。我已经尝试了我能想到的 BindingMode、UpdateSourceTrigger、NotifyOnSourceUpdated 和 NotifyOnTargetUpdated 的所有组合,但都没有成功。

MainWindow.xaml:

<Grid>
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="200" />
        <ColumnDefinition Width="*" />
    </Grid.ColumnDefinitions>

    <local:MyListBoxControl Grid.Column="0"
        MyItems="{Binding
            RelativeSource={RelativeSource AncestorType={x:Type Window}, Mode=FindAncestor},
            Path=DataContext.MyItems}"
        SelectedMyItem="{Binding
            RelativeSource={RelativeSource AncestorType={x:Type Window}, Mode=FindAncestor},
            Path=DataContext.SelectedMyItem}"
        />
</Grid>

MainWindow.xaml.cs:

private readonly MainWindowViewModel _viewModel;

public MainWindow()
{
    InitializeComponent();

    _viewModel = new MainWindowViewModel();
    this.DataContext = _viewModel;
}

MainWindowViewModel.cs:

public MainWindowViewModel()
{
    MyItems = new ObservableCollection<MyItem>()
    {
        new MyItem() { Name = "One" },
        new MyItem() { Name = "Two" },
        new MyItem() { Name = "Thee" },
        new MyItem() { Name = "Four" },
    };
}

private ObservableCollection<MyItem> _myItems;
public ObservableCollection<MyItem> MyItems
{
    get => _myItems;
    set => Set(ref _myItems, value);
}

private MyItem _selectedMyItem;
public MyItem SelectedMyItem
{
    get => _selectedMyItem;
    set
    {
        if (Set(ref _selectedMyItem, value))
        {
            System.Diagnostics.Debug.WriteLine($"Main View Model Selected Item Set: {SelectedMyItem?.Name}");
        }
    }
}

MyListBoxControl.xaml:

<Grid>
    <Grid.RowDefinitions>
        <RowDefinition Height="*" />
        <RowDefinition Height="Auto" />
    </Grid.RowDefinitions>

    <ListBox Grid.Row="0"
             ItemsSource="{Binding MyItems}"
             SelectedItem="{Binding SelectedMyItem}"
             SelectedIndex="{Binding SelectedIndex}">
        
        <ListBox.ItemTemplate>
            <DataTemplate>
                <TextBlock Text="{Binding Name}" />
            </DataTemplate>
        </ListBox.ItemTemplate>
    </ListBox>

    <Grid Grid.Row="1">
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="Auto" />
            <ColumnDefinition Width="*" />
            <ColumnDefinition Width="Auto" />
        </Grid.ColumnDefinitions>

        <Button Grid.Column="0"
                Command="{Binding PrevCommand}"
                >Prev</Button>

        <Button Grid.Column="2"
                Command="{Binding NextCommand}"
                >Next</Button>
    </Grid>
</Grid>

MyListBoxControl.xaml.cs:

private readonly MyListBoxControlViewModel _viewModel;

public MyListBoxControl()
{
    InitializeComponent();

    _viewModel = new MyListBoxControlViewModel();
    this.DataContext = _viewModel;
}

public static readonly DependencyProperty MyItemsProperty =
    DependencyProperty.Register("MyItems", typeof(ObservableCollection<MyItem>), typeof(MyListBoxControl),
        new FrameworkPropertyMetadata(null, MyItemsChangedCallback));

public ObservableCollection<MyItem> MyItems
{
    get => (ObservableCollection<MyItem>)GetValue(MyItemsProperty);
    set
    {
        SetValue(MyItemsProperty, value);
        _viewModel.MyItems = MyItems;
    }
}

private static void MyItemsChangedCallback(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
    if (d is MyListBoxControl myListBoxControl)
    {
        myListBoxControl.MyItems = (ObservableCollection<MyItem>)e.NewValue;
    }
}

public static readonly DependencyProperty SelectedMyItemProperty =
    DependencyProperty.Register(nameof(SelectedMyItem), typeof(MyItem), typeof(MyListBoxControl),
        new FrameworkPropertyMetadata(null, SelectedMyItemChangedCallback)
        {
            BindsTwoWayByDefault = true,
            DefaultUpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged
        });

public MyItem SelectedMyItem
{
    get => (MyItem)GetValue(SelectedMyItemProperty);
    set
    {
        SetValue(SelectedMyItemProperty, value);
        _viewModel.SelectedMyItem = SelectedMyItem;
    }
}

private static void SelectedMyItemChangedCallback(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
    if (d is MyListBoxControl myListBoxControl)
    {
        myListBoxControl.SelectedMyItem = (MyItem)e.NewValue;
    }
}

最后

MyListBoxControlViewModel.cs:

private ObservableCollection<MyItem> _myItems;
public ObservableCollection<MyItem> MyItems
{
    get => _myItems;
    set => Set(ref _myItems, value);
}

private MyItem _selectedMyItem;
public MyItem SelectedMyItem
{
    get => _selectedMyItem;
    set
    {
        if (Set(ref _selectedMyItem, value))
        {
            System.Diagnostics.Debug.WriteLine($"Child View Model Selected Item Set: {SelectedMyItem?.Name}");
        }
    }
}

private int _selectedIndex;
public int SelectedIndex
{
    get => _selectedIndex;
    set => Set(ref _selectedIndex, value);
}

private ICommand _prevCommand;
public ICommand PrevCommand => _prevCommand ?? (_prevCommand = new RelayCommand((param) => Prev(), (param) => CanPrev()));
public bool CanPrev() => SelectedIndex > 0;
private void Prev()
{
    SelectedIndex--;
}

private ICommand _nextCommand;
public ICommand NextCommand => _nextCommand ?? (_nextCommand = new RelayCommand((param) => Next(), (param) => CanNext()));
public bool CanNext() => MyItems != null ? SelectedIndex < (MyItems.Count - 1) : false;
private void Next()
{
    SelectedIndex++;
}

我们的项目中已有类似的示例(代码中的绑定将值传递给 child 虚拟机)- 所以其他人也在努力解决这个问题,看起来他们的解决方案很简单,child 控件从未向 parent 报告 - 它们仅输出一些交易。

我唯一能想到的就是用一个Messenger把选中的值直接传回给parent,或者给child VM一个Action来调用并在依赖属性背后的代码中设置新值——但任何一个选项都只会发出臭意大利面的尖叫声,并且可能是无休止的 setter loop/stack 溢出异常。

这里是否有更好的方法,或者这里是否有我遗漏的东西?

它肯定不漂亮,而且闻起来也不太好 - 但如果这是你唯一的选择,下面是它的工作原理。

我在 ViewModel 中添加了一个 Action,以便在后面的代码中设置 DP - 请注意,它只是调用 SetValue,而不是直接设置 SelectedMyItem,这会阻止 setter 循环 I很担心。

MyListBoxControlViewModel.cs

public Action<MyItem> SelectedSetter { get; set; }

private MyItem _selectedMyItem;
public MyItem SelectedMyItem
{
    get => _selectedMyItem;
    set
    {
        if (Set(ref _selectedMyItem, value))
        {
            SelectedSetter?.Invoke(value);
            System.Diagnostics.Debug.WriteLine($"Child View Model Selected Item Set: {SelectedMyItem?.Name}");
        }
    }
}

MyListBoxControl.xmal.cs

public MyListBoxControl()
{
    InitializeComponent();

    _viewModel = new MyListBoxControlViewModel();
    _viewModel.SelectedSetter = (value) => SetValue(SelectedMyItemProperty, value);

    this.DataContext = _viewModel;
}

虽然效果不佳,但可能会在有限的使用范围内发挥作用。

通过构造函数传入 Action 以说明其在操作中的重要性可能是明智的。

控件永远不应依赖于显式或内部视图模型。它必须单独依赖于它自己的成员,例如 public 属性。然后数据上下文可以稍后绑定到这个 public 属性。

这将实现独立于实际 DataContext 类型的可重用性,并消除冗余代码(和冗余复杂性),否则将值委托给私有视图模型是必要的。

MVVM并不意味着每个控件都必须有自己专用的视图模型。它旨在为 application 提供一个结构。 MVVM 的目标是应用程序级设计,而不是控制级设计。控件必须在其自己的视图代码中实现其 UI 相关逻辑。这可以在 code-behind 中或分布在多个 类 中。这样的 类 将被直接引用(而不是通过数据绑定),因为它们共享相同的 MVVM 上下文。 UI逻辑的MVVM上下文总是View.
数据绑定基本上是一种解耦 ViewView Model 的技术(允许 View Model 发送数据到 View 而不必引用它——这对 MVVM 模式至关重要)。 数据操作通常发生在 View Model(从 View 角度看数据的所有者)。 View 只会对数据视图进行操作(例如过滤或排序集合)。但永远不要直接在数据上。

查看以下示例如何将所有 View 相关逻辑移至控件。
您的修复和改进(在设计方面)MyListBoxControl,可能如下所示:

MyListBoxControl.xaml.cs

public partial class MyListBoxControl : UserControl
{
  public static RoutedCommand NextCommand { get; } = new RoutedUICommand("Select next MyItem", "NextCommand", typeof(MyListBoxControl));
  public static RoutedCommand PreviousCommand { get; } = new RoutedUICommand("Select previous MyItem", "PreviousCommand", typeof(MyListBoxControl));

  public ObservableCollection<MyItem> MyItemsSource
  {
    get => (ObservableCollection<MyItem>)GetValue(MyItemsSourceProperty);
    set => SetValue(MyItemsSourceProperty, value);
  }

  public static readonly DependencyProperty MyItemsSourceProperty = DependencyProperty.Register(
    "MyItemsSource", 
    typeof(ObservableCollection<MyItem>), 
    typeof(MyListBoxControl), 
    new PropertyMetadata(default));

  public int SelectedMyItemIndex
  {
    get => (int)GetValue(SelectedMyItemIndexProperty);
    set => SetValue(SelectedMyItemIndexProperty, value);
  }

  public static readonly DependencyProperty SelectedMyItemIndexProperty = DependencyProperty.Register(
    "SelectedMyItemIndex", 
    typeof(int), 
    typeof(MyListBoxControl), 
    new FrameworkPropertyMetadata(default(int), FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));

  public MyItem SelectedMyItem
  {
    get => (MyItem)GetValue(SelectedMyItemProperty);
    set => SetValue(SelectedMyItemProperty, value);
  }

  public static readonly DependencyProperty SelectedMyItemProperty = DependencyProperty.Register(
    "SelectedMyItem", 
    typeof(MyItem), 
    typeof(MyListBoxControl), 
    new FrameworkPropertyMetadata(default(MyItem), FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));

  public MyListBoxControl()
  {
    InitializeComponent();
    this.CommandBindings.Add(new CommandBinding(NextCommand, ExecuteNextCommand, CanExecuteNextCommand));
    this.CommandBindings.Add(new CommandBinding(PreviousCommand, ExecutePreviousCommand, CanExecutePreviousCommand));
  }

  private void CanExecutePreviousCommand(object sender, CanExecuteRoutedEventArgs e)
    => e.CanExecute = this.MyItems?.Any() ?? false  && this.SelectedMyItemIndex > 0;

  private void ExecutePreviousCommand(object sender, ExecutedRoutedEventArgs e)
    => this.SelectedMyItemIndex = Math.Max(this.SelectedMyItemIndex - 1, 0);

  private void CanExecuteNextCommand(object sender, CanExecuteRoutedEventArgs e) 
    => e.CanExecute = this.MyItems?.Any() ?? false  && this.SelectedMyItemIndex < this.MyItemsSource.Count - 1;

  private void ExecuteNextCommand(object sender, ExecutedRoutedEventArgs e) 
    => this.SelectedMyItemIndex = Math.Min(this.SelectedMyItemIndex + 1, this.MyItemsSource.Count - 1);
}

MyListBoxControl.xaml

<UserControl>
  <Grid>
    <Grid.RowDefinitions>
      <RowDefinition Height="*" />
      <RowDefinition Height="Auto" />
    </Grid.RowDefinitions>

    <ListBox Grid.Row="0"
             ItemsSource="{Binding RelativeSource={RelativeSource AncestorType=UserControl}, Path=MyItemsSource}"
             SelectedItem="{Binding RelativeSource={RelativeSource AncestorType=UserControl}, Path=SelectedMyItem}"
             SelectedIndex="{Binding RelativeSource={RelativeSource AncestorType=UserControl}, Path=SelectedMyItemIndex}">
      <ListBox.ItemTemplate>
        <DataTemplate>
          <TextBlock Text="{Binding Name}" />
        </DataTemplate>
      </ListBox.ItemTemplate>
    </ListBox>

    <Grid Grid.Row="1">
      <Grid.ColumnDefinitions>
        <ColumnDefinition Width="Auto" />
        <ColumnDefinition Width="*" />
        <ColumnDefinition Width="Auto" />
      </Grid.ColumnDefinitions>

      <Button Grid.Column="0"
              Command="{x:Static local:MyListBoxControl.PreviousCommand}"
              Content="Prev" />
      <Button Grid.Column="2"
              Command="{x:Static local:MyListBoxControl.NextCommand}"
              Content="Next" />
    </Grid>
  </Grid>
</UserControl>

使用示例

MainWindow.xaml

<Window>
  <Window.DataContext>
    <MainViewModel />
  </Window.DataContext>

  <MyListBoxControl MyItemsSource="{Binding MyItems}"
                    SelectedMyItem="{Binding SelectedMyItem}" />
</Window>

如果您打算添加行为或更改现有 ListBox 的行为,扩展 ListBox 将是更好的选择。这将允许开箱即用地为其项目设置模板。

此外,如果您的主要目的是分离视图和相关逻辑,请始终扩展 Control,即不要创建 UserControl。在没有 code-behind 文件的情况下实现控件也会感觉更自然。它还将在定制方面提供更大的灵活性。例如,虽然 UserControlContentControl,但它不能承载内容。