![]() |
Platforms, Frameworks & Libraries »
Windows Presentation Foundation »
General
Intermediate
License: The Code Project Open License (CPOL)
A WPF Problem Solved Two Very Different Ways - Using XAML Only - Using A Custom ControlBy Karl ShifflettArticle on solving a problem using a XAML only approach and then solving that same problem using WPF custom controls. |
C#, VB 8.0, Windows, .NET 3.0, XAML, WPF, VS2005, Dev, Design
|
||||||||
|
Advanced Search |
|
|
|
||||||||||||||||
This article is about developing XAML only WPF CheckListBox and ListBox With Selection Indicator control Styles. Taking the same requirements and building a custom control that takes a very different approach to the problem. This article is about taking a concept and running with. It's about this developer's desire to recreate a complex control using only XAML markup. We will explore the XAML only approach then toss everything into a blender and pour out two WPF Custom Controls using VB.NET.
This article and its controls were inspired by Josh Smith's awesome article, The WPF Thought Process. It's with Josh's permission and encouragement I write this article and post it here.
I read Josh's article several times attempting to glean and learn as I do from all the WPF articles here on, The Code Project.
To get the most from this article, please read Josh's article before reading this one. I won't be repeating his teaching here. We both solved the same problem in three different ways.
Some would ask, why would you want to spend time, developing a solution that doesn't look like a 'standard' ListBox? Well...
I've only been programming WPF since April 2007. So I take every opportunity to learn the how and why of a solution to a problem. I can't tell you how many times I've read a posting or article and two days later could use those techniques to solve a problem I was working on.
I dove into this project because you never know where one coding technique will lead. In this case, after finishing the ListBoxWithSelectedIndicator custom control, I was thinking about it and the creative juices started to flow. Why not replace the indicator arrow with a CheckBox and build a WPF CheckListBox? WPF does not come with a CheckListBox control. I used the ASP.NET CheckListBox control when doing web sites and figured I'll need one soon in my WPF work. Honestly, coding a WPF CheckListBox was a snap to code after fully understanding how to place an indicator arrow next to an item in a ListBox control. *** Note *** you can place CheckBoxes inside a ListBox control in XAML markup. Some of my WPF books as show how to swap out the DataTemplate and data bind to the ListBox which will create one CheckBox for each data item. However, there is a catch. When the SelectionMode property is set to Extended and you start selecting and deselecting items, it does not work correctly. Example, if you select the first item and scroll to the last and SHIFT-Click, you would expect all items are selected, this does not happen.
In mid October I gave two sessions at the Charlotte Developers GUILD Fall Code Camp and want to use something new for one of my presentations.
Lastly I did this because I love writing custom controls for ASP.NET, WinForms and WPF. Some of you can remember the days of VB 1.0 and Access 1.0 and wanting to write our own controls, but didn't know how. During those days I spent most of my time programming VAX/VMS and SCO Unix so I didn't have time to learn MFC. Today, Microsoft and the .NET Framework have made this task easy and fun for their development environments and languages.
I love challenges. So I set out to recreate the cool ListBoxWithIndicator solution Josh authored, except I only wanted to only use XAML. No Code, Just XAML! This desire also lead to authoring a custom control version. The custom control version functions very differently from the XAML version and can do one thing that the XAML version can't; scroll the ListBoxItem horizontally while at the same time, not scrolling the CheckBox bullet or indicator arrow. The XAML version uses text wrapping for narrow a ListBox with long text items.
Let's have a look at the requirements to see where we are heading. In addition to staying in lock step with Josh's ListBox specifications I added the following requirements:
XAML Only Requirements
CheckBox are visually separated from the corresponding ListBoxItem. ListBox must support Single, Multiple & Extended SelectionModes Custom Control Additional Requirements
ListBox control at run time. For the XAML version, I would need to develop a Style for the ListBox control that would accomplish the mission. Many years of web programming actually made this task easier and I'll point out down below where this came into play.
I completed the original XAML version but it had one minor limitation (the version here does not.) I could not make the text scroll horizontally without moving the CheckBox bullet or indicator arrow. So I wrote it to not scroll and called it a limitation, hey just make the ListBox wide enough for the text. No, that is not a good solution so for a few days I put the XAML only version on the shelf. As you can tell, at this point, I had not thought about text wrapping the text.
Not wanting to be denied my solution, I wrote a custom control that would meet all the above requirements and used that control for my presentation. This solution is VERY different from the XAML version. My control actually wraps a ListBox control, listens in to what that control is doing and takes the proper action to deliver required UI. Technically speaking, I set up Routed Event handlers.
Once the custom control was completed I didn't want to give up on my XAML only version. So I went to work and found the solution that had escaped me. While the XAML only version still can't scroll the text horizontally without also scrolling the CheckBox or arrow indicator, I got around this by making the ListBoxItem text wrap if its longer than the width of the ListBox. Problem solved and No Code, Just XAML is born.
The project download includes an application with 6 XAML only demos along with 6 custom control demos. I'll show a few images from the application.
Image shows our Style sporting the indicator arrow inside its own region. The ListBoxItem item selection colors have been overridden and set to Transparent. The indicator arrow region has its own color while the ListBox Border surrounds the arrow region. The below samples do not have the surrounding border.
Image shows our Style sporting the customized CheckListBox bullet, the CheckBox cleanly separated from the ListBoxItem. Notice that this separation is different from the above image. There is no visible border around the CheckBox bullets. The ListBoxItem item selection colors have been overridden and set to Transparent. With Halloween coming, I couldn't resist with the pumpkins.
Image shows our Style sporting the CheckListBox with the CheckBox cleanly separated from the ListBoxItem. Notice the ListBoxItem item selection colors are normal.
Image shows our Style sporting the CheckListBox with the CheckBox cleanly separated from the ListBoxItem. The ListBoxItem item selection colors have been overridden and set to Transparent.
There are essentially three pieces to the solution, ListBox and ListBoxItem control templates and the XAML markup for the actual ListBox. I'll go over the three pieces from the Demo3.xaml file. When running the application, its tab item header text reads, CheckListBox. This is also the demo that I figured most developers may want to use, a XAML only WPF CheckListBox. Keep in mind, each XAML only demo control templates varies slightly because we don't have any code that can make changes at run time.
To use the sample XAML Styles in your applications, copy the two control templates from resources in the desired file and author your ListBox markup like the demo does. Remember, each demo has slightly different control templates and ListBox markup. Better yet, go ahead and place the Style in a ResourceDictionary in your application.
<Style x:Key="aeroCheckListBoxStyle" TargetType="{x:Type ListBox}">
<Setter Property="ItemContainerStyle"
Value="{StaticResource aeroCheckListBoxItemStyle}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type ListBox}">
<!--This is the secret to having the area that the CheckBoxes are in
blend with the color of the parent border control
making this area look like a separate control.-->
<Border CornerRadius="0" Background="{TemplateBinding Background}">
<!--Disabling the HorizontalScrollBarVisibility
allows the ContentPresenter TextBlocks to wrap if the
ListBox is not wide enough for the text-->
<ScrollViewer HorizontalScrollBarVisibility="Disabled">
<!--This border is Mr. Cool. It places a line down the
middle of the control between the CheckBoxes and the
ListBoxes items.-->
<Border Background="{StaticResource scrollViewerBackgroundBrush}"
Margin="20,0,0,0"
BorderBrush="{StaticResource scrollViewerBorderBrush}"
BorderThickness="1,0,0,0" x:Name="border">
<!--Our ListBoxItems are here-->
<ItemsPresenter/>
</Border>
</ScrollViewer>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
The second line is where the ListBoxItem has its Style assigned by setting the ItemContainerStyle property. Placing this assignment here, saves the consuming designer or developer from having to do this in the ListBox XAML markup.
I mentioned that web programming helped out on this project, well here is the first place.
Take a look at the Sample Four image. The CheckBoxes are contained inside the ListBox. Our spec calls for the CheckBox to not have a visible border around it and for the CheckBox to be visually separated from the ListBoxItem.
This is accomplished by nesting Border controls. The outer Border provides the Background color but is void of any Borders. The inner Border 'Mr. Cool' has the responsibilities of providing the single line Border between the CheckBox and the ListBoxItem. Setting the left margin to 20, effectively moves the Border in between them, right where we need it.
Someone has to be thinking now, where are the top, right and bottom Borders? Well, we can't allow the ListBox control to provide them in this case because we want the CheckBox to not have a Border around it. So, when we get to the third piece of the solution, we'll pull another web trick to get the Border. You may want to minimize this article now, open an empty solution and give this exercise a go and see if you can get your UI to look like the example.
Another reader may wonder why Mr. Cool just can't provide all the borders? Well, Mr. Cool is a child of the ScrollViewer and the top and bottom Borders would move out of view when the ScrollViewer scrolled vertically.
Speaking of the ScrollViewer setting the HorizontalScrollBarVisibility to Disabled actually allows the ListBoxItem text to wrap. Without this setting, the text will not wrap.
The Border control that wraps the ItemsPresenter provides the Background color for the ScrollViewer scrolling region. This is accomplished by the {StaticResource scrollViewerBackgroundBrush} binding. This solution will not work without this color because as you'll read below, there is a black Rectangle sitting below this ScrollViewer.
I have removed some of the XAML from the below control template to highlight more important sections of code. Some of the removed code is the standard triggers that the ListBoxItem control template have. The provided source has all the code.
<Style x:Key="aeroCheckListBoxItemStyle" TargetType="{x:Type ListBoxItem}">
<Setter Property="Padding" Value="2,0,0,0"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type ListBoxItem}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="16"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!--The grid left margin of -20, gets the
first column over to the left to give
us that look that the CheckBoxes are
outside our ListBox control-->
<Grid Background="Transparent"
Width="16" Height="16"
HorizontalAlignment="Left"
Margin="-20,0,0,0">
<BulletDecorator Margin="2,0,0,0"
SnapsToDevicePixels="true"
Background="Transparent"
VerticalAlignment="Center">
<BulletDecorator.Bullet>
<Microsoft_Windows_Themes:BulletChrome
BorderBrush="{StaticResource checkBoxBorderBrush}"
IsChecked="{Binding Path=IsSelected,
RelativeSource={RelativeSource AncestorLevel=1,
AncestorType={x:Type ListBoxItem},
Mode=FindAncestor}}"
RenderMouseOver="{TemplateBinding IsMouseOver}" />
</BulletDecorator.Bullet>
</BulletDecorator>
</Grid>
<!--This positions our content in the perfect position-->
<Border Margin="-10,0,10,0"
Grid.Column="1"
SnapsToDevicePixels="true"
x:Name="Bd"
VerticalAlignment="Center"
MinHeight="16"
Background="Transparent">
<ContentPresenter Content="{TemplateBinding Content}">
<ContentPresenter.Resources>
<Style TargetType="{x:Type TextBlock}">
<Setter Property="TextWrapping" Value="Wrap"/>
</Style>
</ContentPresenter.Resources>
</ContentPresenter>
</Border>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
Again web programming comes into play here. Please keep in mind, this control template represents one ListBoxItem. One task we have to accomplish is that the CheckBox needs to be placed in the colored section of the ListBox control. This is accomplished by setting a negative left margin (-20) on the CheckBox parent container.
The BulletChrome is data bound to the ListBoxItem IsSelected property.
One small detail that could be over looked. The Border control that wraps the ContentPresenter has its MinHeight property set to 16. This is here and in all other templates in case the ListBoxItem text is smaller in height than an indicator arrow or custom sized CheckBox. Since WPF will only allocate the required height of the text we set the minimum height of this Grid cell to match the height of our indicator or CheckBox. Need to give credit again to Josh for seeing this while he was tested the code, thanks Josh.
We finally get to the ContentPresenter that displays the actual ListBoxItem Content. Since this XAML only approach can't allow the ListBoxItem to scroll horizontally, I had to force the ListBoxItem text to wrap. By wrapping the ContentPresenter with a Style that targets a TextBlock we can set the required TextWrapping for any TextBlocks that the ContentPresenter creates.
<Border CornerRadius="10"
Background="{StaticResource outerBorderBackgroundBrush}"
Padding="10"
Grid.Row="1"
BorderBrush="{StaticResource outerBorderBorderBrush}"
BorderThickness="1"
Margin="10,10,10,10" >
<Grid>
<Rectangle
Margin="20,-1,-1,-1"
Fill="{StaticResource scrollViewerBorderBrush}"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"/>
<ListBox Background="{StaticResource outerBorderBackgroundBrush}"
ItemsSource="{Binding Path=Items, Mode=OneWay,
Source={StaticResource someTestData}}"
SelectionMode="{Binding Path=Text,
ElementName=selectionModeCombo, Mode=Default}"
Style="{StaticResource aeroCheckListBoxStyle}"
IsSynchronizedWithCurrentItem="True" FontSize="12"
Height="Auto" VerticalAlignment="Stretch" SelectedIndex="0"/>
</Grid>
</Border>
The outer Border here provides a container for color and spacing for the ListBox.
The Grid houses a Rectangle and our ListBox. Remember we still don't have a top, right or bottom Border for the ListBox. The Rectangle provides this magic by settings its margins. The left margin of 20, pushes our Rectangle right so that it does not display behind the CheckBoxes. To get the appearance of a Border surrounding the ListBox we stretch the Rectangle with the negative margins (-1) up, to the right and down. Since the ListBox is smaller than the Rectangle a small amount of the Rectangle Fill color is visible. This fellow developers is the last of that web programmer stuff.
The ListBox is just standard WPF stuff. Allowing the control template to do all the work, just like it should be.
Go ahead and fire up the demo application and view all the XAML only demos. Try out all the SelectionModes. Notice that when in Extended mode you can Shift-Click groups of items, by clicking over the ListBoxItem test or CheckBox.
Each XAML only demo has two ListBoxes. The top one is data bound to an array resources and the bottom one is filled in the XAML markup with text and shapes.
Don't forget to look for the included Easter Egg. When you think you've found it, keep looking for the real one. BTW: The Easter Egg is No Code, Just XAML too.
When I sat down to author this control, I figured creating a control that included a ContentPresenter that would have a standard WPF ListBox as its Content was the way to go. The reason for this approach was that it allowed the consuming developer or designer to use the familiar ListBox and just attach behavior to it. The first beta release was derived from Control and included a Content property. Josh set me straight and suggested I derive from ContentControl because it has the Content property built in.
By using a ListBox as the control Content the first three requirements are met. Since our control in effect just attaches behavior to a ListBox the developer is free to load ListBoxItems in the XAML markup, using declarative data binding or add the items in code without taking our control into consideration.
This concept of writing a decoupled wrapper control appealed to me. The consumer is free to modify their ListBox as they see fit without bothering our control. The same limitation Josh mentioned in his article, about the ListBox rendering its child items horizontally also applies here. This would not work with the current implementation.
Before diving into a bunch of control code, let's look at how simple the control XAML markup is. In each below example a standard ListBox is the child or Content of our control.
Image shows the selection indicator height and width set to 32 and a larger ListBoxItem text font. ListBoxItem item selection colors have been left using the default color, transparent. Notice the horizontal scroll bar. The XAML only solution didn't have this feature. This solution is actually wrapping is child ListBox so we can now allow the ListBox to scroll.
<CustomControls:ListBoxWithSelectedItemIndicator
HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
Width="Auto" Height="Auto" Margin="10,10,10,10"
FontSize="12" ClipToBounds="True" Background="#FFDEFDFF"
BorderBrush="#FF00A7B9" BorderThickness="1,1,1,1"
Padding="5,5,5,5" Grid.Row="3" Grid.Column="1"
IndicatorHeightWidth="32" >
<ListBox ScrollViewer.CanContentScroll="False"
x:Name="lbDemoTwo" Width="Auto" Height="Auto"
IsSynchronizedWithCurrentItem="True"
DataContext="{DynamicResource SystemDrawingPrintingValues2}"
ItemsSource="{Binding}"
FontSize="24" SelectionMode="{Binding Path=Text,
ElementName=SelectionModeCombo, Mode=OneWay}"/>
</CustomControls:ListBoxWithSelectedItemIndicator>
That is really not much in the way of XAML markup! In the above code the last property of the control, IndicatorHeightWidth has been set to 32 which makes the indicator twice the size of the default indicator.
The child ListBox control is data binding to a DynamicResource from within the XAML markup. I used the System.Drawing.Printing.PaperKind enum as the data source because that enum has a lot of members.
The child ListBox control also has smooth scrolling enabled. When the attached property ScrollViewer.CanContentScroll is set to False, this actually turns on smooth scrolling by telling the ScollViewer that the ListBox does not know how to scroll its items so the ScollViewer takes over the job of scrolling the content. Normally when a ListBox scrolls up or down, it scrolls one ListBoxItem at a time, setting this attached property just overrides this default scrolling.
Image shows the standard indicator with the modified ListBoxItem item selection colors.
<CustomControls:ListBoxWithSelectedItemIndicator
HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
Width="Auto" Height="Auto" Margin="10,10,10,10"
FontSize="12" ClipToBounds="True" Background="#FFFFFFFF"
BorderBrush="#FF000000" BorderThickness="1,1,1,1"
Padding="5,5,5,5" Grid.Row="3" IndicatorHeightWidth="16">
<CustomControls:ListBoxWithSelectedItemIndicator.Resources>
<LinearGradientBrush
x:Key="{x:Static SystemColors.HighlightBrushKey}"
EndPoint="1,0.5" StartPoint="0,0.5">
<GradientStop Color="#FF3FBA00" Offset="0"/>
<GradientStop Color="#FFFFFFFF" Offset="1"/>
</LinearGradientBrush>
<SolidColorBrush
x:Key="{x:Static SystemColors.ControlBrushKey}" Color="#FFCDFFC9"/>
</CustomControls:ListBoxWithSelectedItemIndicator.Resources>
<ListBox x:Name="lbDemoOne" Width="Auto" Height="Auto"
IsSynchronizedWithCurrentItem="True"
DataContext="{DynamicResource SystemDrawingPrintingValues}"
ItemsSource="{Binding}"
FontSize="16" SelectionMode="{Binding Path=Text,
ElementName=SelectionModeCombo, Mode=OneWay}"/>
</CustomControls:ListBoxWithSelectedItemIndicator>
This control is authored to override the standard ListBoxItem item selector colors and make them transparent. This is performed in the control template. The markup shows one way to override the transparent color using markup by assigning a Brush to two StaticResources, SystemColors.HighlightBrushKey and SystemColors.ControlBrushKey.
The SystemColors.HighlightBrushKey resource is used by the ListBoxItem control as the color for selected items when the control has focus.
The SystemColors.ControlBrushKey resource is used by the ListBoxItem control as the color for selected items when the control does not have focus.
Since these transparent colors were assigned in the control template, by placing resources between the child ListBox and the parent control, the ListBoxItem will use the resources declared here. A developer could also edit the control template and change the colors there, but this is much simpler. Hint: you can use this same technique with ListBoxes or ComboBoxes not associated with this control. I understand that a green gradient is probably not the proper ListBoxItem item selected color, but how to change the color is important thing to understand.
Image shows a custom indicator Brush. The ListBoxItems have been added in the XAML markup. Control was also wrapped in a border. ListBoxItem item selection colors have been left using the default color, transparent.
<CustomControls:ListBoxWithSelectedItemIndicator
HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
Width="Auto" Height="Auto" FontSize="12" ClipToBounds="True"
Background="#FFFFFFFF" BorderBrush="{x:Null}"
BorderThickness="0,0,0,0" Padding="5,5,5,5" Grid.Row="2"
Grid.Column="2" IndicatorHeightWidth="16" >
<CustomControls:ListBoxWithSelectedItemIndicator.IndicatorBrush>
<LinearGradientBrush EndPoint="0.5,1" StartPoint="0.5,0">
<GradientStop Color="#FFF8FF72" Offset="0"/>
<GradientStop Color="#FFFF0000" Offset="0.992"/>
</LinearGradientBrush>
</CustomControls:ListBoxWithSelectedItemIndicator.IndicatorBrush>
<ListBox x:Name="lbDemoThree" Width="Auto" Height="Auto"
IsSynchronizedWithCurrentItem="True"
FontSize="16" SelectionMode="{Binding Path=Text,
ElementName=SelectionModeCombo, Mode=OneWay}">
<ListBoxItem Content="How"/>
<ListBoxItem Content="Cool"/>
<ListBoxItem Content="Is"/>
<ListBoxItem Content="WPF!"/>
<Ellipse Fill="#FFF32929" Stroke="#FF000000"
Margin="0,3,3,0" Width="30" Height="30"/>
<Ellipse Fill="#FF0C5FB7" Stroke="#FF000000"
Margin="0,3,3,0" Width="30" Height="30"/>
<Ellipse Fill="#FF13D141" Stroke="#FF000000"
Margin="0,3,3,0" Width="30" Height="30"/>
<Rectangle Fill="#FFE46614" Stroke="#FFFF0000"
RadiusX="5" RadiusY="5" Margin="0,3,3,0" Width="50" Height="30"/>
<Rectangle Fill="#FFD5D724" Stroke="#FFFF0000"
RadiusX="5" RadiusY="5" Margin="0,3,3,0" Width="50" Height="30"/>
<Rectangle Fill="#FF2133CF" Stroke="#FFFF0000"
RadiusX="5" RadiusY="5" Margin="0,3,3,0" Width="50" Height="30"/>
<Rectangle Fill="#FF67DF1F" Stroke="#FFFF0000"
RadiusX="5" RadiusY="5" Margin="0,3,3,0" Width="50" Height="30"/>
<Rectangle Fill="#FF09A18C" Stroke="#FFFF0000" RadiusX="5"
RadiusY="5" Margin="0,3,3,0" Width="50" Height="30"/>
</ListBox>
</CustomControls:ListBoxWithSelectedItemIndicator>
Here the ListBox is loaded with items in XAML markup. Our solution does not choke on vastly different ListBoxItems, text and shapes.
The example shows how to set a custom indicator Brush using XAML markup.
Image screen shot from the demo application shows the WPF CheckListBox version of this custom control. The CheckBox Stroke brush and StrokeThickness are set by custom dependency properties of the control. Also, the size of the CheckBox is controlled by a dependency property. Notice the middle CheckListBox has larger CheckBoxes. Again, this control delivers nice visual separation between the CheckBox bullet and the ListBoxItem and shows off the horizontal scrollbar that only scrolls the ListBoxItem Content and not the CheckBox bullet.
This control is included in the download. It is very similar to the control that renders an indicator arrow so I won't be writing about it.
From the above examples it's clear that our control wraps a child ListBox. Because of the awesome WPF architecture and our new best friend Routed Events, our control can easily listen in to what the child ListBox and the ListBox's child ScrollViewer are doing and then take action.
I suggest putting your custom controls in their own project. When you first add a Custom Control(WPF) to your project you will get a code file and a shell control template will be added to the generic.xaml file in the \themes subdirectory of your project. If you don't have this subdirectory it will be added for you by Visual Studio.
Normally I'll add required dependency properties and routed events to my code before beginning the control template editing. This way, as I'm editing the control template, I can make use of the dependency properties and possibly data bind to them if my control requires this.
Let us have a look at how a dependency property is declared and exposed in the VB.NET code file.
Public Shared ReadOnly IndicatorHeightWidthProperty As DependencyProperty = _
DependencyProperty.Register("IndicatorHeightWidth", GetType(Double), _
GetType(ListBoxWithSelectedItemIndicator), New PropertyMetadata(16.0))
<Description("Size of indictor. Indicator is ..."), Category("Custom")> _
Public Property IndicatorHeightWidth() As Double
Get
Return CType(GetValue(IndicatorHeightWidthProperty), Double)
End Get
Set(ByVal value As Double)
SetValue(IndicatorHeightWidthProperty, value)
End Set
End Property
IndicatorHeightWidth dependency property is named IndicatorHeightWidthProperty. Double. When setting a default value for a Double, you must use 16.0 and not 16 otherwise .NET will throw an exception. BTW: 16D does not work either. (Yes, I wasted 10 minutes trying to figure this out.) IndicatorHeightWidth property is a wrapper for the IndicatorHeightWidthProperty dependency property. This allows .NET code to access the property with normal get and set operations. Without this wrapper, developers would have to use the GetValue and SetValue statements to access the property. System.ComponentModel.Descrption and System.ComponentModel.Category attributes to your dependency property wrappers. If you do this, developers or designers using Microsoft Expression Blend can view the Description attribute information in the ToolTip that displays when the user mouses over the property name. Also, by adding the Category attribute, this adds another section to the bottom of Blend's property inspector. If you put all your dependency properties in the same Category it can make it easier for developers and designers to find them. Otherwise your properties will be placed in the Misc section. <DataTemplate>
<Grid
Width="{Binding Path=IndicatorHeightWidth,
RelativeSource={RelativeSource FindAncestor,
AncestorType={x:Type local:ListBoxWithSelectedItemIndicator}}}"
Height="{Binding Path=IndicatorHeightWidth,
RelativeSource={RelativeSource FindAncestor,
AncestorType={x:Type local:ListBoxWithSelectedItemIndicator}}}">
<!-- A lightweight drop shadow under the selection indicator. -->
<Path Fill="LightGray" Stretch="Uniform" Data="M4,4 L16,10 L4,16 z"
RenderTransformOrigin="0.5,0.5" SnapsToDevicePixels="True">
<Path.RenderTransform>
<TransformGroup>
<TranslateTransform X="2" Y="2"/>
</TransformGroup>
</Path.RenderTransform>
</Path>
<!-- The selection indicator itself. -->
<Path
Fill="{Binding Path=IndicatorBrush,
RelativeSource={RelativeSource FindAncestor,
AncestorType={x:Type local:ListBoxWithSelectedItemIndicator}}}"
Stretch="Uniform" Data="M2,2 L14,8 L2,14 z"
SnapsToDevicePixels="True"/>
</Grid>
</DataTemplate>
The IndicatorHeightWidth property is used within the control template to set the height and width of the selection indicator arrow by controlling the size of the Grid control that has the above two Path controls as its children. Since the two Path controls have their Stretch property set to Uniform, the arrow indicator simply sizes itself to its container. Nothing like WPF's vector based rendering!
What's interesting is how the IndicatorHeightWidth property is accessed in the XAML. When working in control templates and many times in User Controls, the object that has the property you want to bind to is not accessible by using its Name property. Instead you must walk up the element tree and find the object you are looking for that has the property you want to bind to. This is easily accomplish with the binding statement, RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type local:ListBoxWithSelectedItemIndicator}}. WPF does the heavy lifting for you, walks up the element tree until it finds the first object of the type you requested in the AncestorType property and then binds to the object property indicated by the binding Path.
The IndicatorHeightWidth property fulfills the requirement to Allow the indicator size to be set in XAML.
Likewise the control's other dependency property IndicatorBrush fulfills the requirement to Allow the indicator brush to be set in XAML.
In WPF, custom controls do not have a XAML file directly associated with them the way Window.xaml or UserControl.xaml files have a corresponding .vb or .cs code file. Instead a control template is associated with the custom control. Custom controls have a default control template that is defined in their Shared (static for c#) constructor, but like we all know, WPF control templates can be replaced at will by the consumer. Additionally, a custom control can have multiple control templates defined by the developer and these can be applied by theme or can be swapped out in code. Such flexibility! Don't you just love WPF!
'Special thanks to Josh Smith for his great teachings.
'Without his below article, this control would have never been authored.
'
Imports System.ComponentModel
<TemplatePart(Name:="PART_IndicatorList", Type:=GetType(ItemsControl))> _
Public Class ListBoxWithSelectedItemIndicator
Inherits System.Windows.Controls.ContentControl
#Region " Private Declarations "
Private _objIndicatorList As ItemsControl
Private _objIndicatorOffsets As _
System.Collections.ObjectModel.ObservableCollection(Of Double)
Private _objListBox As ListBox
#End Region
#Region " Public Declarations "
Public Shared ReadOnly IndicatorBrushProperty As DependencyProperty = _
DependencyProperty.Register("IndicatorBrush", GetType(Brush), _
GetType(ListBoxWithSelectedItemIndicator), _
New PropertyMetadata(New _
LinearGradientBrush(Colors.LightBlue, Colors.Blue, _
New Point(0.5, 0), New Point(0.5, 1))))
Public Shared ReadOnly IndicatorHeightWidthProperty As _
DependencyProperty = _
DependencyProperty.Register("IndicatorHeightWidth", _
GetType(Double), GetType(ListBoxWithSelectedItemIndicator), _
New PropertyMetadata(16.0))
#End Region
#Region " Properties "
<Description("Brush used to paint the indicator."), Category("Custom")> _
Public Property IndicatorBrush() As Brush
Get
Return CType(GetValue(IndicatorBrushProperty), Brush)
End Get
Set(ByVal value As Brush)
SetValue(IndicatorBrushProperty, value)
End Set
End Property
<Description("Size of indictor. Default value is 16."), _
Category("Custom")> _
Public Property IndicatorHeightWidth() As Double
Get
Return CType(GetValue(IndicatorHeightWidthProperty), Double)
End Get
Set(ByVal value As Double)
SetValue(IndicatorHeightWidthProperty, value)
End Set
End Property
#End Region
#Region " Methods "
Shared Sub New()
'This OverrideMetadata call tells the system that this
' element wants to provide a style that is different than
' its base class.
'This style is defined in themes\generic.xaml
DefaultStyleKeyProperty.OverrideMetadata( _
GetType(ListBoxWithSelectedItemIndicator), _
New FrameworkPropertyMetadata( _
GetType(ListBoxWithSelectedItemIndicator)))
End Sub
Public Sub New()
End Sub
Protected Overrides Sub OnContentChanged(ByVal oldContent As Object, _
ByVal newContent As Object)
MyBase.OnContentChanged(oldContent, newContent)
'this is our insurance policy that the developer does not
' add content that is not a ListBox
If newContent Is Nothing OrElse TypeOf newContent Is ListBox Then
'this ensures that our reference to the child ListBox
' is always correct or nothing.
'if the child ListBox is removed, our reference is
' set to Nothing
'if the child ListBox is swapped out, our reference
' is set to the newContent
_objListBox = TryCast(newContent, ListBox)
'this removes our references to the ListBox items
If _objIndicatorOffsets IsNot Nothing AndAlso _
_objIndicatorOffsets.Count > 0 Then
_objIndicatorOffsets.Clear()
End If
Exit Sub
Else
Throw New System.NotSupportedException("Invalid content." & _
" ListBoxWithSelectedItemIndicator only accepts" & _
" a ListBox control as its content.")
End If
End Sub
Public Overrides Sub OnApplyTemplate()
MyBase.OnApplyTemplate()
'when the template is applied, this give the developer
' the opportunity to get references to name objects
' in the control template.
'in our case, we need a reference to the ItemsControl
' that holds the indicator arrows.
'
'what your control does in the absence of an
' expected object in the control template is up
' to the control developer.
'in my case here, without the items control,
' we are dead in the water.
'
'remember that custom controls are supposed
' to be Lookless. Meaning the visual
' and code are highly decoupled.
'Any designer using Blend fully expects to be
' able edit the control template anyway they want.
'My using the "PART_" naming convention, you indicate
' that this object is probably necessary for the
' control to work, but this is not true in all cases.
'
_objIndicatorList = _
TryCast(GetTemplateChild("PART_IndicatorList"), ItemsControl)
If _objIndicatorList Is Nothing Then
Throw New Exception("Hey! The PART_IndicatorList" & _
" is missing from the" _
" template or is not an ItemsControl. " & _
"Sorry but this ItemsControl is required.")
End If
End Sub
Private Sub ListBoxWithSelectedItemIndicator_Loaded(ByVal sender As Object, _
ByVal e As System.Windows.RoutedEventArgs) Handles Me.Loaded
If _objIndicatorList Is Nothing Then
'remember how much "fun" tabs were be in VB and Access? Well...
'
'this is here because when you place a custom control
' in a tab, it loads the control once before it runs OnApplyTemplate
'when the TabItem its in gets clicked (focus), OnApplyTemplate runs
' then Loaded runs again.
'
'since OnApplyTemplate has not run yet, we are out of here
Exit Sub
End If
_objIndicatorOffsets = _
New System.Collections.ObjectModel.ObservableCollection(Of Double)
_objIndicatorList.ItemsSource = _objIndicatorOffsets
'How cool are routed events!
'We can listen into the child ListBoxes activities and act accordingly.
Me.AddHandler(ListBox.SelectionChangedEvent, _
New SelectionChangedEventHandler(AddressOf ListBox_SelectionChanged))
Me.AddHandler(ScrollViewer.ScrollChangedEvent, _
New ScrollChangedEventHandler(AddressOf ListBox_ScrollViewer_ScrollChanged))
UpdateIndicators()
End Sub
Private Sub UpdateIndicators()
'This is the awesome procedure that Josh Smith
' authored with a few modifications
If _objIndicatorOffsets Is Nothing Then
Exit Sub
End If
If _objListBox Is Nothing Then
Exit Sub
End If
If _objIndicatorOffsets IsNot Nothing AndAlso _
_objIndicatorOffsets.Count > 0 Then
_objIndicatorOffsets.Clear()
End If
If _objListBox.SelectedItems.Count = 0 Then
Exit Sub
End If
Dim objGen As ItemContainerGenerator = _objListBox.ItemContainerGenerator
If objGen.Status <> _
Primitives.GeneratorStatus.ContainersGenerated Then
Exit Sub
End If
For Each objSelectedItem As Object In _objListBox.SelectedItems
Dim lbi As ListBoxItem = _
TryCast(objGen.ContainerFromItem(objSelectedItem), ListBoxItem)
If lbi Is Nothing Then
Continue For
End If
Dim objTransform As GeneralTransform = _
lbi.TransformToAncestor(_objListBox)
Dim pt As Point = objTransform.Transform(New Point(0, 0))
Dim dblOffset As Double _
= pt.Y + (lbi.ActualHeight / 2) - (Me.IndicatorHeightWidth / 2)
_objIndicatorOffsets.Add(dblOffset)
Next
End Sub
Private Sub ListBox_ScrollViewer_ScrollChanged(ByVal sender As Object, _
ByVal e As System.Windows.Controls.ScrollChangedEventArgs)
'if the user is scrolling horizontality, no reason to run any of our
' attached behavior code
If e.VerticalChange = 0 Then
Exit Sub
End If
UpdateIndicators()
End Sub
Private Sub ListBox_SelectionChanged(ByVal sender As Object, _
ByVal e As System.Windows.Controls.SelectionChangedEventArgs)
UpdateIndicators()
End Sub
Private Sub ListBoxWithSelectedItemIndicator_Unloaded(ByVal sender As Object,_
ByVal e As System.Windows.RoutedEventArgs) Handles Me.Unloaded
Me.RemoveHandler(ListBox.SelectionChangedEvent, _
New SelectionChangedEventHandler(AddressOf _
ListBox_SelectionChanged))
Me.RemoveHandler(ScrollViewer.ScrollChangedEvent, _
New ScrollChangedEventHandler(AddressOf _
ListBox_ScrollViewer_ScrollChanged))
End Sub
#End Region
End Class
When authoring a custom control that has a Content property and your control needs to act on that Content you'll want to override OnContentChanged. This routine gives the developer access to the oldContent and newContent properties when the consumer is adding, removing or swapping out the Content.
In our control, we want to make SURE two things happen and OnContentChanged is the place to do them.
Content property contain either nothing or a ListBox control. OnContentChanged allows us to test the type of Content and reject it if necessary. ListBox and acts upon it. It's important that our control remove any references to the child ListBox when the consumer removes or swaps it out. Use the OnContentChanged routine to clean up any references to the Content if required. WPF custom controls should be lookless. A new term that means the control visual and code are highly decoupled. Translated, consumers of a control are free to modify the control template and the control code should continue function normally. If your default control template contains child controls that the code requires to function, the OnApplyTemplate routine is the place you can verify their existence and also obtain a reference to them. It is up to the control developer to decide to throw an exception or continue gracefully without the child control it was looking for.
If your control has child controls that the template needs to function, you should attribute your control class to indicate this. The <TemplatePart(Name:="PART_IndicatorList", Type:=GetType(ItemsControl))> attribute accomplishes this for our control. By convention these control names are prefixed with PART_.
When WPF was released, a new kid on the block appeared. Routed Events. Let's look at how Routed Events allow our control act upon the users activity with our child ListBox.
In the Loaded event of our control we add two Routed Event handlers.
Me.AddHandler(ListBox.SelectionChangedEvent, New SelectionChangedEventHandler(AddressOf ListBox_SelectionChanged)) Me.AddHandler(ScrollViewer.ScrollChangedEvent, New ScrollChangedEventHandler(AddressOf ListBox_ScrollViewer_ScrollChanged)) These Routed Event handlers are not attached to an instance of any specific control. This is vastly different from standard event handlers we use in ASP.NET or WinForms programming. Instead, we are listening for any child ListBox or ScrollViewer controls that raise the event we are targeting. In our case these are the SelectionChangedEvent and ScrollChangedEvent events. It is important to mention, these Routed Event handlers are handling events for any ListBox or ScrollViewer that is a child of our control. If our control had multiple ListBox or ScrollViewer controls, our Routed Event handler code must be authored to handle this. In our case there is only one of each of these controls.
When our child ListBox selection changes or the child ListBox's ScrollViewer is scrolled, our control's event handler code gets called.
Both of these event handlers call the UpdateIndicators code which synchronizes the ListBoxes SelectedItems with the indicator arrows. It does this by looping through the child ListBoxes SelectedItems collection and adding the ListBoxItems vertical offset position within the ListBox to a module level collection in our control. That collection of offsets is data bound to an ItemsControl in our control template which renders an arrow indicator at the same vertical offset position as its corresponding ListBoxItem. This is the heart of Josh's article mentioned above.
The control template XAML markup for this control were borrowed from Josh's article. He had authored his awesome solution as a UserControl, I moved it into a custom control and changed the way the control interacts with the ListBox by wrapping the child ListBox and adding behavior to it. His article provides a blow by blow account of how the code in this control template works.
I hope you like this article and code. I was really stretched during this expedition into XAML only land. I hope that you gained some insight into the WPF concept of loosely coupled Routed Event handling that allows us to write applications where a parent control can handle events from child controls. Microsoft really did an awesome job with WPF.
General
News
Question
Answer
Joke
Rant
Admin
|
PermaLink |
Privacy |
Terms of Use
Last Updated: 28 Oct 2007 Editor: |
Copyright 2007 by Karl Shifflett Everything else Copyright © CodeProject, 1999-2009 Web12 | Advertise on the Code Project |