简体   繁体   中英

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

I have been trying to implement a WPF UserControl with some common functionality between a few different views without success. The UserControl is essentially a ListBox with some Previous & Next buttons and a Search filter. Previous and Next logic is easily copied and pasted, but the filtering is a pain each time, so it would be really nice to encapsulate that all into its own UserControl and ViewModel .

But I've been running into a wall to get the child UserControl / ViewModel to two way bind back to the parent VM.

This works if the child UserControl doesn't have its own ViewModel , but then I have to implement all the functionality in the code behind for that logic, which is unappealing, but not impossible.

I've boiled this down to a demo project- MRE Project - ChildVMBindingDemo

I have a MainWindow, MainWindowViewModel, MyListBoxControl, and a MyListBoxControlViewModel.

The MainWindow.xaml hosts the MyListBoxControl, and forwards two bindings to DependencyProperty in the code behind of the MyListBoxControl. That code behind then forwards those values to the MyListBoxControlViewModel. This is obviously my issue- the "traffic" hits the code behind, sets the values in the child VM, and it's a one way street from there. I've tried every combination of BindingMode, UpdateSourceTrigger, NotifyOnSourceUpdated, and NotifyOnTargetUpdated that I can think of without success.

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

And finally

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

There were preexisting examples similar to this in our project (with the bindings in the code behind passing the values to the child VM)- so someone else struggled with this as well, and it looks like their solution was simply, that the child control never reported back to the parent- they were output only kinda deals.

The only thing I can really think of is to use a Messenger to send the selected value back to the parent directly, or give the child VM an Action to call and set the new value in the code behind dependency properties- but either option just screams of odorous spaghetti, and a probably an endless setter loop/stack overflow exception.

Is there a better approach here, or is there something here that I am just missing?

A control should never depend on an explicit or internal view model. It must depend on its own members, like public properties, alone. Then the data context can later bind to this public properties.

This will enable reusability independent from the actual DataContext type and eliminates redundant code (and redundant complexity) that otherwise would be necessary to delegate values to the private view model.

MVVM does not mean that each control must have its own dedicated view model. It is meant to give the application a structure. MVVM targets application level design and not control level design. A control must implement its UI related logic in its own view code. This can be in code-behind or spread across multiple classes. Such classes would be referenced directly (and not via data binding) as they share the same MVVM context. The MVVM context of UI logic is always View .
Data binding is basically a technology to decouple View and View Model (to allow the View Model to send data to the View without having to reference it - which is crucial to the MVVM pattern). Data operations usually take place in the View Model (the owner of the data from the View perspective). View would only operate on data views (eg to filter or sort collections). But never on data directly.

See how the following example moved all View related logic to the control.
Your fixed and improved (in terms of design) MyListBoxControl , could look as follows:

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>

Usage example

MainWindow.xaml

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

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

In case you meant to add behavior or change the behavior of the existing ListBox , extending ListBox would be the far better option. This would allow to template its items out of the box.

Additionally, if your primary intention was to separate view and related logic, always extend Control ie don't create a UserControl . It will also feel more natural to implement the control without a code-behind file. It will also enable more flexibility in terms of customization. For example, although UserControl is a ContentControl , it can't host content.

It's sure not pretty, and it doesn't smell great- but if this is your only option, here is how this could work.

I added an Action to the ViewModel, to set the DP in the code behind- note that it's only calling SetValue, and not directly setting the SelectedMyItem, which prevents the setter loop I was worried about.

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

and

MyListBoxControl.xmal.cs

public MyListBoxControl()
{
    InitializeComponent();

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

    this.DataContext = _viewModel;
}

While not great, it would probably work in limited use.

Probably smart to pass the Action in via the constructor to state its importance in the operation.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM