适用于 .NET Compact Framework 的图标菜单





5.00/5 (12投票s)
如何使用 .NET Compact Framework 实现 iPhone 风格的图标菜单。
引言
不久前,我还在向一位朋友抱怨我没有有趣的业余项目可做;我的朋友让我停止抱怨,直接实现一个 iPhone 风格的菜单。
本文演示了一种创建动画、可编辑的图标菜单的方法,类似于 iPhone 主屏幕上的菜单。
作为免责声明,我想说这不是直接模仿 iPhone 菜单的尝试;本文旨在描述一些我们可以用来为 GUI 制作动画的编码技术(iPhone 主菜单比这个实现要好得多,好得多,好得多)。
本文中的一些代码与我“实现平滑动画的 ListBox”文章中的动画代码相似,但这篇文章使用了自定义绘制和离屏技术,这些概念在为 GUI 增添色彩时非常有用。
要求
在我开始这个实现之前,我决定我的菜单必须满足以下要求:
- 易于用手指操作,而不是用触笔。
- 能够在运行时手动重新排列图标布局。
- 能够在运行时手动删除图标。
我没有实现 iPhone 上精美的分页功能,即菜单跨越多个屏幕,但我很想看看是否能将这个菜单连接到我的平滑列表框并实现相同的效果。
Using the Code
Zip 文件包含一个 Visual Studio 解决方案,其中有两个项目:一个用于 PocketPC 2003,一个用于 Windows 桌面。我发现将我的东西放在桌面上测试更容易、更快,因为我不必等待模拟器。
菜单的工作方式如下:
- 单击图标即可启动或打开它。
- 单击并按住即可进入编辑模式;编辑模式通过图标的摆动以及可删除图标左上角的删除图标来指示。拖放即可重新排列图标。
- 要退出编辑模式,单击任意位置。如果单击了删除图标,则该图标将被删除。
在示例实现中,只有一个计算器图标 attached 了事件处理程序,因此它是一个单击后会产生效果的图标。
菜单
对于这个项目,我开始时创建了一个用于菜单的用户控件。该控件负责托管图标并为它们添加动画,但它不负责每个图标的实际位置,这由 Icon
类负责。IconMenu
类只告诉其图标它们大概应该在什么位置,由图标自行决定是否要在该位置渲染自身。
对齐到网格
在 iPhone 上,图标会自行对齐到一个整齐的网格,因此我的 IconMenu
类包含一个 Slot
列表,Slot
类有一个定义其边界的 Rectangle
,并且可能有一个对图标的引用。IconMenu
拥有一个 Slot
列表而不是一个二维 Slot
数组,因为它会在屏幕倾斜时重新排列 Slot
。
public class Slot
{
private Rectangle bounds;
private Icon icon;
public Slot(Rectangle bounds)
{
this.bounds = bounds;
}
public bool IsEmpty
{
get { return icon == null; }
}
public Icon Icon
{
get { return icon; }
set { icon = value; }
}
public Rectangle Bounds
{
get { return bounds; }
}
}
IconMenu 类
IconMenu
类有四个主要职责:
- 拥有图标并为它们提供首选布局
- 处理动画
- 处理鼠标/触笔输入
- 向客户端代码触发事件
图标所有权
通过维护一个 Icon
列表和一个 Slot
列表,IconMenu
可以将每个 Icon
分配给一个 Slot
,并且由于 Slot
存储了 Rectangle
形式的布局信息,因此 IconMenu
在将每个图标分配给 Slot
后即可获得其首选位置。
Icon
s 还跟踪它们所属的 Slot
,以便在需要渲染到屏幕时获取它们的首选位置。
Icon
s 和 Slot
s 在 AssignSlotToIcons
方法中分配。
private void AssignSlotToIcons()
{
foreach (Slot slot in slots)
{
slot.Icon = null;
}
foreach (Icon icon in icons)
{
icon.Slot = null;
foreach (Slot slot in slots)
{
if (slot.IsEmpty)
{
icon.Slot = slot;
slot.Icon = icon;
break;
}
}
}
}
每当 IconMenu
需要重新排列 Icon
s 时,就会执行此操作,这又发生在 IconMenu
大小发生变化或 Icon
被删除时。
Relayout
方法负责重新排列 Slot
s。
private void Relayout()
{
// This forces a new offscreen to be generated
// when the screen changes size.
offscreen = null;
// Calculate how many vertical and horizontal slots will fit
int numberOfHorizontalSlots = Width / slotWidth;
int numberOfVerticalSlots = Height / slotHeight;
// Figure out how much padding is required to center the icons
int horizontalPadding = (Width % slotWidth) / 2;
int verticalPadding = (Height % slotHeight) / 2;
// Sort the icons according to the slot they're currently in
icons.Sort(delegate(Icon left, Icon right)
{
if (left.Slot == null)
return 1;
else
{
if (right.Slot == null)
return -1;
}
return slots.IndexOf(left.Slot) - slots.IndexOf(right.Slot);
});
slots = new List<slot>();
// Iterate over as many slots that fit on the screen
for (int i = 0; verticalPadding + (i + 1) * slotHeight < Height; ++i)
{
for (int j = 0; j < numberOfHorizontalSlots; ++j)
{
// Add a new Slot with the preferred position
slots.Add(
new Slot(
new Rectangle(horizontalPadding + j * slotWidth,
verticalPadding + i * slotHeight,
slotWidth,
slotHeight)));
}
}
// Assign the Slots to Icons and vice versa
AssignSlotToIcons();
Invalidate();
}
这样,Icon
s 几乎总会有一个它们所属的 Slot
;规则的例外是当用户在运行时手动更改图标布局时。在这种情况下,图标负责忽略其分配的 Slot
,并在触笔/鼠标所在的位置自行渲染。
渲染
为了提高性能,我决定采用自定义渲染,这也很方便,因为它让我能够更好地控制控件的绘制方式。
为了减少闪烁,我使用了“离屏”技术;这是一种概念,所有 UI 元素首先被渲染到一个不可见的内存缓冲区,然后将该缓冲区的内容复制到屏幕内存中。由于复制操作速度很快,这可以防止任何未完全绘制的控件暂时显示给用户。这可以减少或消除闪烁。
离屏实际上只是一个来自 Image
的 Graphics
对象,并且每当菜单大小改变时,都会根据 IconMenu
的大小重新创建该 Image。
public class IconMenu : UserControl
{
...
private Image offscreenImage = null;
private Graphics offscreen = null;
private void CreateOffscreen()
{
offscreenImage = new Bitmap(Math.Max(Width, 1), Math.Max(Height, 1));
offscreen = Graphics.FromImage(offscreenImage);
}
...
}
渲染在 OnPaintBackground
中完成,首先用背景色填充整个离屏,然后请求所有 Icon
s(当前选中的除外)将自身渲染到离屏。之所以排除选中的图标,是因为要最后渲染它,从而使其渲染在所有其他图标之上,因为当前控件被另一个控件部分或完全遮挡会很烦人。
protected override void OnPaintBackground(PaintEventArgs e)
{
if (offscreen == null)
{
CreateOffscreen();
}
offscreen.FillRectangle(new SolidBrush(BackColor), ClientRectangle);
foreach (Icon icon in icons)
{
if (!icon.IsMouseControlled)
{
icon.Paint(offscreen, state == State.Edit, deleteMarker);
}
}
if (selectedIcon != null)
{
selectedIcon.Paint(offscreen, state == State.Edit, deleteMarker);
}
e.Graphics.DrawImage(offscreenImage, 0, 0);
}
Icon 类
Icon
类负责图标的视觉表示、其位置以及维护如果单击图标应调用的委托。
为了让 Icon
s 在用户手动重新排列图标时移动到新 Slot
,我决定存储两个位置:实际位置和期望位置。这意味着,如果用户将选中的 Icon
拖动到另一个 Icon
的 Slot
中,我只需要为需要移出的 Icon
设置一个新的期望位置,以便为选中的 Icon
让出空间。Icon
可以自行确定如何到达那里以及以何种速度。
public class Icon
{
...
private Vector location = new Vector();
private Vector desiredLocation = new Vector();
...
public void UpdateOnMouseLocation(Point mouseLocation)
{
desiredLocation = (Vector)mouseLocation;
// Adjust the desired location based on this Icon's Image's size
// (right shifting one is a neat way of dividing by two).
desiredLocation.X -= image.Width >> 1;
desiredLocation.Y -= image.Height >> 1;
}
}
图标动画
Icon
的动画方法会重新计算 Icon
的实际位置,并考虑两件事:
- 图标当前是否正在被拖动(
mouseControlled
为true
)? IconMenu
是否正在被用户手动重新排列?
如果 Icon
正在被拖动,那么它的位置始终设置为其期望位置,即鼠标/触笔的位置。
如果 IconMenu
正在被重新排列,那么会添加一个随机偏移量,使 Icon
s 稍微晃动一下。不幸的是,我无法像 iPhone 那样轻松应用旋转变换,所以我只让图标稍微摆动一下。它看起来不如 iPhone 上的好,但对于这篇文章来说,我觉得足够了。
public void Animate(float elapsedTime, bool inEditState)
{
if (mouseControlled)
{
location = desiredLocation;
}
else
{
if (slot != null)
{
int x = slot.Bounds.Location.X +
((slot.Bounds.Width - size.Width) >> 1);
int y = slot.Bounds.Location.Y +
((slot.Bounds.Height - size.Height) >> 1);
if (inEditState)
{
x += random.Next(3, 6) * (random.Next(10) > 5 ? -1 : 1);
y += random.Next(3, 6) * (random.Next(10) > 5 ? -1 : 1);
}
desiredLocation.Set(x, y);
}
// Calculate the direction from the current location to the
// desired location.
Vector targetVector = desiredLocation - location;
// Calculate the velocity with which the Icon should move from
// the current location to the desired location; this velocity
// is always the number of pixels to the target per second.
float velocity = targetVector.Length;
targetVector.Normalize();
location += targetVector * velocity * elapsedTime;
}
}
图标渲染
Icon
的渲染负责在期望位置渲染 Icon
,并在编辑模式下渲染左上角的删除图标。
private void DrawImage(Graphics graphics, Image image,
Point point, int width, int height)
{
graphics.DrawImage(image, new Rectangle(point.X, point.Y, width, height),
0, 0, width, height,
GraphicsUnit.Pixel, imageAttributes);
}
public void Paint(Graphics graphics, bool inEditState, Image deleteMarker)
{
Point point = (Point)location;
DrawImage(graphics, image, point, size.Width, size.Height);
if (!readOnly && inEditState && deleteMarker != null)
{
DrawImage(graphics, deleteMarker, point,
deleteMarker.Width, deleteMarker.Height);
}
}
此图像显示了处于编辑状态的图标菜单。
处理输入
与我的平滑列表框一样,通过处理鼠标按下、移动和释放事件来确定单击或拖动了什么。其中第一个,鼠标按下,非常简单。
private void HandleMouseDown(object sender, MouseEventArgs e)
{
mouseIsDown = true;
mouseDownTimestamp = DateTime.Now;
mouseDownPosition = new Vector(e.X, e.Y);
Icon icon = GetIconAtPoint((Point)mouseDownPosition);
if (icon != null)
{
selectedIcon = icon;
}
}
只需记录时间戳,以便我们可以确定这是单击还是长按,并获取鼠标/触笔下的图标(如果有)。这通过两个辅助方法之一来完成。
private Slot GetSlotAtPoint(Point point)
{
foreach(Slot slot in slots)
{
if (slot.Bounds.Contains(point))
{
return slot;
}
}
return null;
}
private Icon GetIconAtPoint(Point point)
{
foreach (Icon icon in icons)
{
if (icon.Slot.Bounds.Contains(point))
{
return icon;
}
}
return null;
}
另一个方法 GetSlotAtPoint
,当然,用于获取鼠标/触笔下的 Slot
。
第二个事件,鼠标移动,稍微复杂一些,因为如果选中了鼠标(如 HandleMouseDown
中检测到的),则需要处理更多事情。
void HandleMouseMove(object sender, MouseEventArgs e)
{
if (selectedIcon != null)
{
// Are we already in edit mode
if (state == State.Edit)
{
// Figure out if the dragged icon has been dragged onto another
// slot, and it that case, swap the Slots referenced Icons.
// Also move all the other icons out of the way as well so
// that the current icon order is maintained.
Point mouseLocation = new Point(e.X, e.Y);
selectedIcon.UpdateOnMouseLocation(mouseLocation);
Slot slot = GetSlotAtPoint(mouseLocation);
if (slot != null)
{
int selectedIndex = slots.IndexOf(selectedIcon.Slot);
int currentIndex = slots.IndexOf(slot);
if (selectedIndex != currentIndex)
{
for (int i = selectedIndex;
i != currentIndex;
i += Math.Sign(currentIndex - selectedIndex))
{
Slot slotA = slots[i];
Slot slotB = slots[i + Math.Sign(currentIndex - selectedIndex)];
slotA.Icon = slotB.Icon;
if (slotA.Icon != null)
{
slotA.Icon.Slot = slotA;
}
}
slot.Icon = selectedIcon;
selectedIcon.Slot = slot;
}
}
}
else
{
// If the mouse has been down for more than clickDelay go into edit mode
if (DateTime.Now - mouseDownTimestamp >= clickDelay)
{
state = State.Edit;
selectedIcon.IsMouseControlled = true;
}
}
}
}
最后一部分,鼠标释放,也有一些事情要做。
private void HandleMouseUp(object sender, MouseEventArgs e)
{
mouseIsDown = false;
if (selectedIcon != null)
{
// Was the mouse down long enough for a click?
if (DateTime.Now - mouseDownTimestamp < clickDelay)
{
switch (state)
{
case State.Launch:
// Fire the launch event to let client code
// figure out how to start some application
selectedIcon.OnLaunch();
break;
case State.Edit:
// If we're in edit mode and this was a click in the
// upper left corner of an icon and that is not
// read only, run delete icon code
Slot slot = GetSlotAtPoint((Point)mouseDownPosition);
if (deleteMarker != null &&
slot != null &&
!slot.IsEmpty &&
!slot.Icon.IsReadOnly)
{
Vector slotPosition = (Vector)slot.Icon.Location;
Vector positionInSlot = mouseDownPosition - slotPosition;
if (positionInSlot.X < deleteMarker.Width &&
positionInSlot.Y < deleteMarker.Height)
{
DialogResult result =
MessageBox.Show(
"Are you sure you want to delete this?",
"Delete",
MessageBoxButtons.OKCancel,
MessageBoxIcon.Question,
MessageBoxDefaultButton.Button2);
if (result == DialogResult.OK)
{
icons.Remove(slot.Icon);
OnDeleted(slot.Icon);
slot.Icon = null;
Relayout();
}
break;
}
}
state = State.Launch;
selectedIcon.IsMouseControlled = false;
break;
}
}
else
{
int selectedIndex = slots.IndexOf(selectedIcon.Slot);
if (selectedIndex > icons.Count - 1)
{
selectedIcon.Slot.Icon = null;
Slot slot = slots[icons.Count - 1];
slot.Icon = selectedIcon;
selectedIcon.Slot = slot;
}
}
selectedIcon.IsMouseControlled = false;
selectedIcon = null;
}
else
{
// If we were in edit and got a single click we're leaving edit mode
if (state == State.Edit)
{
state = State.Launch;
}
}
}
遗漏的方面
正如我在引言中提到的,这不是一个完整的实现,只是展示了这类功能是如何实现的。因此,这个实现有很多不足之处:
- 当图标不可见时,动画应该暂停,以节省 CPU 周期。
- 当启动图标时,图标应该有一个预定义的模式,用于移出屏幕和返回屏幕。
- 当图标数量多于槽位时,会有一个导致列表损坏的 bug。
对此表示歉意。
一如既往,欢迎任何评论和建议。
历史
- 2009-04-21:第一个版本。