Search code examples
c#wpfmvvmviewmodeldependency-properties

Proper way to return value from custom UserControl's DependencyProperty and uses viewModel?


I am wrapping a custom user control into a dll and would like other references to the control to be able to get or set the result value by binding to the "Result" property.

This user control displays and binds two lists and implements something like a list item selector, where the user takes values from the "source" list to the "selected" list, but of course there is some additional logic that is ignored here.

Here's the CustomControl.cs code:

public partial class CustomControl : UserControl
{
    public IEnumerable<string> Result
    {
        get { return (IEnumerable<string>)GetValue(resultProperty); }
        set { SetValue(resultProperty, value); }
    }
    public static readonly DependencyProperty resultProperty = DependencyProperty.Register("Result", typeof(IEnumerable<string>), typeof(CustomControl), 
        new FrameworkPropertyMetadata(null,FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, ResultChangedEvent));
    
    private static void ResultChangedEvent(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        var uc = d as CustomControl;
        if (uc == null) return;
        uc.viewModel.InitResult((IEnumerable<string>)e.NewValue);
    }

    //There's another IEnumerable<string> DependencyObject here called 'Source'
    //....

    internal CustomControlViewModel viewModel;
    public CustomControl()
    {
        InitializeComponent();
        ContentGrid.DataContext = viewModel = new CustomControlViewModel();
    }
}

CustomControlViewModel.cs:
The SelectedList should be return a final list as 'Result' data.

internal class CustomControlViewModel
{
    //In fact, the type here should contain a boolean status of whether it is selected or not, not a string.
    public ObservableCollection<string> SourceList { get; set; } = new ObservableCollection<string>();

    public ObservableCollection<string> SelectedList { get; set; } = new ObservableCollection<string>();


    public void InitResult(IEnumerable<string> items)
    {
        SelectedList.Clear();
        foreach (var name in items.Distinct())
            SelectedList.Add(name);
    }       
 
    public void OtherEventHandleList(string text)
    {
        //select item from SourceList
        //SelectedList.Add(text);
    }
}

Code for externally referencing UserControl:
MainWindowViewModel.cs:

class MainWindowViewModel
{    
    public ObservableCollection<string> RawList { get; set; } = new ObservableCollection<string>()
    { "data1", "data2", "data3" , "data4" };
    public ObservableCollection<string> ResultList { get; set; } = new ObservableCollection<string>()
    { "data1", "data2" };

    public void GetResult()
    {
        MessageBox.Show(string.Join(",", ResultList));
    }
}

MainWindow.xaml:

<Window xmlns:cc="clr-namespace:CustomControl;assembly=CustomControl">
    <Grid>
        <cc:CustomControl Source="{Binding RawList}" Result="{Binding ResultList}"/>
    </Grid>
</Window>

But the 'Result' Binding of this UserControl is only valid when it is assigned a value, and I could never find a suitable way to make the Result return the SelectedList in CustomControlViewModel.
How do I get DependencyProperty to return the viewModel's properties?


Solution

    1. Because you set the DataContext inside the constructor explicitly, you break the DataContext for external bindings that target your UserControl. For this reason and for reasons of reusability you never set the DataContext of a custom control internally. A control must always ignore its current DataContext.

    2. A control should not contain any data logic. It should not calculate data and return any results.
      Controls are modules of the application view. The view has the only responsibility to display data and interact with the user e.g. to collect input or modify data. It does not generate data.
      Any computations and data processing must takle place in the view model (if it is data presentation related) or in the model (if it is business logic realted).

    A custom control must not care about its DataContext. If your control needs external data you must introduce a dependency property that the client (the external DataContext e.g., your MainViewModel) can bind to.

    Aside from that, you don't have a view model for each control but for each data scope. A view model per control is automatically eliminated when you follow the common practice of designing a custom control - which is to not have your control to depend on a DataContext.
    MVVM is an application architectural design pattern. View model describes an application component and not control components. The application view model is composed of many classes. It somehow became a convention to name those root classes (within the class hierarchy) with the ViewModel suffix. Those root classes usually describe a data scope of the view. Multiple controls usually share the same DataContext.

    From an MVVM perspective, all the logic of the control is UI related and therefore part of the view. It does not belong to any view model. Instead move it to "code-behind" (C# code). Those classes are not view model classes.

    If you design your control properly you don't even have to care about the type of the source collections. Very much like the ListBox can display any data without knowing their type. It does this by not caring about the actual data items. It just loads the item containers, assigns the data item to the DataContext of the item container and delegates the layout to the client. The client then will define a DataTemplate that tells the item containers how they are rendered.

    You can replicate this behavior very easily.

    The following example shows how to modify your code to make it data and data context agnostic:

    CustomControl.xaml.cs

    public partial class CustomControl : UserControl
    {
      public IList Results
      {
        get => (IList)GetValue(ResultsProperty);
        set => SetValue(ResultsProperty, value);
      }
    
      // A TwoWay binding on an collection is redundant
      // because your control will never change the instance.
      // It only displays the values of the current instance.
      // The instance is provided by the external view model.
      public static readonly DependencyProperty ResultsProperty = DependencyProperty.Register(
        "Results",
        typeof(IList),
        typeof(CustomControl),
        new FrameworkPropertyMetadata(default));
    
      // Enable the client to template the item the way you would do with an ItemsControl
      public DataTemplate ResultTemplate
      {
        get => (DataTemplate)GetValue(ResultTemplateProperty);
        set => SetValue(ResultTemplateProperty, value);
      }
    
      public static readonly DependencyProperty ResultTemplateProperty = DependencyProperty.Register(
        "ResultTemplate",
        typeof(DataTemplate),
        typeof(CustomControl),
        new PropertyMetadata(default));
    
      public CustomControl()
      { 
        InitializeComponent();
      }
    }
    

    CustomControl.xaml

    <UserControl x:Name="Root">
      <ListBox ItemsSource="{Binding ElementName=Root, Path=Results}"
               ItemTemplate="{Binding ElementName=Root, Path=ResultTemplate}" />
    </UserControl>
    

    MainViewModel.cs
    It's important that your view models (or non-DependencyObject binding sources in general) implement INotifyPropertyChanged - even when they don't change property values.

    Move the code from CustomControlViewModel to the application view model (e.g. the MainViewModel class). If it contains data logic it doesn't belong to the view. It then belongs to the view model or model.

    class MainWindowViewModel : INotifyPropertyChanged
    {
      public ObservableCollection<string> RawList { get; set; } = new ObservableCollection<string>()
      { "data1", "data2", "data3" , "data4" };
      public ObservableCollection<string> ResultList { get; set; } = new ObservableCollection<string>()
      { "data1", "data2" }; 
        
      private ObservableCollection<string> SelectedList { get; set; } = new ObservableCollection<string>();
      //public ObservableCollection<string> SourceList { get; set; } 
    
      public void GetResult()
      {
        MessageBox.Show(string.Join(",", ResultList));
      }
    }
    

    MainWindow.xaml

    <Window>
      <Window.DataContext>
        <MainViewModel />
      </Window.DataContext>
    
      <cc:TagSelectControl Results="{Binding ResultList}">
        <cc:TagSelectControl.ResultTemplate>
          <DataTemplate>
            <TextBlock Text="{Binding}" />
          </DataTemplate>
        </cc:TagSelectControl.ResultTemplate>
      </cc:TagSelectControl>
    </Window>
    

    If you only want to display a list of items and require your custom control to have some special behavior, then the recommended approach would be to extend ItemsControl (or ListBox) instead of UserControl:

    CustomControl.xaml.cs

    public partial class CustomControl : ListBox
    {
    }
    

    MainWindow.xaml

    <Window>
      <Window.DataContext>
        <MainViewModel />
      </Window.DataContext>
    
      <cc:TagSelectControl ItemsSource="{Binding ResultList}">
        <cc:TagSelectControl.ItemTemplate>
          <DataTemplate>
            <TextBlock Text="{Binding}" />
          </DataTemplate>
        </cc:TagSelectControl.ItemTemplate>
      </cc:TagSelectControl>
    </Window>