Andy
Andy

Reputation: 30418

Forcing WPF to create the items in an ItemsControl

I want to verify that the items in my ListBox are displayed correctly in the UI. I figured one way to do this is to go through all of the children of the ListBox in the visual tree, get their text, and then compare that with what I expect the text to be.

The problem with this approach is that internally ListBox uses a VirtualizingStackPanel to display its items, so only the items that are visible are created. I eventually came across the ItemContainerGenerator class, which looks like it should force WPF to create the controls in the visual tree for the specified item. Unfortunately, that is causing some weird side affects for me. Here is my code to generate all of the items in the 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);
    }
}

(I can provide the code for GetItemText() if you'd like, but it just traverses the visual tree until a TextBlock is found. I realize that ther are other ways to have text in an item, but I'll fix that up once I get item generation working properly.)

In my app, ItemsListBox contains 20 items, with the first 12 items initially visible. The text for the first 14 items is correct (likely because their controls have already been generated). However, for items 15-20, I don't get any text at all. In addition, if I scroll to the bottom of the ItemsListBox, the text of items 15-20 is also blank. So it seems like I'm interfering with WPF's normal mechanism for generating controls some how.

What am I doing wrong? Is there a different/better way of forcing the items in an ItemsControl to be added to the visual tree?

Update: I think that I have found why this is occurring, although I do not know how to fix it. My assumption that the call to PrepareItemContainer() would generate any necessary controls to display the item, and then add the container to the visual tree in the correct location. It turns out that it is not doing either of these things. The container isn't added to the ItemsControl until I scroll down to view it, and at that time only the container itself (i.e. ListBoxItem) is created - its children are not created (there should be a few controls added here, one of which should be the TextBlock that will display the text of the item).

If I traverse the visual tree of the control that I passed to PrepareItemContainer() the results are the same. In both cases only the ListBoxItem is created, and none of its children are created.

I could not find a good way to add the ListBoxItem to the visual tree. I found the VirtualizingStackPanel in the visual tree, but calling its Children.Add() results in an InvalidOperationException (cannot add items directly to the ItemPanel, since it generates items for its ItemsControl). Just as a test, I tried calling its AddVisualChild() using Reflection (since it is protected), but that didn't work, either.

Upvotes: 6

Views: 9427

Answers (7)

REghZY
REghZY

Reputation: 69

If you know the index of the items that you want to check, then you could just essentially scroll the ListBox down until you reach the item you want to check

This is the function I wrote a while ago to do this:

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;
}

If you need the FindDescendant function:

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;
}

It's an async function and combined with using the Dispatcher's InvokeAsync function, allows the scrolling to be done (mostly) smoothly without completely freezing the UI, but the DispatcherPriorty is render, which means you won't be able to click anything until it's done scrolling

The scroller parameter is the ScrollViewer for the list (or tree, if you're using a TreeView)

Upvotes: 0

Glenn Slayden
Glenn Slayden

Reputation: 18749

In my case, I found that calling UpdateLayout() on the ItemsControl (ListBox, ListView, etc.) started up its ItemContainerGenerator, such that the generator's status changed from "NotStarted" to "GeneratingContainers", and null containers were no longer being returned by ItemContainerGenerator.ContainerFromItem and/or ItemContainerGenerator.ContainerFromIndex.

For example:

    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);
    }

Upvotes: -1

MarkoW
MarkoW

Reputation: 301

The Solution from Andy is a very good idea, but is incomplete. For example, the first 5 containers are created and in the panel. The list hast 300 > items. I request the last container, with this logic, ADD. Then I request the last index - 1 container, with this logis ADD! That's the problem. The order of the Children inside the panel is not valid.

A Solution for this:

    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;
            }
        }
    }

Upvotes: 1

Jordan0Day
Jordan0Day

Reputation: 1416

For anyone else wondering about this, in Andy's case, perhaps swapping out the VirtualizingStackPanel with a normal StackPanel would be the best solution here.

The reason calling PrepareItemContainer on the ItemContainerGenerator isn't working is that an item must be in the visual tree for PrepareItemContainer to work. With a VirtualizingStackPanel, the item won't be set as a visual child of the panel until the VirtualizingStackPanel determines that it is/is about to be on screen.

Another solution (the one I use) is to create your own VirtualizingPanel, so you can control when items are added to the visual tree.

Upvotes: 0

David Turner
David Turner

Reputation: 31

You may be going about this the wrong way. What I did is hook up the Loaded event of [the content of] my DataTemplate:

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

...and then process the newly-displayed row in the event handler:

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);
}

This approach does the initialization/checking of the row when it is first displayed, so it won't do all the rows up-front. If you can live with that, then perhaps this is the more elegant method.

Upvotes: 3

Andy
Andy

Reputation: 30418

I think I figured out how to do this. The problem was that the generated items were not added to the visual tree. After some searching, the best I could come up with is to call some protected methods of the VirtualizingStackPanel in the ListBox. While this isn't ideal, since it's only for testing I think I'm going to have to live with it.

This is what worked for me:

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);
    }
}

Upvotes: 1

user76035
user76035

Reputation: 1536

Just quick looking, if the ListBox uses VirtualizingStackPanel - maybe it will be enough to substitute it with StackPanel like

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

Upvotes: 2

Related Questions