65.9K
CodeProject 正在变化。 阅读更多。
Home

InternetToolTip

starIconstarIconstarIconstarIconstarIcon

5.00/5 (28投票s)

2011年12月2日

CPOL

15分钟阅读

viewsIcon

52009

downloadIcon

3050

为 Windows Forms 及更多功能提供更好的 ToolTip。

InternetToolTip control provides the basic ToolTip functionality and a lot more

引言

此控件扩展了标准的 ToolTip,提供了多种可能性,例如从外部(例如 Internet)源异步检索 ToolTip。它易于实现和扩展。

背景

多年来,我一直在与在 Windows Forms 应用程序中使用 .NET Framework 提供的 ToolTip 控件的想法作斗争。一方面,它是一个很好的控件,提供了轻松集成可见工具提示的可能性。另一方面,它无法以任何方式进行扩展。由于我目前正在对我的一个应用程序进行重大更新,因此我想包含一些可以来自任意数据源的精美工具提示。

此标准 ToolTip 控件不包含两个主要的自定义功能:按需绘制和按需使用数据。InternetToolTip 为编写我们自己的数据提供者(如何处理工具提示字符串)以及我们自己的视图提供者奠定了基础。我编写的那些使 InternetToolTip 拥有与标准控件相同的能力,甚至更多。

要求

此代码需要 C#(2.0 及更高版本)以及 Microsoft .NET 2.0 或更高版本。所使用的技术是 Windows Forms。但是,Windows Forms 仅在测试应用程序/与示例控件和示例视图结合使用时需要。基本上,它可以移植到其他表示技术甚至 ASP.NET。但是,我认为主类 InternetToolTip 的继承需要进行一些更改。因此,该项目主要面向 Windows Forms。

目标

InternetToolTip 控件应提供完全自定义的可能性,即以程序员希望的方式显示数据。数据源不应依赖于控件本身,也可以由程序员提供。

应实现以下目标

  • 数据视图和数据源可以完全自定义或重写,而无需重写控件本身。
  • 控件应类似于现有的工具提示控件,即可以不进行任何重写,并具有大致相同的功能。
  • 控件应支持异步请求。
  • 由于语法相似,因此用新控件替换旧控件的努力应降到最低。

我的目标

所有这些目标都很好,但有点太笼统了。我对这个控件的个人目标是什么?

  • 我想编写一个(比提供的更复杂的)Web 服务数据提供程序,该程序从特定的 Web 服务获取数据。
  • 表单中提供的工具提示用作数据请求字符串,即它们将是 Web 服务调用的参数之一。
  • 返回值用于绘制一些信息,并可用于打开浏览器以获取更详细的帮助条目。

示例应用程序

提供的代码构建了工具提示控件和派生控件的基础。我包含了两个基本控件

  1. 一个非常简单的灯泡控件。它的用法非常简单——它只是一个自定义图像(默认为显示的灯泡)。
  2. 一个更复杂的工具提示文本框控件。它扩展了标准文本框,并显示一个微小的灯泡在文本框后面。此控件实际包含一个 InternetToolTip 控件——在这里您只需要提供自定义数据提供程序和数据视图(如果需要)。

我还包含了一个基本的数据提供程序和一个基本的数据视图。两者旨在为 InternetToolTip 提供与标准工具提示控件相同的功能。视图控件更进一步,包含一些动画函数,例如

  • 出现(工具提示将直接出现),
  • 淡入(工具提示将淡入),
  • 滑动(工具提示将从屏幕顶部向下滑动),以及
  • 滑动淡入(工具提示将淡入并从屏幕顶部向下滑动)。

这些动画可以运行任意设定的时间(以毫秒为单位)(例如,500ms2000ms,...)。

提供的解决方案包含一个自定义数据提供程序的示例实现。在这里,我使用 Timer 类的实例来模拟从任意数据源(互联网网站/服务、数据库……)获取数据的过程。Tick 事件用于模拟异步请求,而 Thread.Sleep() 方法用于模拟同步请求。这应该能显示异步请求的强大功能。

概念

The concept behind the structure of the InternetToolTip control

为了保持灵活性,我使用了接口。接口比使用(抽象)类具有许多优势。首先,它不限制您继承现有类,这意味着您不必直接继承我的接口。只需在需要的地方使用构造计划。其次,我无需在每个地方重复 abstract 关键字,因为接口只能包含抽象方法和属性。我也无需指定这些方法是 virtual 的,因为接口中的每个方法都是虚拟的。因此,我获得了更简洁的代码(需要更少的关键字),这提供了更大的灵活性。

InternetToolTip 控件依赖于两个接口

  • IToolTipView – 负责在屏幕上显示。我提供了一个示例,它类似于标准 ToolTip 控件的视图。此视图有一些优点。稍后我将详细介绍。
  • IToolTipDataProvider – 在这里我们获取数据。为了不使事情复杂化,我只使用了一个数据字符串。这个数据字符串可以通过我提供的示例实现表示为工具提示。但是,数据字符串仅负责区分不同的工具提示——而不是工具提示本身。因此,在您(免费)实现数据提供程序时,您可以使用数据字符串从数据库或其他资源获取实际的工具提示。示例控件仅返回数据字符串,从而模拟了真实工具提示控件的行为。

数据视图必须包含以下方法

void ShowToolTip(Point pt, Size sz);
void HideToolTip();
void DrawLoading(string text);
void DrawToolTip(object tooltip);
void DrawException(Exception exception);

这确保了可以在某个坐标处显示工具提示,其中传递了请求此工具提示的控件的左上角坐标和大小作为参数。坐标始终以屏幕坐标传递。此外,InternetToolTip 控件可以指示视图提供程序停止显示其信息。当鼠标离开工具提示区域时就是这种情况。绘制方法会通知视图状态的变化。如果启动了异步请求,则必须呈现加载屏幕。如果请求成功返回,则必须绘制工具提示。发生异常时,需要一个特殊例程。

数据提供程序必须包含以下方法

object RequestData(string request);
void RequestDataAsync(string request, Control userState);
void CancelDataAsync(Control userState);
event ToolTipDataEventHandler RequestDataHandled;

这确保了数据可以同步和异步请求。后者有一个作为事件实现的 callback。该事件与包含更详细 EventArgs 实现的新委托集成。给出的更具体的 EventArgs 包含

  • Control
  • 结果
  • 成功
  • 异常

由于可能发生多个(异步请求的)请求,我们需要一个状态来确定请求了哪个工具提示。这通过包含工具提示的控件来完成。这也可以用于缓存工具提示!

结果保存为对象,因为我心中有各种各样的对象。我有一个想法是获取更多数据并将其作为预览字符串与较长的字符串以及最详细版本的 Internet URL 返回。因此,我需要三个信息(“预览”、“视图”、“URL”),这需要一个特殊的数据视图。为了保持灵活性,我决定选择 object 并在我的数据视图通用实现中使用 ToString() 方法。因此,如果您想传输自定义对象,并且需要以自定义方式绘制它们,则必须重写我的基本视图提供程序或实现您自己的。无论如何,您不必触摸 InternetToolTip 或数据提供程序接口。

Success 决定请求是否成功返回,即是否发生异常。异常也已保存,并且可以(应该)显示。我提供的基本视图显示了异常。

这是 InternetToolTip 控件的属性

IToolTipDataProvider dataProvider;
IToolTipView dataView;
bool async;
string loadText;

这些是可以通过相应属性更改的变量。前两个只是特定接口实现的占位符。借助 Async 属性,您可以决定是要使用异步还是同步请求。LoadText 属性设置在加载工具提示时显示的字符串。

实现问题

The class diagram

实现(一如既往)并非总是一帆风顺。我将详细介绍最有趣的部分。

控件本身是一个 Component。这意味着我们在某个基本级别,开销不大。由于我们不希望控件(直接)显示,因此无需继承自 Control 或其他更复杂的类。

[ProvideProperty("ToolTip", typeof(Control))]
public class InternetToolTip : Component, IExtenderProvider
{ /* ... */ }

我还使用了 IExtenderProvider 接口来告诉设计器关于扩展具有以下功能的控件的可能性

public bool CanExtend(object extendee)
{
  return (extendee is Control && !(extendee is Form) && 
         !(extendee is InternetToolTip));
}

这意味着只有控件而不是窗体或 InternetToolTip 控件本身可以被扩展。由于我将 ToolTip 属性设置为随控件一起提供,因此我必须创建两个方法

public void SetToolTip(Control control, string caption)
{
  if (caption.Equals(string.Empty))
  {
    if (collection.ContainsKey(control))
    {
      control.MouseEnter -= new EventHandler(control_MouseEnter);
      control.MouseLeave -= new EventHandler(control_MouseLeave);
      collection.Remove(control);
    }
    return;
  }
  if (collection.ContainsKey(control))
    collection[control] = caption;
  else
  {
    collection.Add(control, caption);
    control.MouseEnter += new EventHandler(control_MouseEnter);
    control.MouseLeave += new EventHandler(control_MouseLeave);
  }
}
public string GetToolTip(Control control)
{
  if (collection.ContainsKey(control))
    return collection[control].ToString();
  return string.Empty;
}

如果设计器或其他人想设置一个空字符串的工具提示,它将要么从工具提示控件集合(包括所有事件)中删除该控件,要么什么也不做。否则,它将要么更改为该控件设置的工具提示,要么添加具有提供的工具提示的控件到集合中。在这种情况下,我们也必须设置事件。

get 方法仅查找集合中的指定控件。如果集合不包含该控件,则返回空字符串(这意味着未为此控件设置工具提示)。

真正的交互是通过控件事件(在这种情况下是鼠标进入和鼠标离开)发生的。代码读取

void control_MouseLeave(object sender, EventArgs e)
{
  dataView.HideToolTip();
  if (loading)
  {
    dataProvider.CancelDataAsync(sender as Control);
    if ((sender as Control).Equals(active))
      loading = false;
  }
}
void control_MouseEnter(object sender, EventArgs e)
{
  loading = async;
  Control c = sender as Control;
  active = c;
  Point p = c.Parent.PointToScreen(c.Location);
  dataView.ShowToolTip(p, c.Size);
  if (async)
  {
    dataView.DrawLoading(loadText);
    dataProvider.RequestDataAsync(collection[c].ToString(), c);
  }
  else
  {
    try
    {
      dataView.DrawToolTip(dataProvider
        .RequestData(collection[c].ToString()));
    }
    catch (Exception ex)
    {
      dataView.DrawException(ex);
    }
  }
}
void OnRequest(object sender, ToolTipDataEventArgs e)
{
  if (!e.Control.Equals(active))
    return;
  loading = false;
  if (e.Success)
    dataView.DrawToolTip(e.Result);
  else
    dataView.DrawException(e.Exception);
}

这里发生了什么?首先,当鼠标离开控件时,我们想隐藏工具提示。这看起来很简单,但我们也必须考虑异步情况。也许数据提供程序已经实现了取消方法。因此,如果仍在加载(只有在设置了 Asynctrue 时才可能),那么我们应该调用异步数据请求的取消方法。更重要的是,如果控件是活动控件(我们正在查看的控件),那么我们可以将 loading 设置为 false,因为我们刚刚离开了加载模式。

下一件事对应于鼠标指针进入控件的事件。loading 的状态由 async 的总体状态决定。在非异步模式下,我们将直接调用该方法以获取工具提示字符串——因此不会有任何加载。接下来,活动控件由刚刚进入的控件设置。这由发送者确定。我们可以进行此转换——因为 CanExtend() 方法已确保只有继承自 Control 的控件才能被扩展,从而进入我们的集合/事件系统。

读取当前屏幕位置后,我们调用视图的 Show 方法并请求我们的工具提示字符串。async 模式非常简单(我们设置加载然后进行异步请求,该请求在成功或失败时触发事件),而另一种模式则包装在 try-catch 块中。这是从这个非异步请求获取异常作为反馈的唯一方法,因为结果将始终是 object 类型。我们不知道类型为 Exception 的对象是否意味着发生了一个异常。因此,这应该提供一些灵活性。

代码的最后一部分代表了请求完成后触发的方法。在这里,我们只查看状态——如果控件不是当前控件,则该调用已过时。否则,我们区分成功调用和异常。

让我们看看提供的数据提供程序的实现。我包含了 StringDataProvider,以便立即为我的控件提供与普通 ToolTip 控件相同的功能

public class StringDataProvider : IToolTipDataProvider
{
  public object RequestData(string request)
  {
    return request;
  }
  public void RequestDataAsync(string request, Control userState)
  {
    if (RequestDataHandled != null)
      RequestDataHandled(this, new ToolTipDataEventArgs(request, userState));
  }
public void CancelDataAsync(Control userState)
{ /*Nothing to do here*/ }
public event ToolTipDataEventHandler RequestDataHandled;
}

这是一个非常简单的实现,它只返回传入的参数。因此,我们可以省略 CancelDataAsync。但是,由于接口的使用限制,我们必须实现该方法。所以我们只设置一个空方法体。

提供的视图实现更有趣一些。我称之为 BasicToolTipView。经过一些研究,我发现使用顶级窗体将是一种有条理地绘制工具提示的解决方案。

public partial class BasicToolTipView : Form, IToolTipView
{
  //Membervariables ...
  const int SW_SHOWNOACTIVATE = 4;
  const int HWND_TOPMOST = -1;
  const uint SWP_NOACTIVATE = 0x0010;
  const int MS_PER_FRAME = 13;

  public event DrawToolTipEventHandler DrawToolTipView;
  public event MeasureToolTipEventHandler MeasureToolTipView;

  //Constructor ...
  //Some methods ...

  [DllImport("user32.dll", EntryPoint = "SetWindowPos")]
  static extern bool SetWindowPos(...);
  [DllImport("user32.dll")]
  static extern bool ShowWindow(...);

  //Some more methods ...

  public void ShowToolTip(Point p, Size sz)
  { 
    Location = new Point(p.X + sz.Width / 2 - Width / 2, p.Y - Height);
    ShowWindow(Handle, SW_SHOWNOACTIVATE);
    SetWindowPos(Handle.ToInt32(), HWND_TOPMOST, Left, 
                 Top, Width, Height, SWP_NOACTIVATE);

    if (ShowEffect == Effect.Appear)
    {
      //...
    }
    else
    {
      //...
      effectTimer.Start();
    }
  }
  public void HideToolTip()
  {
    if (frames > 0)
      effectTimer.Stop();
    Hide();
  }

  //Even more methods ... 
  //Properties ...

  public enum Effect
  {
     Appear, Fade, Slide, SlideFade
  }
}

首先,此窗体不仅继承自 Form,还继承自 IToolTipView。这就是我选择接口的原因——完全灵活。这个实现有什么难点?为什么它是一个好的实现?在 .NET 中创建顶级窗体并不是一项真正的艰巨任务。防止窗体抢占焦点很困难。然而,一些聪明的开发者已经进行了良好的研究,并找到了正确的 API 调用来赋予我们的窗体这种(缺失的)能力。我在“参考文献”列表中提供了帮助我解决这个问题的链接。诀窍在于使用正确的 DLL 调用以及正确的方法和常量。总而言之,这 all about the right call! 这就是为什么 ShowToolTip()HideToolTip() 方法通常会优于 Windows Forms 中实现的标准 Show()Hide() 方法。我的 Show 方法执行以下操作

  1. 它将位置设置在其使用的控件的水平中心和垂直顶部。
  2. 它使用 Win API 调用来显示自身——与常规调用的区别在于一个常量,该常量告诉操作系统不要给显示的窗体焦点。
  3. 它使用 Win API 调用将其置于顶级。
  4. 它根据设置的动画执行一些操作。如果效果处于 Appear 模式,它将直接出现。在淡入模式下,它将计算每 Tick 事件可以增加的透明度,并将起始透明度设置为 0。滑动模式的计算也类似。
  5. 如果设置了动画(效果与 Appear 不同),则启动计时器。

我的 Hide 方法检查动画是否正在运行并取消动画。然后,当然,使用 Hide() 方法来隐藏窗体。这里不能使用 Close(),因为我们不想为每个工具提示重新创建窗体——只需在不同位置显示不同内容。

Effect 枚举嵌套在类中以显示关系。它提供了可能的动画效果。目前基本上只有“无动画”、“从顶部滑动”、“淡入”以及这两种效果的组合。此外,还可以设置动画的时长。所有这些(效果和时长)都可以通过属性或以下方法完成

public BasicToolTipView Animation(Effect effect, int duration)
{
  ShowEffectTime = duration;
  ShowEffect = effect;
  return this;
}

此方法一次设置两个属性并返回当前实例。这称为链接。这种概念允许用户使用其他非返回方法。

我包含了使用我预制控件(因此我的“示例”工具提示视图提供程序)进行自定义绘制的可能性。与 ListBox 和其他 Windows Forms 控件一样,这是通过将 DrawMode 属性设置为 OwnerDrawFixedOwnerDrawVariable 并处理由我的视图提供程序触发的事件来完成的。

标准绘制将创建这样的气泡形状

Drawing the geometrie of the tooltip bubble

高度通常为 32 像素。这仅对加载消息很重要。真正的工具提示的内容高度将限制为 18 像素。因此,这 18 像素可被视为内容的一种最小高度。要显示真正的工具提示,必须指定最大宽度。然后,当最大宽度不足以显示工具提示时,高度会增加。文本大小的测量使用静态 TextRenderer 类,该类提供字符串绘制和测量功能,这在与建议的高度和宽度结合使用时非常有用。这些方法也与 TextFormatFlags 枚举很好地协同工作。

使用代码

您可以在设计器中使用该控件。由于我构建该控件是为了模仿原始 ToolTip 控件,因此使用我的控件的方式原则上是相同的。但是,如果您想超越标准功能,则必须使用代码隐藏。以下代码创建了我控件的新实例(也可以通过设计器完成)并自定义了提供程序

//Create instance
InternetToolTip toolTips = new InternetToolTip();
//Sets the text displayed while async. requests
toolTips.LoadText = "Please wait!";
//Sets the data provider to a new one (provided in the sample code)
toolTips.DataProvider = new SampleDataProvider();
//Gets the default view provider
BasicToolTipView toolView = toolTips.DataView as BasicToolTipView;
//Sets the animation effect to slide with a duration of 200ms
toolView.Animation(BasicToolTipView.Effect.Slide, 200);
//Changes the first gradient color to light blue
toolView.GradientColorOne = Color.LightBlue;
//Changes the second gradient color to light gray
toolView.GradientColorTwo = Color.LightGray;

BasicToolTipView 可以通过以下属性和事件进行绘制

//Sets the DrawMode to a custom mode
toolView.DrawMode = DrawMode.OwnerDrawVariable;
//Assigns the events -- setting the callbacks
toolView.DrawToolTipView += 
  new rsi.Controls.iToolTip.DrawToolTipEventHandler(drawToolTipView);
toolView.MeasureToolTipView += new MeasureToolTipEventHandler(measureToolTipView);

在这里,我们将两个重要事件分配给了我们窗体/控件中的回调方法。这些函数可以这样实现

void measureToolTipView(object sender, MeasureToolTipEventArgs e)
{
  e.Height = TextRenderer.MeasureText(e.ToolTip.ToString(), 
             e.Font, new Size(e.Width, e.Height)).Height;
}

void drawToolTipView(object sender, rsi.Controls.iToolTip.DrawToolTipEventArgs e)
{
  //Draws the Background
  e.DrawBackground();
  //Draws some Text in the middle of the bubble
  TextRenderer.DrawText(e.Graphics, e.ToolTip.ToString(), e.Font, e.Bounds, e.ForeColor, 
                        TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
}

关注点

虽然这不是此项目的重点,但我也构建了一个非常小的控件(用于示例应用程序——但它在其他应用程序中也可能很有用),它只不过是一个(可选的)图像。但是,控件的大小始终与其图像的大小完全一致。默认图像是一个灯泡(因此是 LightBulb 控件)。此外,我还构建了一个更大的控件,它是一个带集成 InternetToolTip 的(提示)文本框。我构建这个控件是因为它展示了控件如何在任何自定义控件中使用。另一个原因是展示我的目标方向 InternetToolTip:拥有提供自定义设计其自身工具提示的专用控件。

对我来说,集成动画相当壮观。这是我长期以来想在 Windows Forms 应用程序中实现的东西。我对性能感到好奇,并且喜欢结果。我认为为 Windows Forms 应用程序编写动画框架会非常方便和有用。一个非常好的方法已经在此处 CodeProject 上发布。

参考文献

我发现以下链接很有用

  • 在 StackOverflow 上解决了显示窗体而不抢占焦点的问题(在此处)。
  • 有必要进行介绍性讨论,以确定一种有效的屏幕绘制方法(在此处)。
  • 一篇关于处理异常的短文(在此处)。

历史

  • v1.0.0 | 初始版本 | 2011 年 12 月 1 日。
© . All rights reserved.