DataGrid Virtualization – Strange error after scrolling

AnnaBauer21

New member
Joined
Nov 29, 2024
Messages
4
Programming Experience
3-5
Hello,

I'm facing a problem and can't get any further. I hope you can help me.

Short explanation:
I have a DataGrid (VirtualizingPanel.IsVirtualizing="true") and a search bar. When I type something into the search bar, the list is filtered accordingly and the search text is highlighted using an AttachedProperty.
The highlighting is done by splitting the TextBlock text into several inlines. A corresponding background color is then set in the inlines that contain the search text.
--> Works fine
1732873887403.png


Problem:
As soon as I scroll down and up again, the elements that have left the visible area suddenly contain the same text as the 2nd element. The same thing happens at the end of the list. The strange thing is, when I scroll down and up again, some of the text is correct again.
1732873894419.png


I checked the Grid's LoadingRow event and saw that the Row that comes into the visible area contains the correct data in the DataContext, but the texts in the TextBlocks have not updated. My thought was that perhaps the binding was broken by manipulating the inlines, but that doesn't seem to be the problem.
1732873911250.png

If EnableRowVirtualization is set to false, it works, but unfortunately virtualization is absolutely necessary because the list can basically have n entries, the current estimate is up to 5000.

I hope you can help me, below is the code for my test project.

Formular.xaml:
<DataGrid
          Grid.Row="0"
          local:Highlighter.Filter="{Binding Filter, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}"
          AutoGenerateColumns="true"
          ColumnWidth="100"
          ItemsSource="{Binding Path=DisplayedItems, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
          RowHeight="30"
          SelectionMode="Single" />

<WrapPanel Grid.Row="1">
    <Label Content="Filter: " />
    <TextBox Width="100" Text="{Binding Path=Filter, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
</WrapPanel>

Formular.xaml.cs:
public partial class Formular : INotifyPropertyChanged
{
    #region INotifyPropertyChanged
    public event PropertyChangedEventHandler PropertyChanged = (sender, args) => { };
 
    public void RaisePropertyChanged([CallerMemberName] string propertyName = null)
    {
        this.PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
    }
    #endregion
 
    public ICollectionView DisplayedItems { get; set; }

    private string filter;
    public string Filter
    {
        get => this.filter;
        set
        {
            this.filter = value;

            this.DisplayedItems.Refresh();
            this.RaisePropertyChanged();
        }
    }

    public Formular()
    {
        InitializeComponent();

        this.DataContext = this;

        var listItems = new ObservableCollection<MyListItem>()
        {
            new MyListItem("Alpha", "Mission1"),
            new MyListItem("Beta1", "Mission1"),
            new MyListItem("Beta1", "Mission2"),
            new MyListItem("Beta1", "Mission3"),
            new MyListItem("Beta1", "Mission4"),
            new MyListItem("Beta1", "Mission5"),
            new MyListItem("Beta1", "Mission6"),
            new MyListItem("Beta1", "Mission7"),
            new MyListItem("Beta1", "Mission8"),
            new MyListItem("Beta1", "Mission9"),
            new MyListItem("Beta2", "Mission2"),
        };

        this.DisplayedItems = CollectionViewSource.GetDefaultView(listItems);
        this.DisplayedItems.Filter = this.FilterCallback;
    }
 
    public bool FilterCallback(object obj)
    {
        var item = (MyListItem) obj;

        return string.IsNullOrEmpty(this.Filter)
               || item.Name.ToUpper().Contains(Filter.ToUpper())
               || item.MissionName.ToUpper().Contains(Filter.ToUpper());
    }
}

Highlighter.cs:
public static class Highlighter
{
    private static string filter;

    static Highlighter(){}

    #region Filter
    public static readonly DependencyProperty FilterProperty =
        DependencyProperty.RegisterAttached("Filter", typeof(string), typeof(Highlighter), new PropertyMetadata("", PropertyChangedCallback));

    public static void SetFilter(DependencyObject obj, string value)
    {
        obj.SetValue(FilterProperty, value);
    }

    public static string GetFilter(DependencyObject obj)
    {
        return (string)obj?.GetValue(FilterProperty);
    }

    private static void PropertyChangedCallback(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        Application.Current.Dispatcher.BeginInvoke(DispatcherPriority.Render, new Action(() => DoAction(d)));
    }
    #endregion

    private static void DoAction(DependencyObject d)
    {
        filter = GetFilter(d);
        if (filter == null)
        {
            return;
        }

        var grid = (DataGrid)d;
        grid.LoadingRow += GridOnLoadingRow;

        // Get DataGridRows
        var gridRows = grid.GetDescendants<DataGridRow>().ToList();
        foreach (var row in gridRows)
        {
            HighlightRow(row);
        }
    }
 
    private static void HighlightRow(DataGridRow row)
    {
        // Get TextBlocks
        var txtBlocks = row.GetDescendants<TextBlock>().ToList();
        if (!txtBlocks.Any())
        {
            return;
        }

        foreach (var txtBlock in txtBlocks)
        {
            HighlightTextBlock(txtBlock);
        }
    }

    private static void HighlightTextBlock(TextBlock txtBlock)
    {
        var text = txtBlock.Text;
        if (string.IsNullOrEmpty(text))
        {
            return;
        }

        // Check whether the text contains the filter text
        var index = text.IndexOf(filter, StringComparison.CurrentCultureIgnoreCase);
        if (index < 0)
        {
            // Filter text not found
            return;
        }

        // Generate Inlines with highlighting information
        var inlines = new List<Inline>();
        while (true)
        {
            // Text from beginning to filter text
            inlines.Add(new Run(text.Substring(0, index)));

            // Text that corresponds to the filter text
            inlines.Add(new Run(text.Substring(index, filter.Length))
            {
                Background = Brushes.Yellow
            });

            // Text from filter text to ending
            text = text.Substring(index + filter.Length);

            // Check whether the remaining text also contains the filter text
            index = text.IndexOf(filter, StringComparison.CurrentCultureIgnoreCase);
            if (index < 0)
            {
                // If not, add remaining text and exit loop
                inlines.Add(new Run(text));
                break;
            }
        }

        // Replace Inlines
        txtBlock.Inlines.Clear();
        txtBlock.Inlines.AddRange(inlines);
    }

    private static void GridOnLoadingRow(object sender, DataGridRowEventArgs e)
    {
        var dataContext = (MyListItem) e.Row.DataContext;

        var newData = $"{dataContext.Name}_{dataContext.MissionName}";
        var oldData = string.Join("_", e.Row.GetDescendants<TextBlock>().Select(t => t.Text).ToList());
    }
}

MyListItem.cs:
public class MyListItem : INotifyPropertyChanged
{
    #region INotifyPropertyChanged
    public event PropertyChangedEventHandler PropertyChanged = (sender, args) => { };
 
    public void RaisePropertyChanged([CallerMemberName] string propertyName = null)
    {
        this.PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
    }
    #endregion

 
    public string name;
    public string Name
    {
        get => name;
        set
        {
            this.name = value;
            this.RaisePropertyChanged();
        }
    }

    public string missionName;
    public string MissionName
    {
        get => missionName;
        set
        {
            this.missionName = value;
            this.RaisePropertyChanged();
        }
    }

    public MyListItem(string name, string missionName)
    {
        this.Name = name;
        this.MissionName = missionName;
    }
}
 
Last edited:
Do you see the same thing happen without the highlighting?
 
At the beginning of the method HighlightTextBlock(TextBlock) the call txtBlock.GetBindingExpression(TextBlock.TextProperty) returns the corresponding data but at the end of the method after the Inlines are set, txtBlock.GetBindingExpression(TextBlock.TextProperty) returns null.

Did this break the binding? That would explain why in GridOnLoadingRow the DataContext contains the new data but the TextBlocks still contains the old.
 
That is so weird. Right now, the only thing that comes to mind is that you are hitting a timing issue.
 
I wonder if the words "reuse" and "recycle" in the documentation is more literal than my first reading of it means:
To improve performance, the EnableRowVirtualization property is set to true by default. When the EnableRowVirtualization property is set to true, the DataGrid does not instantiate a DataGridRow object for each data item in the bound data source. Instead, the DataGrid creates DataGridRow objects only when they are needed, and reuses them as much as it can. For example, the DataGrid creates a DataGridRow object for each data item that is currently in view and recycles the row when it scrolls out of view.
 
Hello,

at first, sorry for my bad english, it's a lot of text so i use google translator.

I have found a solution. It's a bit ugly, but in my case it's the only one I found.

The virtualization uses the old list entries when scrolling and inserts them again at the bottom. The DataContext has updated itself according to the new list element, but the texts in the TextBlock have not, but these are used for highlighting. The solution in my case was that I save certain BindingExpressions in the Tag property of the TextBlock and as soon as the next row is loaded while scrolling (event LoadingRow in DataGrid) I reset the BindingExpressions according to the updated DataContext. But this only works if the GUI is already rendered. If the code is executed too early, the BindingExpressions will be null. That's why it's important that LoadingRow is only executed after rendering and after the actual highlighting action.

Here is an overview of the most important changes

Formular9.xaml
  • Using MyGrid instead of DataGrid (see the corresponding class for details)
  • Specification of virtualization
  • New property 'RaiseHighlight'
  • Complete code see Formular9.xaml
Formular9.xaml:
<local:MyGrid
...
local:Highlighter9.RaiseHighlight="{Binding IsFilterReady, Mode=OneWay}"
EnableRowVirtualization="True"
VirtualizingStackPanel.VirtualizationMode="Recycling"/>

Formular9.xaml.cs
  • New property 'IsFilterReady', which is set to true after the list is filtered by refresh
  • Complete code see Form9.xaml.cs
Formular9.xaml.cs:
public string Filter
{
    get => this.filter;
    set
    {
        this.filter = value;
        this.RaisePropertyChanged();

        this.DisplayedItems.Refresh();
        this.IsFilterReady = true;
    }
}

public bool IsFilterReady
{
    get => this.isFilterReady;
    set
    {
        this.isFilterReady = value;
        this.RaisePropertyChanged();
    }
}

MyGrid.cs
  • Own DataGrid that provides required functionalities
MyGrid.cs:
public class MyGrid : DataGrid
{
    public MyGrid()
    {
        this.LoadingRow += GridOnLoadingRow;
    }

    public bool IsHighlightingReady { get; set; }

    public bool IsVirtualization => VirtualizingPanel.GetIsVirtualizing(this) || this.EnableColumnVirtualization || this.EnableRowVirtualization;

    public VirtualizationMode VirtualizationMode => VirtualizingPanel.GetVirtualizationMode(this);

    public string Filter { get; set; }

    private void GridOnLoadingRow(object sender, DataGridRowEventArgs e)
    {
        Highlighter9.GridOnLoadingRow((MyGrid)sender, e);
    }
}

Highlighter9.cs
  • New Property 'RaiseHighlight' to start processing
  • LoadingRow Event that is only executed when the actual highlighting has been performed
  • Determine & set the BindingExpressions
  • Full code see Highlighter9.cs

Highlighter9.cs:
public static class Highlighter9
    {
        // Fires the start of the highlighting process
        public static readonly DependencyProperty RaiseHighlightProperty = DependencyProperty.RegisterAttached(
            ...
            new PropertyMetadata(false, null, CoerceValueCallback));

        ...

        private static object CoerceValueCallback(DependencyObject d, object baseValue)
        {
            // It is important to wait until the list has been filtered before highlighting.
            // If the list elements are not fully rendered, information that is necessary such as BindingExpression is missing.
            if ((bool)baseValue)
            {
                ...

                var grid = (MyGrid)d;
                grid.IsHighlightingReady = false;

                Application.Current.Dispatcher.BeginInvoke(DispatcherPriority.Render, new Action(() =>
                {
                    DoAction(grid, filter);
                }));
            }

            return baseValue;
        }

        #endregion

        ...

        private static void DoAction(MyGrid grid, string filter)
        {
            // Save current filter data to the grid.
            // If there are several grids in the form, complications arise because the grids attract each other's data.
            // Grid also needs the data so that the correct data is known via the LoadingRow event
            grid.Filter = filter;

            var gridRows = grid.GetDescendants<DataGridRow>().ToList();
            foreach (var row in gridRows)
            {
                HighlightRow(row, grid);
            }

            grid.IsHighlightingReady = true;
        }

        ...

        private static void HighlightTextBlock(TextBlock txtBlock, MyGrid grid)
        {
            // Check whether virtualization is activated
            if (grid.IsVirtualization && grid.VirtualizationMode == VirtualizationMode.Recycling)
            {
                // Determine and save property name from binding
                var exp = txtBlock.GetBindingExpression(TextBlock.TextProperty);
                txtBlock.Tag = exp?.ResolvedSourcePropertyName;
            }

            ...
        }
        
        public static void GridOnLoadingRow(MyGrid grid, DataGridRowEventArgs e)
        {
            // When the filter is entered again, the LoadingRow event is executed before the actual processing of the highlighter is carried out.
            // Ignore code execution until the highlighter finishes processing.
            // Otherwise it will lead to the error that BindingExpression information is not available
            if (!grid.IsHighlightingReady)
            {
                return;
            }

            // Leave method if virtualization is deactivated
            if (!grid.IsVirtualization)
            {
                return;
            }

            if (grid.VirtualizationMode == VirtualizationMode.Recycling)
            {
                // Determine text blocks of the row
                if (e.Row.GetDescendants<TextBlock>().Any())
                {
                    HighlightRowWithUpdateBinding(e.Row, grid);
                }
                else
                {
                    // If no text blocks are found, Row is loaded but not yet rendered. Delay call.
                    Application.Current.Dispatcher.BeginInvoke(new Action(() =>
                    {
                        if (e.Row.GetDescendants<TextBlock>().Any())
                        {
                            HighlightRowWithUpdateBinding(e.Row, grid);
                        }
                    }), DispatcherPriority.Send);
                }
            }
            else
            {
                // Here the row has not yet been rendered, GetDescendants in HighlightRow does not find any text boxes, delay the call
                Application.Current.Dispatcher.BeginInvoke(new Action(() =>
                {
                    HighlightRow(e.Row, grid);
                }), DispatcherPriority.Send);
            }
        }

        private static void HighlightRowWithUpdateBinding(DataGridRow row, MyGrid grid)
        {
            // Reset bindings for the text blocks
            foreach (var txtBlock in row.GetDescendants<TextBlock>())
            {
                // Determine property name from binding or tag property
                var exp = txtBlock.GetBindingExpression(TextBlock.TextProperty);
                var path = exp != null ? exp.ResolvedSourcePropertyName : txtBlock.Tag.ToString();

                // Reset binding
                var binding = new Binding { Source = txtBlock.DataContext, Path = new PropertyPath(path) };
                txtBlock.SetBinding(TextBlock.TextProperty, binding);
            }

            HighlightRow(row, grid);
        }
    }

MyListItem.cs
  • Unchanged, see above
 

Attachments

  • Highlighter9.cs.txt
    7.8 KB · Views: 0
  • Formular9.xaml.cs.txt
    1.8 KB · Views: 0
  • Formular9.xaml.txt
    895 bytes · Views: 0
I wonder if it would make more sense to build the highlighted row based directly on the DataContext data directly instead of taking the existing TextBlock and rebuilding the contents of the TextBlock to contain the highlights. WPF prefers MVVM after all where the idea is that the View is just a rendering of the Model. MVVM is different from the old WinApi way of doing things where the data and the view are often one and the same and the WinAPI approach is to manipulate what is already in the view.
 
Back
Top Bottom