-->

Arrow keys don't work after programmatically s

2019-01-18 05:50发布

问题:

I have a WPF ListView control, ItemsSource is set to an ICollectionView created this way:

var collectionView = 
  System.Windows.Data.CollectionViewSource.GetDefaultView(observableCollection);
this.listView1.ItemsSource = collectionView;

...where observableCollection is an ObservableCollection of a complex type. The ListView is configured to display, for each item, just one string property on the complex type.

The user can refresh the ListView, at which point my logic stores the "key string" for the currently selected item, re-populates the underlying observableCollection. The previous sort and filter is then applied to the collectionView. At this point I'd like to "re select" the item that had been selected before the request to refresh. The items in the observableCollection are new instances, so I compare the respective string properties and then just select one that matches. Like this:

private void SelectThisItem(string value)
{
    foreach (var item in collectionView) // for the ListView in question
    {
        var thing = item as MyComplexType;
        if (thing.StringProperty == value)
        {
            this.listView1.SelectedItem = thing;
            return;
        }
    }
}

This all works. If the 4th item is selected, and the user presses F5, then the list is reconstituted and then the item with the same string property as the previous 4th item gets selected. Sometimes this is the new 4th item, sometimes not, but it provides "least astonishment behavior".

The problem comes when the user subsequently uses arrow keys to navigate through the ListView. The first up or down arrow after a refresh causes the first item in the (new) listview to be selected, regardless of which item had been selected by the previous logic. Any further arrow keys work as expected.

Why is this happening?

This pretty clearly violates the "least astonishment" rule. How can I avoid it?


EDIT
Upon further search, this seems like the same anomaly described by the unanswered
WPF ListView arrow navigation and keystroke problem , except I provide more detail.

回答1:

It looks like this is due to a sort of known but not-well-described problematic behavior with ListView (and maybe some other WPF controls). It requires that an app call Focus() on the particular ListViewItem, after programmatically setting the SelectedItem.

But the SelectedItem itself is not a UIElement. It's an item of whatever you are displaying in the ListView, often a custom type. Therefore you cannot call this.listView1.SelectedItem.Focus(). That's not gonna work. You need to get the UIElement (or Control) that displays that particular item. There's a dark corner of the WPF interface called ItemContainerGenerator, which supposedly lets you get the control that displays a particular item in a ListView.

Something like this:

this.listView1.SelectedItem = thing;
// *** WILL NOT WORK!
((UIElement)this.listView1.ItemContainerGenerator.ContainerFromItem(thing)).Focus();

But there's also a second problem with that - it doesn't work right after setting the SelectedItem. ItemContainerGenerator.ContainerFromItem() always seems to return null. Elsewhere in the googlespace people have reported it as returning null with GroupStyle set. But it exhibited this behavior with me, without grouping.

ItemContainerGenerator.ContainerFromItem() is returning null for all objects being displayed in the list. Also ItemContainerGenerator.ContainerFromIndex() returns null for all indicies. What's necessary is to call those things only after the ListView has been rendered (or something).

I tried doing this directly via Dispatcher.BeginInvoke() but that does not work either.

At the suggestion of some other threads, I used Dispatcher.BeginInvoke() from within the StatusChanged event on the ItemContainerGenerator. Yeah, simple huh? (Not)

Here's what the code looks like.

MyComplexType current;

private void SelectThisItem(string value)
{
    foreach (var item in collectionView) // for the ListView in question
    {
        var thing = item as MyComplexType;
        if (thing.StringProperty == value)
        {
            this.listView1.ItemContainerGenerator.StatusChanged += icg_StatusChanged;
            this.listView1.SelectedItem = thing;
            current = thing;
            return;
        }
    }
}


void icg_StatusChanged(object sender, EventArgs e)
{
    if (this.listView1.ItemContainerGenerator.Status
        == System.Windows.Controls.Primitives.GeneratorStatus.ContainersGenerated)
    {
        this.listView1.ItemContainerGenerator.StatusChanged
            -= icg_StatusChanged;
        Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Input,
                               new Action(()=> {
                                       var uielt = (UIElement)this.listView1.ItemContainerGenerator.ContainerFromItem(current);
                                       uielt.Focus();}));

    }
}

That's some ugly code. But, programmatically setting the SelectedItem this way allows subsequent arrow navigation to work in the ListView.



回答2:

I was having this problem with a ListBox control (which is how I ended up finding this SO question). In my case, the SelectedItem was being set via binding, and subsequent keyboard navigation attempts would reset the ListBox to have the first item selected. I was also synchronizing my underlying ObservableCollection by adding/removing items (not by binding to a new collection each time).

Based on the info given in the accepted answer, I was able to work around it with the following subclass of ListBox:

internal class KeyboardNavigableListBox : ListBox
{
    protected override void OnSelectionChanged(SelectionChangedEventArgs e)
    {
        base.OnSelectionChanged(e);

        var container = (UIElement) ItemContainerGenerator.ContainerFromItem(SelectedItem);

        if(container != null)
        {
            container.Focus();
        }
    }
}

Hope this helps someone save some time.



回答3:

I found a somewhat different approach. I'm using databinding to make sure the correct item is highlighted in the code, and then instead of setting focus on every rebind, I simply add a pre-event handler to the code behind for keyboard navigation. Like this.

    public MainWindow()
    {
         ...
         this.ListView.PreviewKeyDown += this.ListView_PreviewKeyDown;
    }

    private void ListView_PreviewKeyDown(object sender, KeyEventArgs e)
    {
        UIElement selectedElement = (UIElement)this.ListView.ItemContainerGenerator.ContainerFromItem(this.ListView.SelectedItem);
        if (selectedElement != null)
        {
            selectedElement.Focus();
        }

        e.Handled = false;
    }

This simply makes sure that the correct focus is set before letting WPF handle the keypress



回答4:

Selecting an item programmatically does not give it keyboard focus. You have to do that explcitly... ((Control)listView1.SelectedItem).Focus()



回答5:

Cheeso, in your previous answer you said:

But there's also a second problem with that - it doesn't work right after setting the SelectedItem. ItemContainerGenerator.ContainerFromItem() always seems to return null.

An easy solution to that is to not set SelectedItem at all. This will automatically happen when you focus the element. So just calling the following line will do:

((UIElement)this.listView1.ItemContainerGenerator.ContainerFromItem(thing)).Focus();


回答6:

This all seems a little bit intrusive...I went with rewriting the logic myself:

public class CustomListView : ListView
{
            protected override void OnPreviewKeyDown(KeyEventArgs e)
            {
                // Override the default, sloppy behavior of key up and down events that are broken in WPF's ListView control.
                if (e.Key == Key.Up)
                {
                    e.Handled = true;
                    if (SelectedItems.Count > 0)
                    {
                        int indexToSelect = Items.IndexOf(SelectedItems[0]) - 1;
                        if (indexToSelect >= 0)
                        {
                            SelectedItem = Items[indexToSelect];
                            ScrollIntoView(SelectedItem);
                        }
                    }
                }
                else if (e.Key == Key.Down)
                {
                    e.Handled = true;
                    if (SelectedItems.Count > 0)
                    {
                        int indexToSelect = Items.IndexOf(SelectedItems[SelectedItems.Count - 1]) + 1;
                        if (indexToSelect < Items.Count)
                        {
                            SelectedItem = Items[indexToSelect];
                            ScrollIntoView(SelectedItem);
                        }
                    }
                }
                else
                {
                    base.OnPreviewKeyDown(e);
                }
            }
}


回答7:

After a lot a fiddling around I couldn't get it to work in MVVM. I gave it a go myself and used a DependencyProperty. This worked great for me.

public class ListBoxExtenders : DependencyObject
{
    public static readonly DependencyProperty AutoScrollToCurrentItemProperty = DependencyProperty.RegisterAttached("AutoScrollToCurrentItem", typeof(bool), typeof(ListBoxExtenders), new UIPropertyMetadata(default(bool), OnAutoScrollToCurrentItemChanged));

    public static bool GetAutoScrollToCurrentItem(DependencyObject obj)
    {
        return (bool)obj.GetValue(AutoScrollToSelectedItemProperty);
    }

    public static void SetAutoScrollToCurrentItem(DependencyObject obj, bool value)
    {
        obj.SetValue(AutoScrollToSelectedItemProperty, value);
    }

    public static void OnAutoScrollToCurrentItemChanged(DependencyObject s, DependencyPropertyChangedEventArgs e)
    {
        var listBox = s as ListBox;
        if (listBox != null)
        {
            var listBoxItems = listBox.Items;
            if (listBoxItems != null)
            {
                var newValue = (bool)e.NewValue;

                var autoScrollToCurrentItemWorker = new EventHandler((s1, e2) => OnAutoScrollToCurrentItem(listBox, listBox.Items.CurrentPosition));

                if (newValue)
                    listBoxItems.CurrentChanged += autoScrollToCurrentItemWorker;
                else
                    listBoxItems.CurrentChanged -= autoScrollToCurrentItemWorker;
            }
        }
    }

    public static void OnAutoScrollToCurrentItem(ListBox listBox, int index)
    {
        if (listBox != null && listBox.Items != null && listBox.Items.Count > index && index >= 0)
            listBox.ScrollIntoView(listBox.Items[index]);
    }

}

Usage in XAML

<ListBox IsSynchronizedWithCurrentItem="True" extenders:ListBoxExtenders.AutoScrollToCurrentItem="True" ..../>


回答8:

It's possible to focus an item with BeginInvoke after finding it by specifying priority:

Dispatcher.CurrentDispatcher.BeginInvoke(DispatcherPriority.Loaded, new Action(() =>
{
    var lbi = AssociatedObject.ItemContainerGenerator.ContainerFromIndex(existing) as ListBoxItem;
    lbi.Focus();
}));


回答9:

Cheeso's solution DOES work for me. Prevent the null exception by just setting a timer.tick to do this, so you have left your original routine.

var uiel = (UIElement)this.lv1.ItemContainerGenerator                        
           .ContainerFromItem(lv1.Items[ix]); 
if (uiel != null) uiel.Focus();

Problem solved when calling timer after a RemoveAt/Insert, and also at Window.Loaded to set focus and select to first item.

Wanted to give back this first post for the much inspiration and solutions I got at SE. Happy coding!