2015 年 8 月

第 30 卷,第 8 期

Windows 10 - Windows 通用应用程序的新型拖放

作者 Alain Zanchetta

本文建立在 Windows 10 和 Visual Studio 2015 的公共预览版的基础上。

拖放是一种直观的方式,用于在 Windows 桌面上的应用程序内部或应用程序之间传输数据。它首次亮相于配备有文件管理器的 Windows 3.1 中,然后扩展到支持 OLE 2 的所有应用程序,如 Microsoft Office。在发布 Windows 8 时,Microsoft 介绍了一种新类型的 Windows 应用程序(称为 Windows 应用商店应用),它是针对平板电脑进行设计的,并且始终以全屏模式显示。由于在指定时间里只能看到一个应用程序,因此跨应用程序拖放没有意义,从而开发了其他共享数据的方法,如“共享”超级按钮。但是,在 Windows 10 中,台式机上的应用程序再一次在窗口模式中运行,这表示屏幕上显示多个窗口,因此拖放又回归到通用 Windows 应用 API,其中添加了改进 UX 的新功能。

拖放概念

通过拖放,用户可以使用标准手势(使用手指长按并平移或使用鼠标或触笔按下并平移)在应用程序之间或应用程序内部传输数据。

拖放源(它是在其中触发拖动手势的应用程序或区域)提供可通过填充包含标准数据格式(包括文本、RTF、HTML、位图、存储项目或自定义数据格式)的数据程序包对象来传输的数据。此外,该源还指明它所支持的操作类型:复制、移动或链接。释放指针之后,出现了放置操作。放置目标(它是指针下方的应用程序或区域)处理数据程序包并返回它执行的操作类型。

在拖放期间,拖动 UI 可提供正在发生的拖放操作类型的可视指示。虽然此可视反馈最终由源提供,但是可随着指针移动到它们上方时由目标进行更改。

台式机、平板电脑和手机上都可以支持新型拖放。尽管本文只重点探讨适用于新型拖放的 XAML API,但是所有类型的应用程序(如经典 Windows 应用)之间或内部都支持数据传输。

实施拖放

拖动源和放置目标起到不同的作用。应用程序可以含有仅拖动源、仅放置目标或包含二者的 UI 组件(如图 1 中的示例 Photo Booth 应用程序所示)。

拖动源和放置目标
图 1 拖动源和放置目标

拖放操作完全由用户输入进行驱动,因此它的实施几乎是完全基于事件,如图 2 中所示。

拖放事件
图 2 拖放事件

在 Windows 8.1 中实施拖放源时,如果 CanDragItems 属性被设置为 true,则 ListView 可以是应用内拖放操作的源:

<ListView CanDragItems="True"
          DragItemsStarting="ListView_DragItemsStarting"
          ItemsSource="{Binding Pictures}"
          ItemTemplate="{StaticResource PictureTemplate}"
          />

该应用程序可以处理源上的 DragItemsStarting 事件。这在外加 DragItemsCompleted 事件(Windows 8.1 应用程序不需要此事件,因为目标和源必须属于相同的流程)的 Windows 10 中仍然受到支持。

新型拖放的主要拖动源是 UIElement,它可以赋予使用所有新型拖放功能的权限,并且是本文讨论的重点。

使 UIElement 变得可拖动的一个方法是将它的 CanDrag 属性设置为 true。这可以在标记中或在代码隐藏中来完成。XAML 框架可处理手势识别并触发 DragStarting 事件以指明拖动操作的开始。该应用程序必须配置 DataPackage,方法是填充它的内容并指明支持哪些操作。源应用程序可在 DataPackage 中放入不同的格式,此举可使它和更多目标相兼容,如图 3 中所示。受支持的操作在 DataPackageOperation 类型中进行了定义,并且可以是复制、移动、链接或这些操作的任意组合。

图 3 处理 DragStarting 和填充 DataPackage

private void DropGrid_DragStarting(UIElement sender,
  DragStartingEventArgs args)
{
  if (Picture == null)
  {
    args.Cancel = true;
  }
  else if (_fileSource != null)
  {
    args.Data.RequestedOperation =
      DataPackageOperation.Copy | DataPackageOperation.Move;
    args.Data.SetStorageItems(new IStorageItem[] { _fileSource });
  ...
}

通过设置 DragStartingEventArgs 参数的 Cancel 属性,您可以取消事件处理程序中的拖动操作;例如,在我们的示例应用程序中,只有在图像占位符收到图像或文件时,它才启动拖动操作。

此外,DragStartingEvent 处理程序是源应用程序可以自定义拖动 UI 的地方,这一点在本文后面有详细解释。

在某些情况下,应用程序可能想要使用一个特殊的手势来启动拖放操作,或者想要允许常规互动影响拖放手势的控件(如 TextBox)通过改变其选择来响应指针向下事件。在这些情况下,应用程序可以实施自己的手势检测,然后通过调用 StartDragAsync 方法启动拖放操作。请注意,此方法需要一个指针标识符,因此您无法在非标准设备(如 Kinect 传感器)中启动拖放操作。调用 StartDragAsync 之后,拖放操作的其他部分遵循相同的模式,好像使用了 CanDrag=True,包括 DragStarting 事件。

用户释放指针之后,拖放操作即完成,并且通过 DropCompleted 事件通知源,其中包括用户释放指针的目标返回的 DataPackageOperation,或者在不接受数据的目标上释放指针或按下 Cancel 的情况下返回的 DataPackageOperation.None。

private void DropGrid_DropCompleted(UIElement sender, DropCompletedEventArgs args)
{
  if (args.DropResult == DataPackageOperation.Move)
  {
    // Move means that we should clear our own image
    Picture = null;
    _bitmapSource = null;
    _fileSource = null;
  }
}

StartDragAsync 返回 IAsyncOperation<DataPackageOperation>;通过等待 IAsyncOperation 或处理 DropCompleted 事件,源应用程序可以处理操作的末尾。通过 IAsyncOperation 接口可能实现在 DragStarting 事件之后以编程的方式取消,但是这会给用户带来困扰。

请注意,尽管在相同的系统服务中实施了 ListView 和 UIElement 拖放,而且它们完全兼容,但是它们在源端上并不能触发相同的事件。也就是说,如果 ListView 将其 CanDragItems 属性设置为 true,则只触发 DragItemsStarting 和 DragItemsCompleted。DragStarting 和 DropCompleted 是与 UIElement 的 CanDrag 属性相关的事件。

实施放置目标 如果 UIElement 的 AllowDrop 属性设置为 true,则所有 UIElement 均可以是放置目标。在拖放操作期间,可以在目标上触发以下事件: DragEnter、DragOver、DragLeave 和 Drop。虽然 Windows 8.1 中早已存在这些事件,但是 Windows 10 中对 DragEventArgs 类进行了扩展,以赋予应用程序使用新型拖放的所有功能的权限。在处理拖放事件时,目标应用程序应该首先通过事件参数的 DataView 属性检查 DataPackage 的内容;在大多数情况下,检查数据类型是否存在就已足够,而且可以同步执行。在某些情况下,例如在文件中,应用程序可能首先检查可用文件的类型,然后再接受或忽略 DataPackage。这是一个异步操作,并且需要目标应用程序可以延迟并在稍后完成(后文对此模式有详细说明)。

当目标确定是否可以处理数据之后,则它必须设置 DragEventArgs 实例的 AcceptedOperation 属性,以允许系统向用户提供适当的反馈。

请注意,如果应用程序返回 DataTransferOperation.None 或者事件处理程序中的源不接受某个操作,则放置不会发生,即使用户在目标上释放指针也是如此;相反,会触发 DragLeave 事件。

该应用程序可以处理 DragEnter 或 DragOver;如果未处理 DragOver,则保留 DragEnter 返回的 AcceptedOperation。因为 DragEnter 只调用一次,则出于性能方面的原因,它应该优先于 DragOver。但是,在嵌套目标的情况下,有必要从 DragOver 中返回正确的值,以防父目标覆盖该值(将 Handled 设置为 true 可以防止事件上升到父级)。在示例应用程序中,除非没有可用的图片,否则每个照片占位符都检查 DataPackage 中的图片,并将该事件路由到父网格,这可允许该网格接受文本,即使实际上已放置到占位符上(请参见图 4)。

图 4 处理 DragEnter 和检查 DataPackage

private async void DropGrid_DragEnter(object sender, DragEventArgs e)
{
  if (!App.IsSource(e.DataView))
  {
    bool forceMove = ((e.Modifiers & DragDropModifiers.Shift) ==
      DragDropModifiers.Shift);
    if (e.DataView.Contains(StandardDataFormats.Bitmap))
    {
      _acceptData = true;
      e.AcceptedOperation = (forceMove ? DataPackageOperation.Move :
        DataPackageOperation.Copy);
      e.DragUIOverride.Caption = "Drop the image to show it in this area";
      e.Handled = true;
    }
    else if (e.DataView.Contains(StandardDataFormats.StorageItems))
    {
      // Notify XAML that the end of DropGrid_Enter does
      // not mean that we have finished to handle the event
      var def = e.GetDeferral();
      _acceptData = false;
      e.AcceptedOperation = DataPackageOperation.None;
      var items = await e.DataView.GetStorageItemsAsync();
      foreach (var item in items)
      {
        try
        {
          StorageFile file = item as StorageFile;
          if ((file != null) && file.ContentType.StartsWith("image/"))
          {
            _acceptData = true;
            e.AcceptedOperation = (forceMove ? DataPackageOperation.Move :
              DataPackageOperation.Copy);
            e.DragUIOverride.Caption = "Drop the image to show it in this area";
            break;
          }
        }
        catch (Exception ex)
        {
          Debug.WriteLine(ex.Message);
        }
      }
      e.Handled = true;
      // Notify XAML that now we are done
      def.Complete();
    }
  }
  // Else we let the event bubble on a possible parent target
}

高级概念

自定义可视反馈 OLE 2 拖放提供的唯一反馈是根据目标对 DragOver 事件的回复更改鼠标光标。新型拖放可允许使用更高级的方案,如向用户提供更加丰富的可视反馈。拖动 UI 由三部分组成:可视内容、字形和标题。

可视内容表示正在被拖动的数据。它可能是可拖动的 UI 元素(如果源是 XAML 应用程序);系统根据 DataPackage 的内容选择的标准图标;或应用程序设置的一个自定义图片。

字形反映了目标可接受的操作类型。根据 DataPackageOperation 类型的值,它可能需要四个不同的形状。虽然不能由应用程序对它进行自定义,但是可以将其隐藏。

标题是目标提供的说明。根据目标应用程序,复制操作可能是将歌曲添加到播放列表、将文件上载到 OneDrive 或纯文本文件复制等。与字形相比,标题提供更加精确的反馈,并且其角色非常类似于工具提示。

图 5 中的表显示源和目标可以如何自定义这些不同的部分。当指针不在放置目标之上时,拖动 UI 就是源配置的内容。当指针位于放置目标之上时,可视内容的某些部分可能被目标覆盖;当指针离开目标后,所有覆盖内容全部被清除。

图 5 可用于源和目标的自定义内容

  目标
可视内容

默认值 = 可拖动的元素

可以使用系统基于 DataPackage 生成的内容

可以使用任意位图

默认值 = 源已设置的内容

无法使用系统生成的内容

可以使用任意位图

可以显示或隐藏

字形 无访问权限

基于 AcceptedOperation 的方面

可以显示或隐藏

字幕 无访问权限

可以使用任意字符串

可以显示或隐藏

当拖放操作启动时,如果源应用程序没有尝试在 DragStarting 事件处理程序中自定义拖动 UI,则 XAML 会拍摄可拖动 UIElement 的屏幕快照,并用作拖动 UI 内容。初始 UIElement 仍显示在它的原始位置中,这不同于 ListView 中的行为,其中可拖动的 ListViewItems 从其原始位置中隐藏了。由于在触发 DragStarting 事件之后拍摄了可拖动 UIElement 的屏幕快照,因此可能在此事件期间触发一个可视状态更改以改变屏幕快照。(请注意,UIElement 的状态也改变了,即使对它进行还原,也可能会发生灯光闪烁)。

在处理 DragStarting 事件时,拖动源可以通过 DragStartingEventArgs 类的 DragUI 属性对可视反馈进行自定义。例如,可通过 SetContentFromDataPackage 要求您的系统使用 DataPackage 的内容生成可视内容,如图 6 中所示。

图 6 使用 SetContentFromDataPackage 生成可视内容

private void DropGrid_DragStarting(UIElement sender, DragStartingEventArgs args)
{
  ...
  if (_fileSource != null)
  {
    args.Data.RequestedOperation = DataPackageOperation.Copy | DataPackageOperation.Move;
    args.Data.SetStorageItems(new IStorageItem[] { _fileSource });
    args.DragUI.SetContentFromDataPackage();
  }
  else if (_bitmapSource != null)
  {
    args.Data.RequestedOperation = DataPackageOperation.Copy | DataPackageOperation.Move;
    args.Data.SetBitmap(_bitmapSource);
    args.DragUI.SetContentFromDataPackage();
  }
}

使用以下两个不同的类:众所周知的 XAML BitmapImage 类或新的 Windows 10 类 SoftwareBitmap,您可以将自定义位图设置为拖动 UI 的内容。如果此位图是应用程序的资源,则更易于使用 BitmapImage,并使用资源的 URI 对其进行实例化:

private void SampleBorder_DragStarting(UIElement sender, DragStartingEventArgs args)
{
  args.Data.SetText(SourceTextBox.SelectedText);
  args.DragUI.SetContentFromBitmapImage(new BitmapImage(new Uri(
    "ms-appx:///Assets/cube.png", UriKind.RelativeOrAbsolute)));
}

当拖动操作开始或指针进入放置目标时,如果急于生成位图,则可从 XAML RenderTargetBitmap 类生成的缓冲中创建 SoftwareBitmap,这会生成一个包含 UIElement 的可视表示形式的位图,如图 7 中所示。此 UIElement 必须位于 XAML 可视树中,但无须位于页面的可视部分中。由于 RenderTargetBitmap 进行的是异步呈现,则必须在此时进行延期以便于 XAML 知道该位图在事件处理程序完成时是否就绪,同时等待延迟的完成以更新拖动 UI 的内容。(在本文的下一节中,我们将解释延迟机制的更多详细信息)。

图 7 使用 RenderTargetBitmap 和 SoftwareBitmap 自定义拖动 UI 内容

private async void PhotoStripGrid_DragStarting(UIElement sender, DragStartingEventArgs args)
{
  if ((Picture1.Picture == null) || (Picture2.Picture == null)
    || (Picture3.Picture == null) || (Picture4.Picture == null))
  {
    // Photo Montage is not ready
    args.Cancel = true;
  }
  else
  {
    args.Data.RequestedOperation = DataPackageOperation.Copy;
    args.Data.SetDataProvider(StandardDataFormats.Bitmap, ProvideContentAsBitmap);
    App.SetSource(args.Data);
    var deferral = args.GetDeferral();
    var rtb = new RenderTargetBitmap();
    const int width = 200;
    int height = (int)(.5 + PhotoStripGrid.ActualHeight / PhotoStripGrid.ActualWidth
                         * (double)width);
    await rtb.RenderAsync(PhotoStripGrid, width, height);
    var buffer = await rtb.GetPixelsAsync();
    var bitmap = SoftwareBitmap.CreateCopyFromBuffer(buffer, BitmapPixelFormat.Bgra8, width, height,
                                                                    BitmapAlphaMode.Premultiplied);
    args.DragUI.SetContentFromSoftwareBitmap(bitmap);
    deferral.Complete();
  }
}

当然,如果已生成 SoftwareBitmap,而且可以对后续的拖放操作进行缓存,则无需延迟。

对于 SetContentFromBitmapImage 和 SetContentFromSoftwareBitmap,您可以指定一个定位点,以指明如何定位拖动 UI 相对于指针位置的位置。如果您使用不含定位点参数的重载,左上角的自定义位图将遵循指针的轨迹。DragStartingEventArgs GetPosition 方法返回指针相对于任何 UIElement 的位置,这可用于在可拖动的 UIElement 所处的确切位置中设置拖动 UI 的起始位置。

在目标端中,可以在 DragEnter 或 DragOver 事件处理程序中对可视的可拖动内容的不同部分进行自定义。自定义通过 DragEventArgs 类的 DragUIOverride 属性进行完成,其揭示四种类似于源端上的 DragUI 的 SetContentFrom 方法以及四个可让您隐藏 DragUI 的不同部分并更改标题的属性。最后,DragUIOverride 还揭示了一个 Clear 方法,它可以重新设置目标进行的 DragUI 的所有覆盖内容。

异步操作 Windows 通用应用程序 API 对可能需要几毫秒以上的时间执行的所有操作执行异步模式。对于拖放等情况,这尤其关键,因为此类操作完全由用户驱动。由于它的丰富性,拖放使用三个不同的异步模式:异步调用、延迟和回调。

当应用程序调用可能需要一些时间来完成的系统 API 时,使用异步调用。现在,此模式已被 Windows 开发人员所熟知,并且可通过 C# 中的 async 和 await 关键字(或 C++ 中的 create_task 和 then)实现。所有从 DataPackage 中检索数据的方法(如 GetBitmapAsync)都遵循此模式,而我们的示例应用程序使用它来检索图像流引用,如图 8 中所示。

图 8 使用异步调用读取 DataPackage

private async void DropGrid_Drop(object sender, DragEventArgs e)
{
  if (!App.IsSource(e.DataView))
  {
    bool forceMove = ((e.Modifiers & DragDropModifiers.Shift) ==
      DragDropModifiers.Shift);
    if (e.DataView.Contains(StandardDataFormats.Bitmap))
    {
      // We need to take a deferral as reading the data is asynchronous
      var def = e.GetDeferral();
      // Get the data
      _bitmapSource = await e.DataView.GetBitmapAsync();
      var imageStream = await _bitmapSource.OpenReadAsync();
      var bitmapImage = new BitmapImage();
      await bitmapImage.SetSourceAsync(imageStream);
      // Display it
      Picture = bitmapImage;
      // Notify the source
      e.AcceptedOperation = forceMove ? DataPackageOperation.Move :
        DataPackageOperation.Copy;
      e.Handled = true;
      def.Complete();
    }
...
}

在以下情况中使用延迟:在返回框架等待的值之前,XAML 框架回调入可能自身发出异步调用的应用程序的代码。此模式并未在以前的 XAML 版本中广泛使用,因此我们需要花点时间对其进行分析。如果一个按钮生成一个 Click 事件,则这是一个单向调用,其意义在于应用程序无需返回任何值。如果该应用程序执行异步调用,则它的结果在完成 Click 事件处理程序之后可用,但是这非常完美,因为此处理程序不返回任何值。

另一方面,当 XAML 触发 DragEnter 或 DragOver 事件,它预期该应用程序设置该事件参数的 AcceptedOperation 属性以指明是否可以处理 DataPackage 的内容。如果该应用程序只关心 DataPackage 内部的可用数据类型,则仍可以通过异步方法来完成。例如:

private void DropTextBox_DragOver(object sender, DragEventArgs e)
{
  bool hasText = e.DataView.Contains(StandardDataFormats.Text);
  e.AcceptedOperation = hasText ? DataPackageOperation.Copy :
    DataPackageOperation.None;
}

但是,举例而言,如果该应用程序仅可接受几个文件类型,则它不仅要查看 DataPackage 内部的数据类型,还必须访问该数据,而这只能以异步方式来完成。这意味着,在读取数据之前代码执行是被暂停的,而且必须先完成 DragEnter(或 DragOver)事件处理程序,然后该应用程序才能知道它是否可以接受数据。这种方案恰恰就是延迟的目的:通过从 DragEventArg 对象中获取延迟,该应用程序会告知 XAML 将延迟它的某些处理,而且通过完成此延迟,该应用程序通知 XAML 此处理已完成,并且已设置 DragEventArgs 实例的输出属性。回头参考图 4,查看我们的示例程序是如何在获得延迟之后检查 StorageItems 的。

当目标端上的拖动 UI 内容的自定义需要异步操作(如 RenderTargetBitmap 的 RenderAsync 方法 )时,也可以使用延迟。

在拖放操作的源端上,DragStartingEvent­Args 也揭示了延迟,它的目的是允许该操作在事件处理程序终止时立即开始(即使延迟尚未完成),以便于最快地向用户提供反馈,即使创建位图来自定义拖动 UI 需要一些时间。

回调在 DataPackage 中使用,以延迟提供数据,直至确实需要数据时。通过此机制,源应用程序可能在 DataPackage 中推广几种格式,但是只需准备和传输目标实际读取的数据。在很多情况下,从不调用回调(例如,如果没有任何目标可以理解相应的数据格式),这是一个非常棒的性能优化。

请注意,在很多情况下,提供真实的数据需要异步调用,因此此回调的 DataProviderRequest 参数揭示延迟,以便应用程序可以通知它们需要更多时间来提供数据以及该数据可用,如图 9 中所示。

图 9 延迟数据,直到实际读取数据为止

private void DeferredData_DragStarting(UIElement sender,
  DragStartingEventArgs args)
{
  args.Data.SetDataProvider(StandardDataFormats.Text, ProvideDeferredText);
}
async void ProvideDeferredText(DataProviderRequest request)
{
  var deferral = request.GetDeferral();
  var file = await KnownFolders.DocumentsLibrary.GetFileAsync(fileName);
  var content = await FileIO.ReadTextAsync(file);
  request.SetData(content);
  deferral.Complete();
}

总结

在编写处理标准数据格式(如文件、图片或文本)的应用程序时,您应该考虑实施拖放,因为这是非常自然的操作,而且也被用户所熟知。使用 Windows Forms 和 Windows Presentation Foundation 进行编程的开发人员已熟知拖放的基础知识,这可减少对此丰富功能及其特定的概念(如拖动 UI 自定义)和相对未使用的模式(延迟模式)的学习曲线。如果您只是想支持基本的拖放方案,则可以依赖以前的经验并直接进行实施,或者如果您愿意,您可以完全利用新功能以向用户提供定制体验。


Anna Pai 是 Xbox 团队的软件工程师。她以前致力于 Silverlight、Silverlight for Windows Phone 的工作,然后致力于 XAML for Windows 和 Windows Phone 的工作。

Alain Zanchetta 是 Windows XAML 团队的软件工程师。以前他是 Microsoft France 咨询部门的架构师。

衷心感谢以下 Microsoft 技术专家对本文的审阅: Clément Fauchère
Clement Fauchere 是 Microsoft 的 Windows Shell 团队的软件工程师。