返回博客首页
← 所有文章

使用基本碰撞检测拖放 UI 元素

2017 年 5 月 16 日 — 作者 Rensu Theart

本文之前发表在 Rensu Theart 的博客 上。

最近我开始学习 NativeScript 为了给朋友开发一个应用程序。之前我使用过 Android SDK,也使用 Unity 进行过一些开发。但是这个新的应用程序需要跨平台(即 iOS 和 Android),并且需要原生感觉,这使得 Unity 不适合这种情况。所以,在尝试了 Ionic 和 NativeScript 后,我非常喜欢 NativeScript 的原生性能和低级能力。

NativeScript 的另一个优点是它允许 100% 的代码复用,这与其他任何原生 API 都不一样。此外,我感觉 Angular 2 和 TypeScript 是 Web 和应用程序开发的未来,所以我决定学习 NativeScript 的 Angular 版本。但这已经足够了,现在回到这篇文章标题的主题:如何在 NativeScript 中实现拖放 UI 元素的能力 - 以及如何在操作中限制边界框内的移动。

NativeScript 手势

检测 UI 元素上触摸屏输入的整个过程方便地封装在 NativeScript 的手势 API 中。在 NativeScript 中,所有 UI 元素都继承自 View 类,该类具有 onoff 方法,允许你订阅或取消订阅 UI 元素检测到的所有事件和手势。然后,你可以将要检测的手势类型以及该手势的回调函数传递给这些方法。

或者,你可以使用 Angular 2 的魔力,只需将你的 TypeScript 函数连接到你在 XML 中定义 UI 元素时描述的事件即可。我觉得在 Angular 实现的代码中,这更容易理解,所以我们将在下面这样做。

你可以在 这里 阅读更多详细信息,包括可以检测哪些手势(API 中包含了你可以在应用程序中想象到的几乎所有内容)。

平移

我们需要检测拖放事件的手势名称称为“平移”。在 NativeScript 文档中,它被描述为“操作:按下你的手指并立即开始移动它。平移执行得更慢,允许更高的精度,并且屏幕在手指离开它时立即停止响应。”

从 NativeScript 文档中,用法如下

对于 TypeScript 侧

onPan(args: PanGestureEventData) {
    console.log("Pan delta: [" + args.deltaX + ", " + args.deltaY + "] state: " + args.state);
}

对于 XML 侧

<Label text="Pan here" (pan)="onPan($event)"></Label>

所以它实际上并没有什么特别的。在 XML 中,在你定义元素的地方,你只需添加 (pan) 事件并在你的 TypeScript 组件中提供回调函数。你将 PanGestureEventData 数据通过 $event 参数传递给函数。在你的函数中,你可以访问几个事件属性。我们关心的三个主要属性是 deltaXdeltaY(它指的是上次更新中平移的设备无关像素数量)和 state(即手指按下、抬起或按下等)属性。

拖动图像(实际使用)

好了,现在让我们创建一个使用它来移动图片的简单应用程序。

我从 这个 Stack Overflow 页面 中获得灵感,它使用的是原生 JavaScript 而不是 Angular。

为了演示这个,让我们使用 NativeScript Angular 教程模板作为基础项目模板。在命令提示符中输入以下内容(显然你需要先设置 NativeScript。说明可以在 这里 找到)

tns create DragDrop --template nativescript-template-ng-tutorial

在模板 XML 代码下的 app.component.ts 文件中添加以下代码

<ActionBar title="Drag and Drop">
<GridLayout width="100%" height="400" backgroundColor="gray">
      <Image #dragImage width="100" height="100" (pan)="onPan($event)" src="~/images/apple.jpg"></Image>
</GridLayout>

注意,我们为 Image 提供了 本地模板变量 #dragImage,我们将在后面引用它。另外请注意,我们将回调函数 onPan(我们将在后面创建)附加到 NativeScript 事件 (pan) 上。因此,当图像被平移(用户用手指拖动图像)时,onPan 函数将被调用。

另外,在文件顶部添加以下导入,我们将在后面需要它

import { Component, ElementRef, OnInit, ViewChild } from "@angular/core";
import { PanGestureEventData } from "ui/gestures";
import { Image } from "ui/image";

在类顶部定义以下变量

@ViewChild("dragImage") dragImage: ElementRef;
dragImageItem: Image;
prevDeltaX: number;
prevDeltaY: number;

上面唯一复杂的代码是我们在使用 Angular 的 @ViewChild 装饰器 和我们的模板变量 dragImage(我们在 XML 代码中定义的)来检索 UI Image 元素。但是,在我们使用这个图像之前,我们需要将它存储在一个 Image 变量中,我们接下来定义它。然后有两个 delta 移动变量,我们需要使用它们来执行平移。

最佳实践是在 ngOnInit 函数中初始化变量,而不是在构造函数中,这是我们从 OnInit 类继承的。因此,让 AppComponent 实现 OnInit,如下所示

export class AppComponent implements OnInit

然后将以下函数添加到类中(在变量声明下)

public ngOnInit() {
    this.dragImageItem = <Image>this.dragImage.nativeElement;
    this.dragImageItem.translateX = 0;
    this.dragImageItem.translateY = 0;
    this.dragImageItem.scaleX = 1;
    this.dragImageItem.scaleY = 1
}

接下来我们实现 onPan 函数,它将所有内容整合在一起。

onPan(args: PanGestureEventData) {
  //console.log("Pana: [" + args.deltaX + ", " + args.deltaY + "] state: " + args.state);
  if (args.state === 1) // down
  {
    this.prevDeltaX = 0;
    this.prevDeltaY = 0;
  }
  else if (args.state === 2) // panning
  {
    this.dragImageItem.translateX += args.deltaX - this.prevDeltaX;
    this.dragImageItem.translateY += args.deltaY - this.prevDeltaY;


    this.prevDeltaX = args.deltaX;
    this.prevDeltaY = args.deltaY;
  }
  else if (args.state === 3) // up
  {

  }
}

在这个函数中,我们首先检查平移手势处于哪个状态。当用户最初触摸 UI 元素时,我们重置平移变量。当用户进行平移时,Image 的 translateX 和 translateY 属性仅使用最后一帧的平移变化进行更新。这些平移属性与 CSS 动画属性的工作原理相同,因此它指的是从原点的平移。这就是我们累积改变这些属性的原因。图像的位置现在将根据平移手势进行更新。

如果我们运行代码,就会得到这里所示的效果

drag and drop gif

边界碰撞检测

现在让我们更进一步,添加一些碰撞检测。这对于许多应用程序来说非常有用,例如在游戏中收集物品或不允许用户将图像移动到定义区域之外,这就是我们将要做的。为了演示如何将碰撞检测与 UI 元素的平移结合起来,我们将扩展到目前为止的内容。

首先,将本地模板变量 #container 添加到 GridLayout 中。在这个例子中,我们还将确保图像从左上角开始,通过添加 horizontalAlignmentverticalAlignment 属性。

<GridLayout #container width="100%" height="400" backgroundColor="gray">
    <Image #dragImage width="100" height="100" horizontalAlignment="left" verticalAlignment="top" (pan)="onPan($event)" src="~/images/apple.jpg"></Image>
</GridLayout>

我们还需要在 app.component.ts 文件的顶部添加以下导入。

import { GridLayout } from "ui/layouts/grid-layout";
import { AnimationCurve } from "ui/enums";

与之前类似,由于我们现在想知道边界框(GridLayout)的边缘在哪里,我们还需要从我们的 XML 中检索 GridLayout

@ViewChild("container") container: ElementRef;
itemContainer: GridLayout;

然后,我们再次将以下行添加到我们的 ngOnInit 函数中。

this.itemContainer = <GridLayout>this.container.nativeElement;

现在回到我们的 onPan 函数。我们将使用之前使用的代码,但现在将其扩展为限制图像只能在边界容器内移动。

onPan(args: PanGestureEventData) {
  //console.log("Pana: [" + args.deltaX + ", " + args.deltaY + "] state: " + args.state);
  if (args.state === 1) // down
  {
    this.prevDeltaX = 0;
    this.prevDeltaY = 0;
  }
  else if (args.state === 2) // panning
  {
    this.dragImageItem.translateX += args.deltaX - this.prevDeltaX;
    this.dragImageItem.translateY += args.deltaY - this.prevDeltaY;

    this.prevDeltaX = args.deltaX;
    this.prevDeltaY = args.deltaY;

    // calculate the conversion from DP to pixels
    let convFactor = this.dragImageItem.width / this.dragImageItem.getMeasuredWidth();
    let edgeX = (this.itemContainer.getMeasuredWidth() - this.dragImageItem.getMeasuredWidth()) * convFactor;
    let edgeY = (this.itemContainer.getMeasuredHeight() - this.dragImageItem.getMeasuredHeight()) * convFactor;      

    // X border
    if (this.dragImageItem.translateX < 0) {
      this.dragImageItem.translateX = 0;
    }
    else if (this.dragImageItem.translateX > edgeX) {
      this.dragImageItem.translateX = edgeX;
    }

    // Y border
    if (this.dragImageItem.translateY < 0) {
      this.dragImageItem.translateY = 0;
    }
    else if (this.dragImageItem.translateY > edgeY) {
      this.dragImageItem.translateY = edgeY;
    }
  }
  else if (args.state === 3) // up
  {      
    this.dragImageItem.animate({
      translate: { x: 0, y: 0 },
      duration: 1000,
      curve: AnimationCurve.cubicBezier(0.1, 0.1, 0.1, 1)
    });      
  }
}

解释几点。我们遇到了两个不同的坐标系被使用的问题,而且没有直接的转换方法。PanGestureEventData 的 deltaXdeltaY 变量使用的是设备无关像素(dp),它与 UI 元素的 View.width 变量返回的值相同。但是,对于 GridLayout,由于没有定义固定宽度,所以这个变量返回“100%”,在 dp 平移变量比较方面不太有用。使用星号 (*) 布局属性定义的元素也会出现同样的问题。所以,由于容器在调用 View.width 时没有返回所需的宽度,所以我们需要使用一个技巧。现在显然有很多方法可以做到这一点,例如检索页面的宽度,但更简单的方法是使用已知的 dp 宽度(在本例中为 100)定义图像,然后使用 getMeasuredWidth() 函数,该函数返回屏幕上的像素宽度,然后通过简单的除法运算就可以计算出转换系数。然后,我们可以将像素的宽度和高度乘以这个转换系数,就能得到容器的 dp 宽度。

现在我们知道了容器的边缘在哪里,我们只需使用四个 if 语句来确保图像不会移动到这个边框之外。然后,作为一种很好的效果,每当用户抬起手指时,图像都会自动使用动画滑回原点位置。

以下是在实践中所有这些内容的示例

drag and drop gif

结论

在这篇文章中,我们学习了如何使用 NativeScript 拖放图像。这个相同的原则可以应用于 NativeScript 的任何 UI 元素,只需对代码进行一些简单的调整。我们还学习了如何将 UI 元素的宽度从屏幕像素转换为设备无关像素。这使我们能够实现一个基本的碰撞检测系统。最后,我们实现了一些基本的动画,使 UI 元素滑回原点位置。

感谢阅读,请留下一些反馈!