WPF: How to automatically open/close the DropDown-portion of a ComboBox when the mouse enters/leaves the control

by Olaf Rabbachin 2. January 2011 17:20

Today, I came about a thread where the poster was asking for a way to automatically open a ComboBox'es DropDown/popup when the mouse hovers over the ComboBox. Pretty easy I thought. Oh well ...

Code or XAML

The easiest approach to opening the DropDown portion would be to attach to the ComboBox'es MouseEnter-event and simply doing a ...

 

private void ComboBox_MouseEnter(object sender, MouseEventArgs e)
{
	((ComboBox)sender).IsDropDownOpen = true;
}

 

But the above would mean you'd have to attach the handler to each and every ComboBox you want to use this with. Not neat. Not neat at all. So, given the fact that we can use Triggers for setting a property, a style with a simple Trigger should do the trick (or so I thought):

 

<ComboBox>
  <ComboBox.Resources>
   <Style TargetType="{x:Type ComboBox}">
     <Setter Property="IsDropDownOpen" Value="False"/>
     <Style.Triggers>
      <Trigger Property="IsMouseOver" Value="True">
        <Setter Property="IsDropDownOpen" Value="True"/>
      </Trigger>
     </Style.Triggers>
   </Style>
  </ComboBox.Resources>
  <ComboBoxItem>Item #1</ComboBoxItem>
  <ComboBoxItem>Item #2</ComboBoxItem>
  <ComboBoxItem>Item #3</ComboBoxItem>
  <ComboBoxItem>Item #4</ComboBoxItem>
  <ComboBoxItem>Item #5</ComboBoxItem>
</ComboBox>

 

Actually, this does work and, while the above XAML targets just a single ComboBox, it could as well be applied to all ComboBoxes throughout a Window or a complete application.
However, it'll only work only once. That is, the first time you hover over the ComboBox, the DropDown will open just as expected. But once you closed the DropDown again, it'll never automatically open again for all subsequent hover-actions.

I have tried all sorts of things to get this to work with XAML only, to no avail (I'll spare you from sharing those attempts). Since the XAML-approach really should be the equivalent of the code I posted in the beginning of this post, I wonder whether this actually is a bug ... Well, at least I don't understand as to why this shouldn't work. If you do know, make sure you post a comment!

So, since - from my point of view - it seems unfeasible to have a XAML-only approach, how about using the code, but in a way so that you still have the option of applying this to all ComboBox controls of a Window or even an application without having to attach to the handlers each and every time?

 

Enter the Attached Behavior

An attached behavior will allow us to write code that will execute depending on a condition that can be set from XAML; which means that we could still use a bit of XAML in order to apply the behavior to a single control, all controls within a container or even all controls within the scope of the application.

Now, if you don't know about attached behaviors - Josh Smith posted a nice and concise article on the CodeProject: Introduction to Attached Behaviors. If you find the article doesn't suffice to get you a firm understanding, I'd suggest you google or bing the term - there's probably thousands of articles and tutorials out there, so I won't dupe those here.

 

Get me something that works and stop babbling

Another goal for the attached behavior was that I thought that if you want a ComboBox to automatically hover-open its DropDown portion, you'd probably want to also close it when the mouse leaves the control. Well, again, this proved to be quite tedious. As you might already be tired of reading about things that don't work, I'll skip the babbling here and show you the final result before going into more detail.

Hence and without further ado, let's see what seems to be working reliably. Here's the complete code of an attached behavior that you can simply copy'n'paste into your own project. Note that, if you're developing with VB, you'll find a VB-version of the code below in the accompanying sample solution (see the bottom of this post for the download):

 

using System;
using System.Windows.Controls;
using System.Windows;
using System.Windows.Controls.Primitives;

namespace WpfTests
{
	public static class ComboBox_DropdownBehavior
	{
		/// <summary>
		/// Gets/sets whether or not the ComboBox this behavior is applied to opens its items-popup
		/// when the mouse hovers over it and closes again when the mouse leaves.
		/// </summary>
		public static readonly DependencyProperty OpenDropDownAutomaticallyProperty =
				 DependencyProperty.RegisterAttached(
				 "OpenDropDownAutomatically",
				 typeof(bool),
				 typeof(ComboBox_DropdownBehavior),
				 new UIPropertyMetadata(false, OnOpenDropDownAutomatically_Changed)
			 );

		//DP-getter and -setter
		public static bool GetOpenDropDownAutomatically(ComboBox cbo)
		{
			return (bool)cbo.GetValue(OpenDropDownAutomaticallyProperty);
		}
		public static void SetOpenDropDownAutomatically(ComboBox cbo, bool value)
		{
			cbo.SetValue(OpenDropDownAutomaticallyProperty, value);
		}

		/// <summary>
		/// Fired when the assignment of the behavior changes (IOW, is being turned on or off).
		/// </summary>
		static void OnOpenDropDownAutomatically_Changed(
				DependencyObject doSource, 
				DependencyPropertyChangedEventArgs e
			)
		{
			//The ComboBox that is the target of the assignment
			ComboBox cbo = doSource as ComboBox;
			if (cbo == null)
				return;

			//Just to be safe ...
			if (e.NewValue is bool == false)
				return;

			if ((bool)e.NewValue)
			{
				//Attach
				cbo.MouseMove += cbo_MouseMove;
				cbo.MouseEnter += cbo_MouseEnter;
			}
			else
			{
				//Detach
				cbo.MouseMove -= cbo_MouseMove;
				cbo.MouseEnter -= cbo_MouseEnter;
			}
		}

		static void cbo_MouseMove(object sender, System.Windows.Input.MouseEventArgs e)
		{
			//Get a ref to the ComboBox
			ComboBox cbo = (ComboBox)sender;
			//Get a ref to the ComboBox'es popup (which is what displays the available items)
			Popup p = (Popup)cbo.Template.FindName("PART_Popup", cbo);
			
			//The DropDown/popup is to close when 
			// - it is still open
			// - the mouse is no longer over the popup
			// - the cbo's IsMouseDirectlyOver returns true (which, albeit strange, is true
			//   when the mouse is neither over the popup NOR the cbo itself
			if (cbo.IsDropDownOpen && !p.IsMouseOver && cbo.IsMouseDirectlyOver)
				cbo.IsDropDownOpen = false;
		}

		static void cbo_MouseEnter(object sender, System.Windows.Input.MouseEventArgs e)
		{
			//Open the DropDown/popup as soon as the mouse hovers over the control
			((ComboBox)sender).IsDropDownOpen = true;
		}
	}
}

 

 

A sample Window that utilizes the above behavior

To test out the above behavior, here's a simple-enough Window that contains four ComboBox controls, three of them having the behavior applied:

 

<Window x:Class="CS.ComboBox_AutoOpenDropDown_AttachedBehavior"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:sys="clr-namespace:System;assembly=mscorlib"
        xmlns:local="clr-namespace:CS"
        Title="ComboBox_AutoOpenDropDown_AttachedBehavior (CS)" 
        Width="450" SizeToContent="Height">
   <Window.Resources>
      <!-- Just to provide a list that the ComboBoxes can bind to -->
      <x:Array Type="sys:String" x:Key="stringList">
         <sys:String>Item #1</sys:String>
         <sys:String>Item #2</sys:String>
         <sys:String>Item #3</sys:String>
         <sys:String>Item #4</sys:String>
         <sys:String>Item #5</sys:String>
      </x:Array>

      <!-- Base-style for all ComboBoxes in this Window (so we don't need to repeat this over and over again) -->
      <Style TargetType="{x:Type ComboBox}">
         <Setter Property="Margin" Value="10"/>
         <Setter Property="StaysOpenOnEdit" Value="True"/>
         <Setter Property="ItemsSource" Value="{Binding Source={StaticResource stringList}}"/>
         <!-- Here's where the attached behavior is applied -->
         <Setter Property="local:ComboBox_DropdownBehavior.OpenDropDownAutomatically" Value="True"/>
      </Style>
   </Window.Resources>

   <Grid>
      <Grid.RowDefinitions>
         <RowDefinition Height="Auto"/>
         <RowDefinition Height="Auto"/>
      </Grid.RowDefinitions>
      <Grid.ColumnDefinitions>
         <ColumnDefinition Width="*"/>
         <ColumnDefinition Width="*"/>
      </Grid.ColumnDefinitions>

      <ComboBox />
      <ComboBox Grid.Row="1"/>

      <!-- 
         Let's have one ComboBox behave as per default (i.e., without the attached behavior). 
         Since the behavior is being applied to *all* ComboBoxes (See the Window.Resources section),
         we'll explicitly turn it off here.
      -->
      <Grid Grid.Column="1">
         <Grid.ColumnDefinitions>
            <ColumnDefinition Width="Auto"/>
            <ColumnDefinition Width="*"/>
         </Grid.ColumnDefinitions>
         <TextBlock Text="No auto-open/-close:" Margin="0,0,5,0" VerticalAlignment="Center"/>
         <ComboBox Grid.Column="1" Background="LightPink" 
                   local:ComboBox_DropdownBehavior.OpenDropDownAutomatically="False"/>
      </Grid>
      <ComboBox Grid.Column="1" Grid.Row="1"/>
   </Grid>
</Window>

 

(You can also find the above Window in the sample solution - see the bottom for the download-link.)

 

What's all the fuzz (or: still not tired of reading?)

As I wrote further up, creating the behavior was quite tedious (for me, anyway). That is, closing the DropDown portion by simply attaching to the MouseLeave-event of the ComboBox wouldn't work. The reason is actually quite simple - the DropDown portion of the ComboBox control (IOW, the PART_popup as found in the ControlTemplate) is a popup control which sort of usurps events as well as the focus once it has been opened/shown, hiding them for the ComboBox control itself. So, once the DropDown is visible, the ComboBox'es MouseLeave-event will not be fired until the DropDown portion has again been closed. As a result, I needed to find a different way of triggering the code that would do the required cbo.IsDropDownOpen = false. So the core of the problem really is to sort of have the MouseLeave-event be triggered for both the ComboBox itself and its DropDown portion. The only reliable and not-all-too-verbose way I could find was to actually use the ComboBox'es MouseMove-event instead. Here's the code of the corresponding handler (again):

 

static void cbo_MouseMove(object sender, System.Windows.Input.MouseEventArgs e)
{
	//Get a ref to the ComboBox
	ComboBox cbo = (ComboBox)sender;
	//Get a ref to the ComboBox'es popup (which is what displays the available items)
	Popup p = (Popup)cbo.Template.FindName("PART_Popup", cbo);

	//The DropDown/popup is to close when 
	// - it is still open
	// - the mouse is no longer over the popup
	// - the cbo's IsMouseDirectlyOver returns true (which, albeit strange, is true when the mouse
	//   neither over the popup NOR the cbo itself
	if (cbo.IsDropDownOpen && !p.IsMouseOver && cbo.IsMouseDirectlyOver)
		cbo.IsDropDownOpen = false;
}

 

So what happens is that, taking the reference to the ComboBox in question, the code creates a reference to the DropDown portion resp. the underlying Popup-control and checks to see whether the mouse is over the popup or the ComboBox control itself, closing the DropDown if the mouse is off of the control. Now you might be wondering whether there isn't a typo. Namely, shouldn't the cbo.IsMouseDirectlyOver (or cbo.IsMouseDirectlyOver == true) really be a !cbo.IsMouseDirectlyOver (or cbo.IsMouseDirectlyOver == false)? The answer is no. Thing is, frankly, that I have no idea as to why the IsMouseDirectlyOver property returns true when the mouse pointer is neither over the popup nor over the ComboBox control itself, but that's how it is and what I had to find out while debugging the code, trying to find out whether I'm still sane. That having been said, the code above does the trick, albeit having some sort of a strange flavor attached. Again, if you can shed some light regarding the reason for this, I'd be happy to see your comment (I spent enough time on this for a Sunday afternoon anyway Foot in mouth). FWIW, I can't find any problems with the behavior as it is now, which means that it passed the few (manuak) tests I did - I haven't yet used this is a productive app.

 

The sample solution

I’ve created a sample solution that contains everything discussed here, containing one project for each the C# and the VB versions.

Download: ComboBox_AutoOpenDropDown_AttachedBehavior.zip (29.50 kb)

 

UPDATE:

As per a private request on 08/15 2011, I've added another sample solution, for those of you still sticking with VS 2008.

Download: ComboBox_AutoOpenDropDown_AttachedBehavior_VS2008.zip (127.61 kb)


Location: PostList

Tags: , , , , ,

WPF (.net)

WPF: Animating objects along a path and connecting them with a line

by Olaf Rabbachin 14. September 2010 19:06

A while ago, I came about a question posted in the (German) WPF-Forum (here's the thread). There, the poster asked for a way to connect two animated objects (residing in a Canvas control) by means of a line. While what I posted there was more like a quickie, I thought it would be fun to elaborate a bit on this, especially with respect to exactly how the line is connecting both shapes (in the post, I only introduced a way to connect the line between both objects' center points) - more on that to come.

Furthermore, I explained how to animate an object or a shape along a pre-defined path bunches of times in the forums, so this might be a good place to share my thoughts and, from now on, link to this post then instead.

 

The Basics - a very simple example

So, given an arbitrary shape, how do we create an animation that moves it from here to there? Let's start out as simple as possible; the following example will show a Rectangle that continuously moves back and forth along a horizontal line.

Assuming that we already have a Canvas control and a Rectangle within, here's the list of additional ingredients we'll need:

  1. a TranslateTransform that is applied to the Rectangle's RenderTransform
  2. a Storyboard that targets the Rectangle and can be run, i.e., after the Rectangle has been loaded
  3. inside the Storyboard, a DoubleAnimationUsingPath - this allows to specify a Path as the source of the transform
  4. a PathGeometry that we assign to the aforementioned animation and which defines the path (and thus the coordinates) that we want our Rectangle to travel along

Here's the XAML for a Window that includes all the above ingredients (click the "1. ..."-button if you're running the sample project):

 

<Window x:Class="AO_01_Simplistic"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="A simplistic sample" 
        Height="300" Width="300">
   <Window.Resources>
      <!-- The Rectangle's animation (path) -->
      <Storyboard x:Key="sb_Rect">
         <!-- The X-axis animation -->
         <DoubleAnimationUsingPath
               Storyboard.TargetName="ttAnimationTarget_Rect"
               Storyboard.TargetProperty="X" 
               Source="X"
               Duration="0:0:01"
               RepeatBehavior="Forever" 
               AutoReverse="True">
            <DoubleAnimationUsingPath.PathGeometry>
               <PathGeometry Figures="M50,0 L300,0"/>
            </DoubleAnimationUsingPath.PathGeometry>
         </DoubleAnimationUsingPath>
      </Storyboard>
   </Window.Resources>

   <!-- The Viewbox will allow to easily scale the Canvas'es contents -->
   <Viewbox Stretch="Uniform" Margin="5">
      <Border BorderBrush="DarkGray" BorderThickness="1">
         <Canvas Name="cvs" Background="#FFF0F0F0"
                    Width="450" Height="260">
            <!-- The Rectangle itself ... -->
            <Rectangle x:Name="rct" 
                          Fill="#E0ADD8E6" Width="50" Height="50"
                          Canvas.Top="100"
                          Stroke="DarkBlue" StrokeThickness="2"
                          Panel.ZIndex="1">
               <Rectangle.RenderTransform>
                  <TranslateTransform x:Name="ttAnimationTarget_Rect" X="0" Y="0" />
               </Rectangle.RenderTransform>
               <Rectangle.Triggers>
                  <!-- Start the animation as soon as the rectangle has been created -->
                  <EventTrigger RoutedEvent="FrameworkElement.Loaded">
                     <BeginStoryboard Storyboard="{StaticResource sb_Rect}"/>
                  </EventTrigger>
               </Rectangle.Triggers>
            </Rectangle>
         </Canvas>
      </Border>
   </Viewbox>
</Window>

 

And here's a little video that shows the above Window in action (don't blame me - Vimeo requires that all video-uploads are at least 200KB large, hence the long video for something as silly as that):

Here's how it works: The Rectangle's TranslateTransform (ttAnimationTarget_Rect) allows us to influence the X/Y offset that we can thus animate. Since we named the TranslateTransform, we can use it within our Storyboard-resource; in the Storyboard, we animate the TranslateTransform's X-property by means of a DoubleAnimationUsingPath which contains the path that we'd like to be followed.

 

Defining a Path (resp. its draw commands) as a resource that can be used arbitrarily

The sample above is simplistic in several ways. For instance:

  • it only animates the X-axis
  • it doesn't display the path that is being followed

Let's provide a more complex path (if you need more info on the draw commands' markup coming up, please refer to the docs: Path Markup Syntax and Geometries How-to Topics). Here's a shape that consists of a bunch of cubic beziers, forming a goofy path that our Rectangle can follow:

 

<PathFigureCollection x:Key="RectanglePathFigureCollection">
   M 20 50 
   C0,100 80,50 100,120
   c50,200 40,-30 200,-10
   c60,10 125,-25 0,-25
   c-20,0 -100,-10 -150,-40
   c-50,-30 -110,-55 -130,5
</PathFigureCollection>

 

Two things are worth noting here, before we continue: Instead of defining a Path along with its Data-property containing the above shapes, I created a PathFigureCollection that keeps nothing but the plain draw commands. Why that? The next subjective will be that we a) change the animation so that it considers both the X- and Y-axis which requires to state the same draw commands twice (more on that later) and b) we also want to draw the path. So, if we don't want to specify our path several times, we probably want to specify a resource and then use that instead. However, if we want to draw our shape, we'll have to specify a Geometry as the Path's Data property, but for the DoubleAnimationUsingPath's PathGeometry, we'll need to specify our shape by means of the Figure property which is a collection of PathFigure objects. Despite the fact that if, in the XAML-tags, you specify your shape as a string for both, you won't succeed in doing so when using a resource instead. You think this is weird and/or confusing? Well, it probably is ... or maybe not, because - behind the hood, (internal) converters do the trick for you when you assign the figure directly as a string in the respective property. As soon as we define a resource, however, we'll have to do that in a strongly typed fashion.

The above PathFigureCollection brings both "worlds" down to a common denominator (at the cost of a bit more markup). If we now want to refer to our shape, we'll do so by referencing the above resource for a PathGeometry's Figures property/collection (fair enough) and, for out visualized Path, we'll need to explicitly create a PathGeometry as the Path's Data-property which will then again allow us to specify the Figures property/collection (making up for the "bit more markup").

Since this shape, amongst several other things, will be used for all subsquent Windows, I created a ResourceDictionary that you'll find in the sample solution's Resources.xaml file and which contains everything that would otherwise only pollute the Windows' markup.

 

Animating a more complex shape and showing the path that is being followed

Now that we got that settled, let's (finally) move on to animating our Rectangle along the more complex shape that we defined in the last paragraph. If you recall, we only animated the Rectangle's TranslateTransform's X-property as the parth only formed a horizontal line. If we need to move in both directions though, this animation needs to target both the X- and the Y-axis. While this doesn't sound like it'd be something all too obvious, it actually is, because we only need to supply a transform in both the X- and Y-directions. Et voilá - the DoubleAnimationUsingPath does all the rest for us. Here's the XAML for the Storyboard and DoubleAnimationUsingPath that we'll use to animate the Rectangle along our goofy shape (again, you'll find this in the sample solution's Resource.xaml file):

 

<!-- The Rectangle's animation (path) -->
<Storyboard x:Key="sb_Rect">
   <!-- The X-axis animation -->
   <DoubleAnimationUsingPath
               Storyboard.TargetName="ttAnimationTarget_Rect"
               Storyboard.TargetProperty="X" 
               Source="X"
               Duration="0:0:05"
               RepeatBehavior="Forever" 
               AutoReverse="False">
      <DoubleAnimationUsingPath.PathGeometry>
         <PathGeometry Figures="{StaticResource RectanglePathFigureCollection}"/>
      </DoubleAnimationUsingPath.PathGeometry>
   </DoubleAnimationUsingPath>

   <!-- The Y-axis animation -->
   <DoubleAnimationUsingPath
               Storyboard.TargetName="ttAnimationTarget_Rect"
               Storyboard.TargetProperty="Y" 
               Source="Y"
               Duration="0:0:05"
               RepeatBehavior="Forever" 
               AutoReverse="False">
      <DoubleAnimationUsingPath.PathGeometry>
         <PathGeometry Figures="{StaticResource RectanglePathFigureCollection}"/>
      </DoubleAnimationUsingPath.PathGeometry>
   </DoubleAnimationUsingPath>
</Storyboard>

 

If you compare the above to the animation we used in the first sample Window, there's only really two (major) differences:

  1. the Storyboard now contains two animations (one for the X-, one for the Y-axis)
  2. the PathGeometry is now referencing the PathFigureCollection-resource that we defined earlier

And if you compare the two animations themselves, you'll see that these differ only in the axis that is being assigned. Worth noting here is the fact that you'd of course get very funny results if your two animations' Duration-, RepeatBehavior- or AutoReverse-properties differ from each other, hence always make sure that both animations only differ in the TargetProperty and Source properties!

As for drawing the shape, this can now be accomplished by adding the following to the Canvas (some additional markup omitted for clarity):

 

<Path Stroke="#200F" StrokeThickness="1">
   <Path.Data>
      <PathGeometry Figures="{StaticResource RectanglePathFigureCollection}"/>
   </Path.Data>
</Path>

 

Here's the XAML of a Window - without the resources that you'll find in the sample solution's Resources.xaml file - that now makes the Rectangle follow the goofy shape and also allows to show/hide the shape itself (watch the video underneath it to see the Window in action or click the "2. ..."-button if you're running the sample project):

 

<Window x:Class="CS.AO_02_OneObject"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="01: A single object, following a pre-defined path" 
        Width="410" Height="300">
   <Grid>
      <Grid.RowDefinitions>
         <RowDefinition Height="*"/>
         <RowDefinition Height="Auto"/>
      </Grid.RowDefinitions>

      <!-- The Viewbox will allow to easily scale the Canvas'es contents -->
      <Viewbox Stretch="Uniform" Margin="5">
         <Border BorderBrush="DarkGray" BorderThickness="1">
            <Canvas Name="cvs" Background="#FFF0F0F0"
                    Width="450" Height="260">
               <!-- The Rectangle itself ... -->
               <Rectangle x:Name="rct" 
                          Fill="#E0ADD8E6" Width="50" Height="50"
                          Stroke="DarkBlue" StrokeThickness="2"
                          Panel.ZIndex="1">
                  <Rectangle.RenderTransform>
                     <TranslateTransform x:Name="ttAnimationTarget_Rect" X="0" Y="0" />
                  </Rectangle.RenderTransform>
                  <Rectangle.Triggers>
                     <!-- Start the animation as soon as the rectangle has been created -->
                     <EventTrigger RoutedEvent="FrameworkElement.Loaded">
                        <BeginStoryboard Storyboard="{StaticResource sb_Rect}"/>
                     </EventTrigger>
                  </Rectangle.Triggers>
               </Rectangle>

               <!-- ... and the path along which it travels -->
               <Path Stroke="#200F" StrokeThickness="1" 
                     Visibility="{Binding ElementName=chkShowPaths, Path=IsChecked,
                        Converter={StaticResource boolToVisibilityConverter}}">
                  <Path.Data>
                     <PathGeometry Figures="{StaticResource RectanglePathFigureCollection}"/>
                  </Path.Data>
               </Path>
            </Canvas>
         </Border>
      </Viewbox>

      <!-- This will allow to show/hide the path along which the shape travels. -->
      <CheckBox x:Name="chkShowPaths" 
                Content="Show Path"
                IsChecked="True"
                Margin="15,5"
                Grid.Row="1"/>
   </Grid>
</Window>

 

And here's a video showing the Window in action:

 

Connecting two objects with a line

Let's introduce a second shape that has its own path to travel along. In the next Window, I added an Ellipse. I also added the Path it travels along (also an ellipse). Both the Rectangle's and the Ellipse's Path overlap each other so we can see the shapes overlap each other at times. I'll skip the XAML for the Ellipse (it's all in the sample solution) as this is more or less the same as for the Rectangle - nothing new involved there.

What I'd rather like to focus on is a third shape that I'm adding: a Line that is continuously connecting both shapes. Due to WPF's binding mechanism, this shape does not require its own animation. Instead, its end-points are simply connected to a point on each the Rectangle and the Ellipse. That is, besides the Ellipse and its (travel-) Path, all we really need to add is a single tag. Here's the markup that defines the line:

 

<!-- The line connecting both objects -->
<Line StrokeThickness="1" Stroke="Red" Panel.ZIndex="2"
      X1="{Binding ElementName=rct, Path=RenderTransform.X}"
      Y1="{Binding ElementName=rct, Path=RenderTransform.Y}"
      X2="{Binding ElementName=el, Path=RenderTransform.X}"
      Y2="{Binding ElementName=el, Path=RenderTransform.Y}"/>

 

Pretty simple, huh? Here's a video of the Window in action (click the "3. ..."-button if you're running the sample project):

Well, while the line now neatly connects both shapes, it does so by using the top left corner - probably not all that neat really, especially with respect to the Ellipse where the line is simply connecting to a (virtual) rectangle that is made up by its Width and Height properties and thus ends outside of the shape. Also (and maybe not all too obvious), the line is utilizing the RenderTransform's current coordinates only; as a result, the line would be off if we would've placed the Rectangle or Ellipse somewhere else on the Canvas (i.e. by means of Canvas.Left/Canvas.Top).

Last but not least, you might have noticed that the two shapes, while traveling along the visualized Path, do so by means of their top left corner. This really is the behavior to be expected, but it would sure look better if the shapes's center would be (or at least seemed to be) on the current position/point of the Path.

 

Making the line's end-points stick to the center of the respective object

In order to have the line's end points connect to each shape's center and, in addition to the shape's TranslateTransform's X- and Y-properties that we used to bind our line to in the previous Window, we'll now need to also consider the shape's Width And Height which will allow us to calculate the offset required to point to the shape's center. While, in the last Window, we were able to directly bind to the TranslateTransform's X- and Y-properties, we will now have to create a MultiBinding. This in turn requires that we create a Converter that takes the bindings defined in the Window's XAML and returns either the center-point's X- or Y-offset.

Here's the converter (which is actually quite simple). Note that, even though I'm showing only C# code here, you'll find all the code in VB in the sample solution's VB-project.

 

/// <summary>
/// Takes a TranslateTransform + Width & Height and calculates/returns
/// the point in the center of the shape (either X or Y).
/// </summary>
public class CenterPointConverter
   : IMultiValueConverter
{
   /// <summary>
   /// Returns either the X- or the Y-axis of the center point/coordinate 
   /// of the passed object/shape.
   /// </summary>
   /// <param name="values">
   /// This array needs to always contain three values:
   ///
   ///   - the shape's TranslateTransform
   ///   - the shape's (Actual)Width
   ///   - the shape's (Actual)Height
   /// </param>
   /// <param name="targetType">(Irrelevant)</param>
   /// <param name="parameter">
   /// The axis-coordinate to return:
   ///   "True" for X
   ///   "False" for Y
   /// </param>
   /// <param name="culture">(Irrelevant)</param>
   /// <returns>
   /// The X- or Y-value of the requested axis'es center point.
   /// </returns>
   public object Convert(
         object[] values,
         Type targetType,
         object parameter,
         System.Globalization.CultureInfo culture
      )
   {
      //
      //Also, the parameter must 
      if (values == null || values.Length != 3) return null;

      TranslateTransform tt = (TranslateTransform)values[0];
      double dblWidth = (double)values[1];
      double dblHeight = (double)values[2];
      bool fGetX = bool.Parse(parameter.ToString());

      //Calc the center of the object
      Point pt = new Point(
            tt.X + dblWidth / 2,
            tt.Y + dblHeight / 2
         );

      //Return the requested axis
      return (fGetX ? pt.X : pt.Y);
   }

   //ConvertBack skipped for clarity (empty / not implemented)
}

 

The only thing worth noting here is the fact that, after the center-point has been calculated, the Convert-method's parameter argument is being used to specify whether to return the calculated point's X- or its Y-position. Why that? Because the Line shape requires that we address each position separately (X1,Y1 + X2, Y2). Here's the XAML for our Line shape that now uses the above converter instead:

 

<!-- 
   Centering both X1/Y1 and X2/Y2 so they're in the center of the 
   respective object 
-->
<Line StrokeThickness="1" Stroke="Red" Panel.ZIndex="2">
   <Line.X1>
      <MultiBinding Converter="{StaticResource centerPointConverter}"
                    ConverterParameter="True">
         <Binding ElementName="rct" Path="RenderTransform"/>
         <Binding ElementName="rct" Path="Width"/>
         <Binding ElementName="rct" Path="Height"/>
      </MultiBinding>
   </Line.X1>
   <Line.Y1>
      <MultiBinding Converter="{StaticResource centerPointConverter}"
                    ConverterParameter="False">
         <Binding ElementName="rct" Path="RenderTransform"/>
         <Binding ElementName="rct" Path="Width"/>
         <Binding ElementName="rct" Path="Height"/>
      </MultiBinding>
   </Line.Y1>
   <Line.X2>
      <MultiBinding Converter="{StaticResource centerPointConverter}"
                    ConverterParameter="True">
         <Binding ElementName="el" Path="RenderTransform"/>
         <Binding ElementName="el" Path="Width"/>
         <Binding ElementName="el" Path="Height"/>
      </MultiBinding>
   </Line.X2>
   <Line.Y2>
      <MultiBinding Converter="{StaticResource centerPointConverter}"
                    ConverterParameter="False">
         <Binding ElementName="el" Path="RenderTransform"/>
         <Binding ElementName="el" Path="Width"/>
         <Binding ElementName="el" Path="Height"/>
      </MultiBinding>
   </Line.Y2>
</Line>

 

Repositioning the visualized Path

Alright, we now achieved that the Line shape will always end in both the Rectangle's and the Ellipse's center. As I mentioned earlier, the DoubleAnimationUsingPath will continuously position our shapes at a given coordinate of the defined Path; since we target the TranslateTransform however, this will always be the respective shape's top-left corner. While we could, of course, introduce another converter that would take the TranslateTransform and again calculate the offset required to position our Rectangle and Ellipse with their center on the visualized path, let's rather shift the visualized Path instead. The technique here is just the same as the one we've been using to animate the Rectangle and Ellipse - we simply apply a TranslateTransform. But this time, we'll apply it to the Path's RenderTransform directly (we don't want this to be animated). Here's the Rectangle's Path that the Rectangle travels along (the one for the Ellipse only differs in the Element-binding and is otherwise identical):

 

<Path Stroke="#200F" StrokeThickness="1" 
      Visibility="{Binding ElementName=chkShowPaths, Path=IsChecked,
      Converter={StaticResource boolToVisibilityConverter}}">
   <Path.Data>
      <PathGeometry Figures="{StaticResource RectanglePathFigureCollection}"/>
   </Path.Data>
   <!--
      The following Transform will allow to draw the path based on the
      center of the Rectangle that follows it.
   -->
   <Path.RenderTransform>
      <TranslateTransform
         X="{Binding ElementName=rct, 
            Path=ActualWidth, 
            Converter={StaticResource divideBy2Converter}}"
         Y="{Binding ElementName=rct, 
            Path=ActualHeight, 
            Converter={StaticResource divideBy2Converter}}"/>
   </Path.RenderTransform>
</Path>

 

Again, the only thing worth noting here is the fact that another converter is required in order to divide the Width/Height by 2 to get the required offset for X/Y. Since the converter is so simple, it has been omitted here (you'll find it in the sample solutions Converters.cs/Converters.vb file). If you plan to use the DivideBy2Converter, please note that it doesn't consider any object displacement, i.e. the offset defined if your object is placed in a Canvas by means of the Canvas.Left/Canvas.Top attached properties as this would really require the use of a MultiBinding along with an IMultiValueConverter and thus have "polluted" the XAML.

Here's the next video of a Window in action which contains all of the above (click the "4. ..."-button if you're running the sample project):

 

Making the line stick to the perimeter center of the respective object

At this point, we actually have a nicer/cleaner version of the quick hack I posted in the forum-thread that I mentioned at the beginning, but not all too much that would go beyond (except for a few littleties). However, I thought I'd rather like the Line connect to the perimeters of both shapes. That is, there's two possible scenarios that I think could be used for i.e. charting or the such:

  • connecting two objects with the shortest line possible

and 

  • connecting between both object's center points, but with the line ending at the shape's perimeter.

Maybe I'll have another go in the future at the first alternative, but for the time being, I went for the latter one which imposed one of those little mathematical challenges that I like wrapping my head around.

Since, for the remainder of this section, we'll focus on the code required to calculate the perimeter points, here's a little excerpt of how the converter - that I'll introduce further down - needs to be used on the XAML-side. Note that, due to the verbosity, I'm showing only the Line's X1-part. The XAML for Y1, X2 and Y2 only differs in the ConverterParameter and/or in the ElementName-binding (reversing the order of appearance of the two shapes), so I guess it makes more sense to only post the relevant excerpt:

 

<Line.X1>
   <MultiBinding Converter="{StaticResource twoShapesPerimeterConverter}"
                 ConverterParameter="True">
      <Binding ElementName="rct"/>
      <Binding ElementName="rct" Path="RenderTransform"/>
      <Binding ElementName="el"/>
      <Binding ElementName="el" Path="RenderTransform"/>
      <Binding RelativeSource="{RelativeSource Self}"/>
   </MultiBinding>
</Line.X1>

 

As you can see in the XAML above, we now need to pass 5 parameters to the TwoShapesPerimeterConverter-converter. That is, we need each shape itself along with its RenderTransform plus the Line-shape. Since I'm passing a reference to each shape, you might ask yourself as to why the RenderTransform is being passed along. The reason is that the binding-mechanism (or rather INotifyPropertyChanged) is smart enough to only call into the converter if a relevant value changes. This "smartness" of course is necessary as, otherwise, the converter would be called over and over again, even if nothing changed that would justify the call in the first place. So, while that really is a good thing, it forces us to not only pass a reference to the shape in question (using which we could of course get at the RenderTransform), but also the RenderTransform. If we wouldn't, the converter wouldn't be called when it should.

Now that we sorted that out, let's look at the code. Before I do, let me once again point out that, even though I'm showing only C# code here, you'll find all the code in VB in the sample solution's VB-project. Also, I'll start with the main body of the Convert-method, skipping parts that I'll get to later:

 

public object Convert(
		object[] values,
		Type targetType,
		object parameter,
		System.Globalization.CultureInfo culture
	 )
  {
	 if (values == null || values.Length != 5) return null;

	 //First shape
	 FrameworkElement fe_O1 = (FrameworkElement)values[0];
	 TranslateTransform tt_O1 = (TranslateTransform)values[1];
	 double dblWidth_O1 = fe_O1.Width;
	 double dblHeight_O1 = fe_O1.Height;
	 //Second shape
	 FrameworkElement fe_O2 = (FrameworkElement)values[2];
	 TranslateTransform tt_O2 = (TranslateTransform)values[3];
	 double dblWidth_O2 = fe_O2.Width;
	 double dblHeight_O2 = fe_O2.Height;
	 //The connecting line
	 Line lne = (Line)values[4];
	 //Whether to return the X- or Y-axis
	 bool fGetX = bool.Parse(parameter.ToString());

	 double dblOffset_X_O1 = 0;
	 double dblOffset_Y_O1 = 0;
	 double dblOffset_X_O2 = 0;
	 double dblOffset_Y_O2 = 0;

	 //We're assuming that the control is part of a canvas, 
	 //so retrieve its position. 
	 //This will, however, only work if Canvas.Left/Canvas.Top 
	 //have been set, so check first.
	 if (!double.IsNaN(Canvas.GetLeft(fe_O1)))
		dblOffset_X_O1 += Canvas.GetLeft(fe_O1);
	 if (!double.IsNaN(Canvas.GetTop(fe_O1)))
		dblOffset_Y_O1 += Canvas.GetTop(fe_O1);

	 if (!double.IsNaN(Canvas.GetLeft(fe_O2)))
		dblOffset_X_O2 += Canvas.GetLeft(fe_O2);
	 if (!double.IsNaN(Canvas.GetTop(fe_O2)))
		dblOffset_Y_O2 += Canvas.GetTop(fe_O2);

	 //Calc the center of the first object
	 Point ptCenter_O1 = new Point(
		   dblOffset_X_O1 + tt_O1.X + dblWidth_O1 / 2,
		   dblOffset_Y_O1 + tt_O1.Y + dblHeight_O1 / 2
		);

	 //Calc the center of the second object
	 Point ptCenter_O2 = new Point(
		   dblOffset_X_O2 + tt_O2.X + dblWidth_O2 / 2,
		   dblOffset_Y_O2 + tt_O2.Y + dblHeight_O2 / 2
		);

	 //The point of which we'll return either X or Y.
	 //In case anything goes wrong, we'll simply return 
	 //a point that is negative and thus hopefully out of view.
	 Point ptPerimeterIntersection = new Point(-1000, -1000);

	 //Calculate the point on the first shape's perimeter,
	 //according to the type passed.
	 string strType = fe_O1.GetType().Name;
	 switch (strType)
	 {
		case "Rectangle":
		   [...]
		   break;

		case "Ellipse":
		   [...]
		   break;

		default:
		   //Oops.
		   throw new NotSupportedException("Unsupported type \"" + strType + "\".");
	 }

	 //Only show the line, if the resulting point is outside of the object, hide it
	 lne.Visibility = (ptPerimeterIntersection.X < 0 || ptPerimeterIntersection.Y < 0 ? Visibility.Hidden : Visibility.Visible);

	 //Return the requested axis
	 return (fGetX ? ptPerimeterIntersection.X : ptPerimeterIntersection.Y);
  }

 

There shouldn't anything overly surprising in the above: the converter first assures that the expected number of arguments has been passed, casts them into strongly typed variables, does a few calculations (to again get both shapes' the center-points) and then finally gets to its "heart" (more on that to come), i.e. the stuff represented by the [...]-placeholders. There's only three things worth noting here:

  1. Other than with the previously introduced converters, this one now considers a potentially existing offset applied to the shape by means of Canvas.Top/Canvas.Left.
  2. The switch-statement (Select Case in VB) is based on the name of the passed shape's type to determine what math is to be applied (see below for the math itself).
  3. The point that we return X or Y for defaults to a location outside of view, so that, if the respective shape's calculation doesn't return a valid value (which would be the case if both shapes overlap each other), the Line can be hidden.

As you can see, I only considered two general types of shapes: a Rectangle and an Ellipse. I'm not really planning on adding more shapes (this is purely academic really - I have no use for this at present), but this would be the place where you'd add the math for other types.

Before we peek at both shapes and the math required to get a point on their perimeter, let me send ahead that the center-points are still the basis for all calculations to follow. That is, first a virtual line will be considered that connects between both shapes' center points, then this line is "shortened" so that it ends on the center of the perimeter (or the center of the shape's stroke) of the first object that has been passed into the converter. This virtual line is actually the reason for the necessity of passing both shapes into the converter.

 

Calculating the perimeter-point for the Rectangle

For the Rectangle, the code in the converter is quite short, but the reason really is that it requires a) repetetive calls to identical code and b) contains generic code that could be used somewhere else, hence I put this into a separate class. FWIW, here's the code that makes up for the previous snippet's [...]-placeholder:

 

Rectangle rct = (Rectangle)fe_O1;
//Create a line that connects both objects' center points.
Line lneConnector = new Line()
{
  X1 = ptCenter_O1.X,
  Y1 = ptCenter_O1.Y,
  X2 = ptCenter_O2.X,
  Y2 = ptCenter_O2.Y
};
Rect r = new Rect()
{
  X = dblOffset_X_O1 + tt_O1.X,
  Y = dblOffset_Y_O1 + tt_O1.Y,
  Size = new Size(rct.ActualWidth, rct.ActualHeight)
};
//The calculations are quite verbose, hence I placed them in a helper-class.
//See the below method's definition for more information.
ptPerimeterIntersection = Helpers.RectAndLineIntersectionPoint(r, rct.StrokeThickness, lneConnector);
break;

 

So all we do here is to prepare the arguments that the RectAndLineIntersectionPoint() method expects. These consist of a rect-structure (which contains all information required about our Rectangle), the Rectangle's StrokeThickness (which we need in order to return a point that sits in the center of the stroke) and a Line that connects the Rectangle itself with our second end-point which really is the center of an arbitrary other shape (the Ellipse in our sample Window).

Here's the RectAndLineIntersectionPoint() method (minus the header comments):

 

public static Point RectAndLineIntersectionPoint(
	Rect rct, 
	double dblRectStrokeWidth, 
	Line lne
 )
{
 Point ptIntersection = new Point(-1000, -1000); //Default: outside of visible area
 double RO = dblRectStrokeWidth / 2; //Offset required due to the Rectangle's stroke-width 

 //We'll need to test the four sides of the Rectangle
 Line lneRect_Top = new Line() { X1 = rct.Left + RO, X2 = rct.Right - RO, Y1 = rct.Top + RO, Y2 = rct.Top + RO };
 Line lneRect_Bottom = new Line() { X1 = rct.Left + RO, X2 = rct.Right - RO, Y1 = rct.Bottom - RO, Y2 = rct.Bottom - RO };
 Line lneRect_Left = new Line() { X1 = rct.Left + RO, X2 = rct.Left + RO, Y1 = rct.Top + RO, Y2 = rct.Bottom - RO };
 Line lneRect_Right = new Line() { X1 = rct.Right - RO, X2 = rct.Right - RO, Y1 = rct.Top + RO, Y2 = rct.Bottom - RO };

 if (!Helpers.DoLinesIntersect(lneRect_Left, lne, ref ptIntersection))
	if (!Helpers.DoLinesIntersect(lneRect_Top, lne, ref ptIntersection))
	   if (!Helpers.DoLinesIntersect(lneRect_Right, lne, ref ptIntersection))
		  if (!Helpers.DoLinesIntersect(lneRect_Bottom, lne, ref ptIntersection))
			 return new Point(-1000, -1000);

 return ptIntersection;
}

 

Here's how it works: in order to find the side that the also passed line intersects, we'll have test each of the Rectangle's sides separately. For the order of the tests/sides, I opted to follow the same approach as with i.e. the Margin attached property does - starting on the left and then going on clockwise to the bottom, stopping the calculations as soon as the first intersection point has been found. I know I could've created the Line-objects only when required, too, but this is one of the occasions where I prefer readability over performance (I didn't do any perf tests to see what the cost really is). The separate Line objects being created, BTW, are solely for the purpose of considering the StrokeThickness and thus calculating the offset we need. That is, the rect-structure is simply reduced by half of the StrokeThickness on each side so that the DoLinesIntersect()-method can operate on the values required to return a point in the center of the stroke.

I won't discuss the code behind the DoLinesIntersect()-method as I simply took this method over from somebody else and converted it into a C# and VB-version. For more information, see the source code in the sample solution (you'll find the above method as well as the DoLinesIntersect()-method in Helpers.cs/Helpers.vb). You can also refer to the Paul Bourke's site where I took the math from: Intersection Point Of Two Lines (2 Dimensions). Thanks from here go out to Paul and the folks that kindly provided the source-code in C/C++ which I used for my C#/VB versions.

 

Calculating the perimeter-point for the Ellipse

So, here goes the last code-snippet which calculates the point on the Ellipse's perimeter that is being intersected by the line traveling between the Ellipse's and the other shape's center points:

 

Ellipse el = (Ellipse)fe_O1;
double dblEllipse_Radius_X = (el.ActualWidth - el.StrokeThickness) / 2;
double dblEllipse_Radius_Y = (el.ActualHeight - el.StrokeThickness) / 2;

//Create a vector for each a flat X- and one for X- and Y-axis - this will
//allow us to calculate the angle of a virtual line connecting the 
//center-points of both objects.
Vector v1 = new Vector(0, ptCenter_O1.Y - ptCenter_O2.Y);
Vector v2 = new Vector(ptCenter_O2.X - ptCenter_O1.X, v1.Y);

//If the second object is left of the ellipse, we'll need to return
//a point on the left half of the ellipse; if not, the perimeter-point 
//will be on its right half.
bool fIsOnLeftHalf = (ptCenter_O2.X < ptCenter_O1.X);

//Get the angle between both vectors
double dblAngle_GRAD = Vector.AngleBetween(v2, v1);

//The Vector.AngleBetween will return a value in degrees (and shifted 90° to the right
//due to the different coordinate-system that with 0° "in the east"), but we'll need 
//the value in radians for the calculations coming up, so convert the value accordingly.
double dblAngle_RAD = (Math.PI * (dblAngle_GRAD - 90d)) / 180d;

//In order to have this work not only for a circle, but also for an ellipse,
//will have to consider both radii (major/minor).
double theta = Math.Atan((dblEllipse_Radius_X / dblEllipse_Radius_Y) * Math.Tan(dblAngle_RAD));
double x = ptCenter_O1.X + dblEllipse_Radius_X * Math.Cos(theta) * (fIsOnLeftHalf ? -1 : 1);
double y = ptCenter_O1.Y + dblEllipse_Radius_Y * Math.Sin(theta) * (fIsOnLeftHalf ? -1 : 1);

//The above will, due to the usage of vectors, not consider whether or not the line is traveling
//through the perimeter, so test whether the calcuated point of the 2nd object lies within the 
//ellipse. Since, further down, we'll be testing as to whether the found point is located
//beyond the visible area (which is the perimeter-point's current value), only assign the
//found point if it is outside of a "virtual" (!) ellipsis defined with the center-point being
//the 2nd object's center - we cannot test with the "real" ellipsis itself since the point is
//ON but not OUTSIDE of the ellipse.
Point ptTemp = new Point(x, y);
if (!Helpers.ContainsPoint(ptCenter_O2, dblEllipse_Radius_X, ptTemp)
	 && !Helpers.ContainsPoint(ptCenter_O2, dblEllipse_Radius_Y, ptTemp))
  ptPerimeterIntersection = ptTemp;
break;

 

I sort of wondered as to why I couldn't find anything on the web for this (should really be something somebody else has done I thought). But well, that part was the most fun (and my personal challenge) anyway, so here's how I went about this.

Taking the Ellipse's radii (major and minor, that is), two Vectors are created that, in turn, allow us to simply utilize the Vector-class'es AngleBetween()-method to calculate the angle of the (virtual) line that connects our Ellipse with the other object (from center to center). Once we have the angle, we need to a) shift it counter-clockwise by 90° (due to .Net's coordinate system starting at 90°/"east") and then convert it back into radians (which is what we'll need for the trigonometry functions). Using the arctangent we can then calculate the X- and Y-coordinates of our point on the perimeter using the ubiquituous sinus/cosinus functions. Since the resulting point will always be on the right half of our Ellipse, a little check will simply multiply the result with -1 if it detects that we need to target the Ellipse's left half. The only thing left to do after the point has been found is to check as to whether our virtual line ends inside the ellipse (and is thus not intersecting its perimeter) in which case we don't want to show the line at all (which again we do by not setting the perimeter-point). To do the check, the code simply checks whether the calculated center-point of the second/other shape is residing within the Ellipse.

 

Wrapping it all up

Here's a video with a Window in action that again contains all of the above (click the "5. ..."-button if you're running the sample project):

 

The last word

As I said, this really was a nice way to waste my time - I don't have any scenario in which I think I could use the stuff introduced here (which is probably why it's been so much fun). That having been said, I'd sure like to know if you find a scenario for which this actually would be useful. Also, my math is problably not the best, so if you think that there would be an easier approach to do the calculations (or just a different one), make sure you drop a comment!

 

The sample solution

I’ve created a sample solution that contains everything discussed here, containing one project for each the C# and the VB versions.

Download: AnimatingObjectsAlongAPath.zip (54.47 kb)


Location: PostList

Tags: , , , , , ,

WPF (.net)

WPF: adding a watermark (background-image) to the ListView (or ListBox)

by Olaf Rabbachin 2. May 2010 13:42

I stumbled over this post in the MSDN's WPF-Forum today where the OP was asking for a way to add a watermark to a ListView (i.e. a background-image). Thought this would make up for a quick blog post, so here goes.

 

The Basics

Tasks like this one are easy to accomplish in WPF by means of a ControlTemplate. That is, taking an arbitrary style that already exists for the control in question, you simply take the predefined one and add whatever is required to solve the imposed task.

Now, there's two good places to get a control's template. The MSDN offers a sample for just about every control you can think of. Starting at the ControlTemplate section, you'll find them in the left section (press "t" to toggle the TreeView section's visibility in your browser - this works at least for Firefox and IE). Here's a screenshot (click to enlarge):

If you click any link in the left section, you'll find an example of a ControlTemplate on the linked page if you scroll down a bit.

For most of all times though, I prefer use a tool called StyleSnooper to get at a template. IMHO it's one of several tools that every WPF-developer should have (and it's free, too!). Thanks to everybody that was involved making it! Here's a screenshot (click to enlarge):

 

The default ControlTemplate

Alright, so here's the (relevant portions of the) default ControlTemplate that StyleSnooper gives me on my system (I'm still running WinXP and VS2008; note that it could be different on yours):

<Style TargetType="{x:Type ListBox}" 
       xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 
       xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" 
       xmlns:s="clr-namespace:System;assembly=mscorlib">
   (*** Setters skipped ***)
   <Setter Property="Control.Template">
      <Setter.Value>
         <ControlTemplate TargetType="{x:Type ListBox}">
            <Border BorderThickness="{TemplateBinding Border.BorderThickness}" 
                    Padding="1,1,1,1" BorderBrush="{TemplateBinding Border.BorderBrush}" 
                    Background="{TemplateBinding Panel.Background}" Name="Bd" SnapsToDevicePixels="True">
               <ScrollViewer Padding="{TemplateBinding Control.Padding}" Focusable="False">
                  <ItemsPresenter SnapsToDevicePixels="{TemplateBinding UIElement.SnapsToDevicePixels}" />
               </ScrollViewer>
            </Border>
            <ControlTemplate.Triggers>
               (*** Triggers skipped ***)
            </ControlTemplate.Triggers>
         </ControlTemplate>
      </Setter.Value>
   </Setter>
</Style>

In the ControlTemplate, we can see that the ItemsPresenter (which is the element that renders the control's items) is wrapped in a ScrollViewer (which will provide the ScrollBars) which in turn is wrapped in a Border (which makes up for the line around the control). This is the place where we can add our own stuff in order to extend what's already there.

 

Where to display the Watermark?

Depending on where we want the watermark to appear, the task can by anything from blazingly simple to a bit tedious. There's actually two different places that I can think of for displaying the image in the lower right. One would be the "absolute" lower right, that is, the bottom right of the ScrollViewer's content. This means that, if the overall size of all items is larger than what can be hosted in the ListView's content, resulting in the ScrollBar(s) appearing, the image would stick to the position of the last item in the list. This would also mean that the watermark would only be visible if you scrolled to the bottom right. This is actually quite easy (as would be if you wanted the image centered or in the top left).

The second place I can think of is the lower right of the ListView control itself. That is, assuming the watermark should be visible at all times, not the ScrollViewer's bottom right but rather that of the ListView itself.

 

The fundamental approach

For both scenarios, the simplest approach I can think of is to use the Grid-control in order to sub-divide the ListView's content area into four cells (i.e. two rows and two columns) and then placing the image into the lower right cell, which would give us the alignment we're looking for. The actual content can then be spread over all 4 cells, using the Grid-attached properties ColumnSpan and RowSpan. Since the Grid control supports more than a single piece of content, it will simply overlay any controls that occupy the same area - one over the other - giving us exactly what we're after.

 

Displaying the watermark in the lower right of the ScrollViewer

This is pretty straight forward - all we need to do is to create a Grid inside the Border of the default ControlTemplate, defining the two rows and two columns, then adding both the image and the original content (i.e. the ScrollViewer and the ItemsPresenter within). Here, the image is placed into the lower right cell (which is the only cell where height and width are set to Auto) while the other three cells take whatever width remains. This makes up for the bottom right alignment.
The ItemsPresenter, on the other hand, will simply be spread over all rows and columns, hence consuming whatever real estate is available.

Here's the XAML, reduced to the ControlTemplate and without the Triggers:

<ControlTemplate TargetType="{x:Type ListBox}">
   <Border BorderThickness="{TemplateBinding Border.BorderThickness}" 
           Padding="1,1,1,1" BorderBrush="{TemplateBinding Border.BorderBrush}" 
           Background="{TemplateBinding Panel.Background}" Name="Bd" SnapsToDevicePixels="True">
      <ScrollViewer Padding="{TemplateBinding Control.Padding}" Focusable="False">
         <Grid>
            <Grid.RowDefinitions>
               <RowDefinition Height="*"/>
               <RowDefinition Height="Auto"/>
            </Grid.RowDefinitions>
            <Grid.ColumnDefinitions>
               <ColumnDefinition Width="*"/>
               <ColumnDefinition Width="Auto"/>
            </Grid.ColumnDefinitions>
            <Image Grid.Row="1" Grid.Column="1" 
                   Name="BackgroundImage"
                   Source="/Resources/SampleImage.png"
                   Margin="0,0,1,1"/>
            <ItemsPresenter SnapsToDevicePixels="{TemplateBinding UIElement.SnapsToDevicePixels}"
                                           Grid.RowSpan="2" Grid.ColumnSpan="2"/>
         </Grid>
      </ScrollViewer>
   </Border>
   <ControlTemplate.Triggers>
      (*** Triggers skipped ***)
   </ControlTemplate.Triggers>
</ControlTemplate>

As you can see, there now is a Grid inside the ScrollViewer. Since the image is placed into the lower right cell, it'll simply stick to the bottom right of the ScrollViewer's content (as opposed to its extent, i.e. the visible portion).

Unless you want this behavior though, the problem with this approach is that you won't see the image unless you scroll to the bottom right. Here's two screenshots of the sample solution's window in action (if you follow along with the project running, click the "A. ..."-button for this window); the left one shows the window after opening it, the right one after having scrolled down to the bottom right:

I can't really think of a scenario where this layout would be desired. So let's move on to adding some more stuff to assure the image is visible all the time.

 

Displaying the watermark in the lower right of the ListView

So, what would we have to do in order to display the image in the lower right of the ListView control instead, i.e. visible regardless of the ScrollViewer's current position? Again, this is easily accomplished by moving the Grid control added in the last step out of the ScrollViewer and - up by one level - into the Border. Here's the amended XAML:

<ControlTemplate TargetType="{x:Type ListBox}">
   <Border BorderThickness="{TemplateBinding Border.BorderThickness}" 
           Padding="1,1,1,1" BorderBrush="{TemplateBinding Border.BorderBrush}" 
           Background="{TemplateBinding Panel.Background}" Name="Bd" SnapsToDevicePixels="True">
      <!-- The Grid allows the Image to appear on the bottom right -->
      <Grid>
         <Grid.RowDefinitions>
            <RowDefinition Height="*"/>
            <RowDefinition Height="Auto"/>
         </Grid.RowDefinitions>
         <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*"/>
            <ColumnDefinition Width="Auto"/>
         </Grid.ColumnDefinitions>
         <!-- 
            The watermark image, placed in the lower right cell of the Grid.
         -->
         <Image Grid.Row="1" Grid.Column="1" 
                Name="BackgroundImage"
                Source="/Resources/SampleImage.png"
                Width="64"
                Height="64"
                Margin="0,0,1,1"/>
         <ScrollViewer Name="sv" Grid.RowSpan="2" Grid.ColumnSpan="2"
                       Padding="{TemplateBinding Control.Padding}" Focusable="False">
            <ItemsPresenter SnapsToDevicePixels="{TemplateBinding UIElement.SnapsToDevicePixels}" />
         </ScrollViewer>
      </Grid>
   </Border>
   <ControlTemplate.Triggers>
      (*** Triggers skipped ***)
   </ControlTemplate.Triggers>
</ControlTemplate>

However, while this is fine as long as no ScrollBars are present (just as with the first sample), this will lead to the ScrollBars appearing above the image (click the "B1. ..."-button if you're running the sample project):

We probably don't want that, do we.

 

(Multi)Triggers to the rescue

In order to allow the image to always appear at the bottom right of the ScrollViewer's visible area, let's add a couple of Triggers. The approach is again simple - by monitoring the Computed[Horizontal/Vertical]ScrollBarVisibility, we can create a Trigger for each of the three possible states - both scrollbars being visible, the horizontal one being visible with the vertical being hidden and, finally, the horizontal being hidden with the vertical one being visible. Here's the XAML that I added to the ControlTemplate's Triggers collection:

<!-- 
   The MultiTriggers below will change the Margin of the BG-image depending on the visibility of scrollbars ->
-->
<!-- MT #1: only the vertical ScrollBar is visible -->
<MultiTrigger>
   <MultiTrigger.Conditions>
      <Condition SourceName="sv" Property="ComputedVerticalScrollBarVisibility" Value="Visible"/>
      <Condition SourceName="sv" Property="ComputedHorizontalScrollBarVisibility" Value="Collapsed"/>
   </MultiTrigger.Conditions>
   <Setter TargetName="BackgroundImage" Property="Margin" Value="0,0,17,1"/>
</MultiTrigger>
<!-- MT #2: only the horizontal ScrollBar is visible -->
<MultiTrigger>
   <MultiTrigger.Conditions>
      <Condition SourceName="sv" Property="ComputedVerticalScrollBarVisibility" Value="Collapsed"/>
      <Condition SourceName="sv" Property="ComputedHorizontalScrollBarVisibility" Value="Visible"/>
   </MultiTrigger.Conditions>
   <Setter TargetName="BackgroundImage" Property="Margin" Value="0,0,1,17"/>
</MultiTrigger>
<!-- MT #3: both ScrollBars are visible -->
<MultiTrigger>
   <MultiTrigger.Conditions>
      <Condition SourceName="sv" Property="ComputedVerticalScrollBarVisibility" Value="Visible"/>
      <Condition SourceName="sv" Property="ComputedHorizontalScrollBarVisibility" Value="Visible"/>
   </MultiTrigger.Conditions>
   <Setter TargetName="BackgroundImage" Property="Margin" Value="0,0,17,17"/>
</MultiTrigger>

As you can see, all I've done is to change the (right and/or bottom) Margin of the Image control, depending on the state of the ScrollViewer's ScrollBars.

 

Wrapping it all up

Another thing I simply forgot doing until here is to change the Image's Opacity so that it leaves the impression of translucency. Here's the complete XAML of the last Window in the sample solution:

<Window x:Class="ListViewWithWatermarkImage.B_2_LV_BottomRight"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="ListViewWithWatermarkImage" 
        Height="300" Width="150" MinWidth="150">
   <Window.Resources>
      <Style TargetType="{x:Type ListView}" 
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" 
             xmlns:s="clr-namespace:System;assembly=mscorlib">
         <Style.Resources>
            <ResourceDictionary />
         </Style.Resources>
         <Setter Property="Panel.Background">
            <Setter.Value>
               <DynamicResource ResourceKey="{x:Static SystemColors.WindowBrushKey}" />
            </Setter.Value>
         </Setter>
         <Setter Property="Border.BorderBrush">
            <Setter.Value>
               <SolidColorBrush>#FF828790</SolidColorBrush>
            </Setter.Value>
         </Setter>
         <Setter Property="Border.BorderThickness">
            <Setter.Value>
               <Thickness>1,1,1,1</Thickness>
            </Setter.Value>
         </Setter>
         <Setter Property="TextElement.Foreground">
            <Setter.Value>
               <DynamicResource ResourceKey="{x:Static SystemColors.ControlTextBrushKey}" />
            </Setter.Value>
         </Setter>
         <Setter Property="ScrollViewer.HorizontalScrollBarVisibility">
            <Setter.Value>
               <x:Static Member="ScrollBarVisibility.Auto" />
            </Setter.Value>
         </Setter>
         <Setter Property="ScrollViewer.VerticalScrollBarVisibility">
            <Setter.Value>
               <x:Static Member="ScrollBarVisibility.Auto" />
            </Setter.Value>
         </Setter>
         <Setter Property="ScrollViewer.CanContentScroll">
            <Setter.Value>
               <s:Boolean>True</s:Boolean>
            </Setter.Value>
         </Setter>
         <Setter Property="Control.VerticalContentAlignment">
            <Setter.Value>
               <x:Static Member="VerticalAlignment.Center" />
            </Setter.Value>
         </Setter>
         <Setter Property="Control.Template">
            <Setter.Value>
               <ControlTemplate TargetType="{x:Type ListBox}">
                  <Border BorderThickness="{TemplateBinding Border.BorderThickness}" 
                          Padding="1,1,1,1" BorderBrush="{TemplateBinding Border.BorderBrush}" 
                          Background="{TemplateBinding Panel.Background}" Name="Bd"
                          SnapsToDevicePixels="True">
                     <!-- The Grid allows the Image to appear on the bottom right -->
                     <Grid>
                        <Grid.RowDefinitions>
                           <RowDefinition Height="*"/>
                           <RowDefinition Height="Auto"/>
                        </Grid.RowDefinitions>
                        <Grid.ColumnDefinitions>
                           <ColumnDefinition Width="*"/>
                           <ColumnDefinition Width="Auto"/>
                        </Grid.ColumnDefinitions>
                        <!-- 
                           The watermark image, placed in the lower right cell of the Grid.
                        -->
                        <Image Grid.Row="1" Grid.Column="1" 
                               Name="BackgroundImage"                               
                               Source="/Resources/SampleImage.png"
                               Width="64"
                               Height="64"
                               Opacity="0.2"
                               Margin="0,0,1,1"/>
                        <!--  
                           The border below will simply overlay the complete contents of the control
                           (including the image) and thus create the impression the image was not
                           completely opaque.
                        -->
                        <!--<Border Grid.RowSpan="2" Grid.ColumnSpan="2"
                                Background="{TemplateBinding Panel.Background}" Opacity="0.8"/>-->
                        <!--
                           The rest is the original ScrollViewer; the only change is the RowSpan
                           and ColumnSpan which makes the control cover the complete extent of 
                           the Grid control (just like the Border above).
                        -->
                        <ScrollViewer Name="sv" Grid.RowSpan="2" Grid.ColumnSpan="2"
                                      Padding="{TemplateBinding Control.Padding}" Focusable="False">
                           <ItemsPresenter 
                              SnapsToDevicePixels="{TemplateBinding UIElement.SnapsToDevicePixels}" />
                        </ScrollViewer>
                     </Grid>

                     <!-- Original: -->
                     <!-- 
                     <ScrollViewer Padding="{TemplateBinding Control.Padding}" Focusable="False">
                        <ItemsPresenter SnapsToDevicePixels="{TemplateBinding UIElement.SnapsToDevicePixels}" />
                     </ScrollViewer>
                      -->
                  </Border>
                  <ControlTemplate.Triggers>
                     <!-- 
                        The MultiTriggers below will change the Margin of the BG-image depending on the 
                        visibility of scrollbars ->
                     -->
                     <!-- MT #1: only the vertical ScrollBar is visible -->
                     <MultiTrigger>
                        <MultiTrigger.Conditions>
                           <Condition SourceName="sv" Property="ComputedVerticalScrollBarVisibility" 
                                         Value="Visible"/>
                           <Condition SourceName="sv" Property="ComputedHorizontalScrollBarVisibility" 
                                         Value="Collapsed"/>
                        </MultiTrigger.Conditions>
                        <Setter TargetName="BackgroundImage" Property="Margin" Value="0,0,17,1"/>
                     </MultiTrigger>
                     <!-- MT #2: only the horizontal ScrollBar is visible -->
                     <MultiTrigger>
                        <MultiTrigger.Conditions>
                           <Condition SourceName="sv" Property="ComputedVerticalScrollBarVisibility"
                                         Value="Collapsed"/>
                           <Condition SourceName="sv" Property="ComputedHorizontalScrollBarVisibility"
                                         Value="Visible"/>
                        </MultiTrigger.Conditions>
                        <Setter TargetName="BackgroundImage" Property="Margin" Value="0,0,1,17"/>
                     </MultiTrigger>
                     <!-- MT #3: both ScrollBars are visible -->
                     <MultiTrigger>
                        <MultiTrigger.Conditions>
                           <Condition SourceName="sv" Property="ComputedVerticalScrollBarVisibility"
                                         Value="Visible"/>
                           <Condition SourceName="sv" Property="ComputedHorizontalScrollBarVisibility" 
                                         Value="Visible"/>
                        </MultiTrigger.Conditions>
                        <Setter TargetName="BackgroundImage" Property="Margin" Value="0,0,17,17"/>
                     </MultiTrigger>
                     <!-- 
                        <- The MultiTriggers below will change the Margin of the BG-image depending on 
                              the visibility of scrollbars
                     -->
                     <Trigger Property="UIElement.IsEnabled">
                        <Setter Property="Panel.Background" TargetName="Bd">
                           <Setter.Value>
                              <DynamicResource ResourceKey="{x:Static SystemColors.ControlBrushKey}" />
                           </Setter.Value>
                        </Setter>
                        <Trigger.Value>
                           <s:Boolean>
                              False</s:Boolean>
                        </Trigger.Value>
                     </Trigger>
                     <Trigger Property="ItemsControl.IsGrouping">
                        <Setter Property="ScrollViewer.CanContentScroll">
                           <Setter.Value>
                              <s:Boolean>
                                 False</s:Boolean>
                           </Setter.Value>
                        </Setter>
                        <Trigger.Value>
                           <s:Boolean>
                              True</s:Boolean>
                        </Trigger.Value>
                     </Trigger>
                  </ControlTemplate.Triggers>
               </ControlTemplate>
            </Setter.Value>
         </Setter>
      </Style>
   </Window.Resources>
   <ListView Margin="10" MinHeight="100" MinWidth="100">
      <ListViewItem>ListViewItem (with somewhat lengthy text) #1</ListViewItem>
      <ListViewItem>ListViewItem (with somewhat lengthy text) #2</ListViewItem>
      <ListViewItem>ListViewItem (with somewhat lengthy text) #3</ListViewItem>
      <ListViewItem>ListViewItem (with somewhat lengthy text) #4</ListViewItem>
      <ListViewItem>ListViewItem (with somewhat lengthy text) #5</ListViewItem>
      <ListViewItem>ListViewItem (with somewhat lengthy text) #6</ListViewItem>
      <ListViewItem>ListViewItem (with somewhat lengthy text) #7</ListViewItem>
      <ListViewItem>ListViewItem (with somewhat lengthy text) #8</ListViewItem>
      <ListViewItem>ListViewItem (with somewhat lengthy text) #9</ListViewItem>
      <ListViewItem>ListViewItem (with somewhat lengthy text) #10</ListViewItem>
      <ListViewItem>ListViewItem (with somewhat lengthy text) #11</ListViewItem>
      <ListViewItem>ListViewItem (with somewhat lengthy text) #12</ListViewItem>
      <ListViewItem>ListViewItem (with somewhat lengthy text) #13</ListViewItem>
      <ListViewItem>ListViewItem (with somewhat lengthy text) #14</ListViewItem>
      <ListViewItem>ListViewItem (with somewhat lengthy text) #15</ListViewItem>
      <ListViewItem>ListViewItem (with somewhat lengthy text) #16</ListViewItem>
      <ListViewItem>ListViewItem (with somewhat lengthy text) #17</ListViewItem>
      <ListViewItem>ListViewItem (with somewhat lengthy text) #18</ListViewItem>
      <ListViewItem>ListViewItem (with somewhat lengthy text) #19</ListViewItem>
      <ListViewItem>ListViewItem (with somewhat lengthy text) #20</ListViewItem>
   </ListView>
</Window>

And here's two screenshots of the sample window (click the "B2. ..."-button to view this Window), with and without the ScrollBars being visible:

 

But wait!

That pretty much concludes the article. However, I guess a few other things are worth to be mentioned:

  • The margin of "17" (in the MultiTriggers) is my attempt to keep it simple. Considering the fact that we want to move the image to the left and/or up depending on the ScrollBar(s) Visibility, we need to do so by means of the height/width of the respective ScrollBar. Now if we wanted to determine the width of the ScrollBar at runtime, we would really require a converter that would take the width on a given system (usually it's just 16) and add one unit to it (i.e. the Image's general Margin).
  • There's a slight tripping hazard to this - if your watermark image is larger than the size of the ListView, the ListView will simply expand until the image fits it in. As a result, if you shrink the containing Window far enough, you'll see the ScrollViewer's ScrollBars disappear. It would certainly be feasible to provide a work-around for that as well, but I presume this should be a rarely encountered situation on one hand and it's getting late (and the Snooker World Championship's final is coming up) on the other, so I won't venture into that.

 

The sample solution

I’ve created a sample solution that contains everything discussed here. Since there is no code-behind (except for the three Button-click-handlers) involved, the solution only contains a C# project - there is no VB counterpart. However, if you want to use this in a VB-project, simply paste the XAML into your VB-window and remove the ListViewWithWatermarkImage. that is preceding each window's x:Class attribute (and indicating the namespace required for C#).

Download: ListViewWithWatermarkImage.zip (36.87 kb)


Location: PostList

Tags: , , , , ,

WPF (.net)

WPF: Concatenating multiple fields (bindings)

by Olaf Rabbachin 12. April 2010 17:21

I'm pretty busy at present, hence I haven't had much time to write new blog entries. However, today I came about a thread in the WPF forum where the OP asks for a way to concatenate multiple fields for a binding. Now, since I (IIRC) have written about this several times in the forums (I guess everybody at one time will stumble over that - I did) and this is actually something that sure qualifies for a less-than-30-minutes-blog-entry (edit: ... or so I thought; I recently updated to BlogEngine 1.6 and the code-formatter was giving me one hell of a hard time!), I thought I'd write another quickie, so here's what I'm using as a sort of generic approach for stuff like this.

Note that, even though I only present C# code in this article, the sample solution (see the bottom for the download) contains both a project for C# and VB.

 

Edit (April 21st 2010)

Please note that I had to find out that the Converter I'm introducing here isn't even required to do the job outlined in this article. See the last paragraph (But wait!) for more information.
I'm leaving the blog-post here anyway (might as well help to teach somebody else the same lesson Embarassed). And, as of April 30th 2010, I'm again turning around 180°, stating that the Converter is useful (again, see the bottom of this post).

 

The problem

The class that provides the data for your window has i.e two fields that are to be shown in a single column of a ListBox (or ListView, GridView, you name it). The classical fields are the LastName/FirstName combos.

 

The sample data

For the remainder of the blog, I've created a simplistic class Person, that will only provide the two properties LastName and FirstName. Here it is:

   public class Person
   {
      private string _LastName;
      public string LastName
      {
         get { return _LastName; }
         set { _LastName = value; }
      }

      private string _FirstName;
      public string FirstName
      {
         get { return _FirstName; }
         set { _FirstName = value; }
      }

      public Person(string LastName, string FirstName)
      {
         this.LastName = LastName;
         this.FirstName = FirstName;
      }
   }

 

 

Possible approaches

There's basically three approaches to working out a solution:

  1. Addding another property to the class that provides the data; for our Person class, this could be a LastFirstName prop, returning this.LastName + " " + this.FirstName
  2. If you're using a database (with or without ORM), either have the SELECT return the concatenated field or, if your DB is capable of it, add a computed column (i.e. as feasible with SQL Server for ages)
  3. Adding a converter that concatenates the fields

So, what approach would win a prize for simplest, best, nicest? It depends, of course. Smile
FWIW, I tend to find generic approaches whereever possible. That having been said, I dislike the another property in my data-class approach - simply because I'll have to provide such a property for every class where I need to concatenate fields. The DB-based approach is not even close to practical either - what if I don't have control over the database (this has happened quite a few times).

Alright, the title of the article suggested it - I'll show you how to create a pretty simple converter to do the trick. But why? The converter is a stand-alone class that I can drop into any project and, from there on, use it with whatever fields I need to concatenate. It also allows me to concatenate a bunch of fields (as opposed to only two of them - think of a delimited list).

 

Enter the IMultiValueConverter interface

I was just about to paste a link to the IMultiValueConverter Interface docs on MSDN, so I looked it up and flew over it. Duh - the article contains quite a few bits that I was to describe here! Besides some error-handling (which of course should be in place!), there's really two tiny bits my converter has that the MSDN samples don't - a) I'm utilizing the parameter argument, allowing to specify the delimiter to be used when concatenating and b) my converter doesn't restrict you to just two fields. Here's the converter's code (note that I stripped off all the comments and the sample XAML - you'll find that in the sample solution (see the bottom of this post for the download-link).

   [ValueConversion(typeof(object), typeof(string))]
   public class ConcatenateFieldsMultiValueConverter : IMultiValueConverter
   {
      public object Convert(
                  object[] values, 
                  Type targetType, 
                  object parameter, 
                  System.Globalization.CultureInfo culture
               )
      {
         string strDelimiter;
         StringBuilder sb = new StringBuilder();

         if (parameter != null)
         {
            //Use the passed delimiter.
            strDelimiter = parameter.ToString();
         }
         else
         {
            //Use the default delimiter.
            strDelimiter = ", ";
         }

         //Concatenate all fields
         foreach (object value in values)
         {
            if (value != null && value.ToString().Trim().Length > 0)
            {
               if (sb.Length > 0) sb.Append(strDelimiter);
               sb.Append(value.ToString());
            }
         }

         return sb.ToString();
      }

      public object[] ConvertBack(
                  object value, 
                  Type[] targetTypes, 
                  object parameter, 
                  System.Globalization.CultureInfo culture
            )
      {
         throw new NotImplementedException("ConcatenateFieldsMultiValueConverter cannot convert back (bug)!");
      }

   }

 

Using the converter with your XAML

Let's see how to actually make use of the converter in XAML. First of all, since we reference a class within the project from XAML, we need to add the underlying namespace to the window:

<Window x:Class="CS.DemoCS"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:CS"
        Title="DemoVB" Height="300" Width="300">
   (...)

From here on, we can now refer to classes in the project with the <local:[class]> syntax. The converter will furthermore need to be added as a resource. While this resource could be placed into the <[Control].Resources> section, I prefer to define my resources on the window-level, this way I only need to reference them once, so here goes:

   <Window.Resources>
      <local:ConcatenateFieldsMultiValueConverter x:Key="mvc"/>
   </Window.Resources>

From this point on, we can refer to the converter by its key mvc. Here's the complete XAML of the window you'll find in the sample solution:

<Window x:Class="CS.DemoCS"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:CS"
        Title="DemoVB" Height="300" Width="300">
   <Window.Resources>
      <local:ConcatenateFieldsMultiValueConverter x:Key="mvc"/>
   </Window.Resources>
   <ListBox ItemsSource="{Binding PersonList}">
      <ListBox.ItemTemplate>
         <DataTemplate>
            <TextBlock>
               <TextBlock.Text>
                  <MultiBinding Converter="{StaticResource mvc}"
                                ConverterParameter=", ">
                     <Binding Path="LastName"/>
                     <Binding Path="FirstName"/>
                  </MultiBinding>
               </TextBlock.Text>
            </TextBlock>
         </DataTemplate>

      </ListBox.ItemTemplate>
   </ListBox>
</Window>

As you can see in the XAML above, the TextBlock I'm using as the ListBox'es DataTemplate uses a MultiBinding in order to pass the field-bindings as well as the delimiter to the converter which, in turn, will return the concatenated result.
If you wanted the ListBox to show its items as in "FirstName LastName", you'd only have to exchange the two bindings (having FirstName appear first) and change the ConverterParameter from ", " to " ".

For the sake of completeness, here's the code-behind of the window, so you can see the source of the bindings:

using System.Collections.Generic;
namespace CS
{
   public partial class DemoCS
   {

      //The source for the ListBox
      private List<Person> _lstPersons;
      public List<Person> PersonList
      {
         get { return _lstPersons; }
         set { _lstPersons = value; }
      }


      public DemoCS()
      {
         InitializeComponent();

         //Create the list of persons and add some entries for display
         _lstPersons = new List<Person>();

         _lstPersons.Add(new Person("Davolio", "Nancy"));
         _lstPersons.Add(new Person("Fuller", "Andrew"));
         _lstPersons.Add(new Person("Leverling", "Janet"));
         _lstPersons.Add(new Person("Peacock", "Margaret"));
         _lstPersons.Add(new Person("Buchanan", "Steven"));
         _lstPersons.Add(new Person("Suyama", "Michael"));
         _lstPersons.Add(new Person("King", "Robert"));
         _lstPersons.Add(new Person("Callahan", "Laura"));
         _lstPersons.Add(new Person("Dodsworth", "Anne"));

         this.DataContext = this;
      }

   }
}   

 

And here's the result of the sample window


The converter bonus

Another major advantage of the converter over any other alternative I can think of is the fact that, using a converter, we'll make use of the terrific data-binding possibilities WPF has to offer! That is, as a result, the converter's result can easily be used in scenarios where the fields that are being concatenated are changed by means of a different control on the same window. For instance, in my data-centric apps I often have windows in which I'm presenting a list of records, the selected item being the one loaded into detail-controls on the same window. Suppose we had a window with the ListBox control (containing list of Person-classes) on the left and one TextBox for each the LastName and FirstName property on the right - when the user selects an entry in the ListBox, the TextBoxes will allow to edit that entry. If you're using the converter, you'll see the data-binding magic kick in - changing i.e. the LastName in the TextBox would thus change it in the ListBox as well (provided INotifyPropertyChanged was properly implemented).

 

But wait!

Today (April 21st 2010), Richard posted a comment that was a real eye-opener. As it seems, the Converter actually isn't required at all. That is, utilizing the MultiBinding's StringFormat really makes the Converter obsolete:

<MultiBinding StringFormat="{}{0}, {1}">
   <Binding Path="LastName"/>
   <Binding Path="FirstName"/>
</MultiBinding>

Although I haven't tested this with lists that are being changed by other means, I'm pretty certain that the data-binding would work equally well for this. Another lesson learned!

Make sure you visit Lester's blog about the WPF 3.5 SP1 feature: StringFormat - wish I had come over that post earlier (again, thanks go out to Richard for mentioning this - see the comments).

Edit (April 30th 2010)

I came back to revisit this today, in an application I'm working on at present. I must admit I still feel stupid for not knowing about the StringFormat mentioned above. However, there actually still is the need for the Converter. That is, while the StringFormat does the job for the data presented in this post, it won't if your persons' names do not necessarily require users to enter a first name. In this case, the StringFormat will always print the comma (or whatever delimiter is being used) if you print your name-fields as in LastName, FirstName, even if there is no value for the FirstName field. That being said, I'm still favoring the Converter-approach (does that save my neck?). Innocent

 

The sample solution

If you still want to download the code, I’ve created a sample solution that contains everything discussed here with one project for each C# and VB.

Download: ConcatenateConverter.zip (26.30 kb)


Location: PostList

Tags: , , , ,

Utilities | WPF (.net)

WPF: WindowHelpers - how to retrieve a window-reference by the window's name and others

by Olaf Rabbachin 16. February 2010 15:56

Today I have a quickie that I thought would be worth sharing. Note that, even though I only present C# code in this article, the sample solution (see the bottom for the download) again contains both a project for C# and VB.

 

The problem

In the past 6 months, I've spent quite a while or two in the WPF forums, crawling over other peoples' questions and learning by answering them. A very common approach to answering is to simply post a sample window that i.e. demonstrates the solution to the OP's question. In the very beginning, I tended to have a sample app with a Window1 which got loaded upon startup, replacing the XAML and/or code-behind everytime I created a sample window. That meant that, everytime I tried something new, the earlier version was either lost or had to be copied into another window. Alternatively, I sometimes named windows and then changed App.xaml to show the window I wanted.

Any of the aforementioned approaches includes either a) the loss of previous work, or b) additional work everytime you add something or need to show a different (older) window. I hence thought it should be fairly easy to simply make a main window, provide a list of windows found throughout the application and just open them on a click (or double-click).

 

Reflection to the rescue

I must say I just love Reflection - it provides a convenient (well, most of all times) approach to obtain information that just wasn't possible (or only with many hacks) in pre-.Net-times!

Here goes.

 

Getting a list of all windows

Here's all you need to do in order to get all windows that are part of the executing assemlby (aka the exe):

 

public static IEnumerable<string> WindowNames
{
   get
   {
      IEnumerable<string> ieWindowNames = null;
      Assembly asm = Assembly.GetExecutingAssembly();

      ieWindowNames =
         from types in asm.GetTypes()
         where types.BaseType.Name == "Window"
         orderby types.Name
         select types.Name;

      return ieWindowNames;
   }
}

 

The above returns a list (IEnumerable) that would allow for binding and, thanks to Linq, sorted alphabetically.

 

Displaying windows using their name

How about providing a convenient way of displaying a window when all you have is its name (i.e., taken from the previously introduced list)?

Again, this can be done with just a few lines:

 

public static bool? ShowWindowByName(string strWindowName, bool fShowDialog)
{
   if (string.IsNullOrEmpty(strWindowName)) return null;

   Assembly asm = Assembly.GetExecutingAssembly();
   string strFullyQualifiedName = asm.GetName().Name + "." + strWindowName;
   object obj = asm.CreateInstance(strFullyQualifiedName);

   Window win = obj as Window;
   if (win == null) return null;

   if (fShowDialog)
   {
      win.ShowDialog();
      return win.DialogResult;
   }
   else
   {
      win.Show();
      return null;
   }
}

 

The above method also allows you to show the window modally in which case the method will return the DialogResult after the window was closed.

 

WindowByName

The ShowWindowByName() method might actually do more than desired. If you only need a reference to a window by its name, here's another little helper method:

 

public static Window WindowByName(string strWindowName)
{
   if (string.IsNullOrEmpty(strWindowName)) return null;

   Assembly asm = Assembly.GetExecutingAssembly();
   string strFullyQualifiedName = asm.GetName().Name + "." + strWindowName;
   object obj = asm.CreateInstance(strFullyQualifiedName);

   if (obj != null)
      return obj as Window;
   else
      return null;
}

 

Closing all windows

If you're using VB, one way to close all open windows when your application exits is to specify this in the project's settings:

However, C# doesn't have a respective counterpart. And, anyway, I tend to rather have my own method for dealing with this, so here's another simple helper:

public static void CloseAllWindows()
{
   Application app = Application.Current;
   for (int intCounter = app.Windows.Count - 1; intCounter >= 0; intCounter--)
      app.Windows[intCounter].Close();
}
Due to the simplicity, it's not really all too worth sharing (must say, I feeling a bit ashamed to post something like this), but I thought the method simply belongs to the class. Embarassed

 

The sample solution

I've placed all of the above (basic documentation included - skipped in the article) into a simple class that you'll find in the solution (see the bottom for the download link). However, since I don't want to provide a bunch of windows just to allow to test things out, the projects' sample window only contains - besides the main window - a dummy second window to have at least two of them. Here's a screenshot:

The code-behind for the above is pretty short:

using System.Windows;
using System.Collections.Generic;
using System.Windows.Controls;

namespace CS
{
   public partial class Main : Window
   {
      public Main()
      {
         InitializeComponent();
         this.DataContext = this;
         this.Unloaded += new RoutedEventHandler(MainWindow_Unloaded);
      }

      //Allows to bind the ListBox to the window names in this assembly
      public IEnumerable<string> WindowNames { get { return Utils.WindowHelpers.WindowNames; } }

      //Show a window after an item in the ListBox has been double-clicked.
      private void lbWindows_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e)
      {
         if (lbWindows.SelectedItem != null)
         {
            Utils.WindowHelpers.ShowWindowByName(
                  lbWindows.SelectedItem.ToString(),
                  (bool)chkShowDialog.IsChecked
               );
            e.Handled = true;
         }
      }

      void MainWindow_Unloaded(object sender, RoutedEventArgs e)
      {
         Utils.WindowHelpers.CloseAllWindows();
      }
   }
}

But wait!

In the code above, you may (or may not) have noted that the selected window is shown when double-clicking a window rather than after a single click has been performed. I have no idea as to why there isn't any DoubleClick-event for the ListBox control, but luckily, it's not all too hard to provide a dummy one. I can't take credit for that as this is something I stumbled over in this StackOverflow thread, so thanks go out to Bob King. All you'll have to remember when going that way though is to set e.Handled = true to avoid the message from bubbling up.

 

The last word

I presently don't really see much "real world" usage scenarios for the class, but my forums-life has gotten a hell of a lot easier. Using the class, I can simply add windows to my test-solution as they come and then access/run them by selecting them from a list of my pre-defined main window. One problem that this might impose on you (it does on me) - the time it takes to build your solution will increase rapidly. My present solution has short of 100 windows ...

 

The sample solution

I’ve created a sample solution that contains everything discussed here.

Download: WindowByName.zip (27.38 kb)


Location: PostList

Tags: , , , , , ,

Utilities | Utilities | WPF (.net) | WPF (.net)

WPF: TabControl series - Part 4: Closeable TabItems

by Olaf Rabbachin 15. February 2010 19:10

Introduction

In the last part of the series, I'd like to present one last extension to the TabControl's TabItems - a close-button.

 

Overview

This is the last article in the multi-part series about the WPF TabControl.

Here's the four parts of the series:

 

Outcome: the result of what's covered in this article

Here's what we'll be left with at the end of this article (click to enlarge):

If you downloaded the sample solution (see the bottom for the link), click the "2. TabItem Close Button" button to show the above window.


Status quo (after Part Three)

As noted before, this article is based upon the stuff I introduced in the other parts, hence I'll simply assume that you read and understood what has been discussed there. Please see the other parts in case you find that I am assuming something you don't see discussed here.

Here's where we'll start in this part, that is, what the TabControl and its "sub-controls" looked like at the end of Part Three:

If you downloaded the sample solution (see the bottom for the link), click the "1. Base-style (ScrollableTabPanel)" button to show the above window.

 

Why closing TabItems

Actually, I saw the need for an easy way to close (read "remove") tab last year when I was working on a project in which we decided to have a "sort of" MDI application. That is, windows aren't windows but rather UserControls which are then loaded as a TabPage into a TabControl, much like what you see in recent versions of browsers like Firefox and IE (funny enough - in German, pronouncing "IE" could be translated as "yuck"; SCNR).

In the end, what I came up with is what I think is a very versatile approach as it is open to both a code-behind approach or MVVM (which is what was used in the aforementioned project), also allowing for a TabItem-related determination about whether it should even be allowed to close a TabItem or not. More on that later, let's first focus on how we deal with ...

 

Extending the TabItem-style

As for the style of the Button control that is to be rendered in TabItems, there isn't really anything special. Here's the style/template of the button:

<Style x:Key="TabItemCloseButtonStyle" TargetType="{x:Type Button}">
   <Setter Property="SnapsToDevicePixels" Value="false"/>
   <Setter Property="Height" Value="{StaticResource CloseButtonWidthAndHeight}"/>
   <Setter Property="Width" Value="{StaticResource CloseButtonWidthAndHeight}"/>
   <Setter Property="Cursor" Value="Hand"/>
   <Setter Property="Focusable" Value="False"/>
   <Setter Property="OverridesDefaultStyle" Value="true"/>
   <Setter Property="Template">
      <Setter.Value>
         <ControlTemplate TargetType="{x:Type Button}">
            <Border x:Name="ButtonBorder"  
                          CornerRadius="2" 
                          BorderThickness="1"
                          Background="{StaticResource TabItemCloseButtonNormalBackgroundBrush}"
                          BorderBrush="{StaticResource TabItemCloseButtonNormalBorderBrush}">
               <Grid>
                  <!-- The Path below will render the button's X. -->
                  <Path x:Name="ButtonPath" 
                              Margin="2"
                              Data="{StaticResource X_CloseButton}"
                              Stroke="{StaticResource TabItemCloseButtonNormalForegroundBrush}" 
                              StrokeThickness="2"
                              StrokeStartLineCap="Round"
                              StrokeEndLineCap="Round"
                              Stretch="Uniform"
                              VerticalAlignment="Center"
                              HorizontalAlignment="Center"/>
                  <!-- We don't really need the ContentPresenter, but what the heck ... -->
                  <ContentPresenter HorizontalAlignment="Center"
                                          VerticalAlignment="Center"/>
               </Grid>
            </Border>
            <ControlTemplate.Triggers>
               <Trigger Property="IsMouseOver" Value="True">
                  <Setter TargetName="ButtonBorder" 
                          Property="Background" 
                          Value="{StaticResource 
                             TabItemCloseButtonHoverBackgroundBrush}" />
                  <Setter TargetName="ButtonPath" 
                          Property="Stroke"
                          Value="{StaticResource 
                             TabItemCloseButtonHoverForegroundBrush}"/>
               </Trigger>
               <Trigger Property="IsEnabled" Value="false">
                  <Setter Property="Visibility" Value="Collapsed"/>
               </Trigger>
               <Trigger Property="IsPressed" Value="true">
                  <Setter TargetName="ButtonBorder" 
                                Property="Background" 
                                Value="{StaticResource 
                                   TabItemCloseButtonPressedBackgroundBrush}" />
                  <Setter TargetName="ButtonBorder" 
                                Property="BorderBrush" 
                                Value="{StaticResource 
                                   TabItemCloseButtonPressedBorderBrush}" />
                  <Setter TargetName="ButtonPath" Property="Stroke" 
                                Value="{StaticResource 
                                   TabItemCloseButtonPressedForegroundBrush}"/>
                  <Setter TargetName="ButtonPath" 
                          Property="Margin" Value="2.5,2.5,1.5,1.5" />
               </Trigger>
            </ControlTemplate.Triggers>
         </ControlTemplate>
      </Setter.Value>
   </Setter>
</Style>

There's only few pieces worth noting:

  • The style includes a Trigger that is applied when the button is down/pressed; here, a Margin shifts the button's content down and to the right. To only have a slight change (a shift by 1px would be to drastic), the Margin is incremented by 0.5 for top/left and decremented by 0.5 for bottom/right. To make this work, SnapToDevicePixels is explicitly set to False in the style. While this setter isn't required (SnapToDevicePixels is False by default), I prefer to explicitly point this out.
  • The button's "image" is, again (see the previous parts), a Path. In this case, it's really only two lines plus round start-/end-caps (the latter being defined in the Path, of course):
    <Geometry x:Key="X_CloseButton">M0,0 L10,10 M0,10 L10,0</Geometry>

To actually integrate this button into the TabItem's ControlTemplate only takes a few minor changes. Here's the part of the template that contains the amendments:

<Border Name="Border"
        Background="{StaticResource TabItem_BackgroundBrush_Unselected}"
        BorderBrush="{StaticResource TabItem_BorderBrush_Selected}" 
        Margin="{StaticResource TabItemMargin_Base}"
        BorderThickness="2,1,1,0" 
        CornerRadius="3,3,0,0">
   <Grid>
      <Grid.ColumnDefinitions>
         <!-- Text / TabItem's Caption -->
         <ColumnDefinition/>
         <!-- Close button -->
         <ColumnDefinition/>
      </Grid.ColumnDefinitions>
      <!-- This is where the Content of the TabItem will be rendered. -->
      <ContentPresenter x:Name="ContentSite"
                        VerticalAlignment="Center"
                        HorizontalAlignment="Center"
                        ContentSource="Header"
                        Margin="7,2,12,2"
                        RecognizesAccessKey="True"/>
      <Button x:Name="cmdTabItemCloseButton"
              Style="{StaticResource TabItemCloseButtonStyle}"
              Command="{Binding Path=Content.DataContext.CloseCommand}"
              CommandParameter="{Binding  
              RelativeSource={RelativeSource FindAncestor, 
              AncestorType={x:Type TabItem}}}"
              Grid.Column="1"
              Margin="-7,5,7,5"/>
   </Grid>
</Border>

So, all we really do in the above is to add another ColumnDefinition to the (already existing) Grid control, placing the button into the second column. This doesn't influence i.e. the TabItemMenu, where the textual content is shown, as that is refering to the ContentPresenter's content. The only thing noteable here is the definition of the button's margin, which sort of "replaces" the right margin of the TabItem by applying a negative left margin - this helps to consider the fact that the button may not be visible at all times, in which case the TabItem's Margin should remain as it was before we added the button.

Now, showing a button wasn't much work, but we're talking about a ControlTemplate for the TabControl, so how can we react to a button-click ..?

 

Enter ICommand

The close-button itself is not worth much if there isn't an easy, versatile and independent way to react to clicks. The "WPF-way" of dealing this is, of course, to use the ICommand interface. For the sake of simplicity and since there's already plenty of tutorials on the web, I won't dig into the specifics of ICommand here. Instead, I've taken over the RelayCommand class, an approach that Josh Smith's introduced in his article on MVVM, published in the MSDN magazine and dropped it into the code behind of the TabControl window. The fact that there's now code-behind in the window is actually neglectable - it could as well be part of a ViewModel instead. This is because, if you look at the binding in the XAML above, the Command associated with the Button control is targetting the DataContext rather than any code-behind, and it also passes a reference to the parent TabItem to the Command - this is all we need in order to utilize the command. Here's the complete code-behind of the window (for the VB-version, please refer to the sample solution - see the bottom of this article for the download link):

using System;
using System.Windows;
using System.Windows.Input;
using System.Diagnostics;
using System.Windows.Controls;

namespace TabControlStyle
{
   public partial class TabControl_2_CloseButton : Window
   {
      /// 
      /// C'tor
      /// 
      public TabControl_2_CloseButton()
      {
         InitializeComponent();
         //For the sample, the Window's DataContext is its code-behind.
         this.DataContext = this;
      }

      #region --- CloseCommand ---

      private Utils.RelayCommand _cmdCloseCommand;
      /// 
      /// Returns a command that closes a TabItem.
      /// 
      public ICommand CloseCommand
      {
         get
         {
            if (_cmdCloseCommand == null)
            {
               _cmdCloseCommand = new Utils.RelayCommand(
                   param => this.CloseTab_Execute(param),
                   param => this.CloseTab_CanExecute(param)
                   );
            }
            return _cmdCloseCommand;
         }
      }

      /// 
      /// Called when the command is to be executed.
      /// 
      /// 
      /// The TabItem in which the Close-button was clicked.
      /// 
      private void CloseTab_Execute(object parm)
      {
         TabItem ti = parm as TabItem;
         if (ti != null)
            tc.Items.Remove(parm);
      }

      /// 
      /// Called when the availability of the Close command needs to be determined.
      /// 
      /// 
      /// The TabItem for which to determine the availability of the Close-command.
      /// 
      private bool CloseTab_CanExecute(object parm)
      {
         //For the sample, the closing of TabItems will only be
         //unavailable for disabled TabItems and the very first TabItem.
         TabItem ti = parm as TabItem;
         if (ti != null && ti != tc.Items[0])
            //We have a valid reference to a TabItem, so return 
            //true if the TabItem is enabled.
            return ti.IsEnabled;

         //If no reference to a TabItem could be obtained, the command 
         //cannot be executed
         return false;
      }

      #endregion

   }
}

For the sample, the only two conditions that would prevent the Command from being executed (resulting in a disabled button) refer to disabled TabItems and the very first one (this way there'll always be at least one enabled item).

Note that, if you were using the MVVM pattern, the CloseCommand-region would be part of the ViewModel and the window's DataContext (see the constructor) would rather point to that ViewModel.

 

The last word

This concludes the last part of the TabControl series. This series has been way longer than I originally thought, but I had enough fun with it to play around with a couple of things that weren't part of the original plan ... Smile

As always, comments are appreciated. Let me know if you have any questions or suggestions for improving the control.

 

The sample solution

I’ve created a sample solution that contains everything discussed here, containing one project for each the C# and the VB versions.

Download: TabControlStyle - Part Four.zip (76.83 kb)


Location: PostList

Tags: , , , , , , , , , ,

TabControl | WPF (.net)

WPF: TabControl series - Part 3: Non-wrapping scrollable TabPanel; TabItem DropDown-Menu

by Olaf Rabbachin 10. February 2010 18:18

Introduction

The previous article left us with a TabControl featuring animated TabItems. Today, I'd like to present another couple of extensions to that TabControl. These include a custom TabPanel which will no longer wrap its TabItems when these won't fit onto a single row and a menu that'll present all TabItems' header-text, allowing users to quickly navigate to a TabItem.

 

Overview

This article is part of a multi-part series. Here's the four parts of the series:

 

Outcome: the result of what's covered in this article

Here's what we'll be left with at the end of this article:

 

Status quo (after Part Two)

As noted before, this article is based upon the stuff I introduced in the other parts, hence I'll simply assume that you read and understood what has been discussed there. Please see the other parts in case you find that I am assuming something you don't see discussed here.

Here's where we'll start in this part, that is, what the TabControl and its "sub-controls" looked like at the end of Part Two:

If you downloaded the sample solution (see the bottom for the link), click the "1. Base-style (animated, without ScrollViewer)" button to show the above window.

Before we start

In the previous parts, I always refered to the panel that contains the TabItems as the TabItemPanel. Actually I don't have the slightest idea as to why I called it like that (I'm getting old I guess - I promise I didn't have too much beer!). Of course, the control's name is TabPanel! I don't know for sure whether I'll update Part One and Part Two accordingly, but since this would require to change the solutions along the way, I'll leave everything "as is", at least for the time being (but I slapped myself for being stupid, if that's OK with you). For this article however, I changed the source code to refer to the control by its correct name instead and this paragraph is the last one where you'll see the term TabItemPanel - promised. So, if you actually follow along with your own code, you might want to do a find & replace.

 

The problem with what we have at this point

While the style provided in Part Two actually contains a "control" that could be used as is, it imposes quite a major drawback if the TabItems' width exceeds the width of the TabPanel. In this case, the TabPanel (which is what is being used up to this point) will wrap its items, leaving you with something more or less like this (click to enlarge):

 

Gee, not really what I'd consider a nifty appearance, huh? Also, the TabPanel will constantly rearrange the TabItems when the SelectedItem changes. This is what I hate very much about i.e. the Options dialogs in Office (like Word).
In the remainder of this part, I'll hence show you how to work around this by (basically) allowing users to scroll the TabPanel instead.

 

Enter the ScrollViewer

Whenever you're in the situation where you need like to display content that could possibly exceed the size of the hosting control (or the size that that control can take in your UI), the ScrollViewer control will most probably be part of your solution to the task. The ScrollViewer is what allows to actually have a "virtual area" that extends beyond the size of your control. Let's consider a simple sample:

In the above image, the black rectangle represents the control as it is being rendered in the UI. The gray rectangle, however, represents the area that would be required in order to completeley render all content in the control. In a scenario like this, theScrollViewer control will allow us to automatically display ScrollBar controls for the X and/or Y axis if the content exceeds the size of the control. The schema above also shows the definition for the two "areas" that exist in the ScrollViewer control:

  • the area that is being rendered by the control is refered to as the Viewport of the control
  • the "virtual area" that makes up for the size as desired by the elements contained within is refered to as the Extent of the control

Now, whenever the control's Extent gets larger than the Viewport, the ScrollViewer will display either horizontal and/or vertical scrollbars; unless you tell it not to, that is. For our specific case, we want the scrolling behavior that the ScrollViewer offers, but we sure don't want the ScrollBars, do we! More on that later.

 

How to enable scrolling

Now what do we need in order to allow our TabPanel to rather scroll when there's more TabItems than we would be able to fit onto a single row? Actually, this is as simple as wrapping your content-control into a ScrollViewer:

<ScrollViewer SnapsToDevicePixels="True"
              HorizontalScrollBarVisibility="Auto" 
              VerticalScrollBarVisibility="Disabled">
   <TabPanel ...>
</ScrollViewer>

The above would already be sufficient in order to force the TabPanel to rather scroll than to wrap TabItems. Also, by setting the VerticalScrollBarVisibility to Disabled, we tell the control to never show the vertical scrollbar.
Well, the above would leave us with a H-ScrollBar popping up when the overall width of all TabItems would exceed the width of the TabPanel and we sure don't want that (or is it a matter of personal preference?). In order to gain more control over what happens when the Extent exceeds the Viewport, the Horizontal-/VerticalScrollBarVisibility properties (which actually refer to the ScrollBarVisibility enumeration) also allow us to set two other values: Visible and Hidden. While Visible will make the control show the respective ScrollBar all of the time, Hidden will never display it. Now what's the difference between Hidden and Disabled? If we set this to Disabled, scrolling will not be possible at all; instead, the size of the control would be extended as long as the UI leaves room for the extension or, if that's not possible, the remainder would simply remain invisible and thus would never be seen.

We really don't want any ScrollBar at all, but rather provide our own buttons to allow for scrolling. That is, two buttons - one for each scrolling to the left and right - will provide the same functionality as the LineButtons (the arrow-buttons on the far left/right resp. top/bottom) do. As a result, let's make a few changes to the XAML above.

 

Exit the TabPanel

While changing the XAML, let's go ahead and get rid of the TabPanel along the way, replacing it with its simpler counter part - the StackPanel. Why that? Comparing the StackPanel to the TabPanel, the latter one really only provides two things that the StackPanel doesn't:

  1. it wraps TabItems in rows when required
  2. it rearranges TabItems when the selected TabItem changes, i.e. assures that the selected TabItem is on the bottom row (actually, that's probably the only difference between the TabPanel and the WrapPanel ...)

We don't need either of those two - all we need is an area that can scroll in one direction, so a StackPanel with Orientation="Horizontal" will provide all we need.

As a result, this would leave us with the following:

<ScrollViewer SnapsToDevicePixels="True"
              HorizontalScrollBarVisibility="Hidden" 
              VerticalScrollBarVisibility="Disabled">
   <StackPanel ...>
</ScrollViewer>

After applying the above change to the XAML of what we finished with in Part Two, we get this (click for a larger image):

If you downloaded the sample solution (see the bottom for the link), click the "2. Standard ScrollViewer added" button to show the above window.

In the XAML for the above window, I added some more TabItems (there's now 15 of them) and, as you can see, the TabPanel no longer wraps. Kewl.
However, while you can loop through the TabItems with the arrow keys, there's no way of getting to the TabItems using the mouse.
If you watched the video in the introduction of this article, you will have seen what I really had in mind was an area to the right of the TabPanel in which the LineButtons (aka the scroll buttons) are placed.

 

Hosting the ScrollButtons

So where do we place the ScrollButtons? It's actually not as easy as you'd think. If you look at the last screenshot again, you'll see that the first tab is missing its leftmost part. One of the reasons for this is the negative (horizontal) margins that are applied by the triggers of the selected TabItem (-4 in the sample). That is, remember that we (err, I Innocent) wanted the selected TabItem to overlap into the adjacent TabItems' "territory" (hey, are those Saddam-tabs? These are successful though!)? In the present situation, this forces us to do quite a substantial amount of additional work. Also, we still want to have the borders be displayed right (see Part One). While there is several possible approaches to all this, I opted to override the ControlTemplate of the ScrollViewer control (not least because this is also a tutorial about the power of styles!). Overriding the ControlTemplate again gives us all the flexibility we need (well, not all exactly, but more on that later).

Here's the part of the XAML that contains the definition/setup of the TabPanel along with the ScrollViewer in which has now been wrapped:

<Border Name="TabPanelBorder"
                             Height="35"
                             Background="{StaticResource TabPanel_BackgroundBrush}">
   <ScrollViewer SnapsToDevicePixels="True"
                                      Name="svTP"
                                      Grid.Row="0"
                                      HorizontalScrollBarVisibility="Hidden" 
                                      VerticalScrollBarVisibility="Disabled">
      <ScrollViewer.Style>
         <Style TargetType="{x:Type ScrollViewer}">
            <Setter Property="Focusable" Value="False"/>
            <Setter Property="Template">
               <Setter.Value>
                  <ControlTemplate>
                     <Grid SnapsToDevicePixels="True" 
                                                Grid.Row="0" Grid.Column="0">
                        <Grid.ColumnDefinitions>
                           <!-- 
                              The TabItems (resp. the TabPanel)
                              will appear here 
                           -->
                           <ColumnDefinition Width="*"/>
                           <!-- 
                              The following two columns will host
                              the Scrollbuttons 
                           -->
                           <ColumnDefinition Width="Auto"/>
                        </Grid.ColumnDefinitions>
                        <ScrollContentPresenter 
                           x:Name="PART_ScrollContentPresenter"
                           VirtualizingStackPanel.IsVirtualizing="False"
                           SnapsToDevicePixels="True" 
                           Grid.Column="0" 
                           Content="{TemplateBinding ScrollViewer.Content}"/>
                        <Grid x:Name="gScrollButtons" 
                              HorizontalAlignment="Right"
                              Grid.Column="1">
                           <Grid.RowDefinitions>
                              <RowDefinition Height="*"/>
                              <RowDefinition Height="Auto"/>
                           </Grid.RowDefinitions>
                           <StackPanel Grid.Row="1"
                                 Orientation="Horizontal"
                                 Margin="{StaticResource 
                                    TabPanelScrollPanel_Margin}">
                              <!-- 
                                 The two RepeatButtons below will actually provide
                                 the scroll-functionality for the TabItems. 
                                 Here, I'm utilizing the Page[Left/Right]Command; 
                                 This could as well be using the 
                                 Page[Left/Right]Command instead.
                              -->
                              <RepeatButton 
                                 Style="{StaticResource LineButtonStyle}"
                                 Command="ScrollBar.PageLeftCommand"
                                 Content="{StaticResource ArrowLeftPath}"
                                 IsEnabled="{Binding ElementName=svTP, 
                                    Path=HorizontalOffset, 
                                    Converter={StaticResource 
                                    scrollbarOnFarLeftConverter}}"/>
                              <RepeatButton 
                                 Style="{StaticResource LineButtonStyle}"
                                 Command="ScrollBar.PageRightCommand"
                                 Content="{StaticResource ArrowRightPath}">
                                 <RepeatButton.IsEnabled>
                                    <MultiBinding Converter="{StaticResource 
                                          scrollbarOnFarRightConverter}">
                                       <Binding ElementName="svTP" 
                                          Path="HorizontalOffset"/>
                                       <Binding ElementName="svTP" 
                                          Path="ViewportWidth"/>
                                       <Binding ElementName="svTP" 
                                          Path="ExtentWidth"/>
                                    </MultiBinding>
                                 </RepeatButton.IsEnabled>
                              </RepeatButton>
                           </StackPanel>
                        </Grid>
                     </Grid>
                     <ControlTemplate.Triggers>
                        <DataTrigger Value="false">
                           <DataTrigger.Binding>
                              <MultiBinding Converter="{StaticResource
                                    scrollbarOnFarRightConverter}">
                                 <Binding ElementName="svTP" 
                                    Path="HorizontalOffset"/>
                                 <Binding ElementName="svTP" 
                                    Path="ViewportWidth"/>
                                 <Binding ElementName="svTP" 
                                    Path="ExtentWidth"/>
                              </MultiBinding>
                           </DataTrigger.Binding>
                        </DataTrigger>
                     </ControlTemplate.Triggers>
                  </ControlTemplate>
               </Setter.Value>
            </Setter>
         </Style>
      </ScrollViewer.Style>
      <!-- 
         This is the area in which TabItems (the strips) 
         will be drawn. 
      -->
      <StackPanel Name="TabPanel"
		 Orientation="Horizontal"
		 IsItemsHost="true" 
		 Margin="{StaticResource TabPanel_Padding}"
		 KeyboardNavigation.TabIndex="1"/>
   </ScrollViewer>
</Border>

Here's the deal: The ScrollViewer contains a Grid with two columns. Let's start with the second column. Here, another Grid (gScrollButtons - named only for the sake of clarity) contains two rows - the top one will remain empty and take whatever the remainder of the overall height leaves; the second will contain a StackPanel with two RepeatButtons (thus allowing for continuos scrolling while holding down the mouse-button - you'll find these in the ScrollBar control, too). Using two rows is just one way of aligning the StackPanel to the bottom so that it's right above the content area of the TabControl. In order to make the RepeatButtons actually perform scrolling, a simple CommandBinding does the trick. That is, the ScrollViewer control exposes commands for scroll-operations such as LineLeft or PageLeft (+ the adequate ones for Right/Up/Down), ScrollToLeftEnd (and various others to scroll to all edges of the Extent), and another bunch for scrolling for the MouseWheel. In the sample above, I opted to utilize the PageLeft/PageRight commands.

In the first column in the ScrollViewer's (main) grid - which will use the remainder of the horizontal extent - you'll find the ScrollContentPresenter. This is the control that actually represents the content to be rendered in the control, aka the Viewport. To make the ScrollViewer render its content in the ScrollContentPresenter, we simply (template-) bind its content to that of the ScrollViewer (TemplateBinding ScrollViewer.Content).
(The reason for the above XAML looking a "bit" clunky is actually really because I wrapped it into many lines so the code-formatter that I'm using doesn't get upset with me ...)

Geometries, Paths, Converters

If you looked closely at the XAML, you probably saw that there's two converters and Path-resources for rendering the buttons' content.
Regarding Geometries - I usually have a whole bunch of those in a ResourceDictionary (or several of them) so that I can use them a) with themes (i.e. different paths for different themes) and b) simply reference them throughout applications. Another  advantage is that - since we're really defining vectors - we can make them (well, paths that use their data) scale inside whatever content-control we use them in, without any loss or pixelation, when they need to grow larger (we Germans don't fancy six-packs, so a crate of beer to whoever came up with that!).

The Path resources are really two-fold; first, there's a Geometry that defines the shape and, second, there's a Path that utilizes them and adds i.e. the colors, etc.; for instance, for the left arrow this looks like the following:

<Geometry x:Key="ArrowLeft">M0,5 L10,10 10,0Z</Geometry>
<Path x:Key="ArrowLeftPath"
      Margin="4,3"
      Data="{StaticResource ArrowLeft}"
      Stroke="{StaticResource LineButtonBrush}" 
      Fill="{StaticResource LineButtonBrush}"
      Stretch="Fill"
      VerticalAlignment="Center"
      HorizontalAlignment="Center"/>
If you're not familiar with Geometries and the "geometry mini language", I suggest to bing or google that - it's so much more convenient compared to the long version of their more verbuous counterparts, especially if only a small count of points is required, in which case you can really learn to read them over time.

Regarding the converters: While, in the beginning, I really wanted a XAML-only solution, this was no longer possible since I really really wanted the scroll buttons to be disabled when scrolling isn't possible, i.e., when the viewport is either on the far left or far right. The far left isn't much of a problem - we could simply compare the HorizontalOffset property to zero, in which case scrolling to the left wouldn't be possible. However, in order to find out whether the Viewport is on the far right, we have to compare the Extent's width against the sum of the Viewport's width plus the HorizontalOffset, IOW, scrolling to the right not possible if [HorizontalOffset + Viewport.Width] = Extent.Width. Sadly, this is not possible without a converter due to the necessity of a MultiBinding, hence this part will require some code. However, we're not talking about the need for code-behind for every TabControl we use, but rather about a loosely coupled class. The TabControl's style can thus still be used throughout your project as long as the Converter is part of that project, too. A drawback for sure, but a minor one, if you ask me. Since we need one converter for the right-button, I thought it'd make sense to also provide one for the left-button.

You'll find both converters in the ScrollBarConverters.cs file (resp. ScrollBarConverters.vb).


Ready to (sc)roll ..?

If you downloaded the sample solution (see the bottom for the link), click the 3. ScrollViewer with Scroll-Buttons button to show the window with the result of the above. You'll get something this (click for a larger image):

That's better - we can now scroll the TabPanel in order to get at the TabItems that are invisible/inaccessible. Let's add some functionality that I personally learned to value.

 

The TabControl in SAP's WebGUI

Actually, the functionality I wanted is is more or less equal to that of the TabControl in SAP's WebGUI. In early 2008 SAP tasked us to build a WinForms companion to SAP's WebGUI, that is, to implement a WinForms counterpart for the controls contained in their library. This set of WinForms controls was then used for implementing an offline client for SAP's cProjects (which is a part of SAP PS). Here's a sample screenshot of our test-client (click to enlarge):

In the above screenshot, you can see the bottom-most TabControl "in action", featuring three buttons on the right extent of the TabPanel - one for each scrolling to the left and right and another one. Another one? Yup, this one opens up a popup-menu in which all TabItem's headers are listed, allowing users to quickly select an item from the list, activating the selected TabItem, even if it's out of view when selected. (This TabControl was one of the most complicated and non-amusing controls I've ever had to build; if you ever need to build your own TabControl with WinForms, tell ya - it's not what I consider "fun". With WPF OTOH, this is just so much easier, less complicated, way more flexible, faster, fun, ... you name it!)

So, what do we have to do in order to create such a menu? Again, we can settle with a no-code / XAML-only solution!

 

Enter the Menu and MenuItem controls

First thing you probably thought of was ... the ContextMenu? Well, I did. But instead of convincing a ContextMenu to popup, I resembled to applying a custom style to the Menu and the MenuItem controls. First, let's have a look at the XAML that we'll need to add to the StackPanel (which already contains the scroll buttons) in order to get the menu in the right place:

<Menu Background="Transparent">
   <MenuItem Style="{StaticResource TabMenuButtonStyle}"
             ItemsSource="{Binding RelativeSource=
               {RelativeSource FindAncestor, 
                  AncestorType={x:Type TabControl}}, 
                  Path=Items}"
             ItemContainerStyle="{StaticResource TabMenuItem}">
   </MenuItem>
</Menu>

Pretty short really, right? Of course, the style for the control isn't part of the XAML, but assuming that, in a real world solution, the style will rather be dropped into a separate ResourceDictionary, this is all you need in your TabControl's style (no no, I wouldn't call that cheating!). Thus, the only really interesting part about the XAML above really is the binding that is applied to make the menu (well, the MenuItem, really) show all TabItems' Header texts. But it's probably easier than you might have thought, because all we have to do is to point the MenuItem to the TabControl and then bind to its TabItems. I love it - with WinForms that was so much more code!

I won't fancy discussing the style for the MenuItem in depth here. If you inspect the XAML in the sample solution, you'll find it documented - just look for TargetType="{x:Type MenuItem}" to find the two styles (one for the MenuItem that makes up for the Button in the StackPanel and one for the popup menu with the items themselves). Two side notes here: First, I actually failed to provide a hover-effect for disabled TabItems, the reason being the fact that disabled items will never receive any HitTest information. As a result, you won't see any indication when you hover over disabled items found in the menu; oh well. Second, I thought that it'd be fun to again use the geometry mini language in order to create the button's image which should be pretty close to SAP's original icon, only that this one's scalable. Cool

Here's a screenshot of what we have now (click for a larger image):

(If you downloaded the sample solution (see the bottom for the link), click the "4. TabItem-menu added" button to show the window above.)

 

Are we done yet?

At this point, you might want to sit back and determine whether the above already gives you what you need for your own TabControl. There's really only a couple of things that are worth dealing with the rest of the article - one minor and two major things:

  1. Selecting a TabItem from the menu will not bring the first and last TabItems into view completely (major)
  2. Clicking the scroll buttons will scroll by whole pages (major)
  3. The TabItems on the left and right of the ScrollViewer's Viewport will be cut off abruptly (minor)

Why am I saying this? The first item might not apply to you - if you don't use negative margins, this would fade away silently. The second and third items might not be important to you. To me, however, all of these three are inacceptable. So ...

 

Enter IScrollInfo

Dealing with the aforementioned drawbacks turned out to be impossible by means of XAML only (I tried real hard!). So I opted to create my own panel instead. The "wanted" features that made it onto my list:

  • more control over the scrolling position when moving to the beginning resp. end of the Viewport (allowing negative margins of contained controls at the edges of the Extent)
  • more control over the offset that's being applied during scrolling
  • animated scrolling
  • a "fading" effect for TabItems that are only partially visible
  • get rid of the converters required for binding the scroll buttons' IsEnabled property

To create your own panel, you can simply inherit from Panel. However, to provide your own scrolling logic, we'll need to implement IScrollInfo.
IScrollInfo really is a beast! If you have VisualStudio create the methods required for implementing this interface for you, you'll be left with as much as 9 properties and 15 methods! Here's the list (in the order that VS creates them):

   public bool CanHorizontallyScroll
   public bool CanVerticallyScroll
   public double ExtentHeight
   public double ExtentWidth
   public double HorizontalOffset
   public void LineDown()
   public void LineLeft()
   public void LineRight()
   public void LineUp()
   public Rect MakeVisible(Visual visual, Rect rectangle)
   public void MouseWheelDown()
   public void MouseWheelLeft()
   public void MouseWheelRight()
   public void MouseWheelUp()
   public void PageDown()
   public void PageLeft()
   public void PageRight()
   public void PageUp()
   public ScrollViewer ScrollOwner
   public void SetHorizontalOffset(double offset)
   public void SetVerticalOffset(double offset)
   public double VerticalOffset
   public double ViewportHeight
   public double ViewportWidth

Most of the above methods are either pretty easy to implement (such as LineLeft/LineRight) or do not need to be covered (LineDown/Up, PageDown/Up, MouseWheel*) at all. However, the MakeVisible method needs some more intense care-taking, as does the SetHorizontalOffset method.

BTW - note that the sample class will simply skip anything related to mouse wheel actions and any actions targetting the vertical axis. If you plan to use the TabControl with its TabItems drawn on the left or right, you'll have to add these accordingly for the latter; in this case however, you'll have to reconsider a whole bunch of other things anyway ... Laughing

Back to IScrollInfo. Besides the fact that we have to implement the methods and properties of the IScrollInfo interface, we also need to override a couple of methods, the most important being MeasureOverride and ArrangeOverride. I won't discuss the whole class here as that could a) get quite boring (with respect to this article being geared at the TabControl) and b), considering that there's a substantial amount of code involved. FWIW - you'll find the code well documented in the sample solution and if you encounter any problems or want to know more about any specifics, leave a comment.

A couple of things can not be set aside though. For instance, the two aforementioned methods deserve some explanation which is critical for understanding the concept behind this, so here goes.

 

IScrollInfo: MeasureOverride and ArrangeOverride

When elements are being added to (or removed from) your control or one of the elements is re-rendered (i.e. after its size has changed), the whole layout of the control (that is, the Extent and Viewport) needs to be rearranged. This requires a two-fold process to which the .Net framework refers to as the two pass layout updating process. This means that, whenever the layout needs to be updated, the compiler will call both methods. In MeasureOverride we need to determine the overall size of the control (i.e., the Extent) that is required to host all contained elements; in the sample class, only the width is relevant, so the class will iterate over all elements, sum up their desired width and return the result; the height will remain constant at all times.

Once that is done, the elements need to be arranged within the Extent, hence the second pass - ArrangeOverride. Here, we again iterate over all children and define the (horizontal) position for each of them. In some situations, the arranging of the children may result in the need to perform both first and second pass again, so this sequence may be called several times. For the ScrollableTabPanel (being the sample class) however, this is not the case.

Again, IScrollInfo would really deserve (require!) its own article, hence I'll skip everything else related to this interface at this point. A little hint though: if you want to know more about those two methods, I suggest you check the MSDN docs on UIElement.Measure and UIElement.Arrange - while you'll find control-specific topics in the docs about MeasureOverride and ArrangeOverride, the detail covered there doesn't compare to what you'll find in the respective ones behind the aforementioned links! Also, there should be plenty of tutorials on IScrollInfo basics throughout the web.

 

Animating the Panel

One of the two other things that I think are worth mentioning is the fact that, whenever the ScrollableTabPanel scrolls, the process will be animated (as opposed to instantly switching to the final position). Two simple reasons for that - when the user scrolls the panel, there is no real visual indication of what happens; by animating the process the user has (IMHO) a far better chance to see what's going on behind the scenes. Second, the animation is stupidly easy to implement - it's basically not more than a single line of code. In the solution's class, you'll actually see a couple of lines, but that's rather because I wanted the animation to a) accelerate and decelerate and b) because an update of the TabItems is required - after the animation has ended (the OpacityMasks need to be updated - more on that below).

 

Having the TabItems at the edges of the Viewport fade into nothingness

Last but not least, I wanted to give the user a visual indication in the case a TabItem (strip) was only partially visible, indicating that there is more items to the left or right. Achieving that was way trickier than I originally thought really (but hey, we all like digging into stuff like that, don't we ...). When I was thinking about the fade-effect, I thought of applying an Opacity Mask right away. My first attempt (call me dumb) was to apply a mask to the left and right edge of the ScrollViewer (that was actually before I implemented the ScrollableTabPanel), which is as simple as creating a (horizontal) LinearGradientBrush that fades into Colors.Transparent at its edges and then applying the resulting Brush to the OpacityMask property of whatever control in question. (BTW - it doesn't matter at all what other color(s) you place into such a brush - for an OpacityMask, only the alpha channel is important, thus the color itself (meaning the R, G and B channels) is irrelevant.) This way, only those portions (colors) of the control/content itself with an alpha-value >0 will be affected by the "fader brush".

Well, it of course wasn't that easy - the OpacityMask will be applied to the Extent of the control, rather than the Viewport - I hence did not succeed in defining an Opacity mask that would stick to the Viewport's bounds; if you know a way, make sure you leave a comment!
I thus opted to apply the mask to the TabItems themselves. In this case though, the whole task gets a little more complicated because the brush itself needs to consider the width of each TabItem, if the fade effect is to remain as constant as possible (which is not all too much, depending on how narrow the visible portion gets, but you'll see that yourself). In the ScrollableTabPanel class, you'll see that I simply calculate exactly how much of each TabItem is visible (i.e. ranging from 0 = completely invisible to 1 = completely visible). The factor or ratio gained will then be applied to the StartPoint or EndPoint respectively.
A minor quirk with this is the fact that all OpacityMasks need to be removed prior to performing scrolling as, otherwise, the faded edges would remain visible until the Viewport has reached its final position. But oh well, you can't have it all, can you.

 

But wait!

One last thing before we go and have a beer or two: If you used the keyboard to tab through the controls in the previous versions, you may have noticed that the focus indicator (aka the dashed border) was either not looking OK or even off limits at times. In the last window of the sample solution, I have therefore added another style for that (look for the key "TabItemFocusVisual"). This, again, imposes another minor issue: this can't be dealt with by means of an adorner (Dr. WPF has recently published a nice article on this), so I had to stick with calling InvalidateArrange() instead. However, this again can be called (reliably) only after any scrolling has taken place resp. finished. You will thus see the dashed border "move" when you loop through the TabItems with the keyboard (to do so, focus the slider and then hit the left/right arrow keys). However, IMNSHO the effect is too minor to deserve some decent appreciation (especially if you don't zoom in), so I'll leave that as it is now and rather go and have my beer.

 

The last word

This concludes Part Three of the TabControl series. It shouldn't really take too long to assemble the last part (the last part currently planned, that is Cool) as I've dealt with the close-button (and images) in another solution already. Maybe next week - we'll see. As for me, I sure learned a bunch of new things and details about the ScrollViewer and IScrollInfo - I hope you enjoyed it a bit, too.

As always, I'd appreciate you leaving a comment - whether you liked it or not, or in case you need further clarification on any of the topics discussed here - I'll do my very best to answer them.

 

The sample solution

I’ve created a sample solution that contains everything discussed here. Other than with the previous parts, the solution now again contains one project for each the C# and the VB versions.

Download: TabControlStyle - Part Three.zip (105.68 kb)


Location: PostList

WPF: ColorHelper - how to retrieve the name of a given color or to retrieve a color by its name

by Olaf Rabbachin 5. February 2010 19:47

Today I stumbled over a thread in the WPF forum in which the OP was looking for a way to retrieve the (known) name for a given color.
That is, given a color such as #FFB22222 (or ARGB :: 255, 178, 32, 32), to retrieve "Firebrick". What I thought was darn simple actually isn't, because System.Windows.Media.Colors is a class and thus exposes all (known) colors as properties (as opposed to System.Drawing.KnownColor which is an enum). So there really isn't any other way than to use reflection to obtain a list of all colors. Here's a little helper class that returns the name of a color passed to it:

Retrieving the name of a given Color object

/// <summary>
/// Returns the known name of the color passed (if found), or an empty string.
/// </summary>
/// <param name="clr">The color whose name is to be returned.</param>
/// <returns>
/// The name of the passed color resp. an empty string if 
/// no matching known color could be found.
/// </returns>
public static string GetKnownColorName(Color clr)
{
   Color clrKnownColor;

   //Use reflection to get all known colors
   Type ColorType = typeof(System.Windows.Media.Colors);
   PropertyInfo[] arrPiColors = ColorType.GetProperties(BindingFlags.Public | BindingFlags.Static);

   //Iterate over all known colors, convert each to a <Color> and then compare
   //that color to the passed color.
   foreach (PropertyInfo pi in arrPiColors)
   {
      clrKnownColor = (Color)pi.GetValue(null, null);
      if (clrKnownColor == clr) return pi.Name;
   }

   return string.Empty;
}

If you call the above method with i.e. ...

string strColorName = GetKnownColorName(Color.FromArgb(255, 178, 32, 32));
MessageBox.Show(strColorName == "" ? "(None found)" : strColorName);

... the MessageBox will show "Firebrick".

 

Update to the above (Feb 13 2010)

I just updated the code for the method above after it became obvious that the GetHashCode() method is not returning unique values. I did a bit of searching in order to find out more about that, but couldn't find anything; the docs are no help at all with this respect or even when it comes to the reason for the existance of this method in the first place.

Actually, I don't have the slightest idea as to how the hashes couldn't be unique - colors resp. their ARGB values are uniqe in itself after all. However, the hashes for Colors.White and Colors.Blue are equal. Now if that isn't plain stupid! Lesson learned: never assume anything ...

Thanks to Pedro for catching this (see the comments).

 

Retrieving a list with all known colors (names + Color objects)

If you actually need a list with all color values and their name-counterparts (might make sense if you need this frequently), here's another little helper-method that will return all known colors along with the name of each color:

/// <summary>
/// Returns a list containing all known colors, each as a KeyValuePair with the name
/// as the key and the Color as the value.
/// </summary>
public static List<KeyValuePair<string, Color>> GetKnownColors()
{
   List<KeyValuePair<string, Color>> lst = new List<KeyValuePair<string, Color>>();
   Type ColorType = typeof(System.Windows.Media.Colors);
   PropertyInfo[] arrPiColors = ColorType.GetProperties(BindingFlags.Public | BindingFlags.Static);

   foreach (PropertyInfo pi in arrPiColors)
      lst.Add(new KeyValuePair<string, Color>(pi.Name, (Color)pi.GetValue(null, null)));
   return lst;
}

You could use the above in various ways, here's just one sample:

Color clr = Color.FromArgb(255, 178, 32, 32);
List> lstKnownColors = GetKnownColors();
string strColorName = (
   from c in lstKnownColors
   where
      c.Value.A == clr.A &&
      c.Value.R == clr.R &&
      c.Value.G == clr.G &&
      c.Value.B == clr.B
   select c.Key
   ).FirstOrDefault();

 

Retrieving a color via its name

Last but not least, if you need to go the opposite way, how about another simple helper method:

 

/// <summary>
/// Returns the Color which is represented by the name passed.
/// </summary>
/// <param name="strColorName">The name of the Color to retrieve.</param>
/// <returns>Nullable color - either the found Color or null.</returns>
public static Color? GetColorByName(string strColorName)
{
   Color? clrResult = null;
   try
   {
      object value = ColorConverter.ConvertFromString(strColorName);
      if (null != value) clrResult = (Color)value; 
   }
   catch (Exception) { }

   return clrResult;
}

 

Update to the above (Feb 15 2010)

I've just updated the GetColorByName method after Richard pointed out that it was prone to fail if an invalid name or an empty string were passed (see the comments). I've only resolved for a general purpose handler rather than just to catch FormatExceptions.

 

Wrapping it all up

To simplify things, I dropped the above into a class that you can simply drop into your projects.
Download it here:

C# version: ColorHelpers.cs (3.43 kb)

VB version: ColorHelpers.vb (3.48 kb)

 

Happy coding!


Location: PostList

Tags: , , , , , ,

Utilities | WPF (.net)

WPF: TabControl series - Part 2: Animating TabItems

by Olaf Rabbachin 26. January 2010 17:36

Introduction

In my previous article, I started the TabControl series, demonstrating how to define a new Style for the TabControl, its TabItemPanel and the TabItems. Today I will extend the sample introduced there, adding transition effects that are being applied to the TabItems when they change state (for instance, from Selected to Unselected).

 

Overview

This article is part of a multi-part series. Here's the four parts of the series:

 

Outcome: the result of what's covered in this article

Here's what we'll be left with at the end of this article:

 

Status quo (after Part One)

As noted before, this article is based upon the stuff I introduced in Part One. If you haven't read it and find that you need to find out more about the basics, I suggest that you start there.
Here's where we'll start here, i.e. what the TabControl and its "sub-controls" looked like at the end of Part One:

 

Animating ... what?

Basically, the above style IMHO makes up for a much better appearance compared to the original template. However, when the user hovers over a TabItem or selects a different TabItem, the change will be instant. The goal of this article is to change that so that there's more of a smooth transition between states.

Let's do a quick recap on the different states that a TabItem can take: 

  1. Unselected (i.e., the default)
  2. Selected
  3. Disabled
  4. Hover (i.e., the mouse is over the TabItem)

I'll leave out the Disabled state with respect to animations as I presume that, for most of all times, that state will have been determined to be required before the control is actually being shown, hence animating it shouldn't be required. If you do want to add a transition effect for that as well, it should be pretty easy once the concept has become clear.

Let's start with what state-changes we would need to support. These are:

  • Unselected » Selected
  • Unselected » Hover
  • Selected » Unselected
  • Hover » Unselected
  • Hover » Selected

So, where's the Selected » Hover state change? I'm simply not considering it due to the fact that I don't want any effect when the mouse hovers over the selected TabItem. As a matter of fact, the style introduced in Part One doesn't consider this either.

 

States of the TabItems

Back when I created Part One's style I thought that adding some animations to the TabItem would be a matter of a couple of minutes. However, as it turned out, the whole thing got me pretty much stuck for quite a while. To be precise, I spent an absolutely ridiculous amount of time trying to find out why it wouldn't work as desired. Up until now I still think that there's a bug somewhere in the part of the framework that's responsible for the animations resp. Storyboards. But let's start with the fundamentals first. In order to provide the transition between TabItem-states, we first need to determine what type of animation is the most suitable for what we're planning to achieve. In Part One, I introduced a couple of Thickness resources that were targetting the TabItems' Margin property. These were defined as follows:

<Thickness x:Key="TabItemMargin_Base">0,8,-4,0</Thickness>
<Thickness x:Key="TabItemMargin_Selected">-4,0,-4,0</Thickness>
<Thickness x:Key="TabItemMargin_Hover">0,2,0,0</Thickness>
<Thickness x:Key="TabItemPanel_Padding">4,0,0,0</Thickness>

The last one (TabItemPanel_Padding) isn't of any interest with respect to animations (see the comments in the XAML of the sample solution) - it's only part of the group in order to rather have all the Margin assignments in a central place (allowing for easier changing them when required). The resources' names should be pretty self-explanatory for all but the first: TabItemMargin_Base actually refers to what I would call the default state of TabItems - the Unselected state. That being said, whenever a TabItem changes into that state, its size will be determined by the available height minus the 8px top-margin (making it a little smaller in height). Also, adding -4px as the right margin will effectively apply a negative amount, allowing the TabItem to the right (of the one to which this is being applied) to overlay it, which in turn "removes" (covers) the rounded corner on the TabItem's top right, resulting in the overlay-effect (can I call that 2.9D?). The TabItemMargin_Hover resource will make two changes compared to TabItemMargin_Base: the height-reduction is two pixels less, which will render the hovered TabItem 2px higher in comparison. Also, the negative right margin is removed which results in the hovered TabItem no longer being covered by the TabItem to the right (if there is one anyway). Finally, the TabItemMargin_Selected resource further increases the height resp. allows the TabItem to extend to the full height that the TabItemPanel allows for; since this is also the state at which TabItems are to receive the user's strongest attention, negative left and right margins are being applied so that the selected TabItem will always cover the TabItems to the left and right respectively. What you can't see in the XAML above is the fact that the ZIndex property is part of the gameplay here, too. That is, the ZIndex is changed with respect to the TabItem's state - from back to front:

  1. Disabled (lowest)
  2. Unselected
  3. Hover
  4. Selected (highest)

 

ThicknessAnimation

With the above Margin-settings we actually have all we need to apply state-transitions resp. create/define our animations. The framework provides a wealth of animation-types; that being said, there is more than one way to create the transitions. However, there also is the ThicknessAnimation which is just what we need, since we really want to animate the TabItems' Margin-property, and the Margin actually is a ... Thickness! Also, utilizing a ThicknessAnimation allows us to simply neglect any starting parameters or values. Instead, we can define all we need only by defining the Margin as it is to be applied when the animation ends - the ThicknessAnimation will thus run from whatever source Margin it uses to the value we specify (there is other combinations such as using From or By or a combination of them - see the MSDN docs for more information). To a) keep things in a central place and b) allow for not having to define something more than once, we'll again make use of resources instead. Here's a sample of the Storyboard-resource that is to run when a TabItem enters the Selected state:

<Storyboard x:Key="TabItemStoryBoard_Selected">
   <ThicknessAnimation Storyboard.TargetName="Border" 
                             Storyboard.TargetProperty="Margin"
                             To="{StaticResource TabItemMargin_Selected}" 
                             FillBehavior="HoldEnd"
                             Duration="0:0:0.1"/>
</Storyboard>

A couple of points deserve attention in the above XAML: We want to apply the animation to the Margin-property of the TabItem, hence the TargetProperty-assignment. The TargetName property is set to Border simply because that's the name of the the Border-control that we defined in the TabItem's Template and which is the parent-control of its ContentPresenter (see Part One for more info). By setting the FillBehavior property to HoldEnd, we determine that the animation is to not bother with resetting the Margin back to its original value but rather to leave it where it is, once the animation ends. (Actually, HoldEnd really is the default value, but in cases like this I tend to be explicit since it adds to the code's overall readability.)
Finally, since we defined a Margin-resource that is to be reached by the time the animation ends, we'll simply pass its name to the To property, making up for the state that we'd like to have reached when the animation ends.

The above principle can actually be applied to all other animations in the exact same way, only replacing the Margin-resource in the To-property with the respective resource's name.

 

A (better) alternative?

However, I'd like to introduce another type of animation that allows us to perform the same task with an alternative approach - the ThicknessAnimationUsingKeyFrames. If we wanted to instead use the ThicknessAnimationUsingKeyFrames to achieve the same effect as in the prior XAML, the equivalent would look like this:

<Storyboard x:Key="TabItemStoryBoard_Selected">
   <ThicknessAnimationUsingKeyFrames Storyboard.TargetName="Border" 
                                           Storyboard.TargetProperty="Margin"
                                           FillBehavior="HoldEnd">
      <SplineThicknessKeyFrame KeyTime="0:0:0.1"
                                     Value="{StaticResource TabItemMargin_Selected}"/>
   </ThicknessAnimationUsingKeyFrames>
</Storyboard>

A little bit more complex compared to the ThicknessAnimation, huh, so why the hell bother with it? The reason is that the ThicknessAnimationUsingKeyFrames gives us a chance to define several states resp. changes during the scope of a single Storyboard/animation. To better illustrate this (ah well, maybe that's more about finding a valid reason), I thought it'd be fun to utilize this type of animation when animating the change of height that is to be applied when a TabItem enters the Hover state. That is, instead of just having the TabItem extend the height by 4px (well, actually we only reduce the reduction, luv'it Cool) when the mouse hovers over it, we'll apply two KeyFrames, the first with a 2px and the second with the final value of a 4px reduction. As a result, the TabItem will first extend above its final height and then swing back to the final value (being 4px). Since we have defined the target margins as resources, we'll simply split up the original TabItemMargin resource into two new ones, dumping the original one:

<!--<Thickness x:Key="TabItemMargin_Hover">0,4,-4,0</Thickness>-->
<Thickness x:Key="TabItemMargin_Hover_Start">0,2,0,0</Thickness>
<Thickness x:Key="TabItemMargin_Hover_Final">0,4,0,0</Thickness>

Note: If you fell for the right Margin of -4 having been replaced by 0 - don't bother, we'll get to that later on.


Now the ThicknessAnimationUsingKeyFrames comes back into play, which is where all we have to do is to provide the two KeyFrames that will utilize the above two resources:

<Storyboard x:Key="TabItemStoryBoard_Hover">
   <ThicknessAnimationUsingKeyFrames Storyboard.TargetName="Border" 
                                           Storyboard.TargetProperty="Margin"
                                           FillBehavior="HoldEnd">
      <SplineThicknessKeyFrame KeyTime="0:0:0.1"
                                     Value="{StaticResource TabItemMargin_Hover_Start}"/>
      <SplineThicknessKeyFrame KeyTime="0:0:0.2"
                                     Value="{StaticResource TabItemMargin_Hover_Final}"/>
   </ThicknessAnimationUsingKeyFrames>
</Storyboard>

Et voilà - that's all we have to do. And, by the way, both the ThicknessAnimation and the ThicknessAnimationUsingKeyFrames are not restricted to a single dimension such as either height, width, left, top, etc., but to the Thickness definition itself. That is, this would work just as well if we wanted a transition between a Margin of 10,5,0,2 and 5,0,2,10.

 

At a glance: the Storyboards

Here's all three Storyboards that we need:

<!-- This will run when a TabItem enters the "Unselected" state -->
<Storyboard x:Key="TabItemStoryBoard_Unselected">
   <ThicknessAnimation Storyboard.TargetName="Border" 
                             Storyboard.TargetProperty="Margin"
                             To="{StaticResource TabItemMargin_Base}"
                             FillBehavior="HoldEnd"
                             Duration="0:0:0.1"/>
</Storyboard>
<!-- This will run when a TabItem enters the "Selected" state -->
<Storyboard x:Key="TabItemStoryBoard_Selected">
   <ThicknessAnimation Storyboard.TargetName="Border" 
                             Storyboard.TargetProperty="Margin"
                             To="{StaticResource TabItemMargin_Selected}" 
                             FillBehavior="HoldEnd"
                             Duration="0:0:0.1"/>
</Storyboard>
<!-- This will run when a TabItem enters the "Hover" state -->
<Storyboard x:Key="TabItemStoryBoard_Hover">
   <ThicknessAnimationUsingKeyFrames Storyboard.TargetName="Border" 
                                           Storyboard.TargetProperty="Margin"
                                           FillBehavior="HoldEnd">
      <SplineThicknessKeyFrame KeyTime="0:0:0.1"
                                     Value="{StaticResource TabItemMargin_Hover_Start}"/>
      <SplineThicknessKeyFrame KeyTime="0:0:0.2"
                                     Value="{StaticResource TabItemMargin_Hover_Final}"/>
   </ThicknessAnimationUsingKeyFrames>
</Storyboard>

Alright, now all we have to do is to go ahead and apply the Storyboards we just defined. Since the Triggers that come into play for state-changes are already in place, that would (should) mean that this would be a matter of adding EnterActions for the three Triggers we already have in our XAML.

 

Ain't that easy after all, it seems ...

However, this is where what I thought to be overly simply became more like a nightmare. Since this is a rather long story, you can read this thread on the MSDN WPF forums (German!) for the very long story or to this thread (in English) for the long story. I have posted a simplified and ready-to-run sample in both threads which illustrates the issues I was encountering. Since I don't want to rap so much about what doesn't work but rather demonstrate what does, let's just cut this short - here's what I really had to do and what you might stumble over in the XAML ahead:

  1. While the documentation states that the default RepeatBehavior of a Storyboard means that the animation will only run once, there seems to be a bug of some sort that forced me to manually stop them at certain occasions.
  2. The above in turn leads to the necessity of naming the Storyboards when they are started from the Triggers. Since the names have to be unique, I suffixed them with an indication of the respective Trigger it was created in.
  3. There seems to be some sort of interference that must be happening due to the fact that the Storyboard that runs when a TabItem enters the Unselected state is being used multiple times (i.e. Hover » Unselected and Selected » Unselected). This forced me to replace the Unselected Trigger to a MultiTrigger (analogous to the Hover-MultiTrigger). 

Ah well ...

 

At a glance: the Triggers with the Storyboards applied

... here's the complete XAML section in which all Triggers are defined and which is where the Storyboards are put into action:

<ControlTemplate.Triggers>
   <!-- The appearance of a TabItem when it's inactive/unselected -->
   <MultiTrigger>
      <MultiTrigger.Conditions>
         <Condition Property="Border.IsMouseOver" Value="False"/>
         <Condition Property="IsSelected" Value="False"/>
      </MultiTrigger.Conditions>
      <!-- The Triggers required to animate the TabItem when it enters/leaves the "Unselected" state (added in part two) -->
      <MultiTrigger.EnterActions>
         <BeginStoryboard x:Name="sbUnselected"
                                            Storyboard="{StaticResource TabItemStoryBoard_Unselected}"/>
      </MultiTrigger.EnterActions>
      <MultiTrigger.ExitActions>
         <StopStoryboard BeginStoryboardName="sbUnselected"/>
      </MultiTrigger.ExitActions>
      <Setter Property="Panel.ZIndex" Value="90" />
      <Setter TargetName="Border" Property="Background" 
                                Value="{StaticResource TabItem_BackgroundBrush_Unselected}" />
      <Setter TargetName="Border" Property="BorderBrush" 
                                Value="{StaticResource TabItem_Border_Unselected}" />
      <Setter Property="Foreground" 
                                Value="{StaticResource TabItem_TextBrush_Unselected}" />
      <!-- Except for the selected TabItem, tabs are to appear smaller in height. -->
      <Setter TargetName="Border" Property="Margin" 
                                Value="{StaticResource TabItemMargin_Base}"/>
   </MultiTrigger>

   <!-- 
                        The appearance of a TabItem when it's disabled 
                        (in addition to Selected=False)
                     -->
   <Trigger Property="IsEnabled" Value="False">
      <Setter Property="Panel.ZIndex" Value="80" />
      <Setter TargetName="Border" Property="BorderBrush"
                                Value="{StaticResource TabItem_DisabledBorderBrush}" />
      <Setter TargetName="Border" Property="Background" 
                                Value="{StaticResource TabItem_BackgroundBrush_Disabled}" />
      <Setter Property="Foreground" 
                                Value="{StaticResource TabItem_TextBrush_Disabled}" />
      <Setter TargetName="Border" Property="Margin" 
                                Value="{StaticResource TabItemMargin_Base}"/>
   </Trigger>

   <!-- The appearance of a TabItem when the mouse hovers over it -->
   <MultiTrigger>
      <MultiTrigger.Conditions>
         <Condition Property="Border.IsMouseOver" Value="True"/>
         <Condition Property="IsSelected" Value="False"/>
      </MultiTrigger.Conditions>
      <!-- The Triggers required to animate the TabItem when it enters/leaves the "Hover" state (added in part two) -->
      <MultiTrigger.EnterActions>
         <StopStoryboard BeginStoryboardName="sbUnselected_Hover_Exit"/>
         <BeginStoryboard x:Name="sbHover"
                                            Storyboard="{StaticResource TabItemStoryBoard_Hover}"/>
      </MultiTrigger.EnterActions>
      <MultiTrigger.ExitActions>
         <BeginStoryboard x:Name="sbUnselected_Hover_Exit" Storyboard="{StaticResource TabItemStoryBoard_Unselected}"/>
      </MultiTrigger.ExitActions>
      <Setter Property="Panel.ZIndex" Value="99" />
      <Setter Property="Foreground" Value="{StaticResource TabItem_TextBrush_Hover}" />
      <Setter Property="BorderBrush" 
                                TargetName="Border" 
                                Value="{StaticResource TabItem_HoverBorderBrush}" />
      <Setter TargetName="Border" Property="BorderThickness" Value="2,1,1,1" />
      <Setter Property="Background" TargetName="Border"
                                Value="{StaticResource TabItem_HoverBackgroundBrush}"/>
      <!-- 
                           To further increase the hover-effect, extend the TabItem's height a little
                           more compared to unselected TabItems.
                        -->
      <Setter TargetName="Border" Property="Margin" 
                                Value="{StaticResource TabItemMargin_Hover_Final}"/>
      <!--
                           At runtime, we want a transition when changing between the regular/hover/regular
                           states.
                        -->
   </MultiTrigger>

   <!-- The appearance of a TabItem when it's active/selected -->
   <Trigger Property="IsSelected" Value="True">
      <!-- The Triggers required to animate the TabItem when it enters/leaves the "Selected" state (added in part two) -->
      <Trigger.EnterActions>
         <StopStoryboard BeginStoryboardName="sbUnselected_Selected_Exit"/>
         <BeginStoryboard x:Name="sbSelected"
                                            Storyboard="{StaticResource TabItemStoryBoard_Selected}"/>
      </Trigger.EnterActions>
      <Trigger.ExitActions>
         <BeginStoryboard x:Name="sbUnselected_Selected_Exit" Storyboard="{StaticResource TabItemStoryBoard_Unselected}"/>
      </Trigger.ExitActions>
      <!-- We want the selected TabItem to always be on top. -->
      <Setter Property="Panel.ZIndex" Value="100" />
      <Setter TargetName="Border" Property="BorderBrush" 
                                Value="{StaticResource TabItem_BorderBrush_Selected}" />
      <Setter TargetName="Border" Property="Background" 
                                Value="{StaticResource TabItem_BackgroundBrush_Selected}" />
      <Setter TargetName="Border" Property="BorderThickness" Value="1,1,1,0" />
      <Setter Property="Foreground" 
                                Value="{StaticResource TabItem_TextBrush_Selected}"/>
      <Setter TargetName="Border" Property="Margin" 
                                Value="{StaticResource TabItemMargin_Selected}"/>
   </Trigger>
</ControlTemplate.Triggers>

 

But wait!

If you stumbled over the fact that I got rid of the right Margin of 4px for the hover-effect when I replaced the TabItemMargin_Hover_Start Margin resource with the split up ones - here's the reason. If you run the sample solution, comparing the two Windows it comes with (one for what we started with and one for what we have now), you'll notice that, for the non-animated sample, the hover-effect will include the hovered TabItem to come up to the front. The reason is simple - each state-Trigger comes with its own ZIndex-setting; the Hover-Trigger's ZIndex places it only behind the selected TabItem, but in front of all others. Now, by removing the right Margin in the animated TabItem's Style, we actually animate the width of the item along the way, resulting in the hovered TabItem seeming to "push" any other TabItem to its right out of the way. Hey, I like that. We could as well have defined the right margin of the TabItemMargin_Hover_Start resource (i.e., the first of the animation's two frames) to be 1px which would've smoothed the effect a little more, but I'll leave that up to you.

 

The last word

This concludes Part Two of the TabControl series. As always, I'm very happy to receive any kind of feedback for the stuff I'm publishing.

 

The sample solution

I’ve created a sample solution that contains everything discussed here. As with Part One, the solution is C# only, but there is no code behind involved whatsoever (not taking the main form into account) which is why you won't find a VB counterpart. However, if you want to use this in a VB-project, simply paste the XAML into your VB-window and remove the TabControlStyle. that is preluding each window's x:Class attribute (and indicating the namespace that is required for C#).

Download: TabControlStyle - Part Two.zip (23.29 kb)


Location: PostList

Tags: , , , , , ,

TabControl | WPF (.net)

WPF: TabControl Series - Part 1: Colors and Sizes

by Olaf Rabbachin 25. January 2010 14:42

Introduction

The out-of-the-box TabControl is pretty ugly (is that just my personal opinion?). Attempting to KISS, meaning i.e. simply changing the colors used for rendering the TabControl itself and/or the TabItems however will not give you all too much of a chance to "remedy" its appearance. Instead, you'll have to completely replace the control's Style (that is, its default template). I'll try to cover some aspects in the scope of this article.

 

Overview

This article is part of a multi-part series. Here's the four parts:

 

Outcome: the result of what's covered in this article

First, let's see what we'll be left with at the end of this article (in other words "lessening the ugly screenshot coming up next" Wink):

Before we start, please note that I wrapped up everything provided in the scope of this article as a sample solution. See the end of the article for the download-link.

 

Also, you'll notice that, in the screenshots, the TabControl appears larger than it would be in a real-world application. For the sake of visibility (and of course for debugging the tons of minor tweaks I encountered) I added a Slider control to the forms. This slider will allow you to zoom in on the TabControl, with a factor between 1 and 10. All the screenshots here were taken with a factor of 2.

 

Status quo: the default appearance

Alright, let's get started. Here's a little screenshot of an "unstyled" TabControl in which all I did was to change a couple of colors, in all its shy uglyness (click to enlarge):

And here's the markup that produced the above TabControl (I skipped everything but the TabControl itself):

<TabControl x:Name="tc" Margin="5" SelectedIndex="0"
			Background="CadetBlue">
	<TabControl.LayoutTransform>
	<!-- Allows to zoom the control's content using the slider -->
	<ScaleTransform CenterX="0" 
					CenterY="0"
					ScaleX="{Binding ElementName=uiScaleSlider,Path=Value}"
					ScaleY="{Binding ElementName=uiScaleSlider,Path=Value}"/>
	</TabControl.LayoutTransform>
	<TabItem Header="Tab 1" Background="CadetBlue">
		<Canvas Background="AliceBlue"/>
	</TabItem>
	<TabItem Header="Tab 2" Background="CadetBlue">
		<Canvas Background="Lavender"/>
	</TabItem>
	<TabItem Header="Tab 3" IsEnabled="False"
		  ToolTip="I'm disabled.">
		<Canvas Background="PaleGreen"/>
	</TabItem>
	<TabItem Header="Tab 4" Background="CadetBlue">
		<Canvas Background="Cornsilk"/>
	</TabItem>
	<TabItem Header="Tab 5" Background="CadetBlue">
		<Canvas Background="WhiteSmoke"/>
	</TabItem>
</TabControl>

Zooming into the control (which is what the slider in the screenshot is for) even better reveals that it's far from looking good (and the colors don't matter much either ... or did I intend to make it look as ugly as possible, after all ..? Innocent).

 

Fundamentals: the TabControl's sections/panels

TabControl, TabItemPanel, TabItem, TabPage ... huh!?

There's a bunch of sections that the TabControl really consists of. Knowing that we'll have to override the default template of the control, we should make sure that we're on the same page regarding a couple of basics.

Here's the (fundamental) sections that the TabControl is made of (click to enlarge):

The colored rectangles in the above screenshots and their meanings:

  • Blue: the TabControl itself - all other panels are placed inside this rectangle.
  • Orange: the ContentPresenter - this is what will host the content of the selected TabItem (the active TabPage).
  • Red: the TabItemPanel - this is the panel that hosts the TabItems (strips).
  • Green: the TabItem - this is a single TabItem (strip), i.e. the portion of the control that allows users to change the currently selected TabPage.

The terms TabItem and TabStrip are really interchangeable - both refer to the portion of the control that allows users to select the element that is to be shown resp. rendered. The same actually applies to ContentPresenter and TabPage - both mean the same thing.

That being said, what you can learn from the above structure is that, in order to create a homogeneous look, we'll actually have to style two controls rather than one: the TabControl and the TabItem (-control).

 

Styling the TabControl

Let's start with styling the TabControl itself. Here's a basic style. Again, I've left out some parts (such as the definitions for colors and margins) that aren't relevant to understanding what's going on; instead, you'll see them being used as resource-references which IMHO makes up for better readable XAML:

<Style TargetType="{x:Type TabControl}">
	<Setter Property="SnapsToDevicePixels" Value="true"/>
	<Setter Property="Template">
	<Setter.Value>
	   <ControlTemplate TargetType="TabControl">
		  <Grid>
			 <Grid.RowDefinitions>
				<RowDefinition Height="Auto"/>
				<RowDefinition Height="*"/>
			 </Grid.RowDefinitions>

			 <Border Padding="{StaticResource TabItemPanel_Padding}">
				<!-- This is the area in which TabItems (the strips) will be drawn. -->
				<TabPanel IsItemsHost="True"/>
			 </Border>

			 <Border BorderThickness="1,0,1,1" 
					 Grid.Row="1" 
					 BorderBrush="{StaticResource TabItem_BorderBrush_Selected}" 
					 Background="{StaticResource TabControl_BackgroundBrush_Base}">
				<!-- 
					 This is where the Content of the selected TabPage 
					 will be rendered. 
				-->
				<ContentPresenter ContentSource="SelectedContent" Margin="0"/>
			 </Border>
		  </Grid>
	   </ControlTemplate>
	</Setter.Value>
	</Setter>
</Style>

Using the above will produce the following (click to enlarge):

I admit this isn't much of a change to the better really (actually it's worse!) - it's just a different color for the inner portion of the TabControl. However, a couple of things are worth noting here (and we'll need that knowledge later on):

  1. The template consists of a Grid control that separates the area for the TabItemPanel and the ContentPresenter; if you wanted to render the TabItems at the bottom of the control, all you would need to do would be to exchange the Grid.Row assignments for the two Border controls (i.e move the TabItemPanel to the bottom row and the ContentPresenter to the top row of the Grid). Likewise, if you wanted the TabItems on the left, you would replace the RowDefinitions with ColumnDefinitions and change the Grid.Row assignments to Grid.Column assignments.
  2. Note the IsItemsHost assignment in the style - this is where we tell the TabControl where to render the TabItemPanel.
  3. Also note the ContentPresenter assignment - this is is where we tell the TabControl where to render the content of a TabPage.
  4. The TabControl has no top border; actually, looking from the bottom of the TabControl to its top, the left and right borders stop when they reach the TabItemPanel.

The first three points deserve no further discussion, but let me clarify the fourth: In the XAML, you can see that I chose to not display the top border of the TabControl. However, where would you think the top border would be drawn? Since we're targetting the TabControl itself, one could assume that it would be drawn above the TabItemPanel - that's the top border of the control, after all. However, it would really be drawn at the top of the ContentPresenter (i.e. at the top of the blue area in the screenshot), separating the ContentPresenter and the TabItemPanel. So, from a Style-perspective, the TabControl is really targetting the area of the ContentPresenter rather than the control itself (including the TabItemPanel).

That being said, if we were drawing the top border, the control would look like this (click to enlarge):

Here you'll notice that, while we now have a line underneath the TabItems (the strips, that is) as well as underneath the TabItemPanel's empty area (above the right arrow), this is not really what we want because this line is also drawn underneath the selected TabItem (Tab 1, in this case, above the left arrow). Since we'll cover a workaround for this later on, let's just keep in mind that we won't draw a top border.

Styling the TabItem

So, let's create a Style for the TabItems and, while we're at it, add another border to the TabControl's Style definition. Here goes:

<!-- The TabControl's Style. -->
<Style TargetType="{x:Type TabControl}">
   <Setter Property="SnapsToDevicePixels" Value="true"/>
   <Setter Property="Template">
      <Setter.Value>
         <ControlTemplate TargetType="TabControl">
            <Grid>
               <Grid.RowDefinitions>
                  <RowDefinition Height="Auto"/>
                  <RowDefinition Height="*"/>
               </Grid.RowDefinitions>

               <!-- 
                        The Border around each TabItem will allow us to draw the line
                        between the TabItemPanel and the TabControl (resp. the 
                        TabPage-container) when a TabItem is NOT selected, which 
                        replaces the bottom line of the TabItemPanel's border. 
                        Thus, we'll avoid drawing the bottom line for the selected
                        TabItem. Also, since the TabItem, when selected, applies a left
                        Margin of 4px, we need to add these here as Padding.
                     -->
               <Border Background="{StaticResource TabItemPanel_BackgroundBrush}" 
                             Padding="{StaticResource TabItemPanel_Padding}">
                  <!-- This is the area in which TabItems (the strips) will be drawn. -->
                  <TabPanel IsItemsHost="True"/>
               </Border>

               <!-- 
                        This is the outer border of the TabControl itself, actually meaning
                        the Panel that will host a TabItem's content.
                        The top-border here will not be drawn as, otherwise, the TabItemPanel
                        would always show a thin line for the selected Tab (which we want 
                        to avoid).
                     -->
               <Border BorderThickness="1,0,1,1" 
                             Grid.Row="1" 
                             BorderBrush="{StaticResource TabItem_BorderBrush_Selected}" 
                             Background="{StaticResource TabControl_BackgroundBrush_Base}">
                  <!-- This is the first/outer Border drawn on the TabPage -->
                  <Border BorderThickness="1" 
                                BorderBrush="{StaticResource TabPage_InnerBorderBrushDark}" 
                                CornerRadius="3" 
                                Margin="8">
                     <!-- 
                              This is the second/inner Border drawn on the TabPage. 
                              This Border is drawn with a horizontal Gradient that is transparent
                              on the left which produces the fading effect.
                           -->
                     <Border BorderThickness="1" 
                                   BorderBrush="{StaticResource TabPage_InnerBorderBrushBright}" 
                                   CornerRadius="2" 
                                   Margin="0" 
                                   Padding="2,2,3,3"
                                   >
                        <!-- 
                                 This is where the Content of the selected TabPage 
                                 will be rendered. 
                              -->
                        <ContentPresenter ContentSource="SelectedContent" Margin="0"/>
                     </Border>
                  </Border>
               </Border>
            </Grid>
         </ControlTemplate>
      </Setter.Value>
   </Setter>
</Style>

<!-- The Style for TabItems (strips). -->
<Style TargetType="{x:Type TabItem}">
   <Setter Property="Template">
      <Setter.Value>
         <ControlTemplate TargetType="{x:Type TabItem}">
            <!-- The Grid helps defining the general height of TabItems. -->
            <Grid Height="35" VerticalAlignment="Bottom">
               <!-- 
                        The important aspect here is that the bottom of the Border is 0px thick,
                        helping the TabItem/strip to blend into the TabPage.

                     -->
               <Border Name="Border"
                             Background="{StaticResource TabItem_BackgroundBrush_Unselected}"
                             BorderBrush="{StaticResource TabItem_BorderBrush_Selected}" 
                             Margin="{StaticResource TabItemMargin_Selected}" 
                             BorderThickness="2,1,1,0" 
                             CornerRadius="3,3,0,0" 
                             >
                  <!-- This is where the Content of the TabItem will be rendered. -->
                  <ContentPresenter x:Name="ContentSite"
                                          VerticalAlignment="Center"
                                          HorizontalAlignment="Center"
                                          ContentSource="Header"
                                          Margin="7,2,12,2"
                                          RecognizesAccessKey="True"/>
               </Border>
            </Grid>
         </ControlTemplate>
      </Setter.Value>
   </Setter>
</Style>

Compared to the previously defined TabControl Style, there now is a border inside the TabControl's ContentPresenter (actually it's two - an outer blue one and a [fading] inner one).
Regarding the TabItems' Style, all I did was to determine the fundamental appearance, such as the Grid that hosts a TabItem, the Border inside and the Content presenter which is actually being refered to as the ContentSite. Also, some brushes make up for the (default) appearance of each TabItem.

The above would look like this (click to enlarge):

This is still kept very simple and doesn't actually give us what we want. If you run this sample you'll notice that no change whatsoever is applied to the appearance of a TabItem when, for instance, it is being selected. Yikes.

Triggers to the rescue

In order to provide a different appearance for different states of a TabItem, Triggers will help us solve the task. From my perspective, there's the following states that a TabItem can be in:

  1. Unselected (i.e., the default)
  2. Selected
  3. Disabled
  4. Hover (i.e., the mouse is over the TabItem)

Generally, all states should be easy to differentiate which, IMHO, is best done with size and color. That is, each state should have its own (set of) color(s) and size; I won't pay much attention to  the colors here (you'll find them all in the XAML of the sample solution), so let's just note that these will be part of the Triggers being applied below. The different sizes of the TabItems deserve a little more attention though. So, let's concentrate on the height (changes) applied for the above states:

  1. Unselected TabItems should have the lowest height; this includes TabItems that are presently disabled (IsEnabled = "False").
  2. Selected TabItems should have the largest height.
  3. When the mouse hovers over a TabItem, the height should be somewhere in between the height of selected and unselected TabItems.

Also, I'd like the ZIndex to change with respect to the TabItem's state. That is, the ZIndex should be applied as follows, from back to front:

  1. Disabled (lowest)
  2. Unselected
  3. Hover
  4. Selected (highest)

All the above can be achieved with the help of triggers. Here's the four triggers which would need to be added before the end of the TabItem's Style:

<ControlTemplate.Triggers>
   <!-- The appearance of a TabItem when it's inactive/unselected -->
   <Trigger Property="IsSelected" Value="False">
      <Setter Property="Panel.ZIndex" Value="90" />
      <Setter TargetName="Border" Property="BorderBrush" 
                                Value="{StaticResource TabItem_Border_Unselected}" />
      <Setter Property="Foreground" 
                                Value="{StaticResource TabItem_TextBrush_Unselected}" />
      <!-- Except for the selected TabItem, tabs are to appear smaller in height. -->
      <Setter TargetName="Border" Property="Margin" 
                                Value="{StaticResource TabItemMargin_Base}"/>
   </Trigger>

   <!-- 
                        The appearance of a TabItem when it's disabled 
                        (in addition to Selected=False)
                     -->
   <Trigger Property="IsEnabled" Value="False">
      <Setter Property="Panel.ZIndex" Value="80" />
      <Setter TargetName="Border" Property="BorderBrush"
                                Value="{StaticResource TabItem_DisabledBorderBrush}" />
      <Setter TargetName="Border" Property="Background" 
                                Value="{StaticResource TabItem_BackgroundBrush_Disabled}" />
      <Setter Property="Foreground" 
                                Value="{StaticResource TabItem_TextBrush_Disabled}" />
   </Trigger>

   <!-- The appearance of a TabItem when the mouse hovers over it -->
   <MultiTrigger>
      <MultiTrigger.Conditions>
         <Condition Property="Border.IsMouseOver" Value="True"/>
         <Condition Property="IsSelected" Value="False"/>
      </MultiTrigger.Conditions>
      <Setter Property="Panel.ZIndex" Value="99" />
      <Setter Property="Foreground" Value="{StaticResource TabItem_TextBrush_Hover}" />
      <Setter Property="BorderBrush" 
                                TargetName="Border" 
                                Value="{StaticResource TabItem_HoverBorderBrush}" />
      <Setter TargetName="Border" Property="BorderThickness" Value="2,1,1,1" />
      <Setter Property="Background" TargetName="Border"
                                Value="{StaticResource TabItem_HoverBackgroundBrush}"/>
      <!-- 
                           To further increase the hover-effect, extend the TabItem's height a little
                           more compared to unselected TabItems.
                        -->
      <Setter TargetName="Border" Property="Margin" 
                                Value="{StaticResource TabItemMargin_Hover}"/>
   </MultiTrigger>

   <!-- The appearance of a TabItem when it's active/selected -->
   <Trigger Property="IsSelected" Value="True">
      <!-- We want the selected TabItem to always be on top. -->
      <Setter Property="Panel.ZIndex" Value="100" />
      <Setter TargetName="Border" Property="BorderBrush" 
                                Value="{StaticResource TabItem_BorderBrush_Selected}" />
      <Setter TargetName="Border" Property="Background" 
                                Value="{StaticResource TabItem_BackgroundBrush_Selected}" />
      <Setter TargetName="Border" Property="BorderThickness" Value="1,1,1,0" />
      <Setter Property="Foreground" 
                                Value="{StaticResource TabItem_TextBrush_Selected}"/>
      <Setter TargetName="Border" Property="Margin" 
                                Value="{StaticResource TabItemMargin_Selected}"/>
   </Trigger>
</ControlTemplate.Triggers>

In the above XAML you can see that one Trigger is actually a MultiTrigger - why is that? The MultiTrigger is required due to the fact that, in order to create a Hover-Style, we need to pay attention to more than a single property. While, for the Selected-Trigger, we only need to pay attention to the IsSelected state of the TabItem, we need to also watch out for the position of the mouse for the Hover-Trigger to work correctly.

Here's what the Window will look like with the Triggers added (see the start of the article for a video that shows the control in action):

That pretty much concludes part one.

But wait!

What happened to the line between the TabItemPanel and the TabControl's ContentPresenter? Maybe someone else has an easier approach to working around this, but - FWIW - here's what I did.

Actually it's pretty simple - in the above screenshot, the lines you see between the TabItemPanel, the TabItems and the TabControl resp. ContentPresenter aren't lines, but rather the result of some stupid gradients being drawn. Let me explain that; instead of drawing a line (or border), I'm painting the "line" along with the background. Let's take the TabItemPanel as an example. When the TabItemPanel and its content is rendered, the TabItemPanel itself will be rendered before the TabItems themselves. That is, if you drew the TabItemPanel with a red background, the TabItems would overlay the red background. However (sadly!), this doesn't apply to the Border of the TabItemsPanel - this Border would be drawn above the TabItems. Thus, we cannot use that Border to render our line in the area that is not covered by TabItems. Pretty much the same concept applies to the Border of the TabControl - we cannot draw its top Border and selectively hide it.

So, back to taking the TabItemPanel as an example, we want the TabItemPanel to be generally transparent, so how do we insert a line at the bottom? The simple trick is to define a (vertical) LinearGradientBrush that is transparent for 99% of its height, with the bottom 1% being drawn with the same color that is being used to draw the the TabControl's Border-lines. Since the SnapsToDevicePixels property has been set to True, this will result in the last 1% to make up for a 1px line. Not pretty, but it works.

So, here's the Brush that is being used to draw the background of the TabItemPanel:

<LinearGradientBrush x:Key="TabItemPanel_BackgroundBrush" 
                           StartPoint="0,0" EndPoint="0,1">
   <LinearGradientBrush.GradientStops>
      <GradientStop Offset="0.98" Color="Transparent"/>
      <GradientStop Offset="0.99" 
               Color="{StaticResource BorderColor_Base}"/>
   </LinearGradientBrush.GradientStops>
</LinearGradientBrush>

In the XAML of the sample solution, you'll see that I used this concept all over the place. I'm happy with what this leaves me, however, if you know of a simpler way of dealing with this, I'm all ears!

 

A note regarding the definition of Thickness-resources in VS2008

When you load the sample solution, VS2008 (I haven't tried VS2010 so I can't tell whether the issue has disappeared in .Net4) may or may not give you a compile error that is geared at the definition of the Margin-resources. These are defined like:

<Thickness x:Key="TabItemMargin_Base">0,8,-4,0</Thickness>

The Visual Studio 2008 compiler seems to dislike this format, even though it is perfectly valid. If you see an error, just hit Ctrl-B to do a build and the error will go away. However, it might re-appear at some point, namely when you change a portion of the XAML close to one of those definitions. If that disturbes you, use the alternative syntax, i.e., the equivalent for the above excerpt:

<Thickness x:Key="TabItemMargin_Base" Left="0" Top="8" Right="-4" Bottom="0"/>

I opted to use the first format because I simply consider it to be much better readable compared to the second.

 

The last word

As you probably noticed at this point, there's a bunch of resources being used throughout the XAML. The concept behind that is the attempt to define stuff only once and reuse it wherever required. In the previous XAML, I referenced the resource BorderColor_Base. This is a color that is being used in many places, hence I defined the color at the very beginning of the XAML and reference it wherever appropriate.

Also, the XAML in the sample solution contains a lot of comments (and another trick or two) that I skipped here in order to keep the XAML-sections short and to allow to better concentrate on the points I wanted to discuss. I thus encourage you to download the sample solution and check the XAML yourself.

And, last but not least, I'm always happy to receive some feedback for the stuff I'm publishing. So, please leave a comment, regardless of wether you liked it or not.

 

The sample solution

I’ve created a sample solution that contains everything discussed here. This time, the solution is C# only, but there is no code behind involved whatsoever (not taking the main form into account) which is why you won't find a VB counterpart. However, if you want to use this in a VB-project, simply paste the XAML into your VB-window and remove the TabControlStyle. that is preluding each window's x:Class attribute (and indicating the namespace that is required for C#).

Download: TabControlStyle - Part One.zip (27.32 kb)


Location: PostList

Tags: , , , ,

TabControl | WPF (.net)

WPF: Visualizing arbitrary XML documents in a TreeView control

by Olaf Rabbachin 28. December 2009 14:08

 

When I was playing around with the TreeView control and HierarchicalDataTemplates yesterday I thought it would be nice to have some sort of generic piece of XAML/code (window) that would allow me to render an arbitrary XML document in a TreeView control. Surprisingly, a little search on the web turned up this thread in the WPF forum which was already very close to what I had in mind.

In this blog, I’ll show you how to create a window that’ll render arbitrary XML documents in a WPF TreeView control. The window will have an initial XML-doc provided directly via XAML, but will allow you to load any other XML document into the TreeView. I’ll also show how to, instead of iterating through all nodes via code, utilize Styles to help expanding or collapsing the entire XML hierarchy, allowing to then do the trick with a single line of code.

See the bottom of this post for downloading the complete solution (both VB and C# are provided).

Here’s an image of what the application’s window will look like:

Sample App - Startup-Screenshot 

Using a HierarchicalDataTemplate for rendering

We’ll need to define a HierarchicalDataTemplate that will render the XML-document’s nodes and values. Here’s its definition:

  1:   <HierarchicalDataTemplate x:Key="NodeTemplate">
  2: 	 <TextBlock x:Name="tb"/>
  3: 	 <HierarchicalDataTemplate.ItemsSource>
  4: 		<Binding XPath="child::node()" />
  5: 	 </HierarchicalDataTemplate.ItemsSource>
  6: 	 <HierarchicalDataTemplate.Triggers>
  7: 		<DataTrigger Binding="{Binding Path=NodeType}" Value="Text">
  8: 		   <Setter TargetName="tb" Property="Text" Value="{Binding Path=Value}"></Setter>
  9: 		</DataTrigger>
 10: 		<DataTrigger Binding="{Binding Path=NodeType}" Value="Element">
 11: 		   <Setter TargetName="tb" Property="Text" Value="{Binding Path=Name}"></Setter>
 12: 		</DataTrigger>
 13: 	 </HierarchicalDataTemplate.Triggers>
 14:   </HierarchicalDataTemplate>

It's actually pretty simple - each node is to be displayed in the TextBlock named "tb". To display data, we bind the HDT's ItemsSource to the path expression "child::node()" - this will display the child nodes for each child axis. However, the nodes in XML documents will contain either the node's name (i.e., an Element) or a the value-representation (i.e., a Value), hence we'll need to bind to either the element or the value. Attaching a DataTrigger to the NodeType property though, the TextBlock's Text-property - which actually renders the content - can be bound to the appropriate property.

Expanding and collapsing nodes

Since the TreeView control doesn't offer any method that would allow to easily expand or collapse all its nodes, we would obviously have to use code where we iterate through all the TreeView's nodes and set the IsExpanded-property to the appropriate value in order to allow users to expand/collapse by means of a button. Obviously, yes, but with WPF, a Style will do the trick without having to do the iteration. Here’s the XAML required for the two styles – one that will expand all nodes and one that’ll collapse them:

  1:   <Style x:Key="TV_AllExpanded"  TargetType="{x:Type TreeView}">
  2: 	 <Style.Resources>
  3: 		<Style TargetType="TreeViewItem">
  4: 		   <Setter Property="IsExpanded" Value="True" />
  5: 		</Style>
  6: 	 </Style.Resources>
  7:   </Style>
  8:   <Style x:Key="TV_AllCollapsed" TargetType="{x:Type TreeView}">
  9: 	 <Style.Resources>
 10: 		<Style TargetType="TreeViewItem">
 11: 		   <Setter Property="IsExpanded" Value="False" />
 12: 		</Style>
 13: 	 </Style.Resources>
 14:   </Style>

The trick here is to not only define the Style for TreeViewItems, but to wrap it in another style applied to the TreeView control itself.

With those two styles in place, we can simply apply the respective Style to the TreeView control itself rather than having to apply it to TreeViewItems. That said, expanding/collapsing the complete hierarchy is now a matter of a single line of code. For instance, to expand all nodes in C#, all we need is a:

tv.Style = (Style)this.FindResource("TV_AllExpanded");

Some sample XML-data

It would be nice to have some data to initially be displayed. Again, XAML to the rescue – while the XmlDataProvider enables us to load an arbitrary doc at runtime, we can also define XML-content directly in XAML. This MSDN articlecontains a nice little example that I burrowed for the blog. Here’s the appropriate section in our XAML:

  1: <XmlDataProvider x:Key="xmlDP" XPath="*">
  2:   <x:XData>
  3:     <Inventory xmlns="">
  4:       <Books>
  5:        <Book ISBN="0-7356-0562-9" Stock="in" Number="9">
  6:         <Title>XML in Action</Title>
  7:         <Summary>XML Web Technology</Summary>
  8:        </Book>
  9:        <Book ISBN="0-7356-1370-2" Stock="in" Number="8">
 10:         <Title>Programming Microsoft Windows With C#</Title>
 11:         <Summary>C# Programming using the .NET Framework</Summary>
 12:        </Book>
 13:        <Book ISBN="0-7356-1288-9" Stock="out" Number="7">
 14:         <Title>Inside C#</Title>
 15:         <Summary>C# Language Programming</Summary>
 16:        </Book>
 17:        <Book ISBN="0-7356-1377-X" Stock="in" Number="5">
 18:         <Title>Introducing Microsoft .NET</Title>
 19:         <Summary>Overview of .NET Technology</Summary>
 20:        </Book>
 21:        <Book ISBN="0-7356-1448-2" Stock="out" Number="4">
 22:         <Title>Microsoft C# Language Specifications</Title>
 23:         <Summary>The C# language definition</Summary>
 24:        </Book>
 25:       </Books>
 26:       <CDs>
 27:        <CD Stock="in" Number="3">
 28:         <Title>Classical Collection</Title>
 29:         <Summary>Classical Music</Summary>
 30:        </CD>
 31:        <CD Stock="out" Number="9">
 32:         <Title>Jazz Collection</Title>
 33:         <Summary>Jazz Music</Summary>
 34:        </CD>
 35:       </CDs>
 36:     </Inventory>
 37:   </x:XData>
 38: </XmlDataProvider>

Setting the display-root of the XML file

In addition, the sample app allows for specifying what node should be used as the root-node. That is, a TextBox control allows you to specify the XPath that should be the display-root. The sample XML-data (see the previous section) contains Books and CDs. If you wanted to only render the CDs in the TreeView, you could filter accordingly by specifying Inventory/CDs in the TextBox and clicking the Go-button. The result would look like this:

Sample App - XPath-Filter Screenshot

The code behind the Go-button is again very simple. Since we defined the XmlDataProvider in the Window’s resources, we can reference the resource and then, using whatever the user entered before clicking the Go-button, either apply that to the XmlDataProvider’s XPath-property or, if the Reset-button was clicked, reset the XPath to show all nodes by setting it to “*”:

  1: //Get a reference to the XDP that has been created as one of the Window's resources
  2: XmlDataProvider dp = (XmlDataProvider)this.FindResource("xmlDP");
  3: if (txt.Text == "")
  4:    //(Reset to root)
  5:    dp.XPath = "*";
  6: else
  7:    //Use the specified path as the new root display-node.
  8:    dp.XPath = txt.Text;

The sample solution

I’ve created a sample solution that contains everything discussed here, with a project for each the VB and the C# version. Download: Load_XML_Into_TreeView.zip (27.36 kb)


Location: PostList

Tags: , ,

WPF (.net) | XML

About

Hi and welcome to my blog!

I'm a developer from Germany, currently focusing on .Net and WPF.

More about me ...