Resolved ListBox Expanders close when filtered

archie456

Member
Joined
Apr 19, 2020
Messages
11
Programming Experience
1-3
Hi,

I have a small routine has a ListView with textbox - the ListView is bound to a view model. I use a CollectionView to group items in the list box - and the Text box run a filter to hide items depending on whats typed in.

This all works really well - except when the textbox is typed in and the filter is run all of the Expanders that are dynamically created by the CollectionView are closed.

This is caused by having to use ListView.Refresh() to update and display the filtered ListView - Refresh() closes all of the expanders.

Is there any way around this, I can't believe this is the standard way of operation as closing the Expanders each time something is typed is really jarring.

I'd appreciate some advise.


This is the class of 'Dog' objects that are displayed in the ListView:
    public partial class Dogs : ViewModelBase
    {

        //Group one nesting level
        private string _l1grouping;
        public string L1grouping
        {
            get
            {
                return _l1grouping;
            }
            set
            {
                _l1grouping = value;
                OnPropertyChanged(nameof(L1grouping));
            }
        }

        //Group two nesting level
        private string _l2grouping;
        public string L2grouping
        {
            get
            {
                return _l2grouping;
            }
            set
            {
                _l2grouping = value;
                OnPropertyChanged(nameof(L2grouping));
            }
        }

        //Group three nesting level
        private string _l3grouping;
        public string L3grouping
        {
            get
            {
                return _l3grouping;
            }
            set
            {
                _l3grouping = value;
                OnPropertyChanged(nameof(L3grouping));
            }
        }

        //Name
        private string _name;
        public string Name
        {
            get => _name;
            set
            {
                if (_name != value)
                {
                    _name = value;
                    OnPropertyChanged(nameof(Name));
                }
            }
        }
    }


This is the View model, that sets the CollectionViewSource to dynamically create the Expanders and the filter:
    class MainWindowViewModel : ViewModelBase
    {
        public ICollectionView DogsCollectionView { get; }

        private ObservableCollection<Dogs> _dogs = new ObservableCollection<Dogs>();

        public ObservableCollection<Dogs> Dogs
        {
            get => _dogs;
            set
            {
                if (value != _dogs)
                {
                    _dogs = value;
                    OnPropertyChanged(nameof(Dogs));
                }
            }
        }

        private string _dogFilter = string.Empty;
        public string DogFilter
        {
            get
            {
                return _dogFilter;
            }
            set
            {
                _dogFilter = value;
                OnPropertyChanged(nameof(DogFilter));
                DogsCollectionView.Refresh();
            }
        }

        public MainWindowViewModel()
        //Constructor
        {
            DogsCollectionView = CollectionViewSource.GetDefaultView(_dogs);

            //Set up filter
            DogsCollectionView.Filter = FilterBrowserItems;

            //Set up grouping
            DogsCollectionView.GroupDescriptions.Add(new PropertyGroupDescription(nameof(SaveExpanderState.Dogs.L1grouping)));
            DogsCollectionView.GroupDescriptions.Add(new PropertyGroupDescription(nameof(SaveExpanderState.Dogs.L2grouping)));
            DogsCollectionView.GroupDescriptions.Add(new PropertyGroupDescription(nameof(SaveExpanderState.Dogs.L3grouping)));
        }

        private bool FilterBrowserItems(object obj)
        {
            if (obj is Dogs check)
            {
                string nametocheck = check.Name.ToLower();
                return nametocheck.Contains(DogFilter.ToLower());
            }

            return false;
        }
    }


MainWindow code that creates the Dogs:
   public partial class MainWindow : Window
    {
        MainWindowViewModel DogViewModel
        {
            get;
            set;
        }

        public MainWindow()
        {
            InitializeComponent();

            DogViewModel = new MainWindowViewModel();

            DogViewModel.Dogs.Add(new Dogs() { Name = "Max", L1grouping = "Large",  L2grouping = "Brown", L3grouping = "Fast" }); ;
            DogViewModel.Dogs.Add(new Dogs() { Name = "Bob", L1grouping = "Small", L2grouping = "Brown", L3grouping = "Slow" });
            DogViewModel.Dogs.Add(new Dogs() { Name = "Fido", L1grouping = "Large", L2grouping = "Brown", L3grouping = "Fast" });
            DogViewModel.Dogs.Add(new Dogs() { Name = "Brian", L1grouping = "Small", L2grouping = "Brown", L3grouping = "Fast" });
            DogViewModel.Dogs.Add(new Dogs() { Name = "Steve", L1grouping = "Large", L2grouping = "Black", L3grouping = "Fast" });
            DogViewModel.Dogs.Add(new Dogs() { Name = "Emma", L1grouping = "Large", L2grouping = "Black", L3grouping = "Slow" });
            DogViewModel.Dogs.Add(new Dogs() { Name = "Shep", L1grouping = "Large", L2grouping = "Black", L3grouping = "Fast" });
            DogViewModel.Dogs.Add(new Dogs() { Name = "Lassy", L1grouping = "Large", L2grouping = "White", L3grouping = "Fast" });
            DogViewModel.Dogs.Add(new Dogs() { Name = "Burt", L1grouping = "Large", L2grouping = "White", L3grouping = "Fast" });
            DogViewModel.Dogs.Add(new Dogs() { Name = "Siggy", L1grouping = "Small", L2grouping = "White", L3grouping = "Fast" });
            DogViewModel.Dogs.Add(new Dogs() { Name = "Heidi", L1grouping = "Small", L2grouping = "Black and White", L3grouping = "Fast" });
            DogViewModel.Dogs.Add(new Dogs() { Name = "Loki", L1grouping = "Large", L2grouping = "Yellow", L3grouping = "Fast" });

            ListViewDogs.DataContext = DogViewModel;

            FilterBoxText.DataContext = DogViewModel;
        }
    }
 
Solution
This solutions seems easier: How to save the IsExpanded state in group headers of a listview - Take 2
You can attach to the Loaded and Expanded/Collapsed events on the Expander.
C#:
private Dictionary<string, bool> expandStates = new Dictionary<string, bool>();

private void Expander_Loaded(object sender, RoutedEventArgs e)
{
    var expander = (Expander)sender;
    var dc = (CollectionViewGroup)expander.DataContext;
    var groupName = dc.Name.ToString();
    if (expandStates.TryGetValue(groupName, out var value))
        expander.IsExpanded = value;           
}

private void Expander_ExpandedCollapsed(object sender, RoutedEventArgs e)
{
    var expander = (Expander)sender;
    var dc = (CollectionViewGroup)expander.DataContext...
This solutions seems easier: How to save the IsExpanded state in group headers of a listview - Take 2
You can attach to the Loaded and Expanded/Collapsed events on the Expander.
C#:
private Dictionary<string, bool> expandStates = new Dictionary<string, bool>();

private void Expander_Loaded(object sender, RoutedEventArgs e)
{
    var expander = (Expander)sender;
    var dc = (CollectionViewGroup)expander.DataContext;
    var groupName = dc.Name.ToString();
    if (expandStates.TryGetValue(groupName, out var value))
        expander.IsExpanded = value;           
}

private void Expander_ExpandedCollapsed(object sender, RoutedEventArgs e)
{
    var expander = (Expander)sender;
    var dc = (CollectionViewGroup)expander.DataContext;
    var groupName = dc.Name.ToString();
    expandStates[groupName] = expander.IsExpanded;
}
 
Solution
Is there any way around this, I can't believe this is the standard way of operation as closing the Expanders each time something is typed is really jarring.
You have to recall that when WPF and Silverlight were conceived as Project Avalon (early 2000's), the UI "language" back then did not include the current behavior we have now where typing into a search box dynamically updates the search results. Back then you typed in a search or filter and pressed Enter to see the results. Autocomplete back then was the closest to what we have now, and at that time autocomplete was thought of a user aid to help them remember what they had previously done, and only just being started to be used in the Intellisense way of having a large corpus to find potential matches.
 
Thanks for your responses. @JohnH I've been trying a similar solution to the one you've pointed to with the code below.

I can save the state easily enough, but restoring the state is an issue as when I try to get the expanders to re-expand them it only returns the top two and not the lower levels (I think this is something to do with lazy loading?)

Code to save expander states:
        public static Dictionary<string, bool> ExpanderState
        {
            get;
            set;
        }
       
        private void Expander_CollapsedorExpand(object sender, RoutedEventArgs e)
        {
            Expander thisExpander = sender as Expander;

            TextBlock HeaderTextBlock = thisExpander.Header as TextBlock;

            string HeaderText = HeaderTextBlock.Text;

            //Add expander state to dictionary
            if (!ExpanderState.ContainsKey(HeaderText))
            {
                //Its not in the dictionary so add it
                ExpanderState.Add(HeaderText, thisExpander.IsExpanded);
            }
            else
            {
                //Its in the dictionary so update it
                ExpanderState[HeaderText] = thisExpander.IsExpanded;
            }

        }


This code returns the expanders, but for some reason only the top two (the visible ones):
        private void Button_Click(object sender, RoutedEventArgs e)
        {
            List<DependencyObject> AllExpanders = FindExpanders(recordListViewDogs as DependencyObject);

            Debug.WriteLine("");
            Debug.WriteLine("All found Expanders...");

            foreach (DependencyObject f in AllExpanders)
            {
                Debug.WriteLine(f.ToString());
            }

        }


        private List<DependencyObject> FindExpanders(DependencyObject parent)
        {
            List<DependencyObject> AllExpanders = new List<DependencyObject>();

            for (int i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
            {
                DependencyObject child = VisualTreeHelper.GetChild(parent, i);

                if (child.GetType() == typeof(Expander))
                {
                    AllExpanders.Add(child as DependencyObject);
                }

                AllExpanders.AddRange(FindExpanders(child));
            }

            return AllExpanders;
        }


Is there a method I'm missing to find all of the expanders regardless of whether they are being displayed or not?

Cheers.
 
//Its not in the dictionary so add it
That is not necessary, ExpanderState[HeaderText]=value adds it or set the existing key. Not sure why you use the header text as key instead of the CollectionViewGroup name, but it shouldn't matter as long as it is a unique group identifier.

The gist of the code I posted is that IsExpanded is registered on Collapsed/Expanded, this happens when expander is visible and user interacts with it. Loaded event of each expander is used to restore state if it was registered before, if not the default value that is defined in ControlTemplate is used. Loaded event happens when window is first displayed and each time view is refreshed by filtering. I can't see that anything is missing in that logic.

Your "code returns the expanders" is not relevant here as I see it.
 
I can't see that anything is missing in that logic.
On rethinking I can see the problem when you have multi-level groups with same names, for example:
group A
- group 1
- group 2
group B
- group 1
- group 2
Here both "group 1" and "group 2" would be two expanders that share the key in dictionary. One would think using the Expander object itself as a key could be a solution, but they are recreated when filtering, so new objects arrive all the time.
Another solution is to combine the names of the parent groups, you could have separate keys for "group A group 1" and "group B group 1". To get the parent I think reflection is needed, here's the same code using combined group names:
C#:
private Dictionary<string, bool> expandStates = new Dictionary<string, bool>();

private string GetGroupName(CollectionViewGroup group, string name = null)
{
    var flags = BindingFlags.Instance | BindingFlags.NonPublic;
    var parent = (CollectionViewGroup)group.GetType().GetProperty("Parent", flags).GetValue(group);
    return parent is null ? name : GetGroupName(parent, group.Name.ToString() + name);
}

private void Expander_Loaded(object sender, RoutedEventArgs e)
{
    var expander = (Expander)sender;
    var group = (CollectionViewGroup)expander.DataContext;
    if (expandStates.TryGetValue(GetGroupName(group), out var value))
        expander.IsExpanded = value;
}

private void Expander_ExpandedCollapsed(object sender, RoutedEventArgs e)
{
    var expander = (Expander)sender;
    var group = (CollectionViewGroup)expander.DataContext;
    expandStates[GetGroupName(group)] = expander.IsExpanded;
}
 
@JohnH - Fantastic! That works really well... I didn't appreciate the approach you were taking which was why my code in the post 5 not the same as your suggest.

Thanks for you help!
 
Back
Top Bottom