代码改变世界

一个绚丽的带动画的.NET CF列表控件(转载from Codeproject)

2008-04-18 17:53  cppguy  阅读(1352)  评论(1编辑  收藏  举报

 

 

picture1

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 i 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 snaps back into place. Very cool.

Background

As the standard ListBox in the .NET Compact Framework is kind of limited I wanted to create one which fulfilled three requirements;

  • 1. The list must scroll smoothly and rely on "friction" and "springs" to give it a intuitive feel.
  • 2. 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.
  • 3. 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 items 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 herits from UserControl.

picture3

The SmoothListBox showing Formla 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 Controls 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 Controls. To accomodate 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 going mySmoothListBox.Controls.Add(new MyListItem()) won't work as it will not add the list items to the correct container. Therefore two new methods called AddItem and RemoveItem needs to be implemented. This is actually quite good as the sematics 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 item to 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:

/// <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 handling

A custom event that fires whenever a list item was 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 definition

So, having defined all the necessary parts (well, kind of) for the list box the class definition can be constructed;

Collapse

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]);
        }

        /// <summary>
        /// Adds a new item to the list box.
        /// </summary>
        public void AddItem(Control control)
        {
            ...
        }

        /// <summary>
        /// Removes an item from the list box.
        /// </summary>
        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 alot 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 an uniform way. To accomodate 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 an static helper class called Utils:

Collapse

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 AddItem the method Utils.SetHandlers is called and after that the list box is aware of every mouse related thing that happends 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 happends 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 Point</C />s that
/// will be used to determine animation velocity.
/// </summary>
private void MouseDownHandler(object sender, MouseEventArgs e)
{
    if (e.Button == MouseButtons.Left)
    {
        mouseIsDown = true;
        // Since list items move when scrolled all locations are 
        // in absolute values (meaning local to "this" rather than to "sender".
        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 explaination.
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 when ever 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 it's 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 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 has 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 later chapter for details on this)checks has to be performed on the type of the affected list item to see if methods needs 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.

Collapse

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;
}
    
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.

picture2

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 deacceleration factor:

velocity *= deaccelerationFactor;
float elapsedTime = animationTimer.Interval / 1000.0f;
float deltaDistance = elapsedTime * velocity;    
    

The elapsed time is estimated, for convinience (read lazyness), to the timer interval. This probably should have been changed to an actual measured delta time between updates for a even smoother animation. A requested differece 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 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 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 beyound 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:

Collapse

 
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 itemsPanel in the ScrollItems method:

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);
}    
    

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:

picture4

Album Information

The album information list item, the Album class, implements IExtendedListItem to allow the list item 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.

Collapse

#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 relayout 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 alot of Controls and by adding such a panel to a SmoothListBox we get a new way of scrolling UI's 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 was 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 list to scroll smoothly, this is largely because of the limited CPU power of the Mobile Devices but also because since I wanted a list box that is really easy to use I couldn't make use of any native drawing methods, such as GDI.