实现流畅动画的 ListBox






4.94/5 (53投票s)
一篇关于创建iPhone风格ListBox的文章。
引言
本文介绍了如何实现一个流畅的列表框,该列表框允许直观的、受摩擦力影响的列表项滚动。所谓的摩擦力,是指用户在一个方向上施加拖动力,鼠标或触笔释放后,列表会继续滚动并自行减速。我研究了iPhone上的列表行为,并在一定程度上试图模仿它,但我远未达到iPhone所呈现的酷炫程度。
我认为iPhone列表框最棒的特性是,你不是通过滚动条来滚动它,而是直接拖动列表项,这要酷得多。
此外,在iPhone上,你可以滚动到列表框边界之外,列表项会自动“弹回”原位。非常酷。
注意:本文和下载内容现已更新,包含一个版本,您还可以使用键盘滚动列表。我在实现此功能时没有Visual Studio 9的访问权限,因此进行了一些更改以恢复到.NET 2.0的兼容性级别。
感谢Mixxer、mbrucedogs和Daniel Bass,他们提出了改进建议和实现思路,并推动我实现了键盘支持。不幸的是,我还没有时间实现他们提出的所有功能。
背景
由于.NET Compact Framework中的标准ListBox
功能有限,我希望创建一个满足三个要求的列表框;
- 列表必须平滑滚动,并依靠“摩擦力”和“弹簧”来提供直观的手感。
- 列表必须能够拥有不仅仅是文本和图标的列表项,任何
Control
都可以作为列表项。 - 列表必须足够快,以便在PocketPC 2003设备上运行。
总而言之,这些要求并不复杂,在将它首先为桌面实现,然后移植到.NET Compact Framework后,最让我头疼的要求实际上是第三个。
Using the Code
可下载的Visual Studio解决方案包含三个C#项目;
- Extended ListItems;这是一个类库项目,其中包含两个预定义的列表项控件,可用于显示音乐专辑或F1车队的信息。
- Smooth ListBox;该项目包含实际的列表框实现。
- Smooth ListBox (Test);这是一个测试项目,它创建了一个沙盒应用程序,以三种不同的方式尝试
SmoothListBox
。
我使用.NET 3.5实现了此解决方案,但将其移植到.NET 2.0应该也很容易。
创建自定义ListBox
由于我的SmoothListBox
在外观和行为上都将与System.Windows.Forms
命名空间中的列表框完全不同,我决定从“零”开始构建它,而不是继承自ListBox
。由于列表框应该在设计器中表现得像任何其他组件一样,因此它继承自UserControl
。
SmoothListBox
。列表项存储
首先要解决的是列表项的存储问题。我最初计划使用List<Control>
,但发现我也可以使用UserControl
提供的Controls
属性。从列表项本质上就是由SmoothListBox
拥有和显示的Control
的角度来看,这也很有意义。为了将来的目的,例如添加滚动条(当前实现不支持滚动条,因为我没看到它的必要性),列表项不会直接添加到SmoothListBox
的Controls
中。为了容纳更多不仅仅是列表项的东西被SmoothListBox
拥有,该控件持有一个内部Panel
,该Panel
直接拥有列表项。这意味着,执行mySmoothListBox.Controls.Add(new MyListItem())
将不起作用,因为它不会将列表项添加到正确的容器中。因此,需要实现两个新方法:AddItem
和RemoveItem
。这实际上很好,因为这样语义更合理(向列表添加项与操作容器中的控件不同)。
选择状态处理
为了跟踪当前选择的项(因为SmoothListBox
支持多选),使用了查找字典。它将列表项映射到选择状态,如下所示:
private Dictionary<Control, bool> selectedItemsMap = new Dictionary<Control, bool>();
两个布尔成员定义了列表框的选择模型;
MultiSelectEnabled
:当设置为true
时,选择模型允许同时选择多个项。如果设置为false
,则在选择新项时,已选择的列表项会自动取消选择。UnselectEnabled
:当设置为true
时,用户可以显式取消选择已选定的值;如果设置为false
,则唯一取消选择的方法是选择另一个值。
而且,由于这是一个.NET 3.5项目,这些被声明为自动属性
/// <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;
}
列表事件处理
还定义了一个自定义事件,当列表项被单击时触发
/// <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;
...
}
基本列表框定义
因此,在定义了列表框所有必要的部分(好吧,差不多)之后,就可以构造类定义了
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;
}
}
}
}
实现流畅
好的,存储一些信息和触发一个事件并不能使列表框以任何方式平滑滚动。为了实现这部分功能,需要在实现中添加大量的鼠标处理和动画代码。
鼠标处理
滚动时,我不想拖动滚动条的滑块,我想“抓住”列表中的任意位置进行拖动滚动(在后面会解释一些限制)。在iPhone上,用户只需用手指在列表中的任意位置“滑动”即可滚动列表。这是一种非常直观的滚动方式,我也希望我的列表框能有相同的行为。
根据要求#2,列表框必须支持任何继承自Control
的列表项。这意味着列表项可以包含任意数量的嵌套子控件,无论“基础”列表项控件是接收鼠标事件的控件,还是列表项的子控件之一,事件都需要以统一的方式处理。为了实现这一点,列表框全局鼠标事件监听器会递归添加到使用AddItem
方法添加的任何列表项中。在WPF中,这可以通过事件的冒泡或下沉来处理,但在WinForms中,我们必须自己来做。SmoothListBox
有三个成员,都是MouseEventHandler
,用于监听鼠标按下、抬起和移动事件。递归添加监听器是由一个名为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);
}
}
}
}
因此,在AddItem
方法中,会调用Utils.SetHandlers
,之后,列表框就能够感知到发生在列表项或其子控件上的所有鼠标相关事件。我们现在可以继续实现拖动功能了。
拖动
MouseDown
自然,处理鼠标拖动时首先要处理的是鼠标按下事件。就平滑滚动而言,鼠标按下事件中实际上并没有发生什么,除了存储一些稍后将被证明至关重要的信息。
/// <summary>
/// Handles mouse down events by storing a set of Point 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;
}
}
Utils.GetAbsolute
方法是一个辅助方法,它将子控件的局部坐标转换为父控件的局部坐标;这很重要,因为在拖动过程中我们会移动列表项,如果我们不查看绝对坐标,它们将不会按预期行为。
MouseMove
接下来自然要处理的是鼠标移动事件。同样,处理此事件(至少在概念上)不需要太多内容,但实现会涉及一些可能需要进一步解释的奇怪之处。鼠标移动处理程序需要做的是测量鼠标垂直移动的距离,然后确保列表项也按该距离滚动。很简单,对吧?好吧,这里是我遇到的一些奇怪之处。
由于列表必须定期重绘才能给人以遵守摩擦力和弹簧定律的动画效果,因此必须在特定的计时器事件触发时重绘。但是,当用户拖动鼠标使列表项的当前位置失效时,列表也必须重绘。这最初导致列表重绘过于频繁,导致移动设备挂起或运行极其缓慢。一种解决方法是关闭动画计时器,在鼠标拖动列表时关闭它,在鼠标释放时重新启用它。
我尝试了这种方法,但导致了触笔抬起和列表“自行”滚动之间存在过长的延迟,使其看起来有些不自然。我最终通过一种更简单的方法解决了这个问题。通过维护一个布尔成员,该成员由计时器事件处理方法和鼠标移动方法设置和重置,我可以丢弃发生过于频繁的更新,这些更新会导致列表移动“停滞”。
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;
}
}
}
从这个代码片段可以看出,只有当renderLockFlag
设置为false
时,才会处理鼠标移动事件。这意味着,如果在一个鼠标移动事件处理完毕之前又发生了一个鼠标移动事件,它就会被丢弃。该标志由动画滴答处理方法重置。
private void AnimationTick(object sender, EventArgs e)
{
renderLockFlag = false;
DoAutomaticMotion();
}
MouseUp
最后一个需要处理的鼠标事件是鼠标抬起事件;在这里,计算列表应该继续滚动的速度。此外,在鼠标抬起事件发生时,会处理列表项的选择/取消选择。
要计算列表的速度很简单;它只是拖动的距离(最后两次鼠标移动事件之间的距离)乘以一个常数因子。
要处理列表项的选择或取消选择,需要进行一些检查,因为SmoothListBox
允许不同的选择模式;可以禁用或启用多个项目选择,也可以显式取消选择项目。此外,由于列表项不一定需要实现IExtendedListItem
(有关详细信息请参见后续章节),因此必须对受影响的列表项类型进行检查,以确定是否需要在此列表项上调用方法。实现IExtendedListItem
的好处是,列表项可以自行决定被选择或取消选择时的行为,就像我在示例应用程序中所展示的那样,它们在被选择时会改变大小和/或内容。
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;
}
动画
如上面的一些代码片段所示,使用了一个名为DoAutomaticMovement
的方法。当鼠标或触笔不影响列表时,此方法会动画化列表的运动。
需要处理的一件事是计算列表动画的当前速度,并相应地更新列表项的位置。在此过程中会应用摩擦力,因为当前速度或速率乘以减速因子。
velocity *= deaccelerationFactor;
float elapsedTime = animationTimer.Interval / 1000.0f;
float deltaDistance = elapsedTime * velocity;
为了方便(或可以说是懒惰),估计的已用时间是计时器间隔。这可能应该更改为实际测量更新之间的 delta 时间,以获得更平滑的动画。计算所需的距离差deltaDistance
,因为只有当实际移动距离大于一像素时,我们才希望移动项并重新布局列表框。
// 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
{
...
}
ScrollItems
方法是实际按一定距离重新定位列表项的方法。
else
语句是必需的,因为如果减速导致列表项不再具有任何速度,我们需要检查列表是否已滚动“超出边界”。我希望用户能够施加足够的速度使列表项滚动到列表框的可见区域之外,当它们减速到零速度时,列表框会确保它们“弹回”到视图中。
完整的DoAutomaticMotion
如下所示
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))));
}
}
}
}
}
在ScrollItems
方法中,通过移动整个itemsPanel
来移动项。
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);
}
键盘处理
实现键盘处理的最简单方法是不要尝试像鼠标处理那样将键盘监听器挂接到所有相关元素。原因(根据Daniel Bass的说法)是键盘事件没有按预期触发。
我决定采用一种方法,在动画化列表移动之前读取键盘状态。这样,我就可以在向上或向下的方向上添加速度,这将使列表的速度发生加速变化。
为了获得异步键盘状态,我使用了DLL互操作调用。
public partial class SmoothListbox : UserControl
{
[DllImport("coredll.dll")]
public static extern int GetAsyncKeyState(int vkey);
...
}
然后可以在AnimationTick
方法中查询该方法。
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();
}
keyAcceleration
是一个命名不佳的成员,它定义了应添加多少额外的速度。可以通过KeyAcceleration
属性访问它。
自定义列表项
由于我希望列表框的项能够响应被选中或取消选中,因此需要一个由列表项类实现的接口。但是,由于我也希望此列表框能够处理几乎任何内容(至少是任何Control
),因此即使列表项不实现IExtendedListItem
接口,它们也必须能够工作。
示例应用程序提供了三种不同的SmoothListBox
用法,并提供了一种比较它们的方法。
专辑信息
专辑信息列表项Album
类实现了IExtendedListItem
,以便列表项在被选中时可以显示不同的信息。它还会根据项目在列表框中是偶数行还是奇数行来渲染不同的背景颜色。
#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
此列表项在被选中时会改变其大小,但SmoothListBox
足够智能,可以在选择或取消选择发生时重新布局其列表项,从而为用户整洁地自动处理。
BigPanel
为了展示SmoothListBox
的多功能性,一个示例场景是使用一个名为BigPanel
的类作为单个列表项。BigPanel
只是一个包含大量Control
的面板,通过将这样一个面板添加到SmoothListBox
中,我们获得了一种新的方式来滚动大于屏幕区域的UI。这种方式比在设置AutoScroll
为true
时获得的滚动条要直观。
结论
所有三个要求都已实现,但我希望性能能更好一些,提供更流畅的滚动。一如既往,欢迎对文章或代码提出任何意见。
关注点
让列表平滑滚动相当困难;这在很大程度上是因为移动设备的CPU能力有限。而且,因为我想要一个真正易于使用的列表框,所以我无法使用任何本地绘图方法,如GDI。
历史
- 2008-03-02:第一个版本。
- 2008-06-12:添加了键盘控制。