|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Announcements
Chapters
Services
Feature Zones
|
IntroductionThis 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. BackgroundAs the standard
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 CodeThe downloadable Visual Studio solution contains three C# projects;
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 ListBoxAs my
The
SmoothListBox showing Formula One team information.List Item StorageThe first thing to solve is the storage of the list items. I was first planning on using a Selection State HandlingIn order to keep track of which items are currently selected (as the private Dictionary<Control, bool> selectedItemsMap = new Dictionary<Control, bool>();
Two boolean members define the selection model for the list box;
And, as this is a .NET 3.5 project, these are declared as automatic properties: /// <summary>
/// If set to <c>True</c> multiple items can be selected at the same
/// time, otherwise a selected item is automatically de-selected when
/// a new item is selected.
/// </summary>
public bool MultiSelectEnabled
{
get;
set;
}
/// <summary>
/// If set to <c>True</c> then the user can explicitly unselect a
/// selected item.
/// </summary>
public bool UnselectEnabled
{
get;
set;
}
List Event HandlingA custom event that fires whenever a list item is clicked is also defined: /// <summary>
/// Delegate used to handle clicking of list items.
/// </summary>
public delegate void ListItemClickedHandler(SmoothListbox sender,
Control listItem, bool isSelected);
class SmoothListBox
{
/// <summary>
/// Event that clients hooks into to get item clicked events.
/// </summary>
public event ListItemClickedHandler ListItemClicked;
...
}
Basic List Box DefinitionSo, 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
Making it SmoothOK, 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 HandlingWhen 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 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 DraggingMouseDownThe 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: /// <summary>
/// Handles mouse down events by storing a set of
The MouseMoveThe 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)
{
// The lock flag prevents too frequent rendering of the
// controls, something which becomes an issue of Devices
// because of their limited performance.
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 private void AnimationTick(object sender, EventArgs e)
{
renderLockFlag = false;
DoAutomaticMotion();
}
MouseUpThe 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 private void MouseUpHandler(object sender, MouseEventArgs e)
{
// Only calculate a animation velocity and start animating if the mouse
// up event occurs directly after the mouse move.
if (renderLockFlag)
{
velocity = Math.Min(Math.Max(dragDistanceFactor *
draggedDistance, -maxVelocity), maxVelocity);
draggedDistance = 0;
DoAutomaticMotion();
}
if (e.Button == MouseButtons.Left)
{
// If the mouse was lifted from the same location it was pressed down on
// then this is not a drag but a click, do item selection logic instead
// of dragging logic.
if (Utils.GetAbsolute(new Point(e.X, e.Y), sender as Control,
this).Equals(mouseDownPoint))
{
// Get the list item (regardless if it was a child Control that was clicked).
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;
}
}
// After "normal" selection rules have been applied,
// check if the list items affected are IExtendedListItems
// and call the appropriate methods if it is so.
foreach (Control listItem in itemsPanel.Controls)
{
if (listItem is IExtendedListItem)
(listItem as IExtendedListItem).SelectedChanged(
selectedItemsMap[listItem]);
}
// Force a re-layout of all items
LayoutItems();
}
}
}
}
mouseIsDown = false;
}
AnimatingAs seen in some of the above snippets, a method called
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, // If the velocity induced by the user dragging the list
// results in a deltaDistance greater than 1.0f pixels
// then scroll the items that distance.
if (Math.Abs(deltaDistance) >= 1.0f)
ScrollItems((int)deltaDistance);
else
{
...
}
The The The full private void DoAutomaticMotion()
{
if (!mouseIsDown)
{
velocity *= deaccelerationFactor;
float elapsedTime = animationTimer.Interval / 1000.0f;
float deltaDistance = elapsedTime * velocity;
// If the velocity induced by the user dragging the list
// results in a deltaDistance greater than 1.0f pixels
// then scroll the items that distance.
if (Math.Abs(deltaDistance) >= 1.0f)
ScrollItems((int)deltaDistance);
else
{
// If the velocity is not large enough to scroll
// the items we need to check if the list is
// "out-of-bound" and in that case snap it back.
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 private void ScrollItems(int offset)
{
// Do not waste time if this is a pointless scroll...
if (offset == 0)
return;
SuspendLayout();
itemsPanel.Top += offset;
ResumeLayout(true);
}
Key HandlingThe 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 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;
//System.Diagnostics.Debug.WriteLine("Out: " + velocity);
renderLockFlag = false;
DoAutomaticMotion();
}
The Custom List ItemsAs 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 The example application provides three different usages of the
Album InformationThe album information list item, the #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 BigPanelIn order to show the versatility of the ConclusionAll 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 InterestIt'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
| |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||