Arrow keys don't work after programmatically setting ListView.SelectedItem

孤街醉人 提交于 2019-11-29 02:03:33
Cheeso

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.

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.

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

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

Christian Lang

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

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

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" ..../>

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

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!

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!