Friday, March 22, 2019

Persisting expanded nodes and focus when XamDataGrid data source is refreshed

We have several Infragistics XamDataGrids that have four levels of hierarchy. The user can drill down to the document level and double-click to edit the document. When they return to the original page, we want the document details to be updated in the original XamDataGrid without causing the expanded nodes to be collapsed.

There are several ways to do this, but the technique we chose involved refreshing the entire data source (which collapses all nodes) and enhancing the XamDataGrid to remember which nodes were expanded before the refresh and expand them again after the refresh. At the same time we want to persist the active record.

I sub-classed the XamDataGrid and added the functionality there. Pages that need this functionality replace the igDP:XamDataGrid with a reference to the new class and initialize a new dependency property called UniqueIDPath which specifies which property on the datasource(s) should be used to match old and new records.

Start a new Visual Studio Class Library project using C# called PersistantXamDataGrid and target any Framework from .4.0 or later.



Rename Class1 as PersistentXamDataGrid and also all references to it.
Add references to the Infragistics dlls.

Also add references to
  • PresentationCore
  • PresentationFramework
  • WindowsBase
Replace the usings with the following
using System;
using System.Collections.Generic;
using Infragistics.Windows.DataPresenter;
using System.Windows;
using System.Collections;
using Infragistics.Windows.DataPresenter.Events;

Our data grid inherits from XamDataGrid so...

public class PersistentXamDataGrid:XamDataGrid
{
}

We need the name of a property that can be used to match records from before the refresh with records after. Good database design requires every record to have a single unique identifier. We will allow the XAML to specify the name of this property which we will store in a new dependency property called UniqueIDPath in keeping with DisplayValuePath etc. used by Microsoft. Let's define that property.

Inside the class add...


static readonly DependencyProperty UniqueIDPathProperty;
public String UniqueIDPath
{
    get { return (string)GetValue(UniqueIDPathProperty); }
    set { SetValue(UniqueIDPathProperty, value); }
}

static PersistentXamDataGrid()
{
    UniqueIDPathProperty = DependencyProperty.Register("UniqueIDPath", typeof(string), typeof(PersistentXamDataGrid), new FrameworkPropertyMetadata("ID"));
}


We will maintain a list of expanded node's ids as well as the id of the active record. Note we do not attempt to track which level of the hierarchy a node is on. We assume IDs are unique for the entire dataset. If this is a problem, it's possible to alter GetUniqueID (later) to include the level.


private List<String> ExpandedIDs = new List<string>();
private string SelectedID = "";


In the instance constructor and destructor we need to attach and remove the event handlers that do all the work.

public PersistentXamDataGrid()
{
    this.DataSourceChanged += XamDataGrid_DataSourceChanged;
    this.RecordCollapsed += XamDataGrid_RecordCollapsed;
    this.RecordExpanded += XamDataGrid_RecordExpanded;
    this.RecordActivated += XamDataGrid_RecordActivated;
}

~PersistentXamDataGrid()
{
    this.DataSourceChanged -= XamDataGrid_DataSourceChanged;
    this.RecordCollapsed -= XamDataGrid_RecordCollapsed;
    this.RecordExpanded -= XamDataGrid_RecordExpanded;
    this.RecordActivated -= XamDataGrid_RecordActivated;
}

Now it's time to write the code that does the actual work.

When a row is collapsed we find its ID and remove it from our list of expanded IDs.

private void XamDataGrid_RecordCollapsed(object sender, RecordCollapsedEventArgs e)
{
    object o = (e.Record as DataRecord).DataItem;
    string ID = GetUniqueID(o);
    if (ExpandedIDs.Contains(ID)) ExpandedIDs.Remove(ID);
}

When a row is expanded we find its ID and add it to our list of expanded IDs.

private void XamDataGrid_RecordExpanded(object sender, RecordExpandedEventArgs e)
{
    object o = (e.Record as DataRecord).DataItem;
    string ID = GetUniqueID(o);
    if (!ExpandedIDs.Contains(ID)) ExpandedIDs.Add(ID);
}

When a record becomes the active record we find its ID and make a note of it.

private void XamDataGrid_RecordActivated(object sender, RecordActivatedEventArgs e)
{
    if (UniqueIDPath == "" || e.Record == null || !e.Record.IsDataRecord) return;
    SelectedID = GetUniqueID((e.Record as DataRecord).DataItem);
}


All these functions require a function that can return the value of the ID using the path specified in UniqueIDPath. We use a little reflection to do this. The value is returned as a string. This works for all the types we tend to use as IDs.

private String GetUniqueID(object o)
{
    if (UniqueIDPath == "") return "";
    if (o == null) return "";
    return o.GetType().GetProperty(UniqueIDPath).GetValue(o, null).ToString();
}


Now we have to write the code that will detect a change in the data source and re-expand previously expanded nodes.


private void XamDataGrid_DataSourceChanged(object sender, RoutedPropertyChangedEventArgs<IEnumerable> e)
{
    if (UniqueIDPath == "") return;

    PersistentXamDataGrid cdg = sender as PersistentXamDataGrid;
    ExpandChildrenAndSetFocus(cdg.ViewableRecords);
    if (cdg.ActiveRecord != null)
        cdg.BringRecordIntoView(cdg.ActiveRecord);
}

ExpandChildren recurses through the data grid's records looking for records with IDs in the Expanded IDs list and expanding them. There are different types of records. Only DataRecords can be expanded but all types of records can have interesting children. While we are recursing through the tree we may as well set the active record too.

private void ExpandChildrenAndSetFocus(ViewableRecordCollection rc)
{
    String ID;
    foreach (Record r in rc)
    {
        if (r is DataRecord)
        {
            ID = GetUniqueID((r as DataRecord).DataItem);
            if (ExpandedIDs.Contains(ID))
                r.IsExpanded = true;
            else
                r.IsExpanded = false;

            if (ID == SelectedID)
                r.IsActive = true;
            else
                r.IsActive = false;

        }
        if (r.HasChildren)
            ExpandChildren(r.ViewableChildRecords);
    }
}

At this point we get a clean build. Next we need to use our new grid.

Add a WPF App project to the solution and call it Inventory.


Make Inventory the startup project and add a reference to PersistentXamDataGrid and Infragistics.



The Main Window will have one of our new grids displaying inventory data and two buttons.
The first button will populate the grid with some search results, the second button will populate our grid with different search results with some results overlapping the first search results to demonstrate how the grid handles changes to the data source.

In our example the top level of data is a list of pallets. In each pallet is a list of boxes, and in each box is a list of items. This provides our hierarchy.

Here's the XAML.

<Window x:Class="PersistExpansionAndFocus.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:custom="clr-namespace:CustomXamDataGrid;assembly=CustomXamDataGrid"
        xmlns:local="clr-namespace:PersistExpansionAndFocus"
        xmlns:igDP="http://infragistics.com/DataPresenter"
        mc:Ignorable="d"
        DataContext="{Binding RelativeSource={RelativeSource Self}}"
        Title="MainWindow" Height="450" Width="800">
    <Window.Resources>
        <RoutedCommand x:Key="RefreshAB"/>
        <RoutedCommand x:Key="RefreshAC"/>
    </Window.Resources>
    <Window.CommandBindings>
        <CommandBinding Command="{StaticResource RefreshAB}" Executed="RefreshAB_Executed"/>
        <CommandBinding Command="{StaticResource RefreshAC}" Executed="RefreshAC_Executed"/>
    </Window.CommandBindings>
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="*"/>
            <RowDefinition Height="auto"/>
        </Grid.RowDefinitions>
        <custom:CustomXamDataGrid Grid.Row="0" DataSource="{Binding SearchResults}" ActiveDataItem="{Binding SelectedItem}" UniqueIDPath="ID" >
            <custom:CustomXamDataGrid.FieldLayoutSettings>
                <igDP:FieldLayoutSettings SelectionTypeRecord="Single" AutoGenerateFields="False" ExpansionIndicatorDisplayMode="CheckOnDisplay"/>
            </custom:CustomXamDataGrid.FieldLayoutSettings>
            <custom:CustomXamDataGrid.FieldSettings>
                <igDP:FieldSettings AllowSummaries="False" AllowEdit="False"/>
            </custom:CustomXamDataGrid.FieldSettings>
            <custom:CustomXamDataGrid.FieldLayouts>
                <igDP:FieldLayout Description="Pallet" Key="Pallet">
                    <igDP:FieldLayout.Fields>
                        <igDP:TextField Label="ID" Name="ID"/>
                        <igDP:TextField Label="Name" Name="Name"/>
                        <igDP:Field Label="" Name="Boxes"/>
                    </igDP:FieldLayout.Fields>
                </igDP:FieldLayout>
                <igDP:FieldLayout ParentFieldLayoutKey="Pallet" ParentFieldName="Boxes" Key="Box">
                    <igDP:FieldLayout.Fields>
                        <igDP:TextField Label="ID" Name="ID"/>
                        <igDP:TextField Label="Name" Name="Name"/>
                        <igDP:Field Label="" Name="Items"/>
                    </igDP:FieldLayout.Fields>
                </igDP:FieldLayout>
                <igDP:FieldLayout ParentFieldLayoutKey="Box" ParentFieldName="Items" Key="Item">
                    <igDP:FieldLayout.Fields>
                        <igDP:TextField Label="ID" Name="ID"/>
                        <igDP:TextField Label="Name" Name="Name"/>
                    </igDP:FieldLayout.Fields>
                </igDP:FieldLayout>
            </custom:CustomXamDataGrid.FieldLayouts>
        </custom:CustomXamDataGrid>

        <StackPanel Grid.Row="1" Orientation="Horizontal">
            <Button Content="Refresh 1 and 2" Command="{StaticResource Refresh12}"/>
            <Button Content="Refresh 1 and 3" Command="{StaticResource Refresh13}"/>
        </StackPanel>
    </Grid>
</Window>

In the code behind we need to use the following usings so we can implement INotifyPropertyChanged

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

using System.Linq;

public partial class MainWindow : Window, INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;
    private void NotifyPropertyChanged(String PropertyName = "")
    {
        if (PropertyChanged != null)
        {
            PropertyChanged(this, new PropertyChangedEventArgs(PropertyName));
        }
    }

and then define classes for Pallet, Box, and Item.

public class cPallet
{
    public string ID { get; set; }
    public string Name { get; set; }
    public List<cBox> Boxes { get; set; }
}

public class cBox
{
    public string ID { get; set; }
    public string Name { get; set; }
    public List<cItem> Items { get; set; }
}

public class cItem
{
    public string ID { get; set; }
    public string Name { get; set; }
}

Now let's create our Inventory data store so can pretend we're reading from a database.

private List<cPallet> Inventory;
Our Inventory is populated from our constructor.

public MainWindow()
{
    InitializeInventory();
    InitializeComponent();
}

public void InitializeInventory()
{
    Inventory = new List<cPallet>
    {
        new cPallet() {ID="1", Name="Pallet from UPS", Boxes = new List<cBox>()
               {
                new cBox() {ID="1A", Name="Box A on Pallet 1", Items= new List<cItem>() {new cItem() {ID="1Aa", Name="Item a in Box A" }, new cItem() {ID="1Ab", Name="Item b in Box A"} } }
               ,
               new cBox() {ID="1B", Name="Box B on Pallet 1", Items= new List<cItem>() {new cItem() {ID="1Ba", Name="Item a in Box B" }, new cItem() {ID="1Bb", Name="Item b in Box B"} } }
               }
        }
        ,
        new cPallet() {ID="2", Name="Pallet from FedEx", Boxes = new List<cBox>()
               {
                new cBox() {ID="2A", Name="Box A on Pallet 2", Items= new List<cItem>() {new cItem() {ID="2Aa", Name="Item a in Box A" }, new cItem() {ID="2Aa", Name="Item b in Box A"} } }
               ,
               new cBox() {ID="2B", Name="Box B on Pallet 2", Items= new List<cItem>() {new cItem() {ID="2Ba", Name="Item a in Box B" }, new cItem() {ID="2Bb", Name="Item b in Box B"} } }
               }
        }
        ,
        new cPallet() {ID="3", Name="Pallet from USPS", Boxes = new List<cBox>()
               {
                new cBox() {ID="3A", Name="Box A on Pallet 3", Items= new List<cItem>() {new cItem() {ID="3Aa", Name="Item a in Box A" }, new cItem() {ID="3Aa", Name="Item b in Box A"} } }
               ,
               new cBox() {ID="3B", Name="Box B on Pallet 3", Items= new List<cItem>() {new cItem() {ID="3Ba", Name="Item a in Box B" }, new cItem() {ID="3Bb", Name="Item b in Box B"} } }
               }
    } };
}
We will display parts of the inventory by selecting into SearchResults which our data grid is bound to.

private List<cPallet> _SearchResults = new List<cPallet>();
public List<cPallet> SearchResults
{
    get { return _SearchResults; }
    set
    {
        _SearchResults = value;
        NotifyPropertyChanged("SearchResults");
    }
}

We also need a property to bind the Selected Item to 

public String SelectedItem { get; set; }

Lastly we need command execute event handlers to refresh the SearchResults and demonstrate how the grid keeps expanded nodes open.

private void Refresh12_Executed(object sender, ExecutedRoutedEventArgs e)
{
    SearchResults = new List<cPallet>(Inventory.Where((i) => (i.ID == "1" || i.ID == "2")));
}

private void Refresh13_Executed(object sender, ExecutedRoutedEventArgs e)
{
    SearchResults = new List<cPallet>(Inventory.Where((i) => (i.ID == "1" || i.ID == "3")));
}

Now we can test our new data grid. Start by clicking [Refresh 1 and 2]. You will see two top-level nodes in the tree. Expand some nodes on pallet 1 and 2 and put the focus on an item on pallet 1.

After [Refresh 1 and 2] and expanding some nodes

Now click [Refresh 1 and 3]. Note how the nodes in pallet 1 are still expanded and the same row still has focus. It almost doesn't look like we refreshed - but we did.

After [Refresh 1 and 3]. 

Now click on [Refresh 1 and 2] again. Note how the grid remembers that last time the pallet 2 nodes were displayed they were expanded so it expands them again.

It remembers the node state from last time it was displayed.

This has been a long post but I hope you see that once you've built the persistent data grid, adding the functionality to an existing screen is quite simple assuming you have well defined data structures.


Zipped solution file

No comments:

Post a Comment