Introduction
This article shows how you can implement a smooth list box that allows intuitive, friction affected, scrolling of the list items. By friction based, I mean that the user can apply a dragging force in one direction and the list will keep scrolling after the mouse or stylus is released and then slow down by itself. I've looked at how lists behave on the iPhone and tried to mimic that to a certain extent, but I'm nowhere near anything as cool as what the iPhone presents.
The properties of the iPhone list box that I think are the neatest are the fact that you do not scroll it using a scrollbar but rather just grab the list items and drag them, much neater.
Also, on the iPhone, one is allowed to scroll beyond the bounds of the list box and the items just snap back into place. Very cool.
Note: This article and download has now been updated to include a version where you can use the keys to scroll the list as well. I didn't have access to Visual Studio 9 when implementing this, so there's been a few changes to revert back to the .NET 2.0 level of compliance.
Thanks to Mixxer, mbrucedogs, and Daniel Bass who suggested tips and implementations and pushed me to implement the key support. Unfortunately, I haven't had time to implement everything they asked for.
Background
As the standard ListBox
in the .NET Compact Framework is kind of limited, I wanted to create one which fulfilled three requirements;
- The list must scroll smoothly, and rely on "friction" and "springs" to give it an intuitive feel.
- The list must be able to have list items that are more than just text and an icon, any
Control
must be able to act as a list item.
- The list must be fast enough to run on a PocketPC 2003 device.
All in all, not terribly complicated requirements, and after implementing it first for the Desktop and then porting it to the .NET Compact Framework, the requirement that gave me the most grief was actually #3.
Using the Code
The downloadable Visual Studio solution contains three C# projects;
- Extended ListItems; This is a class library project that has two predefined list item controls that can be used to show information about music albums or Formula One teams.
- Smooth ListBox; This project holds the actual list box implementation.
- Smooth ListBox (Test); This is a test project that creates a sandbox application to try the
SmoothListBox
in three different ways.
I've implemented this solution using .NET 3.5, but it should be trivial to get it working for .NET 2.0 as well.
Creating a Custom ListBox
As my SmoothListBox
was going to be completely different in both appearance and behaviour from the list box found in the System.Windows.Forms
namespace, I decided to build it from "scratch" and not inherit from ListBox
. As the list box should behave like any other component in the designer, it inherits from UserControl
.
The SmoothListBox
showing Formula One team information.
List Item Storage
The first thing to solve is the storage of the list items. I was first planning on using a List<Control>
for this, but found that I could just as well use the Controls
property provided by UserControl
. This also makes sense from the point of view that the list items are really nothing other than Control
s owned and displayed by the SmoothListBox
. For future purposes, such as adding a scrollbar (the current implementation does not support a scrollbar as I didn't really see the need for it), list items are not added directly to the SmoothListBox
's Control
s. To accommodate more than just the list items being owned by the SmoothListBox
, the control holds an internal Panel
that directly owns the list items. This, in turn, means that doing mySmoothListBox.Controls.Add(new MyListItem())
won't work as it will not add the list items to the correct container. Therefore, two new methods, AddItem
and RemoveItem
, need to be implemented. This is actually quite good as the semantics makes more sense that way (adding items to a list is different from manipulating controls in a container).
Selection State Handling
In order to keep track of which items are currently selected (as the SmoothListBox
supports multiple selection), a look-up dictionary is used. This maps list items to a selected state like so:
private Dictionary<Control, bool> selectedItemsMap = new Dictionary<Control, bool>();
Two boolean members define the selection model for the list box;
MultiSelectEnabled
: when true
, the selection model allows several items to be selected at the same time. If set to false
, an already selected list item is automatically de-selected when a new one is selected.
UnselectEnabled
: when true
, the user can explicitly de-select a selected value; if set to false
, the only way to do this is to select another value.
And, as this is a .NET 3.5 project, these are declared as automatic properties:
public bool MultiSelectEnabled
{
get;
set;
}
public bool UnselectEnabled
{
get;
set;
}
List Event Handling
A custom event that fires whenever a list item is clicked is also defined:
public delegate void ListItemClickedHandler(SmoothListbox sender,
Control listItem, bool isSelected);
class SmoothListBox
{
public event ListItemClickedHandler ListItemClicked;
...
}
Basic List Box Definition
So, having defined all the necessary parts (well, kind of) for the list box, the class definition can be constructed:
namespace Bornander.UI
{
public delegate void ListItemClickedHandler(SmoothListbox sender,
Control listItem, bool isSelected);
public partial class SmoothListbox : UserControl
{
public event ListItemClickedHandler ListItemClicked;
private Dictionary selectedItemsMap = new Dictionary();
private void FireListItemClicked(Control listItem)
{
if (ListItemClicked != null)
ListItemClicked(this, listItem, selectedItemsMap[listItem]);
}
public void AddItem(Control control)
{
...
}
public void RemoveItem(Control control)
{
...
}
public bool MultiSelectEnabled
{
get;
set;
}
public bool UnselectEnabled
{
get;
set;
}
public List<Control> SelectedItems
{
get
{
List<Control> selectedItems = new List<Control>();
foreach (Control key in selectedItemsMap.Keys)
{
if (selectedItemsMap[key])
selectedItems.Add(key);
}
return selectedItems;
}
}
}
}
Making it Smooth
OK, so storing some information and firing an event doesn't really make the list box scroll smoothly in any way. To get that part working, quite a lot of mouse handling and animation code has to be added to the implementation.
Mouse Handling
When scrolling, I do not want to have to grab a handle on a scroll bar and drag that around, I want to be able to "grab" anywhere in the list item and drag to scroll (with certain limitations that will be explained later). On the iPhone, the user just "flicks" through the list using his/her finger anywhere in the list. This is a very intuitive way of scrolling, and I want my list box to behave the same way.
According to requirement #2, the list box must support any list item that inherits from Control
. This means that a list item can have any number of nested child controls, and regardless of whether the "base" list item control is the control that receives the mouse events or if it is one of the list item's child control, the events need to be handled in a uniform way. To accommodate this, list box global mouse event listeners are added recursively to any list item being added using the AddItem
method. In WPF, this could have been handled using bubbling up or filtering down of events, but in WinForms, we have to do it ourselves. The SmoothListBox
has three members, all MouseEventHandler
s, that listens to mouse down, up, and move events. The recursive adding of the listeners is done by a static helper class called Utils
:
namespace Bornander.UI
{
static class Utils
{
public static void SetHandlers(
Control control,
MouseEventHandler mouseDownEventHandler,
MouseEventHandler mouseUpEventHandler,
MouseEventHandler mouseMoveEventHandler)
{
control.MouseDown -= mouseDownEventHandler;
control.MouseUp -= mouseUpEventHandler;
control.MouseMove -= mouseMoveEventHandler;
control.MouseDown += mouseDownEventHandler;
control.MouseUp += mouseUpEventHandler;
control.MouseMove += mouseMoveEventHandler;
foreach (Control childControl in control.Controls)
{
SetHandlers(
childControl,
mouseDownEventHandler,
mouseUpEventHandler,
mouseMoveEventHandler);
}
}
public static void RemoveHandlers(
Control control,
MouseEventHandler mouseDownEventHandler,
MouseEventHandler mouseUpEventHandler,
MouseEventHandler mouseMoveEventHandler)
{
control.MouseDown -= mouseDownEventHandler;
control.MouseUp -= mouseUpEventHandler;
control.MouseMove -= mouseMoveEventHandler;
foreach (Control childControl in control.Controls)
{
RemoveHandlers(
childControl,
mouseDownEventHandler,
mouseUpEventHandler,
mouseMoveEventHandler);
}
}
}
}
So, in the AddItem
method, Utils.SetHandlers
is called, and after that, the list box is aware of every mouse related thing that happens to a list item or the list item's children. We can now go ahead and implement the dragging.
Dragging
MouseDown
The first thing, naturally, to handle when processing mouse dragging is the mouse down event. As far as the smooth scrolling goes, nothing really happens in the mouse down event except the storing of some information that'll later prove to be vital:
private void MouseDownHandler(object sender, MouseEventArgs e)
{
if (e.Button == MouseButtons.Left)
{
mouseIsDown = true;
mouseDownPoint = Utils.GetAbsolute(new Point(e.X, e.Y), sender as Control, this);
previousPoint = mouseDownPoint;
}
}
The Utils.GetAbsolute
method is a helper method that converts a point in child-local coordinates to parent-local coordinates; this is important as we're going to move list items around during dragging, and if we're not looking at the absolute coordinates, they won't behave as expected.
MouseMove
The next natural thing to handle is the mouse move event. Again, not very much is required to handle this event (at least, not conceptually), but the implementation calls for some weird things that might require further explanation. What the mouse move handler needs to do is to measure the vertical distance that the mouse has moved and then make sure that the list items are scrolled by that amount. Easy enough, right? Well, here's where I ran into some weird thing.
As the list has to be animated to give the impression of obeying friction and spring laws, it has to be periodically re-drawn whenever a specific timer event fires. But, the list also has to be re-drawn when a user mouse drag has invalidated the list items current positions. This initially caused the list to re-draw too often, causing the Mobile Device to hang or run extremely slow. One way to fix this would have been to turn off the animation timer when the list is being dragged by the mouse and enable it again when the mouse is released.
I tried that approach, but it resulted in a too long delay between when the stylus was lifted and when the list scrolled "on its own", making it look a bit unnatural. I ended up solving it using a much simpler approach. By keeping a boolean member that is set and unset by the timer event handler method and the mouse move method, I can discard updates that occur too often causing the list movement to "stall".
private void MouseMoveHandler(object sender, MouseEventArgs e)
{
if (e.Button == MouseButtons.Left)
{
if (!renderLockFlag)
{
renderLockFlag = true;
Point absolutePoint = Utils.GetAbsolute(new Point(e.X, e.Y),
sender as Control, this);
int delta = absolutePoint.Y - previousPoint.Y;
draggedDistance = delta;
ScrollItems(delta);
previousPoint = absolutePoint;
}
}
}
As seen from this snippet, the mouse move event is only handled if the renderLockFlag
is set to false
. This means that if a mouse move event occurs before the previous one was handled, it is discarded. The flag is reset by the animation tick handler method:
private void AnimationTick(object sender, EventArgs e)
{
renderLockFlag = false;
DoAutomaticMotion();
}
MouseUp
The last mouse event to handle is the mouse up event; here, the velocity that the list should continue to scroll with is calculated. Also, handling of list item selection/de-selection is taken care of when the mouse up event occurs.
To calculate the velocity the list should have is quite easy; it's simply the dragged distance (distance between the last two mouse move events) multiplied with a constant factor.
To handle the selection or de-selection of list items, a few checks have to be made as the SmoothListBox
allows different selection modes; multiple items selection can be disabled or enabled as can explicit de-select of items. Further, as it is not required for a list item to implement IExtendedListItem
(see a later chapter for details on this) checks have to be performed on the type of the affected list item to see if methods need to be called on that list item. The benefit of implementing IExtendedListItem
is that the list items can dictate their behaviour when being selected or de-selected, as shown in my example application where they change size and or content when selected.
private void MouseUpHandler(object sender, MouseEventArgs e)
{
if (renderLockFlag)
{
velocity = Math.Min(Math.Max(dragDistanceFactor *
draggedDistance, -maxVelocity), maxVelocity);
draggedDistance = 0;
DoAutomaticMotion();
}
if (e.Button == MouseButtons.Left)
{
if (Utils.GetAbsolute(new Point(e.X, e.Y), sender as Control,
this).Equals(mouseDownPoint))
{
Control item = GetListItemFromEvent(sender as Control);
if (item != null)
{
bool newState = UnselectEnabled ? !selectedItemsMap[item] : true;
if (newState != selectedItemsMap[item])
{
selectedItemsMap[item] = newState;
FireListItemClicked(item);
if (!MultiSelectEnabled && selectedItemsMap[item])
{
foreach (Control listItem in itemsPanel.Controls)
{
if (listItem != item)
selectedItemsMap[listItem] = false;
}
}
foreach (Control listItem in itemsPanel.Controls)
{
if (listItem is IExtendedListItem)
(listItem as IExtendedListItem).SelectedChanged(
selectedItemsMap[listItem]);
}
LayoutItems();
}
}
}
}
mouseIsDown = false;
}
Animating
As seen in some of the above snippets, a method called DoAutomaticMovement
is used. This method animates the list motion when the mouse or stylus is not affecting it.
One thing to handle is to calculate the current speed of the list animation and update the position of the list items accordingly. Friction is applied during this process as the current speed, or velocity, is multiplied with the de-acceleration factor:
velocity *= deaccelerationFactor;
float elapsedTime = animationTimer.Interval / 1000.0f;
float deltaDistance = elapsedTime * velocity;
The elapsed time is estimated, for convenience (read laziness), to the timer interval. This probably should have been changed to an actual measured delta time between updates for an even smoother animation. A requested difference in distance, deltaDistance
, is calculated because we only want to move the items and re-layout the list box if the actual distance moved is greater than one pixel.
if (Math.Abs(deltaDistance) >= 1.0f)
ScrollItems((int)deltaDistance);
else
{
...
}
The ScrollItems
method is the method that actually re-positions the list items by a certain distance.
The else
statement is necessary because if the de-acceleration has caused the list item to no longer have any velocity, we need to check whether the list has been scrolled "out of bounds". I would like the user to be able to apply enough velocity for the list items to scroll beyond the visible area of the list box, and when they've slowed down to zero velocity, the list box will make sure they "snap" back into view.
The full DoAutomaticMotion
looks like this:
private void DoAutomaticMotion()
{
if (!mouseIsDown)
{
velocity *= deaccelerationFactor;
float elapsedTime = animationTimer.Interval / 1000.0f;
float deltaDistance = elapsedTime * velocity;
if (Math.Abs(deltaDistance) >= 1.0f)
ScrollItems((int)deltaDistance);
else
{
if (itemsPanel.Top != 0)
{
if (itemsPanel.Top > 0)
ScrollItems(-Math.Max(1, (int)(snapBackFactor *
(float)(itemsPanel.Top))));
else
{
if (itemsPanel.Height > ClientSize.Height)
{
int bottomPosition = itemsPanel.Top + itemsPanel.Height;
if (bottomPosition < ClientSize.Height)
ScrollItems(Math.Max(1, (int)(snapBackFactor *
(float)(ClientSize.Height - bottomPosition))));
}
else
ScrollItems(Math.Max(1, -((int)(snapBackFactor *
(float)itemsPanel.Top))));
}
}
}
}
}
The items are moved by moving the entire itemsPanel
in the ScrollItems
method:
private void ScrollItems(int offset)
{
if (offset == 0)
return;
SuspendLayout();
itemsPanel.Top += offset;
ResumeLayout(true);
}
Key Handling
The easiest way to get key handling working was not to try to hook key listeners to all relevant elements as I did for the mouse handling. The reason for this is that (according to Daniel Bass) the key events didn't fire as expected.
I decided instead to use an approach where I simply read the key states just prior to animating the list movement. This way, I could add velocity in either the upwards or downwards direction, and this would give the list an accelerating change in velocity.
To get the asynchronous key states, I used a DLL interop call:
public partial class SmoothListbox : UserControl
{
[DllImport("coredll.dll")]
public static extern int GetAsyncKeyState(int vkey);
...
}
That method could then be queried in the AnimationTick
method:
private void AnimationTick(object sender, EventArgs e)
{
if (GetAsyncKeyState((int)System.Windows.Forms.Keys.Up) != 0)
velocity += keyAcceleration;
if (GetAsyncKeyState((int)System.Windows.Forms.Keys.Down) != 0)
velocity -= keyAcceleration;
renderLockFlag = false;
DoAutomaticMotion();
}
The keyAcceleration
is a poorly named member that defines how much additional velocity should be added. It is accessible using the KeyAcceleration
property.
Custom List Items
As I want the list box to have items that can react to being selected or de-selected, an interface to be implemented by the list item classes is required. But, as I also want this list box to be able to handle just about anything (anything that is a Control
at least), the list items must also work even if they do not implement the IExtendedListItem
interface.
The example application provides three different usages of the SmoothListBox
, and also provides a way of comparing them:
Album Information
The album information list item, the Album
class, implements IExtendedListItem
to allow the list items to display different information when selected. It also renders different background colors depending on whether the item is on an even or odd row in the list box.
#region IExtendedListItem Members
public void SelectedChanged(bool isSelected)
{
if (isSelected)
{
Height = 72;
albumArtPicture.Size = new Size(64, 64);
artist.Visible = true;
releaseYear.Visible = true;
}
else
{
Height = 40;
albumArtPicture.Size = new Size(32, 32);
artist.Visible = false;
releaseYear.Visible = false;
}
}
public void PositionChanged(int index)
{
if ((index & 1) == 0)
BackColor = SystemColors.Control;
else
BackColor = SystemColors.ControlLight;
}
#endregion
This list item changes its size when selected, but the SmoothListBox
is smart enough to re-layout its list items when a selection or de-selection occurs so it is handled neatly and automatically for the user.
BigPanel
In order to show the versatility of the SmoothListBox
, one example scenario is using a class called BigPanel
as a single list item. BigPanel
is just a panel containing a lot of Control
s, and by adding such a panel to a SmoothListBox
, we get a new way of scrolling UIs that are bigger than the screen area. A way that is more intuitive than the scroll bar one gets when AutoScroll
is set to true
.
Conclusion
All three requirements were implemented, but I would have liked the performance to be a bit better, offering even smoother scrolling. As always, any comments on the article or the code are most welcome.
Points of Interest
It's quite difficult to get a list to scroll smoothly; this is largely because of the limited CPU power of the Mobile Devices. But, also because I wanted a list box that is really easy to use, I couldn't make use of any native drawing methods, such as GDI.
History
- 2008-03-02: First version.
- 2008-06-12: Added key control.