Focus a Virtualized ListBoxItem

One of the primary tenets of Halfwit is that it should be completely drivable from the keyboard. That means that while I code it I have to pay very close attention to where the keyboard focus is, and what's currently "selected".

To that end, it's important that when you switch away from a timeline and then switch back, the same tweet that you previously had selected is still selected, and it still has keyboard focus so you can navigate around it with the up and down arrow keys.

Up until recently this was a snap, because the ListBox wasn't using the (default) VirtualizingStackPanel to host its items. That meant that every item in the ListBox was in memory, so it was easy to select one and focus it. Nowadays, though, Halfwit has reverted back to WPF's default behaviour for ListBox and virtualizes the items. That means that if an item is not visible on the screen, chances are it's not in-memory and doesn't "exist" per se.

This raises an interesting challenge. If the item is off screen when you switch back to the view, it doesn't exist. Therefore, how do you focus it?

Let's start by looking at my original code. I handle the "StatusChanged" event on my ListBox's ItemContainerGenerator (the object whose job it is to create ListBoxItems which represent the underlying data) and waiting for it to tell me that it's done generating items. When it's done, I find the selected item and focus it. Note that this is happening in the code-behind of the page itself rather than a ViewModel, because I consider concerns like focus management to be too close to the UI to live back in the VM.

tweetList.ItemContainerGenerator.StatusChanged += ItemContainerGenerator_StatusChanged;

/* ... later ... */

void ItemContainerGenerator_StatusChanged(object sender, EventArgs e)
{
    if (tweetList.ItemContainerGenerator.Status != GeneratorStatus.ContainersGenerated) return;

    tweetList.ItemContainerGenerator.StatusChanged -= ItemContainerGenerator_StatusChanged;
    FocusTweetList();
}

private void FocusTweetList()
{
    tweetList.Focus();
    if (tweetList.Items.Count == 0) return;

    var index = tweetList.SelectedIndex;
    if (index < 0) return;

    var item = tweetList.ItemContainerGenerator.ContainerFromIndex(index) as ListBoxItem;
    if (item == null) return;

    item.Focus();
};

It seems right, and it worked really well with a non-virtualized ListBox. Once I started virtualizing the items, though, my item variable was null when the selected item was off screen!

The logical next step was to scroll the item back into view before trying to get hold of its "container" ListBoxItem:

private void FocusTweetList()
{
    tweetList.Focus();
    if (tweetList.Items.Count == 0) return;

    var index = tweetList.SelectedIndex;
    if (index < 0) return;

    tweetList.ScrollIntoView(tweetList.SelectedItem);

    var item = tweetList.ItemContainerGenerator.ContainerFromIndex(index) as ListBoxItem;
    if (item == null) return;

    item.Focus();
};

This worked surprisingly well, and I didn't notice for a while that it was buggy. Heck, it shouldn't have been buggy in my opinion. Anyway, @Deems let me know that Halfwit was crashing under certain circumstances, and I managed to reproduce it, tracing it back to that ScrollIntoView call. Here's the exception it was throwing:

System.InvalidOperationException: Cannot call StartAt when content generation is in progress.

What the--? I've just finished checking that the ItemContainerGenerator had finished generating! Why is it saying that "content generation is still in progress"?

I wracked my brain for about an hour on this one, trying a lot of different approaches, and finally @shiftkey reached out to help. I was able to knock up an example project with only a few lines of code that reproduced the problem, and about five minutes later he sent me a fix. Here's the code as it stands now:

private void FocusTweetList()
{
    tweetList.Focus();
    if (tweetList.Items.Count == 0) return;

    var index = tweetList.SelectedIndex;
    if (index < 0) return;

    Action action = () =>
        {
            tweetList.ScrollIntoView(tweetList.SelectedItem);

            var item = tweetList.ItemContainerGenerator.ContainerFromIndex(index) as ListBoxItem;
            if (item == null) return;

            item.Focus();
        };

    Dispatcher.BeginInvoke(DispatcherPriority.Background, action);
}

As you can see, I'm taking the code that scrolls the item into view and focuses it, and pushing that into the Dispatcher so that the UI thread can get to it when it has time. Brendan (@shiftkey)'s reasoning is that the ItemContainerGenerator still had some sort of hold over the ListBox despite having finished its work, and we had to give it time to release it so we could scroll around.

This is one of the annoying things about WPF: you can't just "know" this. Things like this you can only learn from experience, and you find out about it from others who know more than you do. No amount of research would have led me to this "fix", and I owe Brendan big time for helping me out with it.

The moral of the story, if I read Brendan's reply right, is that you should use the Dispatcher to make any kind of changes to UI objects when you can. That means that WPF will make the change when it gets around to it, and you won't be trying to muscle in on work that might already be happening. It's something I'll try to remember for the future!

wpf focus ui
Posted by: Matt Hamilton
Last revised: 28 Jul, 2017 06:54 PM History

Comments

Vinod Salunke
Vinod Salunke
22 Apr, 2011 10:27 AM

Thanks For this post. It helped out me..

Dragon Ace
Dragon Ace
16 Apr, 2013 02:37 PM

Hi, i have the same problem.

What is noticed is that the ItemContainerGenerator_StatusChanged is fired for every row that is drawn, so focus is called multiple times as well.

Its not hard to imagine that a situation will occurr where status changed is triggerd telling that the item is generated and while setting focus another status change is triggerd generating the new one.

The solution above seems to work, as all of the status change events are handled after all of the rows are generated.

So lets say 10 rows are generated we will get 10 GeneratingContainers events and 10 ContainersGenerated events and after that the deligates will be tiggerd 10 times.

No new comments are allowed on this post.