强制WPF创建ItemsControl中的项

6
我想验证我的ListBox中的项目在UI中是否正确显示。我想到一种方法是遍历ListBox在可视树中的所有子项,获取它们的文本,然后将其与我期望的文本进行比较。
这种方法的问题在于ListBox内部使用一个VirtualizingStackPanel来显示其项目,因此只有可见的项目才会被创建。最终,我找到了ItemContainerGenerator类,它看起来应该强制WPF为指定的项目创建可视树中的控件。不幸的是,这给我带来了一些奇怪的副作用。以下是生成ListBox中所有项目的代码:
List<string> generatedItems = new List<string>();
IItemContainerGenerator generator = this.ItemsListBox.ItemContainerGenerator;
GeneratorPosition pos = generator.GeneratorPositionFromIndex(-1);
using(generator.StartAt(pos, GeneratorDirection.Forward))
{
    bool isNewlyRealized;
    for(int i = 0; i < this.ItemsListBox.Items.Count; i++)
    {
        isNewlyRealized = false;
        DependencyObject cntr = generator.GenerateNext(out isNewlyRealized);
        if(isNewlyRealized)
        {
            generator.PrepareItemContainer(cntr);
        }

        string itemText = GetControlText(cntr);
        generatedItems.Add(itemText);
    }
}

如果您需要,我可以提供GetItemText()的代码,但它只是遍历可视树,直到找到TextBlock。我意识到还有其他方法可以在项中添加文本,但在正确生成项之后,我将解决这个问题。
在我的应用程序中,ItemsListBox包含20个项目,最初显示前12个项目。前14个项目的文本是正确的(可能是因为它们的控件已经生成)。然而,对于第15-20个项目,我根本没有得到任何文本。此外,如果我滚动到ItemsListBox的底部,第15-20个项目的文本也为空白。因此,似乎我以某种方式干扰了WPF正常生成控件的机制。
我做错了什么?是否有一种不同/更好的方法来强制添加到可视树中的ItemsControl中的项目?
更新:我认为我已经找到了原因,尽管我不知道如何解决它。我假设调用PrepareItemContainer()将生成显示项目所需的任何必要控件,然后将容器添加到正确位置的可视树中。事实证明它没有做这两件事。容器不会添加到ItemsControl中,直到我向下滚动以查看它,并且此时仅创建容器本身(即ListBoxItem)-其子项未创建(此处应添加几个控件之一,其中一个应该是将显示项目文本的TextBlock)。
如果我遍历传递给PrepareItemContainer()的控件的可视树,则结果相同。在两种情况下,只创建ListBoxItem,而不创建任何子项。
我找不到一个好的方法来将ListBoxItem添加到可视树中。我在可视树中找到了VirtualizingStackPanel,但调用它的Children.Add()会导致InvalidOperationException(不能直接向ItemPanel添加项,因为它为其ItemsControl生成项)。只是作为一个测试,我尝试使用反射调用它的AddVisualChild()(因为它是受保护的),但那也行不通。

我不明白。你为什么想要这样做?测试吗? - Kent Boogaart
是的 - 只是为了测试目的。 - Andy
在添加项之前,您可以将VirtualizingStackPanel.IsVirtualizing 附加属性设置为 false,然后再将其设置为 true,当您滚动时,事情将开始虚拟化。 - Robert Macnee
7个回答

3

你可能采用了错误的方法。我所做的是将我的DataTemplate的Loaded事件挂钩:

<DataTemplate DataType="{x:Type local:ProjectPersona}">
  <Grid Loaded="Row_Loaded">
    <!-- ... -->
  </Grid>
</DataTemplate>

...然后在事件处理程序中处理新显示的行:

private void Row_Loaded(object sender, RoutedEventArgs e)
{
    Grid grid = (Grid)sender;
    Carousel c = (Carousel)grid.FindName("carousel");
    ProjectPersona project = (ProjectPersona)grid.DataContext;
    if (project.SelectedTime != null)
        c.ScrollItemIntoView(project.SelectedTime);
}

这种方法会在首次显示行时进行初始化/检查,因此不会一次性处理所有行。如果您可以接受这一点,那么也许这是更优雅的方法。

2

仅仅快速浏览了一下,如果 ListBox 使用 VirtualizingStackPanel - 可能只需要将其替换为 StackPanel 就足够了,例如:

<ListBox.ItemsPanel>
  <ItemsPanelTemplate>
      <StackPanel/>
  <ItemsPanelTemplate>
<ListBox.ItemsPanel>

虽然这样可以解决问题,但如果可能的话,我宁愿不改变 ListBox 本身,因为在实际应用中使用 VirtualizingStackPanel 会更好。 - Andy
我尝试使用ComboBox,但出于某种原因它没有起作用。 - eran otzap

1

我想我找到了如何解决这个问题。问题在于生成的项目没有添加到可视树中。经过一些搜索,我能找到的最好方法是在ListBox中调用VirtualizingStackPanel的一些受保护的方法。虽然这不是理想的解决方案,但由于只是为了测试,我认为我必须接受它。

这是对我有效的方法:

VirtualizingStackPanel itemsPanel = null;
FrameworkElementFactory factory = control.ItemsPanel.VisualTree;
if(null != factory)
{
    // This method traverses the visual tree, searching for a control of
    // the specified type and name.
    itemsPanel = FindNamedDescendantOfType(control,
        factory.Type, null) as VirtualizingStackPanel;
}

List<string> generatedItems = new List<string>();
IItemContainerGenerator generator = this.ItemsListBox.ItemContainerGenerator;
GeneratorPosition pos = generator.GeneratorPositionFromIndex(-1);
using(generator.StartAt(pos, GeneratorDirection.Forward))
{
    bool isNewlyRealized;
    for(int i = 0; i < this.ItemsListBox.Items.Count; i++)
    {
        isNewlyRealized = false;
        UIElement cntr = generator.GenerateNext(out isNewlyRealized) as UIElement;
        if(isNewlyRealized)
        {
            if(i >= itemsPanel.Children.Count)
            {
                itemsPanel.GetType().InvokeMember("AddInternalChild",
                    BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.InvokeMember,
                    Type.DefaultBinder, itemsPanel,
                    new object[] { cntr });
            }
            else
            {
                itemsPanel.GetType().InvokeMember("InsertInternalChild",
                    BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.InvokeMember,
                    Type.DefaultBinder, itemsPanel,
                    new object[] { i, cntr });
            }

            generator.PrepareItemContainer(cntr);
        }

        string itemText = GetControlText(cntr);
        generatedItems.Add(itemText);
    }
}

1

Andy提出的解决方案非常好,但不完整。例如,前5个容器已经创建并在面板中。列表有300个以上的项目。我请求用此逻辑添加最后一个容器。然后我请求添加倒数第二个容器。这就是问题所在。面板内子项的顺序无效。

解决方案:

    private FrameworkElement GetContainerForIndex(int index)
    {
        if (ItemsControl == null)
        {
            return null;
        }

        var container = ItemsControl.ItemContainerGenerator.ContainerFromIndex(index -1);
        if (container != null && container != DependencyProperty.UnsetValue)
        {
            return container as FrameworkElement;
        }
        else
        {

            var virtualizingPanel = FindVisualChild<VirtualizingPanel>(ItemsControl);
            if (virtualizingPanel == null)
            {
                // do something to load the (perhaps currently unloaded panel) once
            }
            virtualizingPanel = FindVisualChild<VirtualizingPanel>(ItemsControl);

            IItemContainerGenerator generator = ItemsControl.ItemContainerGenerator;
            using (generator.StartAt(generator.GeneratorPositionFromIndex(index), GeneratorDirection.Forward))
            {
                bool isNewlyRealized = false;
                container = generator.GenerateNext(out isNewlyRealized);
                if (isNewlyRealized)
                {
                    generator.PrepareItemContainer(container);
                    bool insert = false;
                    int pos = 0;
                    for (pos = virtualizingPanel.Children.Count - 1; pos >= 0; pos--)
                    {
                        var idx = ItemsControl.ItemContainerGenerator.IndexFromContainer(virtualizingPanel.Children[pos]);
                        if (!insert && idx < index)
                        {
                            ////Add
                            virtualizingPanel.GetType().InvokeMember("AddInternalChild", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.InvokeMethod, Type.DefaultBinder, virtualizingPanel, new object[] { container });
                            break;
                        }
                        else
                        {
                            insert = true;
                            if (insert && idx < index)
                            {
                                break;
                            }
                        }
                    }

                    if (insert)
                    {
                        virtualizingPanel.GetType().InvokeMember("InsertInternalChild", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.InvokeMethod, Type.DefaultBinder, virtualizingPanel, new object[] { pos + 1, container });
                    }
                }

                return container as FrameworkElement;
            }
        }
    }

0

对于其他人的疑问,也许在Andy的情况下,用普通的StackPanel替换VirtualizingStackPanel可能是最好的解决方案。

调用ItemContainerGenerator上的PrepareItemContainer不起作用的原因是,必须将项设置为视觉树中的子项才能使PrepareItemContainer工作。使用VirtualizingStackPanel时,直到VirtualizingStackPanel确定该项已经/即将显示在屏幕上,该项才会被设置为面板的可视子项。

另一个解决方案(我使用的)是创建自己的VirtualizingPanel,这样您就可以控制何时将项添加到视觉树中。


0
如果您知道要检查的项目的索引,那么您可以将列表框向下滚动,直到到达所需的项目。

这是我一段时间前编写的函数:

private async Task<TreeViewItem> YuckyGenerateContainer(ScrollViewer scroller, ItemContainerGenerator generator, int index) {
     = VisualTreeUtils.FindDescendant<ScrollViewer>(this);
    bool? direction = null; // up = false, down = true
    bool foundFirst = false;
    for (int i = 0, len = generator.Items.Count; i < len; i++) {
        if (generator.ContainerFromIndex(i) is TreeViewItem) {
            if (i <= index) {
                direction = true;
                break;
            }
            else {
                foundFirst = true;
            }
        }
        else if (foundFirst) {
            direction = i <= index;
            break;
        }
    }
    TreeViewItem treeItem = null;
    if (direction == null) {
        return null;
    }
    else if (direction == true) { // down
        while (treeItem == null && this.PART_ScrollViewier.VerticalOffset < (this.PART_ScrollViewier.ExtentHeight - this.PART_ScrollViewier.ViewportHeight)) {
            this.PART_ScrollViewier.ScrollToVerticalOffset(this.PART_ScrollViewier.VerticalOffset + (this.PART_ScrollViewier.ViewportHeight / 2d));
            treeItem = await this.Dispatcher.InvokeAsync(() => generator.ContainerFromIndex(index) as TreeViewItem, DispatcherPriority.Render);
        }
    }
    else { // up
        while (treeItem == null && this.PART_ScrollViewier.VerticalOffset > 0d) {
            this.PART_ScrollViewier.ScrollToVerticalOffset(Math.Max(this.PART_ScrollViewier.VerticalOffset - (this.PART_ScrollViewier.ViewportHeight / 2d), 0));
            treeItem = await this.Dispatcher.InvokeAsync(() => generator.ContainerFromIndex(index) as TreeViewItem, DispatcherPriority.Render);
        }
    }
    return treeItem;
}

如果您需要FindDescendant函数:
public static T FindDescendant<T>(DependencyObject d) where T : DependencyObject {
    if (d == null)
        return null;
    if (d is T t)
        return t;
    int count = VisualTreeHelper.GetChildrenCount(d);
    for (int i = 0; i < count; i++) {
        DependencyObject child = VisualTreeHelper.GetChild(d, i);
        T result = child as T ?? FindDescendant<T>(child);
        if (result != null) {
            return result;
        }
    }
    return null;
}

这是一个异步函数,结合使用Dispatcher的InvokeAsync函数,可以使滚动在不完全冻结UI的情况下(大部分)平滑完成,但DispatcherPriorty是渲染,这意味着在滚动完成之前您将无法单击任何内容。

scroller参数是列表(或树,如果您正在使用TreeView)的ScrollViewer。


-1
在我的情况下,我发现在ItemsControlListBoxListView等)上调用UpdateLayout()会启动它的ItemContainerGenerator,使得生成器的状态从“NotStarted”变为“GeneratingContainers”,并且ItemContainerGenerator.ContainerFromItem和/或ItemContainerGenerator.ContainerFromIndex不再返回null容器。
例如:
    public static bool FocusSelectedItem(this ListBox listbox)
    {
        int ix;
        if ((ix = listbox.SelectedIndex) < 0)
            return false;

        var icg = listbox.ItemContainerGenerator;
        if (icg.Status == GeneratorStatus.NotStarted)
            listbox.UpdateLayout();

        var el = (UIElement)icg.ContainerFromIndex(ix);
        if (el == null)
            return false;

        listbox.ScrollIntoView(el);

        return el == Keyboard.Focus(el);
    }

很遗憾,listbox.UpdateLayout();无法正常工作。我有一个带有模板项的ListView,它们在ContainerFromIndex()中仍然返回空值。看起来应该有另一种解决方案。 - Vincent
@Vincent 如果你遇到了这个问题,那么从这个答案中可以得到一个有用的提示,就是在 ItemContainerGenerator 上挂钩 StatusChanged 事件,然后找出一些可靠的程序来触发它,并使其具有新的“GeneratingContainers”状态。 - Glenn Slayden

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接