Contents.
This article describes an implementation of lightweight views - visual objects that behave like Windows Forms controls but are windowless. Such objects simplify some tasks user interface design tasks and conserve system resources.
In about 1990 I was working at the Computer Centre of Russian Academy of Science. At the time a colleague of mine was developing a kind of an interactive quiz application. He was working on a pretty descent computer of the time, an Intel 80386-based PC with 8 megabytes of memory running Windows 3.1 in Extended Mode and used an excellent developer's environment Borland C++ 3.1. His first attempt with the quiz UI was a scrollable window that contained a 12x12 matrix of panels. Each panel contained a question (a static text control) and 3 radio buttons with possible answers. The UI was build on top of the Object Windows Library and stylish Borland Windows Custom Controls (BWCC). The program worked, but was making the computer to crawl slowly.
The explanation of this fact was simple. Each panel with a question consumed at least 5 window handles: itself, a static box with the question and three radio buttons. Thus the whole UI consumed at least 1 + 12*12*5 = 721 window handles and the good old Windows 3.1 couldn't bear it.
The developer solved the problem, of course. He turned the matrix into a kind of a wizard. But I remembered well the whole awkward situation.
Recently I faced it again. In a project I needed something that was suspiciously familiar - a grid of panels with controls. The difference is that the number of panels would be smaller and the number of controls on a panel would be significantly bigger. Consuming hundreds of window handless seems to me unwise even if you are running a modern NT-bases operating system.
Obviously, I am not the only person who faces such a problem. Microsoft developers themselves recommend to keep number of controls on a form at minimum. It is recommended to draw text messages, icons and images manually. Sometimes it is easy to perform custom drawing but usually it involves a lot of calculations to lay out manually drawn text and pictures properly. There is, however, a bit simpler solution - use of windowless user interface elements.
I can think of two implementation of such controls right away. The first one is from Borland's class library for Delphi named VCL. It's developers introduce a class named the TControl
that is essentially a windowless control and derive from it the class TWinControl
, a control with a handle and a window function. Most of layout and mouse logic goes to the TControl
, everything related to Win32 API goes to the TWinControl
and it's descendants. For instance, the VCL has two classes for text labels - a windowless TLabel
and windowed TStaticText
. In most cases you can replace the latter with the former thus reducing the number of windows consumed by your application.
The second implementation is a bit more exotic. In the Be Operating System the designers split the UI elements to windows and views. A window is a heavyweight, kernel mode object that represents an almost rectangular area on the screen. The BWindow
class (a side note: BeOS is written in C++ and most system services are implemented as C++ classes) is a proxy for this object. A view in BeOS is a lightweight object that resides in the application space. The BView
class, the root of BeOS' controls or widgets hierarchy, is a rectangular area inside a window that can draw itself and respond on incoming events. BWindow
supports a list of BView
objects, delegates the drawing of it's interior to them, maintains the input focus, passes keyboard and mouse events to the views and lays them out properly when the window changes it's dimensions.
Because in Windows Forms class library the Control
class is derived right from the Component
class and all logic related to input events and layout is in the Control
class it is impossible to implement windowless controls VCL way. So I decided to go BeOS way. There going to be an ordinary control that would host the views.
The goals to achieve:
- The lightweight views should consume as little memory as possible;
- A lightweight view should be a simple object from the GC point of view, i.e. it should be neither finalizable nor disposable;
- The whole system should be easily extendable, flexible and adoptable;
- The source code should be simple.
The lightweight views can be used in particular to create following custom controls:
- Non-standard toolbars;
- Grids and grid-like controls like list views;
- Scrollable icon and image strips;
- Image grids like ones in the Adobe Album application.
Note that in the listings I omit the comments and also most of the implementation code. If you want to see them just download the source code.
A view in this implementation is a rectangular area (or a non-rectangular area that can be inscribed into a rectangle) in the host control that can draw itself and react on some mouse events. I declared an interface with all the properties and callback methods. The interface is called, obviously, IView
.
public interface IView
{
IControlService Parent
{
get;
set;
}
int X
{
get;
set;
}
int Y
{
get;
set;
}
int Width
{
get;
set;
}
int Height
{
get;
set;
}
bool Enabled
{
get;
set;
}
bool Visible
{
get;
set;
}
void Invalidate(int x, int y, int width, int height);
void Draw(Graphics graphics);
void OnMouseDown(int posX, int posY, MouseButtons buttons);
void OnMouseUp(int posX, int posY, MouseButtons buttons);
void OnMouseEnter(int posX, int posY);
void OnMouseLeave(int posX, int posY);
void OnMouseHover(int posX, int posY);
bool HitTest(int posX, int posY);
}
NB: The declaration of the interface has been changed since the last article review.
This interface provides bare minimum of methods and properties, required for the views to position and draw themselves in the host window. The Parent
property provides for the view itself an object that implements IControlService
interface. This interface gives the view the ability to invalidate itself and provides access to the parent windowed control. I give more details on this interface below.
Although a view is enclosed into a rectangle, you may create non-rectangular views. For such a view you have to provide a specific implementation of the HitText
method. It returns true if a coordinates provided are inside the view's boundaries and false if they are not.
While developing this set of classes I came up with an interesting, in my opinion, solution related to caching GDI+ objects.
System.Drawing
namespace provides a whole lot of pre-build objects like various pens and solid brushes. However, if you need more complex objects as linear gradient brushes, you have to create those relatively heavyweight objects. You usually dispose of them right after use or keep for the application lifetime. The DrawHelper
class provides a compromise by caching the graphics objects and releasing the resources at the application's idle time. When you need a new GDI+ object the instance of the DrawHelper
creates it for you, when you ask again for an object with the same parameters, the DrawHelper
first looks up the cache and returns the already created object. It is especially useful for the lightweight views because the container would keep many similar views that draw themselves in similar ways.
However, this class has a drawback. I keep graphics objects in hash tables and keys to them are integers calculated from the parameter's hash values. While it works for simple pens and brushes, there might be a problem with TextureBrush
objects. A TextureBrush
is based on an image. But both the Image
class descendants use the default implementation of the GetHashCode
method. It means that two identical Image
objects (the same image loaded from file or resource twice or an image and it's cloned copy) would have two different hash codes. Thus the DrawHelper
will create and keep two identical TextureBrush
objects. On the other hand, calculating a hash code by scanning the image would be too expensive. Just keep this in mind.
public sealed class DrawHelper
{
public static DrawHelper Instance
{
get
{
. . .
}
}
public Pen CreateColorPen(Color color, float width)
{
. . .
}
public Pen CreateColorPen(Color color)
{
. . .
}
public Pen CreateBrushPen(Brush brush, float width)
{
. . .
}
public Pen CreateBrushPen(Brush brush)
{
. . .
}
public SolidBrush CreateSolidBrush(Color color)
{
. . .
}
public TextureBrush CreateTextureBrush(Image image,
WrapMode wrapMode, RectangleF dstRect)
{
. . .
}
public TextureBrush CreateTextureBrush(Image image,
WrapMode wrapMode, Rectangle dstRect)
{
. . .
}
public Font CreateFont(string familyName, float emSize,
FontStyle style, GraphicsUnit unit, byte characterSet,
bool isVerticalFont)
{
. . .
}
public LinearGradientBrush CreateLinearGradientBrush(Rectangle rect,
Color color1, Color color2, LinearGradientMode linearGradientMode)
{
. . .
}
public HatchBrush CreateHatchBrush(HatchStyle hatchStyle,
Color foreColor, Color backColor)
{
. . .
}
public PathGradientBrush CreatePathGradientBrush(GraphicsPath path)
{
. . .
}
public PathGradientBrush CreatePathGradientBrush(Point[] points,
WrapMode wrapMode)
{
. . .
}
public PathGradientBrush CreatePathGradientBrush(Point[] points)
{
. . .
}
public PathGradientBrush CreatePathGradientBrush(PointF[] points,
WrapMode wrapMode)
{
. . .
}
public PathGradientBrush CreatePathGradientBrush(PointF[] points)
{
. . .
}
public StringFormat CloneDefaultStringFormat()
{
. . .
}
public StringFormat CloneTypographicStringFormat()
{
. . .
}
}
The factory methods have parameters that match the most common constructors of the graphical objects they create.
Also take a look at CloneXXX
methods. They make clones of built-in StringFormat.GenericDefault
and StringFormat.GenericTypographics
objects so you may use these as templates for your own tweaked string formats, see LabelView
class implementation for an example on these usage. The clones, of course, will also be disposed of at the idle time.
public interface IControlService
{
ViewContainer Control
{
get;
}
void Invalidate(int x, int y, int width, int height);
}
NB: The declaration of the interface has been changed since the last article review.
The interface provides for a view the reference to it's host control and a frequently used facility that invalidates a rectangular area inside the view.
At the beginning of the development all the view objects kept back references to the ViewContainer
objects. But I introduced the CompositeView
class, a view that also hosts other view objects. Because these two classes are completely different I had to create this interface to unify access to the parent control for views.
public class ViewCollection: IList, ICollection, IEnumerable
{
. . .
}
This class represents a typed collection of objects that implement IView
interface. I implement it as an old-style collection not only because I still use .Net Framework 1.1 but I also derive from it two private classes with custom functionality.
The class has some methods rarely found in usual collections. For instance, there are MoveXXX
methods that help you to manage the view's z-order. The host control draws views one by one, from index 0 to the upper bound of the collection. It is obvious that views with bigger index are on top of the others in the z-order. You can move a view one step up or down in the z-order with MoveForward
and MoveBackward
methods or bring it to the front or the back of the z-order with MoveFront
and MoveBack
methods. Of course, you can use Remove and Insert methods to achieve the same goal but remember that these methods force repaint of the whole host control and recalculation of it's scrollable area but MoveXXX
methods don't. It can be important if a host contains hundreds of views.
[Designer(typeof(ViewContainerDesigner))]
public class ViewContainer: Panel, IControlService
{
. . .
}
This is a host for the views. It's implementation is pretty simple because the Windows Forms controls provide excellent support for the contents scrolling. I derived the class from the System.Windows.Forms.Panel
because it exposes the BorderStyle
property that I need for prettier UI. However, by modifying control flags in the constructor I make the ViewContainer
selectable so you can set TabStop
property for the instances of this class and also scroll it's contents (the views) with the keyboard. I also set the styles that make the ViewContainer
transparent, perform the background painting on it's own, perform custom mouse processing and double buffer it's drawing. Note that you can turn double buffering off, but the control might flicker, especially when two or more views that use heavy drawing operations overlap.
The class declares two important methods: HitTest
and CalcExtent
. The first one is used to find a view the mouse cursor points at by enumerating all the views:
protected virtual IView HitTest(int posX, int posY)
{
for(int i = views.Count - 1; i >= 0; i--)
{
IView view = views[i];
if(view.Visible)
{
if((view.X <= posX) && (view.X + view.Width > posX) &&
(view.Y <= posY) && (view.Y + view.Height > posY))
return view;
}
}
return null;
}
The second one calculates the size of the canvas that encloses all the views in the ViewContainer
:
protected virtual void CalcExtent()
{
if(Updating)
return;
Size size = new Size();
foreach(IView view in views)
{
if(view.Visible)
{
size.Width = Math.Max(size.Width, view.X + view.Width);
size.Height = Math.Max(size.Height, view.Y + view.Height);
}
}
AutoScrollMinSize = size;
}
Note the Updating
property, it's used to prevent changing of the extent when you insert a bunch of views to the host control at once. You can toggle the Updating
property by calling BeginUpdate
/EndUpdate
methods.
Both methods have rather generic implementations for this class; the derived classes, if they know the exact layout of the views, can override these methods to perform faster search and calculation. I'll show how it can be done in the StringBox sample.
internal class ViewContainerDesigner: ControlDesigner
{
protected override void PostFilterProperties(IDictionary properties)
{
. . .
}
protected override void PostFilterEvents(IDictionary events)
{
. . .
}
}
Because the ViewContainer
performs all mouse processing itself by relaying mouse events to it's views, it does not raise any events of MouseXXX
family. It also performs custom drawing and uses different default values for some properties. It is possible to hide events and change the default values of the properties from the forms designer by overriding them and changing their attributes, but because the number of those changes is big I decided to create for the ViewContainer
it's own custom designer and perform the modifications by implementing PostFilterEvents
and PostFilterProperties
methods. The implementation of these methods is pretty straightforward, just look at the source.
Note that I derive the ViewContainerDesigner
from neither PanelDesigner
nor ScrollableControlDesigner
but right from the ControlDesigner
. I do it so because the advanced designers provide some features like background grid in design mode that I don't need for the ViewContainer
.
This is the root of the lightweight views hierarchy. The AbstractView
class implements the IView
interface, adds a lot of state properties the derived classes may use and with explicit interface implementation hides certain methods from outsiders (it keeps FxCop happy). Note that X
, Y
, Width
and Height
properties are abstract and must be overridden in derived classes. Also keep in mind that by default views are believed to be rectangular, see AbstractView.HitTest
implementation. Derive your custom views from this class if you want the parent control to manage the view's boundaries.
This class defines a stand-alone view that has boundaries. I. e. essentially it is the AbstractView
with X
, Y
, Width
and Height
properties implemented.
The CompositeView
is a container for other views. Just like ViewContainer
it implements IControlService
interface, has it's own private implementation of the ViewCollection
class and corresponding Views
property and it's own HitTest
method. However, it does not support scrolling for obvious reasons.
In version 3 of the views library I have implemented views nesting. It means that you can insert a CompositeView into another CompositeView. This view nesting is good but don't overuse it, drawing and relative mouse position calculations utilize recursion.
This lightweight view mimics the System.Windows.Forms.Label
control. It has two useful properties: Text
and TextAlign
. If you need more flexible solution either use the standard control or add required properties and events yourself.
This view mimics the System.Windows.Forms.Button
control. It has Text
and TextAlign
properties, just like the LabelView
, and Click
event.
This view mimics the System.Windows.Forms.PictureBox
control. It has Image
property that allows one to set and retrieve the current image, associated with the view, and SizeMode
property that specifies how the image will be handled by the view (stretched, centered etc.).
Note that though an Image
object is disposable, the ImageView
objects do not dispose of it. Thus is the rule of thumb for the views: because they are lightweight, they do not manage any resources. Keep track of pens and brushes in the DrawHelper
, Image
objects in your forms or ViewContainer
-derived objects and never add any resource management code to the views.
This view mimics a bit the Sysytem.Windows.Forms.Panel
control and is actually a CompositeView
with borders.
I'm sure that both design and implementation of the lightweight views framework is far from perfect. Consider it kind of a blueprint for real applications.
I provide a sample project with the code for the views. It contains several subprojects.
In the source code provided as an addendum to this article you find the StringBox project. This project actually gave me inspiration to write this article.
The lightweight views framework were an internal project until one day I made a typo. I was writing code for a ListBox
-like control and in a test program, in the initialization method, I by mistake put constant 1200000 instead of 12000. The constant was the number of views to create and put to the control. When I run the program, it hung and then I realized my mistake. But curiosity took over and I left it running. I wanted to see how much memory it would eat. A half of an hour later the ViewContainer
painted itself.
To my surprise I was able to work with it. I could scroll, I could select views. Even hot-tracking worked. It surely was slow, but it worked. At that moment I realized that the views can be used not only in my toy project.
I started to tweak and optimize the code and soon made it possible for the StringBox
(my quick-and-dirty replacement for Windows' ListBox
) to handle 1000000 lines of text. On my computer it takes about 20 seconds to populate the list then you can scroll it and select the items as if you were using the standard ListBox
control. But the standard control cannot handle this many strings on my computer, after an hour of populating it the program crashes with OutOfMemory
exception.
I had created a managed control that was faster and more reliable than the standard, unmanaged control. There was something very wrong with it┼ Anyway, the StringBox
subproject is a grandchild of my buggy program that once generated 12000000 views.
I never wanted to create a replacement for the Windows' own list box, but tried to make my StringBox
to look and feel close to the standard control.
Two points of interest are HitTest
and CalcExtent
implementations. The first one does not enumerate all the views but returns the selected view by calculating it's vertical position:
protected override IView HitTest(int posX, int posY)
{
if(Views.Count <= 0)
return null;
int index = posY / GetStringHeight();
if(index < Views.Count)
return Views[index];
return null;
}
The second one assumes that the StringBox
scrolls only in vertical direction and never has a horizontal scroll bar:
protected override void CalcExtent()
{
if(Updating)
return;
int width = ClientSize.Width;
int height = Views.Count * GetStringHeight();
if(height > ClientSize.Height)
width -= SystemInformation.VerticalScrollBarWidth;
AutoScrollMinSize = new Size(width, height);
}
Both implementations significantly speed up the StringBox
operations.
This project demonstrates use of the views in various situation. The main interface is a window with tabs, each tab contains a test of a particular views functionality:
- Composites - presents two groups of button-like views, with real functionality (state and events); the second group is enclosed into a composite view; it's a test for generic views functionality;
- Rectangles - shows a number of rectangles with hot tracking and scrolling; this is a test for scrolling and mouse handling;
- Nesting - shows a number of nested composite views with hot tracking; this is a test for view nesting;
- Non-rectangular - shows a number of elliptic views with hot tracking, you can select the views with mouse; this is a test for non-rectangular views support and z-ordering.
To build samples you need either Views.cmbx
combine to build with SharpDelelop v 1.1 or nant.build
file to build with NAnt v 0.85. I also provide two batch files to build the samples.
SharpDevelop combine contains two targets, Debug and Release, the names say for themselves. NAnt build file contains four targets, Debug, Release, Doc and Clean with Debug set to default. First two targets are identical to the combine, the Doc target builds the documentation for Pvax.UI.dll, the Clean target, obviously, deletes all files generated.
I also included an FxCop project for FxCop 1.32. It isn't happy about me not validating arguments of the public methods in my private collection classes. I don't do it because my methods call base class implementation that do validate the parameters. It also suggests not to call virtual methods in constructors for ViewCollection class because in the derived classes the overridden versions would be called - but it is right what I want. On your computer FxCop might also be unhappy about short parameter names like X and Y. I disabled these warnings by adding X
and Y
to the FxCop custom dictionary. You can find an XML file named CustomDictionary.xml
in the folder C:\Documents and Settings\{Your profile name}\ApplicationData\Micrososft FxCop\{FxCop version number}. Just add two <Word> tags with X
and Y
texts to <Dictionary>/<Words>/<Recognized> section.
SharpDevelop 2.0 uses msbuid instead of it's own old project engine so I created a VisualStudio 2005-compatible (?) solution. It's, however, is not tested with "real" VisualStudio so use it on your own risk. If it really works, please inform me. And yes, the lightweight views are fully compatible with .Net Framework 2.0.
During the upgrade to SharpDevelop 2.0.0.1462 I found that Pvax.Views.Tests
project references old versions of NUntit.Core
and NUnit.Framework assemblies
. Those of you who get errors in this particular subproject should remove references to them and add back again (from the GAC). Alternatively you may reference private copies of these assemblies. I could do it myself but it would bloat the ZIP with the source code. Anyway, you are warned - beware of NUnit versioning.
The core set of classes that reside in Pvax.UI and enclosed namespaces are covered by a BSD-like license. The rest of the code is public domain.
There are ways to improve the views framework.
First, add design-time capabilities to the views. It can be done fairly easily by implementing System.ComponentModel.IComponent
interface or by even deriving the IView
interface from it. The downside is that by doing so you turn lightweight views into not-so-lightweight components. Probably by using ADAPTER (wrapper) pattern it is possible to separate IComponent
-derived and IView
-derived hierarchies.
Second, further speed optimizations for the generic views host, the ViewContainer
.
Third, more views, they are nice.
Automatic contents scrolling in Windows Forms is excellent.
Some native Windows' controls are weird.
If you want to use keyboard for navigation in your custom control, first make sure that System.Windows.Forms.ControlStyles.Selectable
flag for it is set to true. Then, if you want to intercept arrow keys, do not override OnKeyDown
/OnKeyPressed
methods, they never receive neither arrow keys nor other navigation keys like PgUp/PgDown. Don't waste your time with interop and just override the low-level keyboard hook ProcessDialogKeys
.
Cache you GDI+ resources, use built-in pens and brushes whenever it is possible.
Custom controls, at least, ViewContainer
, don't work if the application is downloaded from the Internet because of security restrictions.
- 05/21/2006 - First version of the document;
- 06/12/2006 - A #D 2.0/VS2005-compatibe solution added and .Net 2.0 compatibility ensured;
- 06/14/2006 - An issue with NUnit assemblies (partially) resolved.
- 07/23/2006 - Breaking changes:
- The
DrawHelper
becomes a global singleton; the IView
declaration changes;
- Now a
CompositeView
may contain another CompositeView
(nesting restrictions removed), IControlService
and IView
declaration change, all code that uses and implements these interfaces change (almost all classes);
- A view named
PanelView
introduced, borders support moved to it from the CompositeView
, ViewTest sample changed accordingly;
- A new sample Nesting provided.
- 08/14/2006 - Breaking changes:
- Non-rectangular views supported;
- Z-order of views inside the
ViewContainer
supported;
- Views/controls invalidation bugs fixed;
- The
IView
and IControlService
declaration and implementations changed accordingly;
IView.HitTest
method introduced for non-rectangular views support.
I'm a system administrator from Moscow, Russia. Programming is one of my hobbies. I presume I'm one of the first Russians who created a Web site dedicated to .Net known that time as NGWS. However, the Web page has been abandoned a long ago.