ObserveableCollection 的 ICollectionView 上的混合 static/dynamic ContextMenu (MCVE)

Mixed static/dynamic ContextMenu on ICollectionView of ObserveableCollection (MCVE)

我正在构建上下文菜单,使用 XAML MenuItems(又名 static)与动态创建的 MenuItems 混合,然后是更多静态。有的没动态显示就隐藏,有动态显示才显示。

(我将 Bindings 和 ValueConverters 留给了 hide/show mcve 之外的东西)

上下文菜单:

Static entry 1        // this one is hidden if any dynamic entries are visible
Static entry 2        // always visible
--------------        // seperator, hidden if no dynamic entry is shown
dynamic entries       // \_
dynamic entries       //   \___  shown sorted if any in collection
dynamic entries       //  _/     and then only those with filter == ok   
dynamic entries       // /
---------------       // seperator - always visible
Static entry 3        // 
Static entry 4        //   \  three more static entries, 
Static entry 5        //   /  always visble 

2 问题:内存不断增加 - 以及几个红色 XAML 错误

System.Windows.Data Error: 4 : Cannot find source for binding with reference 'RelativeSource FindAncestor, AncestorType='System.Windows.Controls.ItemsControl', AncestorLevel='1''. BindingExpression:Path=VerticalContentAlignment; DataItem=null; target element is 'MenuItem' (Name=''); target property is 'VerticalContentAlignment' (type 'VerticalAlignment')

我无法以自动更新 ObersveableCollection 的方式将 ICollecionView 直接绑定到 ContextMenue.ItemSource.CompositeCollection.CollectionContainer.Collection - 视图用作其源的更改。

这就是为什么我使用 ObersveableCollection-Items 中的 INotifyPropertyChanged 来规避它的原因 - 我的猜测是我通过重置 CollectionContainerCollection 到重新创建的 ICollectionView。

如何在不报错且不断增加内存的情况下正确解决?

"Minimal" 示例代码:(来自 WPF App (.NET Framework) 模板)

MainWindow.xaml:

<Window x:Class="DarnContextMenu.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:DarnContextMenu"
        mc:Ignorable="d"
        Title="MainWindow" Height="350" Width="525">


  <Window.Resources>
    <ResourceDictionary>
      <!-- Vm to MenuItem -->
      <local:VmToMenuItemConverter x:Key="VmToMenuItem"/>

      <!-- display template -->
      <DataTemplate  x:Key="vmTemplate">
        <StackPanel Margin="5">
          <TextBlock Text="{Binding ConName}"/>
        </StackPanel>
      </DataTemplate>

    </ResourceDictionary>
  </Window.Resources>

  <Window.ContextMenu>
    <ContextMenu>
      <ContextMenu.ItemsSource>
        <CompositeCollection >
          <!-- Connectoptions -->
          <MenuItem Header="Connect to last used"/>
          <MenuItem Header="Connect to ..."/>
          <Separator/>
          <!-- List of not disabled connections -->
          <CollectionContainer x:Name="cc" Collection="{Binding ConsView, Converter={StaticResource VmToMenuItem}}"/>
          <Separator/>
          <!-- Others -->
          <MenuItem Header="Settings ..."/>
          <MenuItem Header="Do Something ..."/>
          <MenuItem Header="Exit ..."/>
        </CompositeCollection>
      </ContextMenu.ItemsSource>
    </ContextMenu>
  </Window.ContextMenu>

  <DockPanel>
    <Label DockPanel.Dock="Bottom" x:Name="msgBlock" Height="28" VerticalAlignment="Center" HorizontalAlignment="Right"/>

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

    <DockPanel>
      <TextBlock DockPanel.Dock="Top" Margin="0,5" HorizontalAlignment="Center">Listview with ICollectionView</TextBlock>
      <ListView Grid.Column="0" ItemsSource="{Binding ConsView}" ItemTemplate="{StaticResource vmTemplate}" Background="LightGray"/>
    </DockPanel>

    <DockPanel Grid.Column="2">
      <TextBlock DockPanel.Dock="Top" Margin="0,5" HorizontalAlignment="Center">Listview with ObservableCollection:</TextBlock>
      <ListView ItemsSource="{Binding Cons}" ItemTemplate="{StaticResource vmTemplate}"/>
    </DockPanel>
  </Grid>
  </DockPanel>

</Window>

MainWindow.xaml.cs(MCVE 全部压在一起):

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Threading;

namespace DarnContextMenu
{
  // States used for filtering what is displayed via ICollectionView
  public enum EConState { Disabled, LoggedIn, LoggedOff };

  // Stripped down model
  public class Connection
  {
    public Connection (string name)
    {
      Name = name;
    }

    public EConState State { get; set; } = EConState.Disabled;
    public string Name { get; set; } = string.Empty;
  }


  // Viewmodel
  public class ConnectionVM : DependencyObject, INotifyPropertyChanged
  {
    // Simulation of changing States
    static List<EConState> allStates = new List<EConState> { EConState.Disabled, EConState.LoggedIn, EConState.LoggedOff };

    Timer t;

    void changeMe (object state)
    {
      if (state is ConnectionVM c)
        MainWindow.UIDispatcher
          .Invoke (() => c.State =
              allStates
              .Where (s => s != c.State)
              .OrderBy (_ => Guid.NewGuid ().GetHashCode ())
              .First ());
    }
    // End of simulation of changing States


    public static readonly DependencyProperty StateProperty = DependencyProperty.Register ("State", typeof (EConState), typeof (ConnectionVM),
      new PropertyMetadata (EConState.Disabled, (DependencyObject d, DependencyPropertyChangedEventArgs e) =>
      {
        if (d is ConnectionVM vm)
        {
          vm.ConName = $"{vm.Connection.Name} [{(EConState)e.NewValue}]";
          vm.PropertyChanged?.Invoke (vm, new PropertyChangedEventArgs (nameof (vm.State)));
        }
      }));

    // The state of the connection: influences if the connection is shown at all and used in sorting
    public EConState State
    {
      get { return (EConState)GetValue (StateProperty); }
      set { SetValue (StateProperty, value); }
    }

    // name created by models basename and state - changes via callback from StateProperty
    protected static readonly DependencyPropertyKey ConNamePropertyKey = DependencyProperty.RegisterReadOnly ("ConName", typeof (string), typeof (ConnectionVM), new PropertyMetadata (""));
    public static readonly DependencyProperty ConNameProperty = ConNamePropertyKey.DependencyProperty;
    public string ConName
    {
      get { return (string)GetValue (ConNameProperty); }
      protected set { SetValue (ConNamePropertyKey, value); }
    }

    Connection Connection { get; }

    public event PropertyChangedEventHandler PropertyChanged;

    /// <summary>
    /// 
    /// </summary>
    /// <param name="connection">The connection - used for name and initial state</param>
    /// <param name="delay">a delay for the timer until the state-changes start</param>
    /// <param name="period">a delay between state changes </param>
    public ConnectionVM (Connection connection, TimeSpan delay, TimeSpan period)
    {
      t = new Timer (changeMe, this, (int)delay.TotalMilliseconds, (int)period.TotalMilliseconds);
      Connection = connection;
      State = Connection.State; // changing, simulated by timer inside VM
    }

  }

  public class MainViewModel
  {
    // all connections - in RL: occasionally new ones will be added by the user
    public ObservableCollection<ConnectionVM> Cons { get; set; }

    // filtered and sorted view on Cons - Collection
    public ICollectionView ConsView { get; set; }


    public MainViewModel (CollectionContainer cc)
    {
      // demodata - normally connections are created by userinteractions
      // this simulates 9 connections that change status every 4s to 10s
      Cons = new ObservableCollection<ConnectionVM> (
        Enumerable.Range (1, 9)
        .Select (n => new ConnectionVM (new Connection ($"Connection #{n}")
          , TimeSpan.FromMilliseconds (300 * n)
          , TimeSpan.FromMilliseconds (700 * (n + 5))))
      );

      // create a sorted and filtered view
      //  - sort by Status and then by Name
      //  - show only Connecitons that are not Disabled
      ConsView = new CollectionViewSource { Source = Cons }.View;
      using (var def = ConsView.DeferRefresh ())
      {
        ConsView.SortDescriptions.Add (new SortDescription ("State", ListSortDirection.Ascending));
        ConsView.SortDescriptions.Add (new SortDescription ("ConName", ListSortDirection.Ascending));

        ConsView.Filter = obj => (obj is ConnectionVM vm) && vm.State != EConState.Disabled;
      }

      // attach a Refresh-Action of MVM to each ConnectionVMs PropertyChanged which is fired by
      // ConnectionVM.StateProperty.Callback notifies each listener on StateProperty-Change
      foreach (var vm in Cons)
      {
        vm.PropertyChanged += (s, e) => // object s, PropertyChangedEventArgs e
        {
          cc.Collection = ConsView;
          RefreshViewModels ();
        };
      }

      // in case the whole collection is added or removed to/from
      Cons.CollectionChanged += (s, e) =>
        {
          cc.Collection = ConsView;
          RefreshViewModels ();
        };
    }

    void RefreshViewModels ()
    {
      ConsView.Refresh ();
      MainWindow.logger.Content = $"Valid: {Cons.Count (c => c.State != EConState.Disabled)}/{Cons.Count ()}   (In/Off/Disabled: {Cons.Count (c => c.State == EConState.LoggedIn)} / {Cons.Count (c => c.State == EConState.LoggedOff)} / {Cons.Count (c => c.State == EConState.Disabled)})";
    }

  }

  // create a MenuItem from the ConnectionVM - in real theres a bit more code inside due to Icons, Commands, etc.
  public class VmToMenuItemConverter : IValueConverter
  {
    public object Convert (object value, Type targetType, object parameter, CultureInfo culture)
      => new MenuItem { Header = (value as ConnectionVM).ConName ?? $"Invalid '{value.GetType ()}'" };

    public object ConvertBack (object value, Type targetType, object parameter, CultureInfo culture) => null;
  }

  public partial class MainWindow : Window
  {
    public static Dispatcher UIDispatcher = null;
    public static Label logger = null;

    public MainWindow ()
    {
      UIDispatcher = Application.Current.Dispatcher;

      InitializeComponent ();

      logger = msgBlock;
      DataContext = new MainViewModel (cc);
    }

  }
}

I was unable to directly bind the ICollecionView to the ContextMenue.ItemSource.CompositeCollection.CollectionContainer.Collection in a way that it autoupdated on ObersveableCollection-changes that the view uses as its source.

CompositeCollection + CollectionContainer: Bind CollectionContainer.Collection to property of ViewModel that is used as DataTemplates DataType

简单答案:

<CollectionViewSource x:Key="testing" Source="{Binding items}"></CollectionViewSource>

<ContextMenu.ItemsSource>
                <CompositeCollection>
                    <MenuItem Header="Standard MenuItem 3" />
                    <CollectionContainer Collection="{Binding Source={StaticResource testing}}" />
                    <MenuItem Header="Standard MenuItem 6" />
                </CompositeCollection>
            </ContextMenu.ItemsSource>

像这样,您可以将一个项目添加到 datacontext.items,它会立即出现在菜单中