无法在 Fluent Ribbon 中从后台获取项目容器

Can't get Item Container from Backstage in Fluent Ribbon

我无法从 Backstage 中的 ListBox 获取物品容器。说,我有以下 Backstage:

<!-- Backstage -->
<r:Ribbon.Menu>
  <r:Backstage x:Name="backStage">
    <r:BackstageTabControl>
      <r:BackstageTabItem Header="Columns">
        <Grid>
          <ListBox Grid.Row="1" Grid.Column="0" x:Name="lstColumns"/>
        </Grid>
      </r:BackstageTabItem>
    </r:BackstageTabControl>
  </r:Backstage>
</r:Ribbon.Menu>

我填:

public Root()
{
  ContentRendered += delegate
  {
    var list = new List<int> { 1, 2, 3 };
    foreach (var index in list)
    {
      lstColumns.Items.Add(index);
    }
  };
}

接下来,我想从 ListBox 的第一个条目中检索项目容器(在本例中为 ListBoxItem):

private void OnGetProperties(object sender, RoutedEventArgs e)
{
  // Get first item container
  var container = lstColumns.ItemContainerGenerator.ContainerFromIndex(0);
  if (container is not null)
  {
    MessageBox.Show($"container = {container.GetType().FullName}");
  }
  else
  {
    MessageBox.Show("container is null");
  }
}

但是 container 总是 null。但!如果我打开 Backstage 然后隐藏它,我会看到消息:

container = System.Windows.Controls.ListBoxItem.

所以,我决定添加在填充之前打开 Backstage 的代码:

backStage.IsOpen = true;
var list = new List<int> { 1, 2, 3 };
foreach (var index in list)
{
  lstColumns.Items.Add(index);
}
backStage.IsOpen = false;

有效 ,但是当您几乎看不到显示和隐藏 Backstage 时会出现闪烁。这不是完美的解决方案。那么,如何获取物品容器呢?

P.S。测试项目是here.

更新(解释)

我需要物品容器的原因是我需要在填充 ListBox 时添加设置 CheckBox 状态。此 ListBox 的样式包含 CheckBox 项目:

<Window.Resources>
  <Style x:Key="CheckBoxListStyle" TargetType="ListBox">
    <Setter Property="SelectionMode" Value="Multiple"/>
    <Setter Property="ItemContainerStyle">
      <Setter.Value>
        <Style TargetType="ListBoxItem">
          <Setter Property="Margin" Value="2"/>
          <Setter Property="Template">
            <Setter.Value>
              <ControlTemplate TargetType="ListBoxItem">
                <CheckBox Focusable="False"
                    IsChecked="{Binding Path=IsSelected,
                                        Mode=TwoWay,
                                        RelativeSource={RelativeSource TemplatedParent}}">
                  <ContentPresenter />
                </CheckBox>
              </ControlTemplate>
            </Setter.Value>
          </Setter>
        </Style>
      </Setter.Value>
    </Setter>
  </Style>
</Window.Resources>

因此,当我在上面的循环中添加文本时,会创建 CheckBox。然后,我需要设置来自 JSON 的那些复选框的状态。所以,我需要这样的东西:

var list = new List<int> { 1, 2, 3 };
var json = JsonNode.Parse("""
{
  "checked": true
}
""");
foreach (var index in list)
{
  CheckBox checkBox = null;
          
  var pos = lstColumns.Items.Add(index);
  var container = lstColumns.ItemContainerGenerator.ContainerFromIndex(pos);
  // Reach checkbox
  // ...
  // checkBox = ...
  // ...
  checkBox.IsChecked = json["checked"].GetValue<bool>();
}

问题是 container 总是 null。 此外,无论我使用 Loaded 还是 ContentRendered 事件都没有关系 - 在任何一种情况下 container 都是 null.

一个High-Level简介

ContainerFromIndex returns null 的原因是容器只是 is not realized.

Returns the element corresponding to the item at the given index within the ItemCollection or returns null if the item is not realized.

这由负责以下操作的 ItemContainerGenerator 控制。

  • Maintains an association between the data view of a multiple-item control, such as ContainerFromElement and the corresponding UIElement tasks.

  • Generates UIElement items on behalf of a multiple-item control.

A ListBox 是一个 ItemsControl,公开了 ItemsSource 属性 用于绑定或分配集合。

A collection that is used to generate the content of the ItemsControl. The default is null.

另一种选择是简单地将项目添加到 XAML 或代码中的 Items 集合。

The collection that is used to generate the content of the ItemsControl. The default is an empty collection. [...]

The property to access the collection object itself is read-only, and the collection itself is read-write.

Items属性的类型是ItemCollection, which is also a view

If you have an ItemsControl, such as a ListBox that has content, you can use the Items property to access the ItemCollection, which is a view. Because it is a view, you can then use the view-related functionalities such as sorting, filtering, and grouping. Note that when ItemsSource is set, the view operations delegate to the view over the ItemsSource collection. Therefore, the ItemCollection supports sorting, filtering, and grouping only if the delegated view supported them.

不能同时使用ItemsSourceItems,它们是相关的。

[...] you use either the Items or the ItemsSource property to specify the collection that should be used to generate the content of your ItemsControl. When the ItemsSource property is set, the Items collection is made read-only and fixed-size.

ItemsSourceItems 都保持对您的 数据项的引用或绑定 ,这些 不是 容器。 ItemContainerGenerator 负责创建用户界面元素或容器,例如 ListBoxItem 并维护数据与这些项目之间的关系。这些容器不仅存在于应用程序的整个生命周期中,而且会根据需要创建和销毁。什么时候发生?这取决于。当容器显示在 UI 中时,它们被创建或 实现 (使用内部术语)。这就是为什么您只能在容器首次显示后才能访问它。它们实际存在的时间取决于交互、虚拟化或容器回收等因素。我所说的交互是指任何形式的更改视口,这是您实际可以看到的列表的一部分。每当项目滚动到视图中时,它们当然需要被实现。对于包含数万个项目的大型列表,提前实现所有容器或在实现后保留所有容器会影响性能并显着增加内存消耗。这就是虚拟化发挥作用的地方。请参阅 Displaying large data sets 以供参考。

UI Virtualization is an important aspect of list controls. UI virtualization should not be confused with data virtualization. UI virtualization stores only visible items in memory but in a data-binding scenario stores the entire data structure in memory. In contrast, data virtualization stores only the data items that are visible on the screen in memory.

By default, UI virtualization is enabled for the ListView and ListBox controls when their list items are bound to data.

这意味着容器也被删除了。此外,还有 container recycling:

When an ItemsControl that uses UI virtualization is populated, it creates an item container for each item that scrolls into view and destroys the item container for each item that scrolls out of view. Container recycling enables the control to reuse the existing item containers for different data items, so that item containers are not constantly created and destroyed as the user scrolls the ItemsControl. You can choose to enable item recycling by setting the VirtualizationMode attached property to Recycling.

虚拟化和容器回收的结果是所有项目的容器都没有实现。您的绑定或分配项目的子集只有容器,它们可能会被回收或分离。这就是为什么直接引用例如ListBoxItem秒。即使禁用虚拟化,您也可以 运行 遇到像您这样的问题,尝试访问与您的数据项具有不同生命周期的用户界面元素。

本质上,您的方法 可以 行得通,但我推荐一种更加稳定和稳健并且与上述所有注意事项兼容的不同方法。

一个Low-Level视图

这里到底发生了什么?让我们以中等深度探索代码,因为我的手腕已经受伤了。

这里是.NET的ContainerFromIndex method in the reference source

  • for loop in line 931 使用 _itemMap.
  • Next 属性 迭代 ItemBlocks
  • 当您的项目未显示,但在用户界面中时,它们未实现。
  • 在这种情况下,Next 将 return 一个 UnrealizedItemBlockItemBlock 的导数)。
  • 此项目块的 属性 ItemCount 为零。
  • if condition in line 933不会满足
  • 这一直持续到项目块被迭代并且null is returned in line 954.

一旦显示 ListBox 及其项,Next 迭代器将 return 一个 RealizedItemBlock,它的 ItemCount 大于零并且因此将产生一个项目。

那么容器是如何实现的呢?有生成容器的方法。

  • DependencyObject IItemContainerGenerator.GenerateNext(),参见line 230
  • DependencyObject IItemContainerGenerator.GenerateNext(out bool isNewlyRealized), see line 239.

这些在不同的地方被调用,比如 VirtualizingStackPanel - 用于虚拟化。

  • protected internal override void BringIndexIntoView(int index),请参阅 line 1576,它的功能与它的名称完全相同。当需要将具有特定索引的项目带入视图时,例如通过滚动,面板需要创建项目容器才能在用户界面中显示项目。
  • private void MeasureChild(...),见line 8005。在计算显示一个ListView所需的space时使用该方法,该方法受其所需项的数量和大小的影响。
  • ...

从 high-level ListBox 对其基类型 ItemsControl 进行大量间接寻址,最终调用 ItemContainerGenerator 来实现项目。

一个 MVVM 兼容的解决方案

对于前面提到的所有问题,都有一个简单但更好的解决方案。将您的数据和应用程序逻辑与用户界面分开。这可以使用 MVVM 设计模式来完成。介绍可以参考Josh Smith的Patterns - WPF Apps With The Model-View-ViewModel Design Pattern文章

在这个解决方案中,我在这里使用 Microsoft.Toolkit.Mvvm NuGet package from Microsoft. You can find an introduction and a detailed documentation。我使用它是因为对于 WPF 中的 MVVM,您需要一些用于可观察对象和命令的样板代码,这会使初学者的示例变得臃肿。这是一个很好的库,可以作为入门并稍后了解这些工具如何在幕后工作的详细信息。

让我们开始吧。安装前面提到的 NuGet打包在一个新的解决方案中。接下来,创建一个代表我们的数据项的类型。它只包含两个属性,一个用于索引,即read-only,一个用于可以更改的选中状态。绑定仅适用于属性,这就是为什么我们使用它们而不是例如领域。该类型派生自 ObservableObject,它实现了 INotifyPropertyChanged 接口。需要实现此接口才能通知 属性 值更改,否则后面介绍的绑定将不知道何时更新用户界面中的值。 ObservableObject 基本类型已经提供了一个 SetProperty 方法,该方法负责为 属性 的支持字段设置新值并自动通知其更改。

using Microsoft.Toolkit.Mvvm.ComponentModel;

namespace RibbonBackstageFillTest
{
   public class JsonItem : ObservableObject
   {
      private bool _isChecked;

      public JsonItem(int index, bool isChecked)
      {
         Index = index;
         IsChecked = isChecked;
      }

      // ...read-only property assumed here.
      public int Index { get; }

      public bool IsChecked
      {
         get => _isChecked;
         set => SetProperty(ref _isChecked, value);
      }

      // ...other properties.
   }
}

现在我们为您的 Root 视图实现一个视图模型,它保存用户界面的数据。它公开了我们用来存储 JSON 数据项的 ObservableCollection<JsonItem> 属性。如果添加、删除或替换了任何项目,此特殊集合会自动通知。这对于您的示例来说不是必需的,但我想您以后可能会对您有用。您还可以替换整个集合,因为我们再次从 ObservableObject 派生并使用 SetPropertyGetPropertiesCommand是一个命令,它只是一个封装的动作,一个执行任务的对象。它可以绑定并稍后替换 Click 处理程序。 CreateItems 方法只是像您的示例中那样创建一个列表。 GetProperties 是迭代列表并从 JSON 设置值的方法。根据您的需要调整代码。

using System.Collections.ObjectModel;
using System.Windows.Input;
using Microsoft.Toolkit.Mvvm.ComponentModel;
using Microsoft.Toolkit.Mvvm.Input;

namespace RibbonBackstageFillTest
{
   public class RootViewModel : ObservableObject
   {
      private ObservableCollection<JsonItem> _jsonItems;

      public RootViewModel()
      {
         JsonItems = CreateItems();
         GetPropertiesCommand = new RelayCommand(GetProperties);
      }

      public ObservableCollection<JsonItem> JsonItems
      {
         get => _jsonItems;
         set => SetProperty(ref _jsonItems, value);
      }

      public ICommand GetPropertiesCommand { get; }

      private ObservableCollection<JsonItem> CreateItems()
      {
         return new ObservableCollection<JsonItem>
         {
            new JsonItem(1, false),
            new JsonItem(2, true),
            new JsonItem(3, false),
            new JsonItem(4, true),
            new JsonItem(5, false)
         };
      }

      private void GetProperties()
      {
         foreach (var jsonItem in JsonItems)
         {
            jsonItem.IsChecked = // ...set your JSON values here.
         }
      }
   }
}

您的 Root 视图的 code-behind 现在已缩减为基本内容,不再有数据。

using Fluent;
using Fluent.Localization.Languages;
using System.Threading;
using System.Windows;

namespace RibbonBackstageFillTest
{
   public partial class Root
   {
      public Root()
      {
         InitializeComponent();
         WindowStartupLocation = WindowStartupLocation.CenterScreen;
         ContentRendered += delegate
         {
            if (Thread.CurrentThread.CurrentUICulture.Name != "en-US")
            {
               RibbonLocalization.Current.LocalizationMap.Clear();
               RibbonLocalization.Current.Localization = new English();
            }
         };
      }
   }
}

最后,我们为 Root 视图创建 XAML。我已经添加了评论供您跟进。本质上,我们将新的 RootViewModel 添加为 DataContext 并使用 data-binding 通过 ItemsSource 属性 将我们的数据项集合与 ListBox 连接起来.此外,我们使用 DataTemplate 来定义数据在用户界面中的外观,并将 Button 绑定到命令。

<r:RibbonWindow x:Class="RibbonBackstageFillTest.Root"
                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:r="urn:fluent-ribbon"
                xmlns:local="clr-namespace:RibbonBackstageFillTest"
                mc:Ignorable="d"
                Title="Backstage Ribbon"
                Height="450"
                Width="800">
   <r:RibbonWindow.DataContext>
      <!-- This creates an instance of the root view model and assigns it as data context. -->
      <local:RootViewModel/>
   </Window.DataContext>
   <Window.Resources>
      <Style x:Key="CheckBoxListStyle"
             TargetType="ListBox">
         <Setter Property="SelectionMode" Value="Multiple" />

         <!-- This is only used to style the containers, we do not need to change the control template -->
         <Setter Property="ItemContainerStyle">
            <Setter.Value>
               <Style TargetType="ListBoxItem">
                  <Setter Property="Margin" Value="2" />
               </Style>
            </Setter.Value>
         </Setter>

         <!-- An item template is used to define the appearance of a data item. -->
         <Setter Property="ItemTemplate">
            <Setter.Value>
               <!-- We create a data template for our custom item type. -->
               <DataTemplate DataType="local:JsonItem">
                  <!-- The binding will loosely connect the IsChecked property of CheckBox with the IsChecked property of its JsonItem. -->
                  <!-- The binding is TwoWay by default, meaning that you can change IsChecked in code or in the UI by clicking the CheckBox. -->
                  <!-- The IsChecked value will always be synchronized in the view and view model. -->
                  <CheckBox Focusable="False"
                            IsChecked="{Binding Path=IsChecked}"/>
               </DataTemplate>
            </Setter.Value>
         </Setter>
      </Style>
   </r:RibbonWindow.Resources>
   <Grid>
      <Grid.RowDefinitions>
         <RowDefinition Height="Auto" />
         <RowDefinition />
      </Grid.RowDefinitions>

      <r:Ribbon Grid.Row="0">

         <!-- Backstage -->
         <r:Ribbon.Menu>
            <r:Backstage>
               <r:BackstageTabControl>
                  <r:BackstageTabItem Header="Columns">
                     <Grid>
                        <!-- No need for a name anymore, we do not need to access controls. -->
                        <!-- The binding loosely connects the JsonItems collection with the ListBox. -->
                        <ListBox ItemsSource="{Binding JsonItems}"
                                 Style="{StaticResource CheckBoxListStyle}"/>
                     </Grid>
                  </r:BackstageTabItem>
               </r:BackstageTabControl>
            </r:Backstage>
         </r:Ribbon.Menu>

         <!-- Tabs -->
         <r:RibbonTabItem Header="Home">
            <r:RibbonGroupBox Header="ID">
               <!-- Instead of a Click event handler, we bind a command in the view model. -->
               <r:Button Size="Large"
                         LargeIcon="pack://application:,,,/RibbonBackstageFillTest;component/img/PropertySheet.png"
                         Command="{Binding GetPropertiesCommand}"
                         Header="Properties"/>
            </r:RibbonGroupBox>
         </r:RibbonTabItem>
      </r:Ribbon>

   </Grid>
</r:RibbonWindow>

现在有什么区别?数据和您的应用程序逻辑与用户界面分离。无论项目容器如何,数据始终存在于视图模型中。事实上,您的数据甚至不知道有容器或 ListBox。后台开不开都无所谓了,你直接操作你的数据,而不是用户界面。

更快更脏的解决方案

我不推荐这个解决方案,它只是一个快速而肮脏的解决方案,除了 MVVM 之外,在您了解如何正确执行之后可能更容易遵循。它使用之前的 JsonItem 类型,但这次没有外部库。现在你看到了 INotifyPropertyChanged 在幕后做了什么。

using System.ComponentModel;
using System.Runtime.CompilerServices;

namespace RibbonBackstageFillTest
{
   public class JsonItem : INotifyPropertyChanged
   {
      private bool _isChecked;

      public JsonItem(int index, bool isChecked)
      {
         Index = index;
         IsChecked = isChecked;
      }

      // ...read-only property assumed here.
      public int Index { get; }

      public bool IsChecked
      {
         get => _isChecked;
         set
         {
            if (_isChecked == value)
               return;

            _isChecked = value;
            OnPropertyChanged();
         }
      }

      // ...other properties.

      public event PropertyChangedEventHandler PropertyChanged;

      protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
      {
         PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
      }
   }
}

Root 视图的 code-behind 中,只需创建一个字段 _jsonItems 来存储项目。此字段用于稍后访问列表以更改 IsChecked 值。

using Fluent;
using Fluent.Localization.Languages;
using System.Collections.Generic;
using System.Threading;
using System.Windows;

namespace RibbonBackstageFillTest
{
   public partial class Root
   {
      private List<JsonItem> _jsonItems;

      public Root()
      {
         InitializeComponent();
         WindowStartupLocation = WindowStartupLocation.CenterScreen;
         ContentRendered += delegate
         {
            if (Thread.CurrentThread.CurrentUICulture.Name != "en-US")
            {
               RibbonLocalization.Current.LocalizationMap.Clear();
               RibbonLocalization.Current.Localization = new English();
            }
         };

         _jsonItems = new List<JsonItem>
         {
            new JsonItem(1, false),
            new JsonItem(2, true),
            new JsonItem(3, false),
            new JsonItem(4, true),
            new JsonItem(5, false)
         };
         lstColumns.ItemsSource = _jsonItems;
      }

      private void OnGetProperties(object sender, RoutedEventArgs e)
      {
         foreach (var jsonItem in _jsonItems)
         {
            jsonItem.IsChecked = // ...set your JSON value.
         }
      }
   }
}

最后 Root 视图没有太大变化。我们使用 MVVM 示例中的数据模板复制样式并将其设置为 ListBox。它的行为是一样的,因为您的数据不依赖于视图容器。

<r:RibbonWindow.Resources>
   <Style x:Key="CheckBoxListStyle"
          TargetType="ListBox">
      <Setter Property="SelectionMode" Value="Multiple" />

      <Setter Property="ItemContainerStyle">
         <Setter.Value>
            <Style TargetType="ListBoxItem">
               <Setter Property="Margin" Value="2" />
            </Style>
         </Setter.Value>
      </Setter>

      <Setter Property="ItemTemplate">
         <Setter.Value>
            <DataTemplate DataType="local:JsonItem">
               <CheckBox Focusable="False"
                         IsChecked="{Binding Path=IsChecked}"/>
            </DataTemplate>
         </Setter.Value>
      </Setter>
   </Style>
</r:RibbonWindow.Resources>
<ListBox x:Name="lstColumns"
         Style="{StaticResource CheckBoxListStyle}"/>