Introduction
When I started out this app, I wanted to create a simple application that demonstrated how to do sorting within the ItemsControl
in WPF. I have to say that my mind has gone a bit mad on this one, and I've kind of created a bit more (not a lot more, but a bit more) than I originally set out to do. What we have in this article is the following:
- How to create a scrollable design area
- Designing a custom panel
- How to use Adorners in a neat way
- How to sort an
ItemsControl
using a CollectionView
I will explain each of these in more detail along the way.
Table of contents
Since I found out how to make videos and my hosting isn't that bad, I just think it's nice to add a video if it makes sense to the article. So this one also has a video:
The overall class diagram is as follows:
I shall not explain each of these classes, but rather shall be highlighting the points of interest. I think the main bullet points listed above in the table of contents are worth covering, so I will be spending a little bit of time on those, but other than that, just dip into the code if you are interested.
Have you ever had a requirement that called for the user to be able to scroll around a large object, such as a diagram? Well, this is exactly the requirement I had for this article. I wanted to create a custom Panel
where I didn't know how big it would be, so I wanted the user to be able to scroll around using the mouse.
We probably all know that WPF has a ScrollViewer
control which allows users to scroll using scrollbars, which is fine, but it just looks ugly. What I wanted was for the user to not really ever realize that there is a scroll area; I want them to just use the mouse to pan around the large area.
To this end, I set about looking around, and I have pieced together the following subclass of the standard ScrollViewer
control:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Windows.Threading;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
namespace ScrollControl
{
public class FrictionScrollViewer : ScrollViewer
{
#region Data
private DispatcherTimer animationTimer = new DispatcherTimer();
private Point previousPoint;
private Point scrollStartOffset;
private Point scrollStartPoint;
private Point scrollTarget;
private Vector velocity;
#endregion
#region Ctor
static FrictionScrollViewer()
{
DefaultStyleKeyProperty.OverrideMetadata(
typeof(FrictionScrollViewer),
new FrameworkPropertyMetadata(typeof(FrictionScrollViewer)));
}
public FrictionScrollViewer()
{
Friction = 0.95;
animationTimer.Interval = new TimeSpan(0, 0, 0, 0, 20);
animationTimer.Tick += HandleWorldTimerTick;
animationTimer.Start();
}
#endregion
#region DPs
public double Friction
{
get { return (double)GetValue(FrictionProperty); }
set { SetValue(FrictionProperty, value); }
}
public static readonly DependencyProperty FrictionProperty =
DependencyProperty.Register("Friction", typeof(double),
typeof(FrictionScrollViewer), new UIPropertyMetadata(0.0));
#endregion
#region overrides
protected override void OnPreviewMouseDown(MouseButtonEventArgs e)
{
if (IsMouseOver)
{
scrollStartPoint = e.GetPosition(this);
scrollStartOffset.X = HorizontalOffset;
scrollStartOffset.Y = VerticalOffset;
Cursor = (ExtentWidth > ViewportWidth) ||
(ExtentHeight > ViewportHeight) ?
Cursors.ScrollAll : Cursors.Arrow;
CaptureMouse();
}
base.OnPreviewMouseDown(e);
}
protected override void OnPreviewMouseMove(MouseEventArgs e)
{
if (IsMouseCaptured)
{
Point currentPoint = e.GetPosition(this);
Point delta = new Point(scrollStartPoint.X -
currentPoint.X, scrollStartPoint.Y - currentPoint.Y);
scrollTarget.X = scrollStartOffset.X + delta.X;
scrollTarget.Y = scrollStartOffset.Y + delta.Y;
ScrollToHorizontalOffset(scrollTarget.X);
ScrollToVerticalOffset(scrollTarget.Y);
}
base.OnPreviewMouseMove(e);
}
protected override void OnPreviewMouseUp(MouseButtonEventArgs e)
{
if (IsMouseCaptured)
{
Cursor = Cursors.Arrow;
ReleaseMouseCapture();
}
base.OnPreviewMouseUp(e);
}
#endregion
#region Animation timer Tick
private void HandleWorldTimerTick(object sender, EventArgs e)
{
if (IsMouseCaptured)
{
Point currentPoint = Mouse.GetPosition(this);
velocity = previousPoint - currentPoint;
previousPoint = currentPoint;
}
else
{
if (velocity.Length > 1)
{
ScrollToHorizontalOffset(scrollTarget.X);
ScrollToVerticalOffset(scrollTarget.Y);
scrollTarget.X += velocity.X;
scrollTarget.Y += velocity.Y;
velocity *= Friction;
}
}
}
#endregion
}
}
Which I can use in XAML as follows:
<UserControl.Resources>
-->
<Style x:Key="ScrollViewerStyle" TargetType="{x:Type ScrollViewer}">
<Setter Property="HorizontalScrollBarVisibility" Value="Hidden" />
<Setter Property="VerticalScrollBarVisibility" Value="Hidden" />
</Style>
</UserControl.Resources>
....
....
-->
<local:FrictionScrollViewer x:Name="ScrollViewer"
Style="{StaticResource ScrollViewerStyle}">
<ItemsControl x:Name="itemsControl"
Style="{StaticResource mainPanelStyle}">
....
....
</ItemsControl>
</local:FrictionScrollViewer>
All we are doing here is creating an instance of my FrictionScrollViewer
that hosts a single ItemsControl
. You may notice the name of this class is FrictionScrollViewer
, so naturally, there is some friction involved with the scroll movement. What actually happens is that the user's mouse movements are tracked, and a DispatchTimer
is used to update the subclassed ScrollViewer
Horizontal/Vertical offsets, every time period tick. It looks quite nice.
When the user is scrolling, the mouse cursor changes to a scrolling cursor.
You still need to Style
the FrictionScrollViewer
such that the scrollbars are hidden. I could have done this in code, but I didn't know what people would want, so left that option as a Style
fixable thing. I personally didn't want scrollbars, but some may, so just change the Style
shown above in the XAML.
Creating custom panels in WPF is quite cool actually. Suppose you just don't like the current layout options (StackPanel
/Grid
/Canvas
/DockPanel
); we will just write our own.
You know, sometimes you want a custom job. Whilst it's probably true that you make most creations using a combination of the existing layouts, it's sometimes just more convenient to wrap this into a custom panel.
One of my fellow WPF Disciples, Rudi Grobler, has recently published a single application with loads of different custom panels from loads of different sources in one contained demo app. This is available at Rudi's blog. Have a look there if you want more panels:
Now when creating custom panels, there are just two methods that you need to override, these are:
Size MeasureOverride(Size constraint)
Size ArrangeOverride(Size arrangeBounds)
One of the best articles I've ever seen on creating custom panels is the article by Paul Tallett over at CodeProject, Fisheye Panel; paraphrasing Paul's excellent article:
To get your own custom Panel
off the ground, you need to derive from System.Windows.Controls.Panel
and implement two overrides: MeasureOverride
and LayoutOverride
. These implement the two-pass layout system where during the Measure phase, you are called by your parent to see how much space you'd like. You normally ask your children how much space they would like, and then pass the result back to the parent. In the second pass, somebody decides on how big everything is going to be, and passes the final size down to your ArrangeOverride
method where you tell the children their size and lay them out. Note that every time you do something that affects layout (e.g., resize the window), all this happens again with new sizes.
What I wanted to achieve for this article was a column based Panel
that wrapped to a new column when it ran out of space in the current column. Now, I could have just used a DockPanel
that contained loads of vertical StackPanel
s, but that defeats what I am after. I want the panel to work out how many items are in a column based on the available size.
So I set to work exploring, and I found an excellent start within the superb Pro WPF in C# 2008: Windows Presentation Foundation with .NET 3.5, by Mathew McDonald, so my code is largely based on Mathew's book example.
The most important methods of my ColumnedPanel
are shown below:
#region Measure Override
protected override Size MeasureOverride(Size constraint)
{
Size currentColumnSize = new Size();
Size panelSize = new Size();
foreach (UIElement element in base.InternalChildren)
{
element.Measure(constraint);
Size desiredSize = element.DesiredSize;
if (GetColumnBreakBefore(element) ||
currentColumnSize.Height + desiredSize.Height > constraint.Height)
{
panelSize.Height =
Math.Max(currentColumnSize.Height, panelSize.Height);
panelSize.Width += currentColumnSize.Width;
currentColumnSize = desiredSize;
if (desiredSize.Height > constraint.Height)
{
panelSize.Height =
Math.Max(desiredSize.Height, panelSize.Height);
panelSize.Width += desiredSize.Width;
currentColumnSize = new Size();
}
}
else
{
currentColumnSize.Height += desiredSize.Height;
currentColumnSize.Width =
Math.Max(desiredSize.Width, currentColumnSize.Width);
}
}
panelSize.Height = Math.Max(currentColumnSize.Height, panelSize.Height);
panelSize.Width += currentColumnSize.Width;
return panelSize;
}
#endregion
#region Arrange Override
protected override Size ArrangeOverride(Size arrangeBounds)
{
int firstInLine = 0;
Size currentColumnSize = new Size();
double accumulatedWidth = 0;
UIElementCollection elements = base.InternalChildren;
for (int i = 0; i < elements.Count; i++)
{
Size desiredSize = elements[i].DesiredSize;
if (GetColumnBreakBefore(elements[i]) || currentColumnSize.Height
+ desiredSize.Height > arrangeBounds.Height)
{
arrangeColumn(accumulatedWidth, currentColumnSize.Width,
firstInLine, i, arrangeBounds);
accumulatedWidth += currentColumnSize.Width;
currentColumnSize = desiredSize;
if (desiredSize.Height > arrangeBounds.Height)
{
arrangeColumn(accumulatedWidth, desiredSize.Width, i, ++i,
arrangeBounds);
accumulatedWidth += desiredSize.Width;
currentColumnSize = new Size();
}
firstInLine = i;
}
else {
currentColumnSize.Height += desiredSize.Height;
currentColumnSize.Width =
Math.Max(desiredSize.Width, currentColumnSize.Width);
}
}
if (firstInLine < elements.Count)
arrangeColumn(accumulatedWidth, currentColumnSize.Width,
firstInLine, elements.Count, arrangeBounds);
return arrangeBounds;
}
#endregion
#region Private Methods
private void arrangeColumn(double x, double columnWidth,
int start, int end, Size arrangeBounds)
{
double y = 0;
double totalChildHeight = 0;
double widestChildWidth = 0;
double xOffset = 0;
UIElementCollection children = InternalChildren;
UIElement child;
for (int i = start; i < end; i++)
{
child = children[i];
totalChildHeight += child.DesiredSize.Height;
if (child.DesiredSize.Width > widestChildWidth)
widestChildWidth = child.DesiredSize.Width;
}
y = ((arrangeBounds.Height - totalChildHeight) / 2);
for (int i = start; i < end; i++)
{
child = children[i];
if (child.DesiredSize.Width < widestChildWidth)
{
xOffset = ((widestChildWidth - child.DesiredSize.Width) / 2);
}
child.Arrange(new Rect(x + xOffset, y, child.DesiredSize.Width, columnWidth));
y += child.DesiredSize.Height;
xOffset = 0;
}
}
#endregion
I can then use this panel anywhere I could use a standard Panel
. I am actually using it as a ItemsControl
panel, as shown below, where the ItemsControl
is the one that is used by my FrictionScrollViewer
mentioned above:
<ItemsControl x:Name="itemsControl"
Style="{StaticResource mainPanelStyle}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<local:ColumnedPanel IsItemsHost="True"
Loaded="OnPanelLoaded"
MinHeight="360" Height="360"
VerticalAlignment="Center"
Background="CornflowerBlue"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
If you have come from a WinForms world (as I have), the concept of Adorners will probably be a little bit alien. Here is a brief low down on Adorners, from MSDN:
Adorners are a special type of FrameworkElement
, used to provide visual cues to a user. Among other uses, Adorners can be used to add functional handles to elements or provide state information about a control.
An Adorner is a custom FrameworkElement
that is bound to a UIElement
. Adorners are rendered in an AdornerLayer
, which is a rendering surface that is always on top of the adorned element or a collection of adorned elements. Rendering of an adorner is independent from rendering of the UIElement
that the adorner is bound to. An adorner is typically positioned relative to the element to which it is bound, using the standard 2-D coordinate origin located at the upper-left of the adorned element.
Common applications for adorners include:
- Adding functional handles to a
UIElement
that enable a user to manipulate the element in some way (resize, rotate, reposition, etc.)
- Provide visual feedback to indicate various states, or in response to various events
- Overlay visual decorations on a
UIElement
- Visually mask or override part or all of a
UIElement
The attached project actually uses two Adorners, one for the main ScrollerControl
(which is ScrollerControlAdorner
), and one for an ItemHolder
(ItemHolderAdorner
). These are described below.
ScrollerControlAdorner
Provides two extra buttons that are created and managed on the AdornerLayer
. These buttons allow the Adorner to call the Scroll(Point delta)
method in AdornedElement
(the ScrollerControl
). There is a DispatchTimer
that is used to call Scroll(Point delta)
while the mouse is over the buttons within the Adorner. The most important part of this Adorner is the constructor/timer, which is as follows:
#region Constructor
public ScrollerControlAdorner(ScrollerControl sc)
: base(sc)
{
timer.Interval = new TimeSpan(0, 0, 0, 0, 100);
timer.IsEnabled = false;
timer.Tick += new EventHandler(timer_Tick);
this.sc = sc;
host.Width = (double)this.AdornedElement.GetValue(ActualWidthProperty);
host.Height = (double)this.AdornedElement.GetValue(ActualHeightProperty);
host.VerticalAlignment = VerticalAlignment.Center;
host.HorizontalAlignment = HorizontalAlignment.Left;
host.Margin = new Thickness(0);
Button btnLeft = new Button();
Style styleLeft = sc.TryFindResource("leftButtonStyle") as Style;
if (styleLeft != null)
btnLeft.Style = styleLeft;
btnLeft.MouseEnter += new System.Windows.Input.MouseEventHandler(btnLeft_MouseEnter);
btnLeft.MouseLeave += new System.Windows.Input.MouseEventHandler(MouseLeave);
Point parentTopleft = sc.TranslatePoint(new Point(0, 0), sc.Parent as UIElement);
double top = ((host.Height / 2) - (GLOBALS.leftRightButtonScrollSize / 2) -
(parentTopleft.Y / 2) - (GLOBALS.footerPanelHeight/2));
btnLeft.SetValue(Canvas.TopProperty, top);
btnLeft.SetValue(Canvas.LeftProperty, (double)spacer);
Button btnRight = new Button();
Style styleRight = sc.TryFindResource("rightButtonStyle") as Style;
if (styleRight != null)
btnRight.Style = styleRight;
btnRight.MouseEnter +=
new System.Windows.Input.MouseEventHandler(btnRight_MouseEnter);
btnRight.MouseLeave += new System.Windows.Input.MouseEventHandler(MouseLeave);
btnRight.SetValue(Canvas.TopProperty, top);
btnRight.SetValue(Canvas.LeftProperty,
(double)(host.Width - (GLOBALS.leftRightButtonScrollSize + spacer)));
host.Children.Add(btnLeft);
host.Children.Add(btnRight);
base.AddLogicalChild(host);
base.AddVisualChild(host);
}
#endregion
#region Private Methods
private void timer_Tick(object sender, EventArgs e)
{
switch (currentDirection)
{
case ScrollDirection.Left:
sc.Scroll(new Point(10, 0));
break;
case ScrollDirection.Right:
sc.Scroll(new Point(-10, 0));
break;
}
}
new private void MouseLeave(object sender,
System.Windows.Input.MouseEventArgs e)
{
currentDirection = ScrollDirection.None;
timer.IsEnabled = false;
}
private void btnLeft_MouseEnter(object sender,
System.Windows.Input.MouseEventArgs e)
{
currentDirection = ScrollDirection.Left;
timer.IsEnabled = true;
}
private void btnRight_MouseEnter(object sender,
System.Windows.Input.MouseEventArgs e)
{
currentDirection = ScrollDirection.Right;
timer.IsEnabled = true;
}
#endregion
ItemHolderAdorner
Provides a visual copy of the AdornedElement
(ItemHolder
), which is then scaled up a bit. The visual copy is achieved my making a VisualBrush
of the AdornedElement
. The most important part of this Adorner is the constructor, which is as follows:
public ItemHolderAdorner(ItemHolder adornedElement, Point position)
: base(adornedElement)
{
host.Width = adornedElement.Width;
host.Height = adornedElement.Height;
host.HorizontalAlignment = HorizontalAlignment.Left;
host.VerticalAlignment = VerticalAlignment.Top;
host.IsHitTestVisible = false;
this.IsHitTestVisible = false;
Border outerBorder = new Border();
outerBorder.Background = Brushes.White;
outerBorder.Margin = new Thickness(0);
outerBorder.CornerRadius = new CornerRadius(5);
outerBorder.Width = adornedElement.Width;
outerBorder.Height = adornedElement.Height;
Border innerBorder = new Border();
innerBorder.Background = Brushes.CornflowerBlue;
innerBorder.Margin = new Thickness(1);
innerBorder.CornerRadius = new CornerRadius(5);
outerBorder.Child = innerBorder;
innerBorder.Child = new Grid
{
Background = new VisualBrush(adornedElement as Visual),
Margin = new Thickness(1)
};
double scale = 1.5;
host.RenderTransform = new ScaleTransform(scale, scale, -0.5, -0.5);
double hostWidth = host.Width * scale;
double diff = (double)(adornedElement.Width / 4);
Thickness margin = new Thickness();
margin.Top = diff * -1;
margin.Left = diff * -1;
host.Margin = margin;
host.Children.Add(outerBorder);
base.AddLogicalChild(host);
base.AddVisualChild(host);
}
As I stated at the start of this article, this was actually the main reason to write this article... But I got carried away and wrote the rest.
Sorting in WPF when using ItemsControl
(or subclasses, such as ListBox
) can be achieved through either XAML or code-behind. I am using code-behind, but I shall demonstrate an example of XAML also.
The first thing you need to be aware of is what sort of mode you are using with the ItemsControl
; if you are using ItemsSource
, then you are in what I will call NonDirect mode; if you are adding items using Items.Add()
, you are in what I will call Direct mode.
If you have an ItemsControl
, such as a ListBox
that has content, you can use the Items
property to access the ItemCollection
, which is a view. Because it is a view, you can then use the view-related functionalities such as sorting, filtering, and grouping. Note that when ItemsSource
is set, the view operations delegate to the view over the ItemsSource
collection. Therefore, the ItemCollection
supports sorting, filtering, and grouping only if the delegated view supports them.
The following example shows how to sort the content of a ListBox
named myListBox
. In this example, Content
is the name of the property to sort by.
myListBox.Items.SortDescriptions.Add(new SortDescription("Content",
ListSortDirection.Descending));
When you do this, the view might or might not be the default view, depending on how the data is set up on your ItemsControl
. For example, when the ItemsSource
property is bound to a CollectionViewSource
, the view that you obtain using the Items
property is not the default view.
If your ItemsControl
is bound (you are using the ItemsSource
property), then you can do the following to get the default view:
CollectionView myView myView =
(CollectionView)CollectionViewSource.GetDefaultView(myItemsControl.ItemsSource);
In the attached demo project, I am setting up new sorts based on public properties which are available on the ItemHolder
controls that I am storing in the ObservableCollection<ItemHolder>
(ItemList
) which is used as the ItemsSource
for the ItemsControl
.
public void SortItems(SortingType sort)
{
CollectionView defaultView =
(CollectionView)CollectionViewSource.
GetDefaultView(itemsControl.ItemsSource);
switch (sort)
{
case SortingType.Normal:
defaultView.SortDescriptions.Clear();
SetItemsCurrentSort(sort);
break;
case SortingType.ByDate:
defaultView.SortDescriptions.Add(new SortDescription("FileDate",
ListSortDirection.Descending));
SetItemsCurrentSort(sort);
break;
case SortingType.ByExtension:
defaultView.SortDescriptions.Add(new SortDescription("FileExtension",
ListSortDirection.Descending));
SetItemsCurrentSort(sort);
break;
case SortingType.BySize:
defaultView.SortDescriptions.Add(new SortDescription("FileSize",
ListSortDirection.Descending));
SetItemsCurrentSort(sort);
break;
default:
defaultView.SortDescriptions.Clear();
SetItemsCurrentSort(SortingType.Normal);
break;
}
}
To do something like this in XAML, we could simply do something like:
The following example creates a view of the data collection that is sorted by the city name and grouped by the state:
<Window.Resources>
<src:Places x:Key="places"/>
<CollectionViewSource Source="{StaticResource places}" x:Key="cvs">
<CollectionViewSource.SortDescriptions>
<scm:SortDescription PropertyName="CityName"/>
</CollectionViewSource.SortDescriptions>
<CollectionViewSource.GroupDescriptions>
<dat:PropertyGroupDescription PropertyName="State"/>
</CollectionViewSource.GroupDescriptions>
</CollectionViewSource>
</Window.Resources>
The view can then be a binding source, as in the following example:
<ListBox ItemsSource="{Binding Source={StaticResource cvs}}"
DisplayMemberPath="CityName" Name="lb">
<ListBox.GroupStyle>
<x:Static Member="GroupStyle.Default"/>
</ListBox.GroupStyle>
</ListBox>
I started this article while I was waiting for an answer to come for my other last article, but you know, I am pretty pleased with the results of this one. I think it shows a nice set of things like:
- How to create a scrollable design area
- Designing a custom panel
- How to use Adorners in a neat way
- How to sort a list using a
CollectionView
So hopefully, there is still something in here for you to use.