WPF treeview itemselected moves incorrectly when deleting an item

ぐ巨炮叔叔 提交于 2019-12-02 00:49:30

The behavior you mention is controlled by a virtual method in the Selector class called OnItemsChanged (reference: Selector.OnItemsChanged Method) - In order to modify it, you should derive from TreeView and override that function. You might use reflector to base your implementation on the existing implementation, although it's pretty straightforward.

Here's the code for the treeview override TreeView.OnItemsChanged extracted using reflector:

protected override void OnItemsChanged(NotifyCollectionChangedEventArgs e)
{
    switch (e.Action)
    {
        case NotifyCollectionChangedAction.Add:
        case NotifyCollectionChangedAction.Move:
            break;

        case NotifyCollectionChangedAction.Remove:
        case NotifyCollectionChangedAction.Reset:
            if ((this.SelectedItem == null) || this.IsSelectedContainerHookedUp)
            {
                break;
            }
            this.SelectFirstItem();
            return;

        case NotifyCollectionChangedAction.Replace:
        {
            object selectedItem = this.SelectedItem;
            if ((selectedItem == null) || !selectedItem.Equals(e.OldItems[0]))
            {
                break;
            }
            this.ChangeSelection(selectedItem, this._selectedContainer, false);
            return;
        }
        default:
            throw new NotSupportedException(SR.Get("UnexpectedCollectionChangeAction", new object[] { e.Action }));
    }
}

Alternatively, you might hook into the collection NotifyCollectionChanged event from one of your code-behind classes and explicitly change the current selection before the event reaches the TreeView (I'm not sure of this solution though because I am not sure of the order in which event delegates are called - the TreeView might get to process the event before you do - but it might work).

Original answer

In my original answer I guessed that you may be encountering a bug in WPF and gave a generic workaround for this kind of situation, which was to replace item.IsSelected = true; with:

Disptacher.BeginInvoke(DispatcherPriority.Input, new Action(() =>
{
  item.IsSelected = true;
}));

I explained that the reason this kind of workaround does the trick 90% of the time is that it delays the selection until almost all current operations have finished processing.

When I actually tried the code you posted in your other question I discovered that it was indeed a bug in WPF but found a more direct and reliable workaround. I'll explain how I diagnosed the problem and then describe the workaround.

Diagnosis

I added a SelectedItemChanged handler with a breakpoint in it, and looked at the stack trace. This made it obvious where the problem lies. Here are selected portions of the stack trace:

...
System.Windows.Controls.TreeView.ChangeSelection
...
System.Windows.Controls.TreeViewItem.OnGotFocus
...
System.Windows.Input.FocusManager.SetFocusedElement
System.Windows.Input.KeyboardNavigation.UpdateFocusedElement
System.Windows.FrameworkElement.OnGotKeyboardFocus
System.Windows.Input.KeyboardFocusChangedEventArgs.InvokeEventHandler
...
System.Windows.Input.InputManager.ProcessStagingArea
System.Windows.Input.InputManager.ProcessInput
System.Windows.Input.KeyboardDevice.ChangeFocus
System.Windows.Input.KeyboardDevice.TryChangeFocus
System.Windows.Input.KeyboardDevice.Focus
System.Windows.Input.KeyboardDevice.ReevaluateFocusCallback
...

As you can see, KeyboardDevice has a ReevaluateFocusCallback private or internal method which changes the focus to the parent of the deleted TreeViewItem. This causes a GotFocus event which causes the parent item to be selected. This all happens in the background after your event handler returns.

Solution

Normally in this case I would tell you to just manually .Focus() the TreeViewItem you are selecting. That is difficult here because in a TreeView there is no easy way to get from an arbitrary data item to the corresponding container (there are separate ItemContainerGenerators at each level).

So I think your best solution is to force the focus to the parent node (just where you don't want it to end up), then set IsSelected in the child's data. That way the input manager will never decide it needs to move the focus on its own: It will find the focus already set to a valid IInputElement.

Here is some code to do that:

      if(child != null)
      {
        SomeObject parent = child.Parent;

        // Find the currently focused element in the TreeView's focus scope
        DependencyObject focused =
          FocusManager.GetFocusedElement(
            FocusManager.GetFocusScope(tv)) as DependencyObject;

        // Scan up the VisualTree to find the TreeViewItem for the parent
        var parentContainer = (
          from element in GetVisualAncestorsOfType<FrameworkElement>(focused)
          where (element is TreeViewItem && element.DataContext == parent)
                || element is TreeView
          select element
          ).FirstOrDefault();

        parent.Children.Remove(child);
        if(parent.Children.Count > 0)
        {
          // Before selecting child, first focus parent's container
          if(parentContainer!=null) parentContainer.Focus();
          parent.Children[0].IsSelected = true;
        }
      }

This also requires this helper method:

private IEnumerable<T> GetVisualAncestorsOfType<T>(DependencyObject obj) where T:DependencyObject
{
  for(; obj!=null; obj = VisualTreeHelper.GetParent(obj))
    if(obj is T)
      yield return (T)obj;
}

This should be more reliable than using Dispatcher.BeginInvoke because it will work around this particular problem without making any assumptions about input queue ordering, Dispatcher priorities, and so forth.

This works for me (thanks to investigations provided above)

protected override void OnItemsChanged(System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
    {
        base.OnItemsChanged(e);

        if (e.Action == NotifyCollectionChangedAction.Remove)
        {
            Focus();
        }
    }

According to the answer provided by @Kirill I think the correct answer to this specific question would be the following code added to a class derived from TreeView.

protected override void OnItemsChanged(System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{      
    if (e.Action == NotifyCollectionChangedAction.Remove && SelectedItem != null)
    {
        var index = Items.IndexOf(SelectedItem);
        if (index + 1 < Items.Count)
        {
            var item = Items.GetItemAt(index + 1) as TreeViewItem;
            if (item != null)
            {
                item.IsSelected = true;
            }
        }
    }
}

Based on the answers above, here's the solution that worked for me (it has fixed various other problems as well, such as the loss of focus after selecting an item via model etc.)

Note the OnSelected override (scroll all the way down) which actually did the trick.

This was compiled in VS2015 for Net 3.5.

using System.Windows;
using System.Windows.Controls;
using System.Collections.Specialized;

namespace WPF
{
    public partial class TreeViewEx : TreeView
    {
        #region Overrides

        protected override DependencyObject GetContainerForItemOverride()
        {
            return new TreeViewItemEx();
        }
        protected override bool IsItemItsOwnContainerOverride(object item)
        {
            return item is TreeViewItemEx;
        }

        #endregion
    }
    public partial class TreeViewItemEx : TreeViewItem
    {
        #region Overrides

        protected override DependencyObject GetContainerForItemOverride()
        {
            return new TreeViewItemEx();
        }

        protected override bool IsItemItsOwnContainerOverride(object item)
        {
            return item is TreeViewItemEx;
        }
        protected override void OnItemsChanged(NotifyCollectionChangedEventArgs e)
        {
            switch (e.Action)
            {
                case NotifyCollectionChangedAction.Remove:
                    if (HasItems)
                    {
                        int newIndex = e.OldStartingIndex;
                        if (newIndex >= Items.Count)
                            newIndex = Items.Count - 1;
                        TreeViewItemEx item = ItemContainerGenerator.ContainerFromIndex(newIndex) as TreeViewItemEx;
                        item.IsSelected = true;
                    }
                    else
                        base.OnItemsChanged(e);
                    break;
                default:
                    base.OnItemsChanged(e);
                break;
            }
        }
        protected override void OnSelected(RoutedEventArgs e)
        {
            base.OnSelected(e);
            Focus();
        }

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