Introduction
My favorite part of programming in WPF is the ease with which we compartmentalize the application components. Every Page
or UserControl
of course has a markup and a code-behind file, but if you are serious about architectural patterns like I am, then you also have a ViewModel and probably a related resource file as well.
I prefer to keep my markup files clean, defining visual attributes in a separate resource file. In doing so, adding or removing elements later, or altering visual properties, is done more quickly and with more flexibility...all while providing the serenity that comes with S.O.C. (Separation of
Concerns).
Whether your styles are defined in the local file markup or a merged resource dictionary, there are some inherent limitations to stylization that can quite easily be overcome. Take for instance
RowDefinition
s and ColumnDefinition
s...you can be diligent about parsing out the visual attributes of your UserControl
, but ultimately you are forced to define these in your markup file, and unless you modify it in managed code at runtime, these values are inevitably
static. Not very friendly to a skinnable interface, is it?
Background
When I decided that this was a problem that I wanted to resolve, I knew that it had to be something simple. My first thought was a custom Behavior
, but I quickly got away from that idea. Instead, I went to one of the most underrated elements in WPF - the Attached Property.
Attached Property is a special type of Dependency Property that can be assigned to any Dependency Object. Since a
Grid
just happens to be a Dependency Object, the solution was not only sufficient, it was elegant. I use Attached Properties daily, and for a wide variety of purposes. I rarely build an app without them!
Using the code
Attached Properties can be defined within any class in your application. For demonstration purposes, this article will assume that you placed the declaration in the Application
class.
public static DependencyProperty GridRowsProperty =
DependencyProperty.RegisterAttached("GridRows", typeof(string),
MethodBase.GetCurrentMethod().DeclaringType, new FrameworkPropertyMetadata(string.Empty,
FrameworkPropertyMetadataOptions.AffectsArrange, new PropertyChangedCallback(GridRowsPropertyChanged)));
Public Shared GridRowsProperty As DependencyProperty =
DependencyProperty.RegisterAttached("GridRows", GetType(String),
MethodBase.GetCurrentMethod().DeclaringType, New FrameworkPropertyMetadata(String.Empty,
FrameworkPropertyMetadataOptions.AffectsArrange, New PropertyChangedCallback(AddressOf GridRowsPropertyChanged)))
We declare the Attached Property as a String
type, so that in markup we can enter a comma separated list of Column/Row definitions.
Note: Because I use an extensive library of code snippets, I use Reflection to set the
ownerType
in my Dependency Property declarations. It is perfectly fine to replace MethodBase.GetCurrentMethod().DeclaringType
with
GetType(Application)
(Or whatever class you are declaring the Attached property within).
In order for an Attached Property to function, we must define the Get
and Set
Accessors. If you are new to the Dependency Property system, it is important to note that naming convention is very specific.
public static string GetGridRows(Grid This)
{
return Convert.ToString(This.GetValue(GridRowsProperty));
}
public static void SetGridRows(Grid This, string Value)
{
This.SetValue(GridRowsProperty, Value);
}
Public Shared Function GetGridRows(ByVal This As Grid) As String
Return CType(This.GetValue(GridRowsProperty), String)
End Function
Public Shared Sub SetGridRows(ByVal This As Grid, ByVal Value As String)
This.SetValue(GridRowsProperty, Value)
End Sub
In the GridRowsProperty
declaration, we assigned the PropertyChangedCallback
method GridRowsPropertyChanged
. This method will be called when the value of the GridRowsProperty
is initialized or modified.
Private Shared Sub GridRowsPropertyChanged(ByVal Sender As Object, ByVal e As DependencyPropertyChangedEventArgs)
Dim This = TryCast(Sender, Grid)
If This Is Nothing Then Throw New Exception("Only elements of type 'Grid' can utilize the 'GridRows' attached property")
DefineGridRows(This)
End Sub
private static void GridRowsPropertyChanged(object Sender, DependencyPropertyChangedEventArgs e)
{
object This = Sender as Grid;
if (This == null)
throw new Exception("Only elements of type 'Grid' can utilize the 'GridRows' attached property");
DefineGridRows(This);
}
In the callback method, we are simply checking that the property was attached to a
Grid
element, and then calling the method that modifies the
RowDefinition
s.
private static void DefineGridRows(Grid This)
{
object Rows = GetGridRows(This).Split(Convert.ToChar(","));
This.RowDefinitions.Clear();
foreach ( Row in Rows) {
switch (Row.Trim.ToLower) {
case "auto":
This.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) });
break;
case "*":
This.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
break;
default:
if (System.Text.RegularExpressions.Regex.IsMatch(Row, "^\\d+\\*$")) {
This.RowDefinitions.Add(new RowDefinition { Height = new GridLength(
Convert.ToInt32(Row.Substring(0, Row.IndexOf(Convert.ToChar("*")))), GridUnitType.Star) });
} else if (Information.IsNumeric(Row)) {
This.RowDefinitions.Add(new RowDefinition { Height = new GridLength(Convert.ToDouble(Row), GridUnitType.Pixel) });
} else {
throw new Exception("The only acceptable value for the 'GridRows' " +
"attached property is a comma separated list comprised of the following options:" +
Constants.vbCrLf + Constants.vbCrLf + "Auto,*,x (where x is the pixel " +
"height of the row), x* (where x is the row height multiplier)");
}
break;
}
}
}
Private Shared Sub DefineGridRows(ByVal This As Grid)
Dim Rows = GetGridRows(This).Split(CChar(","))
This.RowDefinitions.Clear()
For Each Row In Rows
Select Case Row.Trim.ToLower
Case "auto"
This.RowDefinitions.Add(New RowDefinition With {.Height = New GridLength(1, GridUnitType.Auto)})
Case "*"
This.RowDefinitions.Add(New RowDefinition With {.Height = New GridLength(1, GridUnitType.Star)})
Case Else
If System.Text.RegularExpressions.Regex.IsMatch(Row, "^\d+\*$") Then
This.RowDefinitions.Add(New RowDefinition With {.Height = New _
GridLength(CInt(Row.Substring(0, Row.IndexOf(CChar("*")))), GridUnitType.Star)})
ElseIf IsNumeric(Row) Then
This.RowDefinitions.Add(New RowDefinition With {.Height = New GridLength(CDbl(Row), GridUnitType.Pixel)})
Else
Throw New Exception("The only acceptable value for the 'GridRows' " & _
"attached property is a comma separated list comprised of the following options:" & _
vbCrLf & vbCrLf & "Auto,*,x (where x is the pixel " & _
"height of the row), x* (where x is the row height multiplier)")
End If
End Select
Next
End Sub
The DefineGridRows
method first calls the Get Accessor for the GridRowsProperty
, which returns the string that represents a comma separated list of
RowDefinition
values.
Next we clear any existing RowDefinitions
from the
RowDefinitionCollection
.
Using a String
type allows us to define an indefinite number of Rows/Columns in XAML or through databinding, but it means we have to parse the string data to determine what our
RowDefinition
s will actually be.
Before we evaluate the string value, we call the
String
functions Trim()
and ToLower()
. This adds to the flexibility of the XAML definition (or Binding) because we need not worry about case sensitivity or white space.
Our attached property will support the 4 (I know it's literally 3, but since there's really 4 implementations it makes more sense to call it so) types of Row/Column definitions, explained in detail here. You can see above that we iterate through the supplied values and append a new
RowDefinition
according to the value.
Note that if there is a parsing error, we are throwing a descriptive error message for clarity during the debug process.
That's it for the code! Now all that's left is to mark it up. Make sure that you reference your assembly in your directive:
<UserControl x:class="MyUserControl" mc:ignorable="d"
xmlns:local="clr-namespace:MyApplication"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"/>
Now you can define the RowDefinition
s in XAML like this:
<Grid local:Application.GridRows="Auto,50,*,Auto" />
Which is the equivalent of:
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="50"/>
<RowDefinition/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
</Grid>
Or more importantly, you can define the RowDefinition
s from within a
Style
defined anywhere in your application as a resource:
<Style x:Key="StylizedRowDefinitions" TargetType="Grid">
<Setter Property="local:Application.GridRows" Value="3*,5*,Auto"/>
</Style>
<Grid Style="{DynamicResource StylizedRowDefinitions}">
</Grid>
Now we will implement the GridColumnsProperty
, and we can see all the code together:
Public Shared GridColumnsProperty As DependencyProperty =
DependencyProperty.RegisterAttached("GridColumns", GetType(String),
MethodBase.GetCurrentMethod().DeclaringType, New FrameworkPropertyMetadata(String.Empty,
FrameworkPropertyMetadataOptions.AffectsArrange, New PropertyChangedCallback(AddressOf GridColumnsPropertyChanged)))
Private Shared Sub GridColumnsPropertyChanged(ByVal Sender As Object, ByVal e As DependencyPropertyChangedEventArgs)
RaiseEvent _GridColumnsChanged(Sender, e)
Dim This = TryCast(Sender, Grid)
If This Is Nothing Then
Throw New Exception("Only elements of type 'Grid' " +
"can utilize the 'GridColumns' attached property")
DefineGridColumns(This)
End Sub
Public Shared Function GetGridColumns(ByVal This As Grid) As String
Return CType(This.GetValue(GridColumnsProperty), String)
End Function
Public Shared Sub SetGridColumns(ByVal This As Grid, ByVal Value As String)
This.SetValue(GridColumnsProperty, Value)
End Sub
Private Shared Sub DefineGridColumns(ByVal This As Grid)
Dim Columns = GetGridColumns(This).Split(CChar(","))
This.ColumnDefinitions.Clear()
For Each Column In Columns
Select Case Column.Trim.ToLower
Case "auto"
This.ColumnDefinitions.Add(New ColumnDefinition With {.Width = New GridLength(1, GridUnitType.Auto)})
Case "*"
This.ColumnDefinitions.Add(New ColumnDefinition With {.Width = New GridLength(1, GridUnitType.Star)})
Case Else
If System.Text.RegularExpressions.Regex.IsMatch(Column, "^\d+\*$") Then
This.ColumnDefinitions.Add(New ColumnDefinition With {.Width = _
New GridLength(CInt(Column.Substring(0, _
Column.IndexOf(CChar("*")))), GridUnitType.Star)})
ElseIf IsNumeric(Column) Then
This.ColumnDefinitions.Add(New ColumnDefinition With _
{.Width = New GridLength(CDbl(Column), GridUnitType.Pixel)})
Else
Throw New Exception("The only acceptable value for " &_
"the 'GridColumns' attached property is a comma separated " & _
"list comprised of the following options:" & vbCrLf & _
vbCrLf & "Auto,*,x (where x is the pixel width of the " & _
"column), x* (where x is the column width multiplier)")
End If
End Select
Next
End Sub
public static DependencyProperty GridColumnsProperty =
DependencyProperty.RegisterAttached("GridColumns", typeof(string),
MethodBase.GetCurrentMethod().DeclaringType, new FrameworkPropertyMetadata(string.Empty,
FrameworkPropertyMetadataOptions.AffectsArrange, new PropertyChangedCallback(GridColumnsPropertyChanged)));
private static void GridColumnsPropertyChanged(object Sender, DependencyPropertyChangedEventArgs e)
{
if (_GridColumnsChanged != null) {
_GridColumnsChanged(Sender, e);
}
object This = Sender as Grid;
if (This == null)
throw new Exception("Only elements of type 'Grid' can " +
"utilize the 'GridColumns' attached property");
DefineGridColumns(This);
}
public static string GetGridColumns(Grid This)
{
return Convert.ToString(This.GetValue(GridColumnsProperty));
}
public static void SetGridColumns(Grid This, string Value)
{
This.SetValue(GridColumnsProperty, Value);
}
private static void DefineGridColumns(Grid This)
{
object Columns = GetGridColumns(This).Split(Convert.ToChar(","));
This.ColumnDefinitions.Clear();
foreach ( Column in Columns) {
switch (Column.Trim.ToLower) {
case "auto":
This.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Auto) });
break;
case "*":
This.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
break;
default:
if (System.Text.RegularExpressions.Regex.IsMatch(Column, "^\\d+\\*$")) {
This.ColumnDefinitions.Add(new ColumnDefinition { Width =
new GridLength(Convert.ToInt32(Column.Substring(0,
Column.IndexOf(Convert.ToChar("*")))), GridUnitType.Star) });
} else if (Information.IsNumeric(Column)) {
This.ColumnDefinitions.Add(new ColumnDefinition {
Width = new GridLength(Convert.ToDouble(Column), GridUnitType.Pixel) });
} else {
throw new Exception("The only acceptable value for the 'GridColumns' attached " +
"property is a comma separated list comprised of the following options:" +
Constants.vbCrLf + Constants.vbCrLf +
"Auto,*,x (where x is the pixel width of the column), " +
"x* (where x is the column width multiplier)");
}
break;
}
}
}
Now defining your Column Definitions is as simple as defining your RowDefinition
s:
<Style x:Key="MyGridStyle" TargetType="Grid">
<Setter Property="local:Application.GridColumns" Value="Auto,*"/>
<Setter Property="local:Application.GridRows" Value="1*,*,Auto"/>
<Style>
<Grid Style="{DynamicResource MyGridStyle}">
<Grid>
Points of Interest
Stylizing row/column definitions bridges a gap that exists in WPF scalability, and helps make your application more dynamic.
Attached Properties are a vastly underrated way of achieving scalability with often very little code compared to other approaches.
History
- Submitted: 7/24/2012.
- Added C# samples: 7/24/2012.