简体   繁体   中英

WPF Datagrid Colum ActualWidth incorrect - Wait for ObservableCollection to finish update

I´m kind of stuck here with a little problem. I´ve got a DataGrid whose ItemSource is fed by an ObservableCollection. (I´m creating a little plugin for Revit).

My ObservableCollection contains of some Elements which are created when doing a selection in Revit. It is my goal to create a Stackpanel with Textboxes, which do have the same height as the actualized Columnwidths, which are set to Auto.

When running the code in "normal" speed I get a result like this: 在此处输入图像描述

When I debug the whole process it runs slower and I get a result like this (Which I want to get without debug mode): 在此处输入图像描述

popup.DataGridSelectedObjects.UpdateLayout();
popup.DataGridSelectedObjects.Items.Refresh();
Thread.Sleep(2000)

public void AddRegexItemToStackpanel(string parameterName, DataGridColumn dc)
{
    TextBox printTextBlock = new TextBox();
    printTextBlock.Width = dc.ActualWidth;
    printTextBlock.Margin = new Thickness(0.5, 0, 0, 0);
    printTextBlock.Name = dc.Header.ToString().Replace("__", "_");

    StackRegexPanel.Children.Add(printTextBlock);
}

Is there any method to wait the Observablecollection to update properly?

Thanks a lot and kind regards, Jannis

You should use a different approach. Using data binding should be the most intuitive. Avoiding XAML where XAML is possible will make life a lot harder most of the time and your code will start to smell.

Your Thread.Sleep looks very very suspicious too. It's also very likely that calling UpdateLayout() and Items.Refresh() should be avoided to improve the performance.

Changing the data source ( ObservableCollection ) will already trigger a refresh and, if necessary, a complete layout pass. No need to trigger both a second time. It will only make your UI slow.

Below you find two examples: a static, hard-coded version and a more elegant and dynamic version (where text boxes are added automatically to match the column count).
Both examples will automatically adjust the width of each TextBox if columns are resized (as a bonus when using data binding). The examples also highlight the efficiency of data binding in WPF. Code will always become smelly and overly complex when avoiding data binding and XAML.

To make a TextBox to follow the width of its respective column, simply use data binding:

<StackPanel Orientation="Horizontal">
  <TextBox Width="{Binding ElementName=Table, Path=Columns[0].ActualWidth}"
           Margin="0.5,0,0,0" />
  <TextBox Width="{Binding ElementName=Table, Path=Columns[1].ActualWidth}"
           Margin="0.5,0,0,0" />
  <TextBox Width="{Binding ElementName=Table, Path=Columns[2].ActualWidth}" 
           Margin="0.5,0,0,0" />
</StackPanel>

<DataGrid x:Name="Table" />

And to make it dynamic, simply use an ItemsControl that is configured to display its items horizontally:

<ItemsControl ItemsSource="{Binding ElementName=Table, Path=Columns}">
  <ItemsControl.ItemsPanel>
    <ItemsPanelTemplate>
      <StackPanel Orientation="Horizontal" />
    </ItemsPanelTemplate>
  </ItemsControl.ItemsPanel>

  <ItemsControl.ItemTemplate>
    <DataTemplate DataType="{x:Type DataGridColumn}">
      <TextBox Width="{Binding ActualWidth}"
               Margin="0.5,0,0,0" />
    </DataTemplate>
  </ItemsControl.ItemTemplate>
</ItemsControl>

<DataGrid x:Name="Table" />

In both cases you should implement an IValueConverter to convert from double to Thickness (for the Margin ) and bind the StackPanel.Margin of the first example or the ItemsControl.Margin of the second example to the DataGrid.RowHeaderActualWidth property to adjust for the row header (to align the text boxes properly).


Because you have provided more information, I felt the need to either delete or adjust my answer:

To dynamically change column count, you should always use a DataTable as data source. I have added an extension method that converts a collection to a DataTable , in case you need it.

Since the TextBox elements are meant to filter based on their associated column, I would suggest to modify the DataGrid.ColumnHeaderStyle to add a TextBox to the column header. This will be more convenient as the TextBox will now automatically resize and move (in case the column is dragged).

The column's TextBox will bind to a ObservableCollection of string values (filter expressions), where the index of each item maps directly to a column index. Handling the ColectionChanged event allows to handle the TextBox input.

MainWindow.xaml.cs

partial class MainWindow : Window
{public ObservableCollection<string> FilterExpressions
  {
    get => (ObservableCollection<string>)GetValue(FilterExpressionsProperty);
    set => SetValue(FilterExpressionsProperty, value);
  }

  public static readonly DependencyProperty FilterExpressionsProperty = DependencyProperty.Register(
    "FilterExpressions", 
    typeof(ObservableCollection<string>), 
    typeof(MainWindow), 
    new PropertyMetadata(default));

  public DataTable DataSource
  {
    get => (DataTable)GetValue(DataSourceProperty);
    set => SetValue(DataSourceProperty, value);
  }

  public static readonly DependencyProperty DataSourceProperty = DependencyProperty.Register(
    "DataSource", 
    typeof(DataTable), 
    typeof(MainWindow), 
    new PropertyMetadata(default));

  // Example data models to show 
  // how to convert the collection to a DataTable
  private List<User> Users { get; }

  public MainWindow()
  {
    InitializeComponent();

    this.FilterExpressions = new ObservableCollection<string>();
    this.FilterExpressions.CollectionChanged += OnFilterExpressionsChanged;
      
    this.Users = new List<User>();
    for (int index = 0; index < 500; index++)
    {
      this.Users.Add(new User());
    }

    this.DataSource = this.Users.ToDataTable();
  }

  private void OnFilterExpressionsChanged(object? sender, NotifyCollectionChangedEventArgs e)
  {
    string changedFilterExpression = e.NewItems.Cast<string>().First();
    int columnIndexOfchangedFilterExpression = e.NewStartingIndex;

    // TODO::Handle filter expressions
  }

  // Example on how to modify the column count
  private void AddColumnToDataTable()
  {
    // If only using a DataTable
    this.DataSource.Columns.Add(new DataColumn("Column name", typeof(string)));

    // If underlying data source is a collection, then
    // add new data models and call extension method ToDataTable
    var newItemsWithAdditionalProperties = new List<object>();
    this.DataSource = newItemsWithAdditionalProperties.ToDataTable();
  }
}

MainWindow.xaml
Example to show how to modify the column header to add a TextBox and how to use the attached behavior. The DataGrid now binds to a DataTable in order to allow to add/remove columns dynamically.

<Window>
  <DataGrid ItemsSource="{Binding RelativeSource={RelativeSource AncestorType=Window}, Path=DataSource}"
            local:DataGridColumnFilter.FilterValues="{Binding RelativeSource={RelativeSource AncestorType=Window}, Path=FilterExpressions}">
    <DataGrid.Resources>
      <Style x:Key="ColumnHeaderGripperStyle"
              TargetType="{x:Type Thumb}">
        <Setter Property="Width"
                Value="8" />
        <Setter Property="Background"
                Value="Transparent" />
        <Setter Property="Cursor"
                Value="SizeWE" />
        <Setter Property="Template">
          <Setter.Value>
            <ControlTemplate TargetType="{x:Type Thumb}">
              <Border Background="{TemplateBinding Background}"
                      Padding="{TemplateBinding Padding}" />
            </ControlTemplate>
          </Setter.Value>
        </Setter>
      </Style>
    </DataGrid.Resources>

    <DataGrid.ColumnHeaderStyle>
      <Style TargetType="{x:Type DataGridColumnHeader}">
        <Setter Property="VerticalContentAlignment"
                Value="Center" />
        <Setter Property="Template">
          <Setter.Value>
            <ControlTemplate TargetType="{x:Type DataGridColumnHeader}">
              <Grid>
                <Border x:Name="columnHeaderBorder"
                        BorderThickness="1"
                        Padding="3,0,3,0">
                  <Border.BorderBrush>
                    <LinearGradientBrush EndPoint="0.5,1"
                                          StartPoint="0.5,0">
                      <GradientStop Color="LightGray"
                                    Offset="0" />
                      <GradientStop Color="DarkGray"
                                    Offset="1" />
                    </LinearGradientBrush>
                  </Border.BorderBrush>
                  <Border.Background>
                    <LinearGradientBrush EndPoint="0.5,1"
                                          StartPoint="0.5,0">
                      <GradientStop Color="wHITE"
                                    Offset="0" />
                      <GradientStop Color="SkyBlue"
                                    Offset="1" />
                    </LinearGradientBrush>
                  </Border.Background>
                  <StackPanel>
                    <TextBox x:Name="PART_FilterInput"
                              Width="{TemplateBinding Width}" />

                    <ContentPresenter HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
                                      SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"
                                      VerticalAlignment="{TemplateBinding VerticalContentAlignment}" />
                  </StackPanel>
                </Border>

                <Thumb x:Name="PART_LeftHeaderGripper"
                        HorizontalAlignment="Left"
                        Style="{StaticResource ColumnHeaderGripperStyle}" />
                <Thumb x:Name="PART_RightHeaderGripper"
                        HorizontalAlignment="Right"
                        Style="{StaticResource ColumnHeaderGripperStyle}" />
              </Grid>
            </ControlTemplate>
          </Setter.Value>
        </Setter>
        <Setter Property="Background">
          <Setter.Value>
            <LinearGradientBrush EndPoint="0.5,1"
                                  StartPoint="0.5,0">
              <GradientStop Color="White"
                            Offset="0" />
              <GradientStop Color="DeepSkyBlue"
                            Offset="1" />
            </LinearGradientBrush>
          </Setter.Value>
        </Setter>
      </Style>
    </DataGrid.ColumnHeaderStyle>
  </DataGrid>
</Window>

DataGridColumnFilter.cs
Attached behavior to map the TextBox elements of the column headers to a collection (data source) - direction is one way and update is send on TextBox.LostFocus .

My recommendation is to extend DataGrid to get rid of this attached behavior and to add more convenience to the control handling.

If filtering is only view related (which is usually the case) ie you don't intend to modify the data source based on the filtering, I recommend to move the filtering logic to the attached behavior (or extended DataGrid ). This will keep your models clean.

public class DataGridColumnFilter : DependencyObject
{
  public static IList<string> GetFilterValues(DependencyObject obj) => (IList<string>)obj.GetValue(FilterValuesProperty);
  public static void SetFilterValues(DependencyObject obj, IList<string> value) => obj.SetValue(FilterValuesProperty, value);

  public static readonly DependencyProperty FilterValuesProperty = DependencyProperty.RegisterAttached(
    "FilterValues",
    typeof(IList<string>),
    typeof(DataGridColumnFilter),
    new PropertyMetadata(default(IList<string>), OnFilterValuesChanged));

  private static Dictionary<TextBox, int> GetIndexMap(DependencyObject obj) => (Dictionary<TextBox, int>)obj.GetValue(IndexMapProperty);
  private static void SetIndexMap(DependencyObject obj, Dictionary<TextBox, int> value) => obj.SetValue(IndexMapProperty, value);

  private static readonly DependencyProperty IndexMapProperty = DependencyProperty.RegisterAttached(
    "IndexMap",
    typeof(Dictionary<TextBox, int>),
    typeof(DataGridColumnFilter),
    new PropertyMetadata(default));

  private static Dictionary<DataGridColumnHeadersPresenter, DataGrid> DataGridMap { get; } = new Dictionary<DataGridColumnHeadersPresenter, DataGrid>();

  private static void OnFilterValuesChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
  {
    if (d is not DataGrid dataGrid)
    {
      return;
    }

    if (!dataGrid.IsLoaded)
    {
      dataGrid.Loaded += OnDataGridColumnsGenerated;
    }
    else
    {
      Initialize(dataGrid);
    }
  }

  private static void OnDataGridColumnsGenerated(object? sender, EventArgs e)
  {
    var dataGrid = sender as DataGrid;
    Initialize(dataGrid);
  }

  private static void Initialize(DataGrid dataGrid)
  {
    DataGridColumnHeadersPresenter headerPresenter = dataGrid.FindVisualChildren<DataGridColumnHeadersPresenter>().First();
    DataGridMap.TryAdd(headerPresenter, dataGrid);
    headerPresenter.AddHandler(UIElement.LostFocusEvent, new RoutedEventHandler(OnTextBoxLostFocus));
    DataGridCellsPanel cellsPanel = headerPresenter.FindVisualChildren<DataGridCellsPanel>().First();
    IEnumerable<TextBox> inputFields = cellsPanel.FindVisualChildren<TextBox>();
    RegisterColumnHeaderTextBoxes(dataGrid, inputFields);
  }

  private static void RegisterColumnHeaderTextBoxes(DataGrid dataGrid, IEnumerable<TextBox> inputFields)
  {
    var indexMap = new Dictionary<TextBox, int>();
    SetIndexMap(dataGrid, indexMap);
    for (int index = 0; index < inputFields.Count(); index++)
    {
      TextBox inputField = inputFields.ElementAt(index);
      indexMap.Add(inputField, index);
    }
  }

  private static void OnTextBoxLostFocus(object sender, RoutedEventArgs e)
  {
    if (e.OriginalSource is not TextBox textInputField)
    {
      return;
    }

    var headerPresenter = sender as DataGridColumnHeadersPresenter;
    if (!DataGridMap.TryGetValue(headerPresenter, out DataGrid host))
    {
      return;
    }

    IList<string> filterExpressionSource = GetFilterValues(host);
    Dictionary<TextBox, int> indexMap = GetIndexMap(host);
    int columnIndex = indexMap[textInputField];

    // Preload source collection if empty
    if (columnIndex >= filterExpressionSource.Count)
    {
      for (int index = filterExpressionSource.Count; index <= columnIndex; index++)
      {
        filterExpressionSource.Add(string.Empty);
      }
    }

    UpdateFilterExpressionSource(textInputField, filterExpressionSource, columnIndex);
  }

  private static void UpdateFilterExpressionSource(TextBox textInputField, IList<string> filterExpressionSource, int columnIndex)
  {
    if (!filterExpressionSource[columnIndex].Equals(textInputField.Text, StringComparison.Ordinal))
    {
      filterExpressionSource[columnIndex] = textInputField.Text;
    }
  }
}

ExtensionMethods.cs
Extension method to convert a IEnumerable<TData> to a DataTable .

public static class ExtensionMethods
{
  public static DataTable ToDataTable<TData>(this IEnumerable<TData> source)
  {
    Type dataType = typeof(TData);
    IEnumerable<PropertyInfo> publicPropertyInfos = dataType.GetProperties()
      .Where(propertyInfo => propertyInfo.GetCustomAttribute<IgnoreAttribute>() is null);
    var result = new DataTable();
    var columnNameMapping = new Dictionary<string, string>();
    foreach (PropertyInfo publicPropertyInfo in publicPropertyInfos)
    {
      DataColumn newColumn = result.Columns.Add(publicPropertyInfo.Name, publicPropertyInfo.PropertyType);

      System.ComponentModel.DisplayNameAttribute displayNameAttribute = publicPropertyInfo.GetCustomAttribute<System.ComponentModel.DisplayNameAttribute>();
      if (displayNameAttribute is not null)
      {
        newColumn.ColumnName = displayNameAttribute.DisplayName;
      }

      columnNameMapping.Add(publicPropertyInfo.Name, newColumn.ColumnName);
    }

    foreach (TData rowData in source)
    {
      DataRow newRow = result.NewRow();
      result.Rows.Add(newRow);
      foreach (PropertyInfo publicPropertyInfo in publicPropertyInfos)
      {
        object? columnValue = publicPropertyInfo.GetValue(rowData);
        string columnName = columnNameMapping[publicPropertyInfo.Name];
        newRow[columnName] = columnValue;
      }
    }

    return result;
  }
}

User.cs
The data model used to create the DataTable from in the above example ( MainWindow.xaml.cs ). The class also gives an example on how to use the attributes IgnoreAttribute to control
the visibility of properties/columns and System.ComponentModel.DisplayName to rename the property/column.

public class User : INotifyPropertyChanged
{
  public string UserName { get; set; }

  // Assign new column name for this property
  [System.ComponentModel.DisplayName("Mail address")]
  public string UserMail { get; set; }

  // Don't add this property as column to the DataTable
  [Ignore]
  public int Age { get; set; }
}

IgnoreAttribute.cs

[AttributeUsage(AttributeTargets.All, Inherited = false)]
public class IgnoreAttribute : Attribute
{
}

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM