65.9K
CodeProject 正在变化。 阅读更多。
Home

在 WPF 中显示数据矩阵

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.94/5 (67投票s)

2009年6月14日

CPOL

9分钟阅读

viewsIcon

285377

downloadIcon

3813

回顾一个可以轻松绑定数据和视觉样式化的矩阵控件。

MoneyShot.png

引言

本文介绍了一个WPF控件以及一组相关类,它们可以轻松创建和显示数据矩阵。除了了解矩阵控件的使用方法外,我们还将探讨它是如何工作的。

背景

显示一组相关数据实体的一种常见方法是使用矩阵,这样可以方便地进行相互比较。与数据网格一样,矩阵也包含行和列。与数据网格不同的是,矩阵的行也有标题,每个标题显示与所显示实体相关的属性或值。通过包含行标题,矩阵可以展现出标准数据网格无法显示的额外数据维度。

矩阵的构成

在本文及相关源代码中,我们通过名称引用矩阵的特定部分。下面带有注释的屏幕截图指出了每个部分。

Anatomy.png

介绍 MatrixControl 和 MatrixBase

本文顶部的源代码包包含一个Visual Studio 2008解决方案,其中有两个项目。MatrixLib项目包含MatrixControl类,它是一个UI控件,可用于显示数据矩阵。MatrixControl继承自ItemsControl,并以网格布局排列其子元素。您可以轻松地将MatrixControl放置在任何用户界面中,并将其ItemsSource属性绑定到应显示为矩阵的对象集合。

然而,事情不止于此。将一组数据实体转化为一维对象集合,使其能够绑定到并显示在二维网格布局中,需要一些额外的逻辑。您需要以某种方式让MatrixControl知道每个项目应放置在网格的哪个“槽”中。为了在XAML中轻松地为矩阵应用视觉样式,您需要以某种方式区分列标题、行标题和单元格。此外,您还需要找到一种方法来获取每个单元格中应显示的值(即对应于每个行/列交叉点的_值)。为了使这些任务更轻松、更快地完成,我创建了MatrixBase<TRow, TColumn>类。您只需从MatrixBase派生一个类并覆盖几个方法;所有繁重的工作都将为您处理好。

本文接下来的两个部分将演示如何使用MatrixControlMatrixBase

演示 1 - 国家矩阵

CountryMatrix.png

本文源代码包中的WpfMatrixDemo项目包含两个使用MatrixControl的示例。在本节中,我们将看到如何创建一个矩阵,该矩阵显示国家列表以及关于这些国家的各种属性。

此演示的数据驻留在Country对象中。下面可以看到Country类。

class Country
{
    public double ExportsInMillions { get; set; }
    public double ExternalDebtInMillions { get; set; }
    public string FlagIcon { get; set; }
    public double GDPInMillions { get; set; }
    public double LifeExpectancy { get; set; }
    public string Name { get; set; }
}

Database类创建了一个Country对象数组。

public static Country[] GetCountries()
{
    return new Country[]
    {
        new Country
        {
            Name = "Switzerland",
            ExportsInMillions = 172700,
            ExternalDebtInMillions = 1340000,
            FlagIcon = "Flags/switzerland.png",
            GDPInMillions = 492595,
            LifeExpectancy = 80.62
        },
        new Country
        {
            Name = "United Kingdom",
            ExportsInMillions = 468700,
            ExternalDebtInMillions = 10450000,
            FlagIcon = "Flags/uk.png",
            GDPInMillions = 2674085,
            LifeExpectancy = 78.7
        },
        new Country
        {
            Name = "United States",
            ExportsInMillions = 1377000,
            ExternalDebtInMillions = 13703567,
            FlagIcon = "Flags/usa.png",
            GDPInMillions = 14264600,
            LifeExpectancy = 78.06
        }
    };
}

Country对象数组被加载到下面声明的CountryMatrix中。

/// <summary>
/// A matrix that displays countries in the columns
/// and attributes of a country in the rows.
/// </summary>
class CountryMatrix : MatrixBase<string, Country>
{
    // ...
}

请注意,MatrixBase是一个泛型类,有两个类型参数。第一个类型参数TRow指定将放置在行标题中的对象类型。第二个类型参数TColumn指示将放置在列标题中的对象类型。

CountryMatrix具有以下初始化代码。

public CountryMatrix()
{
    _countries = Database.GetCountries();
    _rowHeaderToValueProviderMap = new Dictionary<string, CellValueProvider>();
    this.PopulateCellValueProviderMap();
}

void PopulateCellValueProviderMap()
{
    // Use the American culture to force currency 
    // formatting to use the dollar sign ($).
    CultureInfo culture = new CultureInfo("en-US");

    _rowHeaderToValueProviderMap.Add(
        "Exports (millions)",
        country => country.ExportsInMillions.ToString("c0", culture));

    _rowHeaderToValueProviderMap.Add(
        "External Debt (millions)",
        country => country.ExternalDebtInMillions.ToString("c0", culture));

    _rowHeaderToValueProviderMap.Add(
        "GDP (millions)",
        country => country.GDPInMillions.ToString("c0", culture));

    _rowHeaderToValueProviderMap.Add(
        "Life Expectancy",
        country => country.LifeExpectancy.ToString("f2"));
}

// Fields
readonly Country[] _countries;
readonly Dictionary<string, CellValueProvider> _rowHeaderToValueProviderMap;

/// <summary>
/// This delegate type describes the signature of a method 
/// used to produce the value of a cell in the matrix.
/// </summary>
private delegate object CellValueProvider(Country country)

_rowHeaderToValueProviderMap字段将行标题中显示的值与用于生成该行中每个单元格的值的回调方法相关联。该回调接收一个Country对象作为参数(来自列标题),并返回一个值以显示在该单元格中。当我们查看CountryMatrix的覆盖方法时,可以看到此技术是如何被应用的。

protected override IEnumerable<Country> GetColumnHeaderValues()
{
    return _countries;
}

protected override IEnumerable<string> GetRowHeaderValues()
{
    return _rowHeaderToValueProviderMap.Keys;
}

protected override object GetCellValue(
    string rowHeaderValue, Country columnHeaderValue)
{
    return _rowHeaderToValueProviderMap[rowHeaderValue](columnHeaderValue);
}

如果您打开AppWindow.xaml文件,您会看到CountryMatrix的一个实例被设置为MatrixControlDataContext,并且其MatrixItems属性是控件ItemsSource属性绑定的源。

<mx:MatrixControl ItemsSource="{Binding Path=MatrixItems}">
  <mx:MatrixControl.DataContext>
    <local:CountryMatrix />
  </mx:MatrixControl.DataContext>
  <mx:MatrixControl.Resources>
    <ResourceDictionary Source="CountryMatrixTemplates.xaml" />
  </mx:MatrixControl.Resources>
</mx:MatrixControl>

上面声明的MatrixControl注入了一个包含在CountryMatrixTemplates.xaml文件中的ResourceDictionary。该文件为矩阵的各个部分包含了DataTemplate。下面是该文件的一个截短版本。唯一保持不变的模板是用于显示列标题的模板。在此演示中,每个列标题都包含一个国旗图标和国家名称。

<ResourceDictionary 
  xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  xmlns:mx="clr-namespace:MatrixLib.Matrix;assembly=MatrixLib"
  >
  <!-- Shared Resources -->
  <SolidColorBrush x:Key="BackBrush" Color="LightBlue" />
  <SolidColorBrush x:Key="BorderBrush" Color="LightBlue" />
  <Thickness x:Key="BorderThickness" Left="0" Top="0" Right="0.5" Bottom="0.5" />
  <SolidColorBrush x:Key="HeaderForeground" Color="DarkBlue" />

  <DataTemplate DataType="{x:Type mx:MatrixColumnHeaderItem}">
    <Border 
      Background="{StaticResource BackBrush}" 
      BorderBrush="{StaticResource BorderBrush}" 
      BorderThickness="{StaticResource BorderThickness}" 
      Padding="0,4"
      >
      <DockPanel>
        <Image 
          DockPanel.Dock="Left" 
          Margin="3,0,0,0"
          Source="{Binding Path=ColumnHeader.FlagIcon}"  
          Width="18" Height="12" 
          />
        <TextBlock 
          FontWeight="Bold"
          Foreground="{StaticResource HeaderForeground}"
          Text="{Binding Path=ColumnHeader.Name}" 
          TextAlignment="Center"
          />
      </DockPanel>
    </Border>
  </DataTemplate>

  <DataTemplate DataType="{x:Type mx:MatrixEmptyHeaderItem}">
    <!-- ... -->
  </DataTemplate>

  <DataTemplate DataType="{x:Type mx:MatrixRowHeaderItem}">
    <!-- ... -->
  </DataTemplate>

  <DataTemplate DataType="{x:Type mx:MatrixCellItem}">
    <!-- ... -->
  </DataTemplate>
</ResourceDictionary>

上面显示的每个DataTemplate都针对一个表示矩阵某个部分的类型,例如MatrixColumnHeaderItem的模板用于渲染列标题。所有这些类型都继承自MatrixItemBase,如下面的类图所示。在创建用于渲染这些类型实例的模板时,此信息非常有用。

MatrixClassDiagram.png

在本文稍后,我们将回顾MatrixControl如何在内部创建和排列这些类型。现在,只需接受MatrixBase将自动将这些类型的实例放入MatrixControl中。

演示 2 - 人员矩阵

PersonMatrix.png

在之前的演示中,我们看到了如何显示一个矩阵,其中包含国家名称作为列标题,国家的属性作为行标题,以及每个国家每个属性的值作为单元格。在此演示中,我们将创建一个不同类型的矩阵。该矩阵将人员姓名显示为列标题,将这些人居住的唯一国家列表显示为行标题,并在单元格中显示一个视觉指示符,以表明某人是否居住在某个国家。我们不显示人员的各种属性作为行标题,而是显示人员一个属性的唯一值列表作为行标题。

以下是Database类中创建Person对象数组的方法。

public static Person[] GetPeople()
{
    return new Person[]
    {
        new Person 
        { 
            Name= "Brennon", 
            CountryOfResidence = "United Kingdom"
        },
        new Person
        {
            Name="Josh", 
            CountryOfResidence ="United States"
        },
        new Person
        {
            Name="Karl", 
            CountryOfResidence= "United States"
        },
        new Person
        {
            Name="Laurent", 
            CountryOfResidence="Switzerland"
        },
        new Person
        {
            Name="Sacha", 
            CountryOfResidence= "United Kingdom"
        }      
    };
}

在此演示中,PersonMatrix类继承自MatrixBase。下面是该类的完整列表。

/// <summary>
/// A matrix that displays people in the columns 
/// and countries in which people live in the rows.
/// </summary>
public class PersonMatrix : MatrixBase<string, Person>
{
    public PersonMatrix()
    {
        _people = Database.GetPeople();
    }

    protected override IEnumerable<Person> GetColumnHeaderValues()
    {
        return _people;
    }

    protected override IEnumerable<string> GetRowHeaderValues()
    {
        // Return a sorted list of unique country names.
        return
            from person in _people
            orderby person.CountryOfResidence
            group person by person.CountryOfResidence into countryGroup
            select countryGroup.Key;
    }

    protected override object GetCellValue(
        string rowHeaderValue, Person columnHeaderValue)
    {
        return rowHeaderValue == columnHeaderValue.CountryOfResidence;
    }

    readonly Person[] _people;
}

由于此矩阵不需要每个数据实体的多个属性值,因此不需要第一演示中使用的行到单元格值映射技术。GetCellValue方法仅当指定的人居住在指定国家时返回true,否则返回false

以下是AppWindow.xaml中配置MatrixControl以显示PersonMatrix实例的标记。

<mx:MatrixControl ItemsSource="{Binding Path=MatrixItems}">
  <mx:MatrixControl.DataContext>
    <local:PersonMatrix />
  </mx:MatrixControl.DataContext>
  <mx:MatrixControl.Resources>
    <ResourceDictionary Source="PersonMatrixTemplates.xaml" />
  </mx:MatrixControl.Resources>
</mx:MatrixControl>

PersonMatrixTemplates.xaml文件包含用于视觉样式化此矩阵的DataTemplate。该文件中一个有趣的点是渲染每个矩阵单元格的模板。它使用DataTrigger来隐藏视觉指示符,如果某人不住在与该单元格所在行关联的国家。

<DataTemplate DataType="{x:Type mx:MatrixCellItem}">
  <Border 
    x:Name="bd" 
    Background="#110000FF" 
    BorderBrush="{StaticResource BorderBrush}" 
    BorderThickness="{StaticResource BorderThickness}" 
    >
    <Ellipse 
      x:Name="ell"
      Fill="DarkBlue"
      HorizontalAlignment="Center"
      Width="16" Height="16"  
      VerticalAlignment="Center" 
      />
  </Border>
  <DataTemplate.Triggers>
    <DataTrigger Binding="{Binding Path=Value}" Value="False">
      <Setter TargetName="ell" Property="Visibility" Value="Collapsed" />
      <Setter TargetName="bd" Property="Background" Value="White" />
    </DataTrigger>
  </DataTemplate.Triggers>
</DataTemplate>

至此,我们已经回顾了使用MatrixControlMatrixBase的两个示例。本文的其余部分将探讨这些类的工作原理。

MatrixControl的工作原理

MatrixControl类非常简单。它只是一个ItemsControl子类,带有一个自定义的ItemsPanelItemContainerStyle。其代码隐藏文件为空,除了在构造函数中调用InitializeComponent的标准样板代码外。以下XAML是MatrixControl的全部内容。

<ItemsControl 
  x:Class="MatrixLib.Matrix.MatrixControl"
  xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  xmlns:layout="clr-namespace:MatrixLib.Layout"
  >
  <ItemsControl.ItemsPanel>
    <ItemsPanelTemplate>
      <layout:MatrixGrid />
    </ItemsPanelTemplate>
  </ItemsControl.ItemsPanel>

  <ItemsControl.ItemContainerStyle>
    <!-- 
    Bind each ContentPresenter's attached Grid 
    properties to MatrixItemBase properties. 
    -->
    <Style TargetType="{x:Type ContentPresenter}">
      <Setter Property="Grid.Row" Value="{Binding Path=GridRow}" />
      <Setter Property="Grid.Column" Value="{Binding Path=GridColumn}" />
    </Style>
  </ItemsControl.ItemContainerStyle>
</ItemsControl>

此控件的实际逻辑存在于MatrixGrid布局面板、MatrixBase以及各种MatrixItemBase子类中。让我们接下来看看它们是如何工作的。

MatrixGrid的工作原理

如上所述,MatrixControl是一个ItemsControl子类,它使用MatrixGrid作为其项目面板。MatrixGrid继承自标准的Grid面板,并能够向自身添加适当数量的行和列,以便正确地容纳其子元素。截至目前,当MatrixGrid的视觉子元素被移除或移动到不同的行或列时,它不会从自身移除行或列,仅仅是因为我没有用例需要此功能。

MatrixGrid监视其视觉子元素(即其“子元素”)。它在其子元素上建立绑定,特别是对其子元素的Grid.RowGrid.Column附加属性进行绑定。当它检测到子元素上任一附加属性的新值时,它将根据需要向自身添加更多的行或列,以便允许该子元素定位在其所需的位置。数据绑定源是子元素,目标绑定是一个MatrixGridChildMonitor,其定义如下:

/// <summary>
/// Exposes two dependency properties which are bound to in
/// order to know when the visual children of a MatrixGrid are
/// given new values for the Grid.Row and Grid.Column properties.
/// </summary>
class MatrixGridChildMonitor : DependencyObject
{
    public int GridRow
    {
        get { return (int)GetValue(GridRowProperty); }
        set { SetValue(GridRowProperty, value); }
    }

    public static readonly DependencyProperty GridRowProperty =
        DependencyProperty.Register(
        "GridRow",
        typeof(int),
        typeof(MatrixGridChildMonitor),
        new UIPropertyMetadata(0));

    public int GridColumn
    {
        get { return (int)GetValue(GridColumnProperty); }
        set { SetValue(GridColumnProperty, value); }
    }

    public static readonly DependencyProperty GridColumnProperty =
        DependencyProperty.Register(
        "GridColumn",
        typeof(int),
        typeof(MatrixGridChildMonitor),
        new UIPropertyMetadata(0));
}

每个子元素在被添加到MatrixGrid时都会绑定到其中一个监视器对象。以下是MatrixGrid中建立绑定的代码。

protected override void OnVisualChildrenChanged(
    DependencyObject visualAdded, DependencyObject visualRemoved)
{
    base.OnVisualChildrenChanged(visualAdded, visualRemoved);

    if (visualAdded != null)
        this.StartMonitoringChildElement(visualAdded);
    else
        this.StopMonitoringChildElement(visualRemoved);
}

void StartMonitoringChildElement(DependencyObject childElement)
{
    // Create a MatrixGridChildMonitor in order to detect
    // changes made to the Grid.Row and Grid.Column attached 
    // properties on the new child element.

    MatrixGridChildMonitor monitor = new MatrixGridChildMonitor();

    BindingOperations.SetBinding(
        monitor,
        MatrixGridChildMonitor.GridRowProperty,
        this.CreateMonitorBinding(childElement, Grid.RowProperty));

    BindingOperations.SetBinding(
        monitor,
        MatrixGridChildMonitor.GridColumnProperty,
        this.CreateMonitorBinding(childElement, Grid.ColumnProperty));

    _childToMonitorMap.Add(childElement, monitor);
}

Binding CreateMonitorBinding(DependencyObject childElement, DependencyProperty property)
{
    return new Binding
    {
        Converter = _converter,
        ConverterParameter = property,
        Mode = BindingMode.OneWay,
        Path = new PropertyPath(property),
        Source = childElement
    };
}

Dictionary<DependencyObject, MatrixGridChildMonitor> _childToMonitorMap;
MatrixGridChildConverter _converter;

您可能想知道,仅仅绑定到元素上的Grid.RowGrid.Child的值如何能让MatrixGrid知道它应该为自己创建多少行和列。答案在于使用一个值转换器,称为MatrixGridChildConverter。该转换器会拦截从子元素传输到其关联的MatrixGridChildMonitor的值,并通知MatrixGrid新值。以下是该值转换器中的Convert方法。

public object Convert(
    object value, Type targetType, 
    object parameter, CultureInfo culture)
{
    if (value is int)
    {
        int index = (int)value;
        if (parameter == Grid.RowProperty)
            _matrixGrid.InspectRowIndex(index);
        else
            _matrixGrid.InspectColumnIndex(index);
    }
    return value;
}

MatrixGrid通过InspectRowIndexInspectColumnIndex方法得知其子元素上新的行或列索引时,它会向自身添加适当数量的行/列。以下是MatrixGrid中添加正确行数的代码。

internal void InspectRowIndex(int index)
{
    // Delay the call that adds rows in case the RowDefinitions 
    // collection is currently read-only due to a layout pass.
    base.Dispatcher.BeginInvoke(new Action(delegate
        {
            while (base.RowDefinitions.Count - 1 < index)
            {
                base.RowDefinitions.Add(new RowDefinition());

                // Make the column headers just tall 
                // enough to display their content.
                if (base.RowDefinitions.Count == 1)
                    base.RowDefinitions[0].Height = 
                        new GridLength(1, GridUnitType.Auto);
            }
        }));
}

InspectColumnIndex方法与上面看到的方法非常相似。

MatrixBase的工作原理

MatrixBaseClassDiagram.png

这个难题的最后一块是MatrixBase类。如前所述,您可以从该类派生,覆盖几个方法,然后使用该类的一个实例作为MatrixControl的数据源。MatrixControlItemsSource属性应绑定到MatrixBaseMatrixItems属性,该属性定义如下:

/// <summary>
/// Returns a read-only collection of all cells in the matrix.
/// </summary>
public ReadOnlyCollection<MatrixItemBase> MatrixItems
{
    get
    {
        if (_matrixItems == null)
        {
            _matrixItems = new ReadOnlyCollection<MatrixItemBase>(this.BuildMatrix());
        }
        return _matrixItems;
    }
}

BuildMatrix方法执行时,子类的覆盖方法会被调用,以检索列标题列表和行标题列表。然后,MatrixBase开始构建MatrixItemBase派生对象,并将它们注入由子类覆盖方法返回的任何对象。当每个MatrixCellItem被创建时,子类会被要求为该单元格提供一个值。下面列出了来自MatrixBase的完整算法。

List<MatrixItemBase> BuildMatrix()
{
    List<MatrixItemBase> matrixItems = new List<MatrixItemBase>();

    // Get the column and row header values from the child class.
    List<TColumn> columnHeaderValues = this.GetColumnHeaderValues().ToList();
    List<TRow> rowHeaderValues = this.GetRowHeaderValues().ToList();

    this.CreateEmptyHeader(matrixItems);
    this.CreateColumnHeaders(matrixItems, columnHeaderValues);
    this.CreateRowHeaders(matrixItems, rowHeaderValues);
    this.CreateCells(matrixItems, rowHeaderValues, columnHeaderValues);

    return matrixItems;
}

void CreateEmptyHeader(List<MatrixItemBase> matrixItems)
{
    // Insert a blank item in the top left corner.
    matrixItems.Add(new MatrixEmptyHeaderItem
    {
        GridRow = 0,
        GridColumn = 0
    });
}

void CreateColumnHeaders(
    List<MatrixItemBase> matrixItems, List<TColumn> columnHeaderValues)
{
    // Insert the column header items in the first row.
    for (int column = 1; column <= columnHeaderValues.Count; ++column)
    {
        matrixItems.Add(new MatrixColumnHeaderItem(columnHeaderValues[column - 1])
        {
            GridRow = 0,
            GridColumn = column
        });
    }
}

void CreateRowHeaders(
    List<MatrixItemBase> matrixItems, List<TRow> rowHeaderValues)
{
    // Insert the row headers items in the first slot 
    // of each row after the column header row.
    for (int row = 1; row <= rowHeaderValues.Count; ++row)
    {
        matrixItems.Add(new MatrixRowHeaderItem(rowHeaderValues[row - 1])
        {
            GridRow = row,
            GridColumn = 0
        });
    }
}

void CreateCells(
    List<MatrixItemBase> matrixItems, 
    List<TRow> rowHeaderValues, 
    List<TColumn> columnHeaderValues)
{
    // Insert a cell item for each row/column intersection.
    for (int row = 1; row <= rowHeaderValues.Count; ++row)
    {
        TRow rowHeaderValue = rowHeaderValues[row - 1];

        for (int column = 1; column <= columnHeaderValues.Count; ++column)
        {
            // Ask the child class for the cell's value.
            object cellValue = this.GetCellValue(
                rowHeaderValue, 
                columnHeaderValues[column - 1]);

            matrixItems.Add(new MatrixCellItem(cellValue)
            {
                GridRow = row,
                GridColumn = column
            });
        }
    }
} 

请注意,上面代码中创建的每个MatrixItemBase派生对象如何被赋值GridRowGridColumn属性。这些属性通过MatrixControlItemContainerStyle进行绑定,因此承载MatrixItemBase对象的ContentPresenter将被赋予正确的Grid.RowGrid.Column附加属性值。来自MatrixControl的XAML完成了此绑定,如下所示。

<ItemsControl.ItemContainerStyle>
  <!-- 
  Bind each ContentPresenter's attached Grid 
  properties to MatrixItemBase properties. 
  -->
  <Style TargetType="{x:Type ContentPresenter}">
    <Setter Property="Grid.Row" Value="{Binding Path=GridRow}" />
    <Setter Property="Grid.Column" Value="{Binding Path=GridColumn}" />
  </Style>
</ItemsControl.ItemContainerStyle>

修订历史

  • 2009年6月14日 – 文章发布到CodeProject
© . All rights reserved.