使用 Vue.js 和 interact.js 创建可滑动卡片堆栈

Avatar of Mateusz Rybczonek
Mateusz Rybczonek

DigitalOcean 为您的旅程的每个阶段提供云产品。立即开始使用 $200 免费积分!

我最近有机会在 Netguru 参与一个很棒的研发项目。该项目(代号:“Wordguru”)的目标是创建一个任何人都可以与朋友一起玩的游戏。您可以查看结果 这里.

开发过程中的一个元素是创建一个交互式卡片堆栈。卡片堆栈有一些要求,包括

  • 它应该包含一些来自集合的卡片。
  • 第一张卡片应该是交互式的。
  • 用户应该能够向不同的方向滑动卡片,这表明了接受、拒绝或跳过卡片的意图。

本文将解释如何创建它并使用 Vue.jsinteract.js 使其具有交互性。我创建了一个示例供您参考,我们将逐步介绍创建负责显示卡片堆栈的组件和负责渲染单个卡片并管理其中用户交互的第二个组件的过程。

查看演示

步骤 1:在 Vue 中创建 GameCard 组件

让我们从创建一个组件开始,该组件将显示卡片,但目前没有任何交互。我们将把这个文件命名为 GameCard.vue,在组件模板中,我们将渲染一个卡片包装器和特定卡片的关键字。这是我们将在这篇文章中一直使用的文件。

// GameCard.vue
<template>
  <div
    class="card"
    :class="{ isCurrent: isCurrent }"
  >
    <h3 class="cardTitle">{{ card.keyword }}</h3>
  </div>
</template>

在组件的脚本部分,我们接收 card 道具,它包含我们的卡片内容,以及 isCurrent 道具,当需要时它会给卡片一个独特的样式。

export default {
  props: {
    card: {
      type: Object,
      required: true
    },
    isCurrent: {
      type: Boolean,
      required: true
    }
  }
},

步骤 2:在 Vue 中创建 GameCardStack 组件

现在我们已经有了单张卡片,让我们创建我们的卡片堆栈。

该组件将接收一个卡片数组,并为每张卡片渲染 GameCard。它还将标记第一张卡片作为堆栈中的当前卡片,以便对其应用特殊样式。

// GameCardsStack.vue
<template>
  <div class="cards">
    <GameCard
      v-for="(card, index) in cards"
      :key="card"
      :card="card"
      :is-current="index === 0"
    />
  </div>
</template>

<script>
  import GameCard from "@/components/GameCard";
  export default {
    components: {
      GameCard
    },
    props: {
      cards: {
        type: Array,
        required: true
      }
    }
  };
</script>

以下是目前的情况,使用从演示中提取的样式

此时,我们的卡片看起来很完整,但没有太大的交互性。让我们在下一步中修复它!

步骤 3:向 GameCard 组件添加交互性

我们所有的交互逻辑都将存在于 GameCard 组件中。让我们从允许用户拖动卡片开始。我们将使用 interact.js 来处理拖动。

我们将在脚本部分将 interactPosition 初始值设置为 0。这些值指示卡片在从其原始位置移动时在堆栈中的顺序。

<script>
import interact from "interact.js";

data() {
  return {
    interactPosition: {
      x: 0,
      y: 0
    },
  };
},
// ...
</script>

接下来,我们创建一个计算属性,它负责创建一个 transform 值,该值将应用于我们的卡片元素。

// ...
computed: {
  transformString() {
    const { x, y } = this.interactPosition;
    return `translate3D(${x}px, ${y}px, 0)`;
  }
},
// ...

mounted 生命周期钩子中,我们使用了 interact.js 及其 draggable 方法。该方法允许我们在每次元素被拖动时触发一个自定义函数 (onmove)。它还公开了一个 event 对象,其中包含有关元素从其原始位置拖动了多远的的信息。每次用户拖动卡片时,我们都会计算卡片的新位置并将其设置在 interactPosition 属性上。这会触发我们的 transformString 计算属性,并在我们的卡片上设置 transform 的新值。

我们使用 interact onend 钩子,它允许我们监听用户何时释放鼠标并完成拖动。此时,我们将重置卡片的位置并将其带回其原始位置:{ x: 0, y: 0 }

我们还需要确保在卡片元素被销毁之前将其从 Interactable 对象中删除。我们在 beforeDestroy 生命周期钩子中通过使用 interact(target).unset() 来做到这一点。这将删除所有事件监听器,并使 interact.js 完全忘记目标。

// ...
mounted() {
  const element = this.$refs.interactElement;
  interact(element).draggable({
    onmove: event => {
      const x = this.interactPosition.x + event.dx;
      const y = this.interactPosition.y + event.dy;
      this.interactSetPosition({ x, y });
    },
    onend: () => {
      this.resetCardPosition();
    }
  });
},
// ...
beforeDestroy() {
  interact(this.$refs.interactElement).unset();
},
// ...
methods: {
  interactSetPosition(coordinates) { 
    const { x = 0, y = 0 } = coordinates;
    this.interactPosition = {x, y };
  },
  
  resetCardPosition() {
    this.interactSetPosition({ x: 0, y: 0 });
  },
},
// ...

我们需要在模板中添加一个东西才能使其正常工作。由于我们的 transformString 计算属性返回一个字符串,我们需要将其应用于卡片组件。我们通过绑定到 :style 属性,然后将字符串传递给 transform 属性来做到这一点。

<template>
  <div 
    class="card"
    :class="{ isCurrent: isCurrent }"
    :style="{ transform: transformString }"
  >
    <h3 class="cardTitle">{{ card.keyword }}</h3>
  </div>
</template>

有了这些,我们就创建了与卡片的交互——我们可以将其拖动到任何地方!

您可能已经注意到,行为不太自然,尤其是在拖动卡片并释放它时。卡片会立即返回到其原始位置,但如果卡片通过动画返回初始位置会更自然,以使过渡平滑。

这就是 transition 发挥作用的地方!但将其添加到我们的卡片会带来另一个问题:卡片跟随光标时会延迟,因为 transition 始终应用于该元素。我们只希望它在拖动结束时应用。我们可以通过绑定另一个类 (isAnimating) 到组件来做到这一点。

<template>
  <div
    class="card"
    :class="{
      isAnimating: isInteractAnimating,
      isCurrent: isCurrent
    }"
  >
    <h3 class="cardTitle">{{ card.keyword }}</h3>
  </div>
</template>

我们可以通过更改 isInteractAnimating 属性来添加和删除动画类。

动画效果应该最初应用,我们通过在 data 中设置我们的属性来做到这一点。

在我们在其中初始化 interact.js 的 mounted 钩子中,我们使用另一个 interact 钩子 (onstart) 并将 isInteractAnimating 的值更改为 false,以便在拖动期间禁用动画。

我们将在 onend 钩子中再次启用动画,这将使我们的卡片在从拖动中释放时平滑地动画到其原始位置。

我们还需要更新 transformString 计算属性,并添加一个保护措施,以仅在拖动卡片时重新计算并返回字符串。

data() {
  return {
  // ...
  isInteractAnimating: true,
  // ...
  };
},

computed: {
  transformString() {
    if (!this.isInteractAnimating) {
      const { x, y } = this.interactPosition;
      return `translate3D(${x}px, ${y}px, 0)`;
    }
    return null;
  }
},

mounted() {
  const element = this.$refs.interactElement;
  interact(element).draggable({
    onstart: () => {
      this.isInteractAnimating = false;
    },
    // ...
    onend: () => {
      this.isInteractAnimating = true;
    },
  });
},

现在事情开始变得不错了!

我们的卡片堆栈已准备好进行第二组交互。我们可以将卡片拖动到任何地方,但实际上没有发生任何事情——卡片总是会回到其原始位置,但没有办法获得第二张卡片。

当我们添加允许用户接受和拒绝卡片的逻辑时,这将发生变化。

步骤 4:检测卡片何时被接受、拒绝或跳过

卡片有三种类型的交互

  • 接受卡片(向右滑动)
  • 拒绝卡片(向左滑动)
  • 跳过卡片(向下滑动)

我们需要找到一个可以检测卡片是否从其初始位置拖动的地方。我们还想确保此检查仅在完成拖动卡片时发生,这样交互就不会与我们刚刚完成的动画冲突。

我们之前在这个地方使用它来平滑动画过程中的过渡——它是 interact.draggable 方法提供的 onend 钩子。

让我们进入代码。

首先,我们需要存储我们的阈值。这些值是卡片从其原始位置拖动时的距离,它们允许我们确定卡片是否应该被接受、拒绝或跳过。我们使用 X 轴表示向右(接受)和向左(拒绝),然后使用 Y 轴表示向下移动(跳过)。

我们还设置了我们希望在卡片被接受、拒绝或跳过之后放置卡片的坐标(超出用户视线的坐标)。

由于这些值不会改变,我们将它们保存在组件的 static 属性中,可以使用 this.$options.static.interactYThreshold 访问它。

export default {
  static: {
    interactYThreshold: 150,
    interactXThreshold: 100
  },

我们需要在 onend 钩子中检查是否满足了我们的任何阈值,然后触发相应的发生方法。如果未满足任何阈值,那么我们将卡片重置到其初始位置。

mounted() {
  const element = this.$refs.interactElement;
  interact(element).draggable({
    onstart: () => {...},
    onmove: () => {...},
    onend: () => {
      const { x, y } = this.interactPosition;
      const { interactXThreshold, interactYThreshold } = this.$options.static;
      this.isInteractAnimating = true;
          
      if (x > interactXThreshold) this.playCard(ACCEPT_CARD);
      else if (x < -interactXThreshold) this.playCard(REJECT_CARD);
      else if (y > interactYThreshold) this.playCard(SKIP_CARD);
      else this.resetCardPosition();
    }
  });
}

好的,现在我们需要创建一个 playCard 方法,它负责处理这些交互操作。

步骤 5:建立接受、拒绝和跳过卡片的逻辑

我们将创建一个方法,它接受一个参数,告诉我们用户的意图。根据该参数,我们将设置当前卡片的最终位置,并发出接受、拒绝或跳过事件。让我们一步一步来。

首先,我们的 playCard 方法将从 Interactable 对象中删除卡片元素,以便它停止跟踪拖动事件。我们通过使用 interact(target).unset() 来做到这一点。
其次,我们根据用户的意图设置活动卡片的最终位置。这个新位置允许我们对卡片进行动画并将其从用户视线中移除。

接下来,我们发出一个事件 到父组件,以便我们处理卡片(例如更改当前卡片,加载更多卡片,洗牌等)。 我们希望遵循DDAU原则,该原则指出组件应该避免修改它不拥有的数据。 由于我们的卡片被传递到我们的组件,因此它应该向卡片来源发出事件。

最后,我们将刚刚播放的卡片隐藏起来,并添加一个超时时间,以便卡片可以动画退出视图。

methods: {
  playCard(interaction) {
    const {
      interactOutOfSightXCoordinate,
      interactOutOfSightYCoordinate,
    } = this.$options.static;

    this.interactUnsetElement();

    switch (interaction) {
      case ACCEPT_CARD:
        this.interactSetPosition({
          x: interactOutOfSightXCoordinate,
        });
        this.$emit(ACCEPT_CARD);
        break;
      case REJECT_CARD:
        this.interactSetPosition({
          x: -interactOutOfSightXCoordinate,
        });
        this.$emit(REJECT_CARD);
        break;
      case SKIP_CARD:
        this.interactSetPosition({
          y: interactOutOfSightYCoordinate
        });
        this.$emit(SKIP_CARD);
        break;
    }

    this.hideCard();
  },

  hideCard() {
    setTimeout(() => {
      this.isShowing = false;
      this.$emit("hideCard", this.card);
    }, 300);
  },
  
  interactUnsetElement() {
    interact(this.$refs.interactElement).unset();
    this.interactDragged = true;
  },
}

就这样!

摘要

让我们回顾一下我们刚刚完成的事情

  • 首先我们创建了一个单个卡片的组件。
  • 接下来,我们创建了另一个组件,该组件将卡片渲染成堆栈。
  • 第三,我们实现了interact.js来允许交互式拖动。
  • 然后我们检测到用户何时希望对当前卡片采取操作。
  • 最后,我们建立了来处理这些操作。

Whew,我们涵盖了很多内容! 希望这能让你在工具箱中获得新的技巧,以及Vue的实际用例。 而且,如果你曾经需要构建类似的东西,请在评论中分享,因为比较笔记会很不错。