Bind to property only if it exists

不打扰是莪最后的温柔 提交于 2021-02-16 16:28:05

问题


I have a WPF window that uses multiple viewmodel objects as its DataContext. The window has a control that binds to a property that exists only in some of the viewmodel objects. How can I bind to the property if it exists (and only if it exists).

I am aware of the following question/answer: MVVM - hiding a control when bound property is not present. This works, but gives me a warning. Can it be done without the warning?

Thanks!

Some example code:

Xaml:

<Window x:Class="WpfApplication1.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="clr-namespace:WpfApplication1"
    Title="MainWindow" Height="350" Width="525">
<Grid>
    <Grid.RowDefinitions>
        <RowDefinition Height="40"/>
        <RowDefinition Height="Auto"/>
        <RowDefinition Height="40"/>
        <RowDefinition Height="Auto"/>
    </Grid.RowDefinitions>
    <ListBox Grid.Row="1" Name="ListView" Margin="25,0,25,0" ItemsSource="{Binding Path=Lst}"
              HorizontalContentAlignment="Center" SelectionChanged="Lst_SelectionChanged">
    </ListBox>
    <local:SubControl Grid.Row="3" x:Name="subControl" DataContext="{Binding Path=SelectedVM}"/>
</Grid>

SubControl Xaml:

<UserControl x:Class="WpfApplication1.SubControl"
         xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
         xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
         xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
         xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
         xmlns:local="clr-namespace:WpfApplication1"
         mc:Ignorable="d" 
         d:DesignHeight="200" d:DesignWidth="300">
<Grid>
    <Grid.RowDefinitions>
        <RowDefinition Height="40"/>
        <RowDefinition Height="Auto"/>
        <RowDefinition Height="40"/>
        <RowDefinition Height="Auto"/>
    </Grid.RowDefinitions>
    <StackPanel Grid.Row="1" Orientation ="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Center" Margin="0,5,0,5">
        <TextBlock Margin="5,0,5,0" Text="{Binding Path=OnOffAlways}"/>
        <CheckBox IsChecked="{Binding Path=Always}">
            <TextBlock Text="On/Off"/>
        </CheckBox>
    </StackPanel>
    <StackPanel Grid.Row="3" Orientation ="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Center" Margin="0,5,0,5">
        <TextBlock Margin="5,0,5,0" Text="{Binding Path=OnOffSometimes}"/>
        <CheckBox IsChecked="{Binding Path=Sometimes}">
            <TextBlock Text="On/Off"/>
        </CheckBox>
    </StackPanel>
</Grid>

MainWindow Code Behind:

    public partial class MainWindow : Window
{
    ViewModel1 vm1;
    ViewModel2 vm2;
    MainViewModel mvm;

    public MainWindow()
    {

        InitializeComponent();

        vm1 = new ViewModel1();
        vm2 = new ViewModel2();
        mvm = new MainViewModel();
        mvm.SelectedVM = vm1;
        DataContext = mvm;
    }

    private void Lst_SelectionChanged(object sender, SelectionChangedEventArgs e)
    {
        ListBox lstBx = sender as ListBox;

        if (lstBx != null)
        {
            if (lstBx.SelectedItem.Equals("VM 1"))
                mvm.SelectedVM = vm1;
            else if (lstBx.SelectedItem.Equals("VM 2"))
                mvm.SelectedVM = vm2;
        }
    }
}

MainViewModel (DataContext of MainWindow):

    public class MainViewModel : INotifyPropertyChanged
{
    ObservableCollection<string> lst;
    ViewModelBase selectedVM;

    public event PropertyChangedEventHandler PropertyChanged;

    public MainViewModel()
    {

        Lst = new ObservableCollection<string>();
        Lst.Add("VM 1");
        Lst.Add("VM 2");
    }

    public ObservableCollection<string> Lst
    {
        get { return lst; }
        set
        {
            lst = value;
            OnPropertyChanged("Lst");
        }
    }


    public ViewModelBase SelectedVM
    {
        get { return selectedVM; }
        set
        {
            if (selectedVM != value)
            {
                selectedVM = value;
                OnPropertyChanged("SelectedVM");
            }
        }
    }
    protected void OnPropertyChanged(string name)
    {
        PropertyChangedEventHandler handler = PropertyChanged;
        if (handler != null)
        {
            handler(this, new PropertyChangedEventArgs(name));
        }
    }
}

ViewModel1 (with sometimes property):

    public class ViewModel1 : ViewModelBase, INotifyPropertyChanged
{
    private bool _always;
    private string _onOffAlways;

    private bool _sometimes;
    private string _onOffSometimes;

    public event PropertyChangedEventHandler PropertyChanged;

    public ViewModel1()
    {
        _always = false;
        _onOffAlways = "Always Off";

        _sometimes = false;
        _onOffSometimes = "Sometimes Off";
    }

    public bool Always
    {
        get { return _always; }
        set
        {
            _always = value;
            if (_always)
                OnOffAlways = "Always On";
            else
                OnOffAlways = "Always Off";
            OnPropertyChanged("Always");
        }
    }

    public string OnOffAlways
    {
        get { return _onOffAlways; }
        set
        {
            _onOffAlways = value;
            OnPropertyChanged("OnOffAlways");
        }
    }

    public bool Sometimes
    {
        get { return _sometimes; }
        set
        {
            _sometimes = value;
            if (_sometimes)
                OnOffSometimes = "Sometimes On";
            else
                OnOffSometimes = "Sometimes Off";
            OnPropertyChanged("Sometimes");
        }
    }

    public string OnOffSometimes
    {
        get { return _onOffSometimes; }
        set
        {
            _onOffSometimes = value;
            OnPropertyChanged("OnOffSometimes");
        }
    }

    protected void OnPropertyChanged(string name)
    {
        PropertyChangedEventHandler handler = PropertyChanged;
        if (handler != null)
        {
            handler(this, new PropertyChangedEventArgs(name));
        }
    }
}

ViewModel2 (without Sometimes property):

    public class ViewModel2 : ViewModelBase, INotifyPropertyChanged
{
    private bool _always;
    private string _onOffAlways;

    public event PropertyChangedEventHandler PropertyChanged;

    public ViewModel2()
    {
        _always = false;
        _onOffAlways = "Always Off";
    }

    public bool Always
    {
        get { return _always; }
        set
        {
            _always = value;
            if (_always)
                OnOffAlways = "Always On";
            else
                OnOffAlways = "Always Off";
            OnPropertyChanged("Always");
        }
    }

    public string OnOffAlways
    {
        get { return _onOffAlways; }
        set
        {
            _onOffAlways = value;
            OnPropertyChanged("OnOffAlways");
        }
    }

    protected void OnPropertyChanged(string name)
    {
        PropertyChangedEventHandler handler = PropertyChanged;
        if (handler != null)
        {
            handler(this, new PropertyChangedEventArgs(name));
        }
    }
}

public class AlwaysVisibleConverter : IValueConverter
{
    #region Implementation of IValueConverter

    public object Convert(object value,
                          Type targetType, object parameter, CultureInfo culture)
    {
        return Visibility.Visible;
    }

    public object ConvertBack(object value, Type targetType,
                              object parameter, CultureInfo culture)
    {
        throw new NotImplementedException();
    }

    #endregion
}

回答1:


There are many different ways one could approach your scenario. For what it's worth, the solution you already have seems reasonable to me. The warning you get (I presume you are talking about the error message output to the debug console) is reasonably harmless. It does imply a potential performance issue, as it indicates WPF is recovering from an unexpected condition. But I would expect the cost to be incurred only when the view model changes, which should not be frequent enough to matter.

Another option, which is IMHO the preferred one, is to just use the usual WPF data templating features. That is, define a different template for each view model you expect, and then let WPF pick the right one according to the current view model. That would look something like this:

<UserControl x:Class="TestSO46736914MissingProperty.UserControl1"
             x:ClassModifier="internal"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:l="clr-namespace:TestSO46736914MissingProperty"
             mc:Ignorable="d" 
             Content="{Binding}"
             d:DesignHeight="300" d:DesignWidth="300">
  <UserControl.Resources>
    <DataTemplate DataType="{x:Type l:ViewModel1}">
      <Grid>
        <Grid.RowDefinitions>
          <RowDefinition Height="40"/>
          <RowDefinition Height="Auto"/>
          <RowDefinition Height="40"/>
          <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>
        <StackPanel Grid.Row="1" Orientation ="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Center" Margin="0,5,0,5">
          <TextBlock Margin="5,0,5,0" Text="{Binding Path=OnOffAlways}"/>
          <CheckBox IsChecked="{Binding Path=Always}">
            <TextBlock Text="On/Off"/>
          </CheckBox>
        </StackPanel>
        <StackPanel Grid.Row="3" Orientation ="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Center" Margin="0,5,0,5">
          <TextBlock Margin="5,0,5,0" Text="{Binding Path=OnOffSometimes}"/>
          <CheckBox IsChecked="{Binding Path=Sometimes}">
            <TextBlock Text="On/Off"/>
          </CheckBox>
        </StackPanel>
      </Grid>
    </DataTemplate>
    <DataTemplate DataType="{x:Type l:ViewModel2}">
      <Grid>
        <Grid.RowDefinitions>
          <RowDefinition Height="40"/>
          <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>
        <StackPanel Grid.Row="1" Orientation ="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Center" Margin="0,5,0,5">
          <TextBlock Margin="5,0,5,0" Text="{Binding Path=OnOffAlways}"/>
          <CheckBox IsChecked="{Binding Path=Always}">
            <TextBlock Text="On/Off"/>
          </CheckBox>
        </StackPanel>
      </Grid>
    </DataTemplate>
  </UserControl.Resources>
</UserControl>

I.e. just set the Content of your UserControl object to the view model object itself, so that the appropriate template is used to display the data in the control. The template for the view model object that doesn't have the property, doesn't reference that property and so no warning is generated.

Yet another option, which like the above also addresses your concern about the displayed warning, is to create a "shim" (a.k.a. "adapter") object that mediates between the unknown view model type and a consistent one the UserControl can use. For example:

class ViewModelWrapper : NotifyPropertyChangedBase
{
    private readonly dynamic _viewModel;

    public ViewModelWrapper(object viewModel)
    {
        _viewModel = viewModel;
        HasSometimes = viewModel.GetType().GetProperty("Sometimes") != null;
        _viewModel.PropertyChanged += (PropertyChangedEventHandler)_OnPropertyChanged;
    }

    private void _OnPropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        _RaisePropertyChanged(e.PropertyName);
    }

    public bool Always
    {
        get { return _viewModel.Always; }
        set { _viewModel.Always = value; }
    }

    public string OnOffAlways
    {
        get { return _viewModel.OnOffAlways; }
        set { _viewModel.OnOffAlways = value; }
    }

    public bool Sometimes
    {
        get { return HasSometimes ? _viewModel.Sometimes : false; }
        set { if (HasSometimes) _viewModel.Sometimes = value; }
    }

    public string OnOffSometimes
    {
        get { return HasSometimes ? _viewModel.OnOffSometimes : null; }
        set { if (HasSometimes) _viewModel.OnOffSometimes = value; }
    }

    private bool _hasSometimes;
    public bool HasSometimes
    {
        get { return _hasSometimes; }
        private set { _UpdateField(ref _hasSometimes, value); }
    }
}

This object uses the dynamic feature in C# to access the known property values, and uses reflection on construction to determine whether or not it should try to access the Sometimes (and related OnOffSometimes) property (accessing the property via the dynamic-typed variable when it doesn't exist would throw an exception).

It also implements the HasSometimes property so that the view can dynamically adjust itself accordingly. Finally, it also proxies the underlying PropertyChanged event, to go along with the delegated properties themselves.

To use this, a little bit of code-behind for the UserControl is needed:

partial class UserControl1 : UserControl, INotifyPropertyChanged
{
    public ViewModelWrapper ViewModelWrapper { get; private set; }

    public UserControl1()
    {
        DataContextChanged += _OnDataContextChanged;
        InitializeComponent();
    }

    public event PropertyChangedEventHandler PropertyChanged;

    private void _OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
    {
        ViewModelWrapper = new ViewModelWrapper(DataContext);
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ViewModelWrapper)));
    }
}

With this, the XAML is mostly like what you originally had, but with a style applied to the optional StackPanel element that has a trigger to show or hide the element according to whether the property is present or not:

<UserControl x:Class="TestSO46736914MissingProperty.UserControl1"
             x:ClassModifier="internal"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:p="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:l="clr-namespace:TestSO46736914MissingProperty"
             mc:Ignorable="d" 
             d:DesignHeight="300" d:DesignWidth="300">
  <Grid DataContext="{Binding ViewModelWrapper, RelativeSource={RelativeSource AncestorType=UserControl}}">
    <Grid.RowDefinitions>
      <RowDefinition Height="40"/>
      <RowDefinition Height="Auto"/>
      <RowDefinition Height="40"/>
      <RowDefinition Height="Auto"/>
    </Grid.RowDefinitions>
    <StackPanel Grid.Row="1" Orientation ="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Center" Margin="0,5,0,5">
      <TextBlock Margin="5,0,5,0" Text="{Binding Path=OnOffAlways}"/>
      <CheckBox IsChecked="{Binding Path=Always}">
        <TextBlock Text="On/Off"/>
      </CheckBox>
    </StackPanel>
    <StackPanel Grid.Row="3" Orientation ="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Center" Margin="0,5,0,5">
      <StackPanel.Style>
        <p:Style TargetType="StackPanel">
          <p:Style.Triggers>
            <DataTrigger Binding="{Binding HasSometimes}" Value="False">
              <Setter Property="Visibility" Value="Collapsed"/>
            </DataTrigger>
          </p:Style.Triggers>
        </p:Style>
      </StackPanel.Style>
      <TextBlock Margin="5,0,5,0" Text="{Binding Path=OnOffSometimes}"/>
      <CheckBox IsChecked="{Binding Path=Sometimes}">
        <TextBlock Text="On/Off"/>
      </CheckBox>
    </StackPanel>
  </Grid>
</UserControl>

Note that the top-level Grid element's DataContext is set to the UserControl's ViewModelWrapper property, so that the contained elements use that object instead of the view model assigned by the parent code.

(You can ignore the p: XML namespace…that's there only because Stack Overflow's XAML formatting gets confused by <Style/> elements that use the default XML namespace.)

While I in general would prefer the template-based approach, as the idiomatic and inherently simpler one, this wrapper-based approach does have some advantages:

  • It can be used in situations where the UserControl object is declared in an assembly different from the one where the view model types are declared, and where the latter assembly cannot be referenced by the former.
  • It removes the redundancy that is required by the template-based approach. I.e. rather than having to copy/paste the shared elements of the templates, this approach uses a single XAML structure for the entire view, and shows or hides elements of that view as appropriate.

For completeness, here is the NotifyPropertyChangedBase class used by the ViewModelWrapper class above:

class NotifyPropertyChangedBase : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;

    protected void _UpdateField<T>(ref T field, T newValue,
        Action<T> onChangedCallback = null,
        [CallerMemberName] string propertyName = null)
    {
        if (EqualityComparer<T>.Default.Equals(field, newValue))
        {
            return;
        }

        T oldValue = field;

        field = newValue;
        onChangedCallback?.Invoke(oldValue);
        _RaisePropertyChanged(propertyName);
    }

    protected void _RaisePropertyChanged(string propertyName)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }
}

For what it's worth, I prefer this approach to re-implementing the INotifyPropertyChanged interface in each model object. The code is a lot simpler and easier to write, simpler to read, and less prone to errors.



来源:https://stackoverflow.com/questions/46736914/bind-to-property-only-if-it-exists

标签
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!