Search code examples
wpflistviewcollectionviewsourcelistcollectionview

How to perform synchronized grouping/filtering of ListViews and DataGrids in WPF?


I am trying to create, in my application, the same effect used by the software MusicBee in its music selection interface (screenshot below).

There is a lower panel with a DataGrid, and an upper panel with some ListViews displaying grouped rows. When I click, say, "Rock" on the "Genre" list in the upper panel, the other lists are updated and the DataGrid is filtered accordingly. If I go on clicking on the other lists in the upper panel, the DataGrid filtering becomes more and more restrictive and goes on being updated accordingly (displaying only the rows matching the filters above).

Also, there are extra rows: All (N items) and [Empty], which I imagine have to be added to the view source somehow.

enter image description here

I started to read about ListCollectionView class, since its documentation says:

"When you bind to a data collection, you may want to sort, filter, or group the data. To do that, you use collection views."

It seems to me that grouping and filtering is all about what I want to accomplish here, but I found a lack of examples and don't know even where to start with this, either ViewModel-side or XAML-side.


Solution

  • This is a very broad question, so I will just show you one way you could go about implementing something like what you are looking for. There are of course multiple ways to achieve the same result. This way just happens to follow along with the stuff you were already trying to use. I also have no idea if it covers all of the features you are looking for.

    Let's say you have a viewmodel for a track that looks something like this:

    internal class Track
    {
        public string Genre { get; private set; }
        public string Artist { get; private set; }
        public string Album { get; private set; }
        public string Title { get; private set; }
        public string FileName { get; private set; }
    
        public Track(string genre, string artist, string album, string title, string fileName)
        {
            Genre = genre;
            Artist = artist;
            Album = album;
            Title = title;
            FileName = fileName;
        }
    }
    

    You will want to make a viewmodel for your overall view which contains an observable collection of these tracks, a collection view for that collection, and additional collections for the filters (the top part of your screenshot). I threw something together locally that ended up looking like this (needs some cleanup):

    internal class MainWindowVM : INotifyPropertyChanged
    {
        // Persistent filter values
        private static readonly FilterValue EmptyFilter;
        private static readonly FilterValue AllFilter;
        private static readonly FilterValue[] CommonFilters;
    
        private ObservableCollection<Track> mTracks;
        private ListCollectionView mTracksView;
    
        private FilterValue mSelectedGenre;
        private FilterValue mSelectedArtist;
        private FilterValue mSelectedAlbum;
    
        private bool mIsRefreshingView;
    
        public ICollectionView Tracks { get { return mTracksView; } }
    
        public IEnumerable<FilterValue> Genres
        {
            get { return CommonFilters.Concat(mTracksView.Groups.Select(g => new FilterValue((CollectionViewGroup)g))); }
        }
    
        public IEnumerable<FilterValue> Artists
        {
            get
            {
                if (mSelectedGenre != null)
                {
                    if (mSelectedGenre.Group != null)
                    {
                        return CommonFilters.Concat(mSelectedGenre.Group.Items.Select(g => new FilterValue((CollectionViewGroup)g)));
                    }
                    else if (mSelectedGenre == AllFilter)
                    {
                        return CommonFilters.Concat(mTracksView.Groups.SelectMany(genre => ((CollectionViewGroup)genre).Items.Select(artist => new FilterValue((CollectionViewGroup)artist))));
                    }
                }
                return new FilterValue[] { EmptyFilter };
            }
        }
    
        public IEnumerable<FilterValue> Albums
        {
            get
            {
                if (mSelectedArtist != null)
                {
                    if (mSelectedArtist.Group != null)
                    {
                        return CommonFilters.Concat(mSelectedArtist.Group.Items.Select(g => new FilterValue((CollectionViewGroup)g)));
                    }
                    else if (mSelectedArtist == AllFilter)
                    {
                        // TODO: This is getting out of hand at this point. More groups will make it even worse. Should handle this in a better way.
                        return CommonFilters.Concat(mTracksView.Groups.SelectMany(genre => ((CollectionViewGroup)genre).Items.SelectMany(artist => ((CollectionViewGroup)artist).Items.Select(album => new FilterValue((CollectionViewGroup)album)))));
                    }
                }
                return new FilterValue[] { EmptyFilter };
            }
        }
    
        // The following "Selected" properties assume that only one group can be selected
        // from each category. These should probably be expanded to allow for selecting
        // multiple groups from the same category.
    
        public FilterValue SelectedGenre
        {
            get { return mSelectedGenre; }
            set
            {
                if (!mIsRefreshingView && mSelectedGenre != value)
                {
                    mSelectedGenre = value;
                    RefreshView();
                    NotifyPropertyChanged("SelectedGenre", "Artists");
                }
            }
        }
    
        public FilterValue SelectedArtist
        {
            get { return mSelectedArtist; }
            set
            {
                if (!mIsRefreshingView && mSelectedArtist != value)
                {
                    mSelectedArtist = value;
                    RefreshView();
                    NotifyPropertyChanged("SelectedArtist", "Albums");
                }
            }
        }
    
        public FilterValue SelectedAlbum
        {
            get { return mSelectedAlbum; }
            set
            {
                if (!mIsRefreshingView && mSelectedAlbum != value)
                {
                    mSelectedAlbum = value;
                    RefreshView();
                    NotifyPropertyChanged("SelectedAlbum");
                }
            }
        }
    
        static MainWindowVM()
        {
            EmptyFilter = new FilterValue("[Empty]");
            AllFilter = new FilterValue("All");
            CommonFilters = new FilterValue[]
            {
                EmptyFilter,
                AllFilter
            };
        }
    
        public MainWindowVM()
        {
            // Prepopulating test data
            mTracks = new ObservableCollection<Track>()
            {
                new Track("Genre 1", "Artist 1", "Album 1", "Track 1", "01 - Track 1.mp3"),
                new Track("Genre 2", "Artist 2", "Album 1", "Track 2", "02 - Track 2.mp3"),
                new Track("Genre 1", "Artist 1", "Album 1", "Track 3", "03 - Track 3.mp3"),
                new Track("Genre 1", "Artist 3", "Album 2", "Track 4", "04 - Track 4.mp3"),
                new Track("Genre 2", "Artist 2", "Album 2", "Track 5", "05 - Track 5.mp3"),
                new Track("Genre 3", "Artist 4", "Album 1", "Track 1", "01 - Track 1.mp3"),
                new Track("Genre 3", "Artist 4", "Album 4", "Track 2", "02 - Track 2.mp3"),
                new Track("Genre 1", "Artist 3", "Album 1", "Track 3", "03 - Track 3.mp3"),
                new Track("Genre 2", "Artist 2", "Album 3", "Track 4", "04 - Track 4.mp3"),
                new Track("Genre 2", "Artist 5", "Album 1", "Track 5", "05 - Track 5.mp3"),
                new Track("Genre 1", "Artist 1", "Album 2", "Track 6", "06 - Track 6.mp3"),
                new Track("Genre 3", "Artist 4", "Album 1", "Track 7", "07 - Track 7.mp3")
            };
    
            mTracksView = (ListCollectionView)CollectionViewSource.GetDefaultView(mTracks);
    
            // Note that groups are hierarchical. Based on this setup, having tracks with
            // the same artist but different genres would place them in different groups.
            // Grouping might not be the way to go here, but it gives us the benefit of
            // auto-generating groups based on the values of properties in the collection.
            mTracksView.GroupDescriptions.Add(new PropertyGroupDescription("Genre"));
            mTracksView.GroupDescriptions.Add(new PropertyGroupDescription("Artist"));
            mTracksView.GroupDescriptions.Add(new PropertyGroupDescription("Album"));
    
            mTracksView.Filter = FilterTrack;
    
            mSelectedGenre = EmptyFilter;
            mSelectedArtist = EmptyFilter;
            mSelectedAlbum = EmptyFilter;
        }
    
        private void RefreshView()
        {
            // Refreshing the view will cause all of the groups to be deleted and recreated, thereby killing
            // our selected group. We will track when a refresh is happening and ignore those group changes.
            if (!mIsRefreshingView)
            {
                mIsRefreshingView = true;
                mTracksView.Refresh();
                mIsRefreshingView = false;
            }
        }
    
        private bool FilterTrack(object obj)
        {
            Track track = (Track)obj;
            Func<FilterValue, string, bool> filterGroup = (filter, trackName) => filter == null || filter.Group == null || trackName == (string)filter.Group.Name;
            return
                filterGroup(mSelectedGenre, track.Genre) &&
                filterGroup(mSelectedArtist, track.Artist) &&
                filterGroup(mSelectedAlbum, track.Album);
        }
    
        #region INotifyPropertyChanged
        public event PropertyChangedEventHandler PropertyChanged;
        protected void NotifyPropertyChanged(params string[] propertyNames)
        {
            PropertyChangedEventHandler handler = PropertyChanged;
            if (handler != null)
            {
                foreach (String propertyName in propertyNames)
                {
                    handler.Invoke(this, new PropertyChangedEventArgs(propertyName));
                }
            }
        }
        #endregion
    }
    
    internal class FilterValue
    {
        private string mName;
    
        public CollectionViewGroup Group { get; set; }
        public string Name { get { return Group != null ? Group.Name.ToString() : mName; } }
    
        public FilterValue(string name)
        {
            mName = name;
        }
    
        public FilterValue(CollectionViewGroup group)
        {
            Group = group;
        }
    
        public override string ToString()
        {
            return Name;
        }
    }
    

    The view I used for this has a list box for each filter and a datagrid at the bottom displaying the tracks.

    <Window x:Class="WPFApplication1.MainWindow"
            x:ClassModifier="internal"
            xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
            xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
            xmlns:local="clr-namespace:WPFApplication1"
            Title="MainWindow" Height="600" Width="800">
        <Window.DataContext>
            <local:MainWindowVM />
        </Window.DataContext>
        <Grid>
            <Grid.RowDefinitions>
                <RowDefinition Height="*" />
                <RowDefinition Height="5" />
                <RowDefinition Height="2*" />
            </Grid.RowDefinitions>
            <Grid>
                <Grid.RowDefinitions>
                    <RowDefinition Height="auto" />
                    <RowDefinition Height="*" />
                </Grid.RowDefinitions>
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="*" />
                    <ColumnDefinition Width="*" />
                    <ColumnDefinition Width="*" />
                </Grid.ColumnDefinitions>
                <Border
                    BorderThickness="1 1 0 0"
                    SnapsToDevicePixels="True"
                    BorderBrush="{x:Static SystemColors.ControlDarkDarkBrush}">
                    <TextBlock
                        Margin="4 1"
                        Text="Genre" />
                </Border>
                <Border
                    Grid.Column="1"
                    Margin="-1 0 0 0"
                    BorderThickness="1 1 0 0"
                    SnapsToDevicePixels="True"
                    BorderBrush="{x:Static SystemColors.ControlDarkDarkBrush}">
                    <TextBlock
                        Margin="4 1"
                        Text="Artist" />
                </Border>
                <Border
                    Grid.Column="2"
                    Margin="-1 0 0 0"
                    BorderThickness="1 1 1 0"
                    SnapsToDevicePixels="True"
                    BorderBrush="{x:Static SystemColors.ControlDarkDarkBrush}">
                    <TextBlock
                        Margin="4 1"
                        Text="Album" />
                </Border>
                <ListBox
                    Grid.Row="1"
                    ItemsSource="{Binding Genres}"
                    SelectedItem="{Binding SelectedGenre, UpdateSourceTrigger=Explicit}"
                    SelectionChanged="ListBox_SelectionChanged" />
                <ListBox
                    Grid.Row="1"
                    Grid.Column="1"
                    ItemsSource="{Binding Artists}"
                    SelectedItem="{Binding SelectedArtist, UpdateSourceTrigger=Explicit}"
                    SelectionChanged="ListBox_SelectionChanged" />
                <ListBox
                    Grid.Row="1"
                    Grid.Column="2"
                    ItemsSource="{Binding Albums}"
                    SelectedItem="{Binding SelectedAlbum, UpdateSourceTrigger=Explicit}"
                    SelectionChanged="ListBox_SelectionChanged" />
            </Grid>
            <GridSplitter
                Grid.Row="1"
                HorizontalAlignment="Stretch"
                VerticalAlignment="Stretch" />
            <DataGrid
                Grid.Row="2"
                ItemsSource="{Binding Tracks}" />
        </Grid>
    </Window>
    

    And this is the code-behind for the view. I had to only update the filter selections in the view model when selection changed in the view. Otherwise, it would end up setting it to null for some reason. I didn't spend time investigating what was causing that issue. I just worked around it by explicitly updating the source only when selection changed.

    internal partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
        }
    
        private void ListBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
        {
            var expression = BindingOperations.GetBindingExpression((DependencyObject)sender, Selector.SelectedItemProperty);
            if (expression != null)
            {
                expression.UpdateSource();
            }
        }
    }
    

    Here is a screenshot of the test app:

    Screenshot

    I have no idea if this meets the feature requirements of what you are looking for, but it will hopefully at least be a good reference for how to do the sorts of things you are trying to do.