WPF 绑定到集合中多个对象的相同 属性

WPF binding to the same property of multiple objects in a collection

我正在尝试使用 WPF 创建一个界面,该界面可以同时显示和修改多个选定对象的属性。我知道这一定是可能的(Visual Studio 中的 属性 网格做到了)但我一直无法找到有关如何实现它的任何信息或示例。我发现了很多关于 MultiBinding 的信息,但它的典型用例似乎是将一个 UI 字段绑定到同一对象的多个属性,而我正试图做相反的事情——绑定一个 UI 字段到多个对象上的相同 属性。

更明确地说,我想要创建的行为是这样的:

例如,这是我的一个旧 WinForms 窗体,它做同样的事情,我或多或少试图在 WPF 中重新创建它。在那种情况下,我在没有数据绑定的情况下在代码隐藏中处理它,我不是特别热衷于重复这种经历。

选择了一项:

选择了多个项目(元素类型,Material 和 Beta 角度属性相同,其他不同):

针对我的特定用例的一些其他注意事项:

我目前最好的猜测是使用 MultiBinding(或它的自定义子 class),跟踪基础集合中的变化并以编程方式添加或删除绑定到每个对象的属性添加到 MultiBinding Bindings 集合,然后编写一个 IMultiValueConverter 来确定显示值。然而,这似乎有点 fiddle,并不是 MultiBindings 的真正设计目的,互联网舆论似乎不赞成使用 MultiBindings,除非绝对必要(尽管我不完全确定为什么)。有 better/more straightforward/standard 方法吗?

在我看来,对象封装在这方面确实对您有帮助,而不是试图让 MultiBinding 做一些它实际上没有能力处理的事情。

因此,在没有看到您的代码的情况下,我将做出一些假设:

  1. 您有一个代表每个对象的 ViewModel。我们称之为 ObjectViewModel.
  2. 您有一个代表页面状态的顶级 ViewModel。我们称之为 PageViewModel.

ObjectViewModel 可能具有以下属性:

string Name { get; set; }
string ElementType { get; set; }
string SelectionProfile { get; set; }
string Material { get; set; }
... etc

PageViewModel 可能有以下内容:

// Represents a list of selected items
ObjectSelectionViewModel SelectedItems { get; }

请注意新的 class ObjectSelectionViewModel,它不仅代表您选择的项目,而且允许您将其绑定到它,就好像它是单个对象一样。它可能看起来像这样:

public class ObjectSelectionViewModel : ObjectViewModel
{
    // The current list of selected items.
    public ObservableCollection<ObjectViewModel> SelectedItems { get; }

    public ObjectSelectionViewModel()
    {
        SelectedItems = new ObservableCollection<ObjectViewModel>();
        SelectedItems.CollectionChanged += (o, e) =>
        {
             // Pseudo-code here
             if (items were added)
             {
                  // Subscribe each to PropertyChanged, using Item_PropertyChanged
             }
             if (items were removed)
             {
                 // Unsubscribe each from PropertyChanged
             }                   
        };
    }

    void Item_PropertyChanged(object sender, NotifyPropertyChangedArgs e)
    {
         // Notify that the local, group property (may have) changed.
         NotifyPropertyChanged(e.PropertyName);
    }

    public override string Name
    {
        get 
        {
            if (SelectedItems.Count == 0)
            {
                 return "[None]";
            }
            if (SelectedItems.IsSameValue(i => i.Name))
            {
                 return SelectedItems[0].Name;
            }
            return string.Empty;
        }
        set
        {
            if (SelectedItems.Count == 1)
            {
                SelectedItems[0].Name = value;
            }
            // NotifyPropertyChanged for the traditional MVVM ViewModel pattern.
            NotifyPropertyChanged("Name");
        }           
    }

    public override string SelectionProfile
    {
        get 
        {
            if (SelectedItems.Count == 0)
            {
                 return "[None]";
            }
            if (SelectedItems.IsSameValue(i => i.SelectionProfile)) 
            {
                return SelectedItems[0].SelectionProfile;
            }
            return "[Multi]";
        }
        set
        {
            foreach (var item in SelectedItems)
            {
                item.SelectionProfile = value;
            }
            // NotifyPropertyChanged for the traditional MVVM ViewModel pattern.
            NotifyPropertyChanged("SelectionProfile");
        }           
    }

    ... etc ...
}

// Extension method for IEnumerable
public static bool IsSameValue<T, U>(this IEnumerable<T> list, Func<T, U> selector) 
{
    return list.Select(selector).Distinct().Count() == 1;
}

您甚至可以在此 class 上实现 IList<ObjectViewModel>INotifyCollectionChanged,将其变成一个您可以直接绑定的全功能列表。

像这样的东西应该可以工作(在 ViewModel 中):

ObservableCollection<Item> _selectedItems;
// used to handle multi selection, the easiest is to set it from View in SelectionChanged event
public ObservableCollection<Item> SelectedItems
{
    get { return _selectedItems; }
    set
    {
        _selectedItems = value;
        OnPropertyChanged();
        // this will trigger View updating value from getter
        OnPropertyChanged(nameof(SomeProperty));
    }
}

// this will be one of your properties to edit, you'll have to do this for each property you want to edit
public double SomeProperty
{
    get { return SelectedItems.Average(); } // as example
    set
    {
        foreach(var item in SelectedItems)
            item.SomeProperty = value;
    }
}

然后只需将 SomeProperty 绑定到 display/edit 其值所需的任何内容即可。

此功能在 WPF 中不是现成的,但是有一些选项可以实现此功能:

  1. 使用一些支持同时编辑多个对象的第 3 方控件,例如PropertyGrid from Extended WPF Toolkit

  2. 创建与您的对象具有相同属性但包装对象集合的包装器对象。然后绑定到这个包装器 class.

    public class YourClassMultiEditWrapper{
        private ICollection<YourClass> _objectsToEdit;
    
        public YourClassMultiEditWrapper(ICollection<YourClass> objectsToEdit)
            _objectsToEdit = objectsToEdit;
    
        public string SomeProperty {
           get { return _objectsToEdit[0].SomeProperty ; } 
           set { foreach(var item in _objectsToEdit) item.SomeProperty = value; }
        }
    }
    
    public class YourClass {
       public property SomeProperty {get; set;}
    }
    

    优点是操作简单。缺点是您需要为每个要编辑的 class 创建包装器。

3. 您可以使用自定义 TypeDescriptor 创建通用包装器 class。在您的自定义 TypeDescriptor 重写 GetProperties() 方法中,它会 return 具有与您的对象相同的属性。您还需要使用覆盖的 GetValueSetValue 方法创建自定义 PropertyDescriptor,以便它与您的对象集合一起编辑

    public class MultiEditWrapper<TItem> : CustomTypeDescriptor {
      private ICollection<TItem> _objectsToEdit;
      private MultiEditPropertyDescriptor[] _propertyDescriptors;

      public MultiEditWrapper(ICollection<TItem> objectsToEdit) {
        _objectsToEdit = objectsToEdit;
        _propertyDescriptors = TypeDescriptor.GetProperties(typeof(TItem))
          .Select(p => new MultiEditPropertyDescriptor(objectsToEdit, p))
          .ToArray();  
      }

      public override PropertyDescriptorCollection GetProperties()
      {
        return new PropertyDescriptorCollection(_propertyDescriptors);
      }
    }

我不认为您可以让绑定按照您希望的方式工作。但是您可以通过在您的类型的项目的包装器 class 中处理它来使 PropertyChanged 事件对您有利。在下面的代码中,MultiEditable class 处理 EditItem 属性 的 PropertyChanged 事件。如果您有一个表单,其中用户正在编辑一个梁的属性,您将希望将表单上的输入控件绑定到 EditItem 的属性。您将需要覆盖 _EditItem_PropertyChanged,如图所示,您可以从那里更新所选项目的属性,因为 EditItem 的属性已更改。不要忘记取消处理事件。

编辑:我忘记添加代码来检查所有属性是否与某个值相同。这很容易做到 - 只需检查集合并将所有项目的 属性 与 EditItem 的相同 属性 进行比较。如果它们都相同 return true,否则 "Multi" 或任何你需要的。您还可以在代码中引用 MultiEditable - 只需更新 EditItem 属性,所选项目和视觉效果都会更新。

public interface ISelectable
{
    bool IsSelected { get; set; }
}

public abstract class MultiEditable<T> : ObservableCollection<T> where T:class,ISelectable,INotifyPropertyChanged
{
    private T _EditItem;
    public T EditItem 
    {
        get { return _EditItem; }
        set 
        { 
            if(_EditItem != value)
            {
                _EditItem = value;
                _EditItem.PropertyChanged += _EditItem_PropertyChanged;
            }
        }
    }

    public bool AreMultipleItemsSelected
    {
        get { return this.Count(x => x.IsSelected) > 1; }
    }

    public virtual void _EditItem_PropertyChanged(object sender, PropertyChangedEventArgs e)
    {

    }
}

public class MultiEditableBeams : MultiEditable<Beam> 
{
    public override void _EditItem_PropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        base._EditItem_PropertyChanged(sender, e);

        foreach (Beam beam in this.Where(x => x.IsSelected))
        {
            if (e.PropertyName == "Material")
                beam.Material = EditItem.Material;
            else if (e.PropertyName == "Length")
                beam.Length = EditItem.Length;

        }
    }
}

public class Beam : ISelectable, INotifyPropertyChanged
{
    private bool _IsSelected;
    public bool IsSelected 
    {
        get { return _IsSelected; }
        set
        {
            if (_IsSelected != value)
            {
                _IsSelected = value;
                RaisePropertyChanged();
            }
        }
    }

    private string _Material;
    public string Material
    {
        get { return _Material; }
        set
        {
            if (_Material != value)
            {
                Material = value;
                RaisePropertyChanged();
            }
        }
    }

    private int _Length;
    public int Length
    {
        get { return _Length; }
        set
        {
            if (_Length != value)
            {
                _Length = value;
                RaisePropertyChanged();
            }
        }
    }


    public event PropertyChangedEventHandler PropertyChanged;

    private void RaisePropertyChanged([CallerMemberName] string propertyName = "")
    {
        if (PropertyChanged != null)
            PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
    }
}