Click here to Skip to main content
15,867,686 members
Articles / Desktop Programming / WPF

WPF DataGrid: Solving Sorting, ScrollIntoView, Refresh and Focus Problems

Rate me:
Please Sign up or sign in to vote.
5.00/5 (4 votes)
5 Feb 2021CPOL5 min read 17.4K   12   1
Letting the user move some rows up and down in a DataGrid should be easy to implement, but it's a nightmare.
Surprisingly, many challenges were encountered when changing some WPF DataGrid data from code behind which required a new sorting of the rows and scrolling the DataGrid to show the initially selected rows. The article focuses on describing the problems encountered and how to solve it. At the end is the complete sample code.

Introduction

I was writing a WPF application using a WPF DataGrid displaying items with a rank property, the items sorted by rank. The user interface should allow the user to select some rows (items) and move them up or down by some rows by clicking on a button:

Image 1

When clicking Move Down, the Item 3-5 with Rank 3-5 get moved down by 20 rows and get new ranks 23-25. Items 6-25 get moved up by 3 ranks to make space for items 3-5. The grid automatically sorts the items by rank, scrolls and shows the 3 selected rows at their new place in the grid.

I thought this was quite easy to implement in the handler of the Move Down Button:

  1. Detect which rows (items) are selected.
  2. Loop over them and increase their Rank by 20.
  3. Loop over the rows which need to be moved away and adjust their Rank.
  4. Refresh DataGrid.

Unfortunately, refreshing the DataGrid made the DataGrid forget which rows were selected. This gives the user a serious problem, if he needs to press the Move Down Button several times to move the selected rows into the right place.

First Approach: Remember Selected Rows, Refresh DataGrid, Select Rows Again

Sounds simple enough, right? Unfortunately, it turns out selecting rows and bringing them into view is VERY complicated with a WPF DataGrid, the reason being that because of virtualisation, only the currently visible items have actually DataRows and DataGridCells assigned to them, but the information if an item is selected is stored in those classes. So if an item disappears from the visible part, it is rather complicated to bring it back into view and to mark it as selected again.

Luckily, I found this Technet article WPF: Programmatically Selecting and Focusing a Row or Cell in a DataGrid

Unfortunately, the required code is complicated and slow. It goes like this (for code see previous link):

  1. Loop through every item that should get selected.
  2. Use DataGrid.ItemContainerGenerator.ContainerFromIndex(itemIndex) to determine if the row is in view.
  3. If not, use TracksDataGrid.ScrollIntoView(item) and then again ContainerFromIndex(itemIndex).
  4. Hopefully, a DataRow is now found. Give it the Focus.

Now, if you think giving a Focus to a DataGridRow, which is in view, is easy, you are mistaken. It involves the following steps (for code see previous link):

  1. Find the DataGridCellsPresenter in the DataGridRow which holds the DataGridCells. If you think that is trivial, you are mistaken again. You need to loop through the visual tree to find the DataGridCellsPresenter.
  2. If you can't find it, it is not in the visual tree and you have to apply the DataRow template yourself and then repeat step 1 again, this time successfully.
  3. Use presenter.ItemContainerGenerator.ContainerFromIndex(0) to find the first column. If nothing is found, it is not in the visual tree and you have to scroll the column into view: dataGrid.ScrollIntoView(rowContainer, dataGrid.Columns[0]).
  4. Now, and only now can you call DataGridCell.Focus().

Now continue with the loop through every row.

This not only sounds complicated, the code executes also slowly. On my top notch workstation, it takes nearly a second. Now imagine the user clicking several times the button (10 times is easily possible, if he increases the rank 10 times only by 1). But a 10 second delay is simply not acceptable. So I had to find another solution.

Final Approach: Use OneWay Binding and Avoid Calling Refresh()

Since the user cannot change any data directly in the datagrid, I made it readonly and used the default binding, which is OneTime, meaning the data gets written once when the data gets assigned to the DataGrid's DataSource. I changed the binding to OneWay, which copies the new value every time the data changes to the DataGrid. For this to work, my item had to implement INotifyPropertyChanged:

C#
public class Item: INotifyPropertyChanged {
  public string Name { get; set; }
  public int Rank {
  get {
      return rank;
    }
    set {
      if (rank!=value) {
        rank = value;
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Rank)));
      }
    }
  }
  int rank;
  public event PropertyChangedEventHandler? PropertyChanged;
}

Each time the Rank changes, the PropertyChanged event gets called, to which the DataGrid subscribes.

The DataGrid did now display the rows with the new Rank values, but did not sort. After some Googling, I found that live sorting needs to be activated like this:

C#
var itemsViewSource = ((CollectionViewSource)this.FindResource("ItemsViewSource"));
itemsViewSource.Source = items;
itemsViewSource.IsLiveSortingRequested = true;
ItemsDataGrid.Columns[0].SortDirection = ListSortDirection.Ascending;
itemsViewSource.View.SortDescriptions.Add
     (new SortDescription("Rank", ListSortDirection.Ascending));

With this change, the click on Move Down button executed reasonably fast and the DataGrid sorted properly, BUT: the selected rows got out of view and could no longer be seen. That should be easy enough to solve by adding DataGrid.ScrollIntoView(DataGrid.SelectedItem). Alas, nothing happened, the DataGrid didn't scroll.

Improvement 1: Getting ScrollIntoView() to Work

After some more Googling, I came to the conclusion that ScrollIntoView() simply did nothing when I called it in the Move Down button click event, because at that time the DataGrid was not sorted yet. So I had to delay the call of ScrollIntoView(), but how? I first considered the use of a timer, but then I found a better solution: using the DataGrid.LayoutUpdated event:

C#
bool isMoveDownNeeded;
bool isMoveUpNeeded;

private void ItemsDataGrid_LayoutUpdated(object? sender, EventArgs e) {
  if (isMoveUpNeeded) {
    isMoveUpNeeded = false;
    ItemsDataGrid.ScrollIntoView(ItemsDataGrid.SelectedItem);
  }
  if (isMoveDownNeeded) {
    isMoveDownNeeded = false;
    ItemsDataGrid.ScrollIntoView
    (ItemsDataGrid.SelectedItems[ItemsDataGrid.SelectedItems.Count-1]);
  }
}

And presto, the click on Move Down button executed reasonably fast, the DataGrid sorted properly and the DataGrid scrolled to the selected rows.

Improvement 2: Showing the Selected Rows as if they have a Focus

When the user selects some rows with the mouse, they are shown with a dark blue background. But once the Move Down button was clicked, the BackGround became gray and difficult to see on my monitor. As explained in the First Approach, it is possible to give rows the focus from code behind, but it is way too complicated and slow. Luckily, there is a much simpler solution:

XAML
<DataGrid.Resources>
  <SolidColorBrush x:Key="{x:Static SystemColors.HighlightBrushKey}" Color="Blue"/>
  <SolidColorBrush x:Key="{x:Static SystemColors.InactiveSelectionHighlightBrushKey}" 
   Color="Blue"/>
  <SolidColorBrush x:Key="{x:Static SystemColors.HighlightTextBrushKey}" Color="White"/>
  <SolidColorBrush x:Key="{x:Static SystemColors.InactiveSelectionHighlightTextBrushKey}" 
   Color="White"/>
</DataGrid.Resources>

The trick here is just to make the row look the same when it is just selected (InactiveSelectionHighlightBrush) and when it is selected and has the focus (HighlightBrush).

Deep Dive into DataGrid Formatting

If you have read until here, it's safe to assume that you are genuinely interested in the DataGrid. In this case, I would like to recommend you also to read my article over DataGrid formatting, a dark art on itself: Guide to WPF DataGrid Formatting Using Bindings.

Using the Code

The sample application didn't need much code, but I spent a long time to make it work, By studying it, I hope you can save some time.

C#
<Window x:Class="TryDataGridScrollIntoView.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:TryDataGridScrollIntoView"
        mc:Ignorable="d"
        Title="Move" Height="450" Width="400">
  <Window.Resources>
    <CollectionViewSource x:Key="ItemsViewSource" CollectionViewType="ListCollectionView"/>
  </Window.Resources>
  <Grid>
    <Grid.RowDefinitions>
      <RowDefinition Height="*"/>
      <RowDefinition Height="auto"/>
    </Grid.RowDefinitions>
    <Grid.ColumnDefinitions>
      <ColumnDefinition/>
      <ColumnDefinition/>
    </Grid.ColumnDefinitions>
    <DataGrid Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="2" x:Name="ItemsDataGrid"
              DataContext="{StaticResource ItemsViewSource}" 
              ItemsSource="{Binding}" AutoGenerateColumns="False" 
              EnableRowVirtualization="True" RowDetailsVisibilityMode="Collapsed" 
              EnableColumnVirtualization="False"
              AllowDrop="False" CanUserAddRows="False" CanUserDeleteRows="False" 
              CanUserResizeRows="False">
      <DataGrid.Resources>
        <SolidColorBrush x:Key="{x:Static SystemColors.HighlightBrushKey}" Color="Blue"/>
        <SolidColorBrush x:Key="{x:Static SystemColors.InactiveSelectionHighlightBrushKey}" 
         Color="Blue"/>
        <SolidColorBrush x:Key="{x:Static SystemColors.HighlightTextBrushKey}" Color="White"/>
        <SolidColorBrush 
         x:Key="{x:Static SystemColors.InactiveSelectionHighlightTextBrushKey}" Color="White"/>
        <!--<SolidColorBrush 
         x:Key="{x:Static SystemColors.InactiveSelectionHighlightBrushKey}" 
         Color="{DynamicResource {x:Static SystemColors.HighlightColor}}"/>-->
      </DataGrid.Resources>
      <DataGrid.Columns>
        <DataGridTextColumn Binding="{Binding Path=Rank, StringFormat=N0, Mode=OneWay}" 
         Header="Rank"  
         IsReadOnly="True" Width="45"/>
        <DataGridTextColumn Binding="{Binding Path=Name}" Header="Name" IsReadOnly="True"/>
      </DataGrid.Columns>
    </DataGrid>
    <Button Grid.Row="1" Grid.Column="0" x:Name="MoveDownButton" Content="Move _Down"/>
    <Button Grid.Row="1" Grid.Column="1" x:Name="MoveUpButton" Content="Move Up"/>
  </Grid>
</Window>

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Windows;
using System.Windows.Data;

namespace TryDataGridScrollIntoView {

  public partial class MainWindow : Window{

    public MainWindow(){
      InitializeComponent();
      MoveDownButton.Click += MoveDownButton_Click;
      MoveUpButton.Click += MoveUpButton_Click;
      ItemsDataGrid.LayoutUpdated += ItemsDataGrid_LayoutUpdated;
      var items = new List<Item>();
      for (int i = 0; i < 100; i++) {
        items.Add(new Item { Name = $"Item {i}", Rank = i });
      }
      var itemsViewSource = ((CollectionViewSource)this.FindResource("ItemsViewSource"));
      itemsViewSource.Source = items;
      itemsViewSource.IsLiveSortingRequested = true;
      ItemsDataGrid.Columns[0].SortDirection = ListSortDirection.Ascending;
      itemsViewSource.View.SortDescriptions.Add(new SortDescription
                                               ("Rank", ListSortDirection.Ascending));
    }

    const int rowsPerPage = 20;

    private void MoveUpButton_Click(object sender, RoutedEventArgs e) {
      var firstSelectedTrack = ItemsDataGrid.SelectedIndex;
      if (firstSelectedTrack<=0) return;//cannot move up any further

      var selectedTracksCount = ItemsDataGrid.SelectedItems.Count;

      int firstMoveTrack;
      int moveTracksCount;
      firstMoveTrack = Math.Max(0, firstSelectedTrack - rowsPerPage);
      moveTracksCount = Math.Min(rowsPerPage, firstSelectedTrack - firstMoveTrack);
      isMoveUpNeeded = true;
      moveTracksDown(firstMoveTrack, moveTracksCount, selectedTracksCount);
      moveTracksUp(firstSelectedTrack, selectedTracksCount, moveTracksCount);
    }

    private void MoveDownButton_Click(object sender, RoutedEventArgs e) {
      var firstSelectedTrack = ItemsDataGrid.SelectedIndex;
      var selectedTracksCount = ItemsDataGrid.SelectedItems.Count;
      var lastSelectedTrack = firstSelectedTrack + selectedTracksCount - 1;
      if (lastSelectedTrack + 1 >= 
          ItemsDataGrid.Items.Count) return;//cannot move down any further

      int lastMoveTrack;
      int moveTracksCount;
      lastMoveTrack = Math.Min(ItemsDataGrid.Items.Count-1, lastSelectedTrack + rowsPerPage);
      moveTracksCount = Math.Min(rowsPerPage, lastMoveTrack - lastSelectedTrack);
      isMoveDownNeeded = true;
      moveTracksUp(lastMoveTrack - moveTracksCount + 1, moveTracksCount, selectedTracksCount);
      moveTracksDown(firstSelectedTrack, selectedTracksCount, moveTracksCount); 
      ItemsDataGrid.ScrollIntoView(ItemsDataGrid.SelectedItem); //doesn't work :-(
    }

    private void moveTracksDown(int firstTrack, int tracksCount, int offset) {
      for (int itemIndex = firstTrack; itemIndex<firstTrack+tracksCount; itemIndex++) {
        Item item = (Item)ItemsDataGrid.Items[itemIndex]!;
        item.Rank += offset;
      }
    }

    private void moveTracksUp(int firstTrack, int tracksCount, int offset) {
      for (int itemIndex = firstTrack; itemIndex<firstTrack+tracksCount; itemIndex++) {
        Item item = (Item)ItemsDataGrid.Items[itemIndex]!;
        item.Rank -= offset;
      }
    }

bool isMoveDownNeeded;
bool isMoveUpNeeded;

    private void ItemsDataGrid_LayoutUpdated(object? sender, EventArgs e) {
      if (isMoveUpNeeded) {
        isMoveUpNeeded = false;
        ItemsDataGrid.ScrollIntoView(ItemsDataGrid.SelectedItem);
      }
      if (isMoveDownNeeded) {
        isMoveDownNeeded = false;
        ItemsDataGrid.ScrollIntoView(ItemsDataGrid.SelectedItems
                                    [ItemsDataGrid.SelectedItems.Count-1]);
      }
    }
  }

  public class Item: INotifyPropertyChanged {
    public string Name { get; set; }
    public int Rank {
    get {
        return rank;
      }
      set {
        if (rank!=value) {
          rank = value;
          PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Rank)));
        }
      }
    }
    int rank;
    public event PropertyChangedEventHandler? PropertyChanged;
  }
}

History

  • 5th February, 2021: Initial version

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Software Developer (Senior)
Singapore Singapore
Retired SW Developer from Switzerland living in Singapore

Interested in WPF projects.

Comments and Discussions

 
QuestionUsed to love wpf Pin
Sacha Barber7-Feb-21 11:33
Sacha Barber7-Feb-21 11:33 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.