Sunday, January 6, 2013

Custom PropertyGrid control

Goal

WPF contains not very much default controls. But it is not difficult to implement them by yourself.

In one day i get request to implement something like PropertyGrid - it should contain property groups and search bar. I was surprised how well this problem could be solved.

In this post i show how to implement something like this (every control uses it's default style):

Planning

Property grid would be implemented with UserControl. Here would be 3 main elements: search bar, property group and properties. CustomPropertyGrid would use only two property editor - for strings and booleans.

Most of the logic (providing update mechanism and searching) would contained in ModelView part. View part consist of UserControl and DataTemplates. View would know about changes through INotifyPropertyChanged interface.

Implementation

Custom PropertyGrid inhereted from UserControl and contains TextBox and ItemsControl binded to DataContext.

  
<UserControl ... >
    <UserControl.Resources>
        ...
    </UserControl.Resources>
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*" />
        </Grid.RowDefinitions>
        
        <customPropertyGridDemo:WatermarkTextBox Text="{Binding SearchString, UpdateSourceTrigger=PropertyChanged}" />
        <ScrollViewer Grid.Row="1" >
            <ItemsControl ItemsSource="{Binding Groups}" >
                <ItemsControl.ItemsPanel>
                    <!--It is not necessary, because StackPanel is default panel. -->
                    <ItemsPanelTemplate>
                        <StackPanel />
                    </ItemsPanelTemplate>
                </ItemsControl.ItemsPanel>
            </ItemsControl>
        </ScrollViewer>
    </Grid>
</UserControl>

DataContext for PropertyGrid contains only search string property and collection of PropertyGroups.

 
public class PropertyGridVM : BaseVM
{
 public PropertyGridVM()
 {
  Groups = new ObservableCollection();
 }

 public ObservableCollection Groups { get; set; }

 private string searchString;
 public string SearchString
 {
  get { return searchString; }
  set
  {
   if (searchString == value) return;
   searchString = value;
   RaisePropertyChanged();
   UpdateFiltering();
  }
 }

 private void UpdateFiltering()
 {
  if (string.IsNullOrWhiteSpace(SearchString))
  {
   foreach (var propertyGroup in Groups)
   {
    propertyGroup.ClearFiltering();
   }
  }
  else
  {
   foreach (var propertyGroup in Groups)
   {
    propertyGroup.ApplyFiltering(SearchString);
   }
  }
 }
}

Base class BaseVM contains only INotifyPropertyChanged interface implementation. So on every change we updating Filtering. Logic for filtering transmitted further to PropertyGroup:

 
public class PropertyGroup : BaseVM
{
 public ObservableCollection Properties { get; set; }
 
 public string Name {get {...} set {...}}
 public bool IsVisible {get {...} set {...}}

 public void ApplyFiltering(string filterString)
 {
  foreach (var property in Properties)
  {
   property.ApplyFiltering(filterString);
  }
  IsVisible = Properties.Any(prop => prop.IsVisible);
 }

 public void ClearFiltering()
 {
  foreach (var property in Properties)
  {
   property.ClearFiltering();
  }
  IsVisible = true;
 }

 public ObservableCollection Properties { get; set; }
}

On every filter change - group updates its property IsVisible. Same thing done in every property only logic a little bit different. Through binding IsVisible updates PropertyGrid visual state. For example there are two DataTemplates - one for PropertyGroup and another for StringProperty.

<BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter"/>

<DataTemplate DataType="{x:Type modelView:PropertyGroup}">
 <Expander IsExpanded="True" Header="{Binding Name}" Visibility="{Binding IsVisible, Converter={StaticResource BooleanToVisibilityConverter}}">
  <ItemsControl ItemsSource="{Binding Properties}" />
 </Expander>
</DataTemplate>

<DataTemplate DataType="{x:Type modelView:StringProperty}">
 <Grid Visibility="{Binding IsVisible, Converter={StaticResource BooleanToVisibilityConverter}}">
  <Grid.ColumnDefinitions>
   <ColumnDefinition Width="0.4*" MinWidth="100" />
   <ColumnDefinition Width="0.6*" MinWidth="100" />
  </Grid.ColumnDefinitions>
  
  <Label Target="{Binding ElementName=valueEditor}" Content="{Binding Name}" HorizontalAlignment="Right" VerticalAlignment="Center" />
  
  <!--This is hack, because TextBox by default tries to get enough space for its content -->
  <Border x:Name="bd" Grid.Column="1" />
  <TextBox x:Name="valueEditor" Grid.Column="1" Text="{Binding Value}" Width="{Binding ElementName=bd, Path=ActualWidth}" VerticalAlignment="Center" />
 </Grid>
</DataTemplate>

Every DataTemplate for Property contains Label and value editor, in this case TextBox. For any other type of editors you should implement it's DataTemplate by your own.

To use this control we would create TestClass with properties we would like to edit:

 
public class TestClass
{
 [Browsable(true)]
 [Category("Group1")]
 [DisplayName("Property1")]
 public string Group1Property1 {get; set;}
 
 // ... other properties.
}

Every editable property we mark by these attributes. Through reflection we create PropertyProxy objects. Its used by Property class to get and set object's editalbe properties.

 
public class PropertyProxy
{
 public object GetValue()
 { ... }

 public void SetValue(object value)
 { ... }
}

There some parts for code improvements, but hope general idea is clear. If you want to make it more like VisualStudio PropertyGrid - you should update Expander Style and all DataTemplates for editors, but logic would be the same.

Link for project sources.

1 comment: