40行核心代码实现探探的卡片飞出效果

780 阅读2分钟

本文档已更新于 【前端橘子君】 【Github】

最近探探的卡片飞出效果比较火热,趁着手头空闲,我自己也手撸了一次这个功能,最后总结的时候发现该功能的核心代码不超过40行,先来看看最终效果。

03

布局

先将整体UI框架搭建起来

<template>
  <div class="lp-card-throw-body">
    <!-- 为了使卡片内容居中 -->
    <div class="lp-card-throw-box" :style="{
      width: cardWidth + 'px',
      height: cardHeight + 'px'
    }">
      <!-- 拖拽卡片 -->
      <div class="card" :style="{
        'z-index': 10 - index,
        width: cardWidth - index * offset + 'px',
        height: cardHeight - index * offset + 'px',
        top: index * offset * 1.5 + 'px',
        left: index * (offset / 2) + 'px',
        background: '#fff'
      }" v-for="(item, index) in list" :key="index"></div>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    // 卡片宽度
    cardWidth: {
      type: Number,
      default: 250
    },
    // 卡片高度
    cardHeight: {
      type: Number,
      default: 250
    },
    // 卡片间的间隔,数值越大,卡片之间间隔越大
    offset: {
      type: Number,
      default: 25
    }
  },
  data () {
    return {
      list: [1, 2, 3, 4, 5, 6, 7]
    };
  }
};
</script>

<style lang="scss" scoped>
.lp-card-throw-body {
  position: relative;
  width: 100%;
  height: 100%;
  .lp-card-throw-box {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    .card {
      position: absolute;
      width: 100%;
      height: 100%;
      box-shadow: 0px 4px 20px rgba(0,0,0,.3);
    }
  }
}
</style>

拖拽并回弹

实现拖拽效果,并在手指松开时回弹到原点

<template>
  <div class="lp-card-throw-body">
    <!-- 为了使卡片内容居中 -->
    <div class="lp-card-throw-box" :style="...">
      <!-- 拖拽卡片 -->
      <div class="card"
      :style="..."
      :class="{ animation: animation }"
      @touchstart.stop.prevent='touchStart'
      @touchmove='touchMove'
      @touchend='touchEnd'
      v-for="(item, index) in list" :key="index"></div>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    ...
  },
  data () {
    return {
      list: [1, 2, 3, 4, 5, 6, 7],
      // 顶部盒子相对于lp-card-throw-box的偏移量,可以是负值
      left: 0,
      top: 0,
      // 触摸开始的坐标
      startPoint: {
        x: 0,
        y: 0
      },
      // 添加动画效果,让效果看起来更加丝滑
      animation: false
    };
  },
  methods: {
    // 触摸开始
    touchStart (event) {
      const touch = event.touches[0];
      // 记录开始触摸时的坐标,方便计算盒子的偏移量
      this.startPoint.x = touch.clientX;
      this.startPoint.y = touch.clientY;
    },
    // 触摸移动
    touchMove (event) {
      const touch = event.touches[0];
      // 顶部盒子移动的距离 = 当前坐标 - 触摸开始时的坐标
      // 即顶部盒子相对于`lp-card-throw-box`的偏移量
      this.left = touch.clientX - this.startPoint.x;
      this.top = touch.clientY - this.startPoint.y;
    },
    // 触摸结束
    touchEnd (event) {
      // 将顶部的盒子恢复原位
      this.top = 0;
      this.left = 0;
      // 添加动画效果,让效果看起来更加丝滑
      this.animation = true;
      setTimeout(() => {
        this.animation = false;
      }, 400);
    }
  }
};
</script>

<style lang="scss" scoped>
.lp-card-throw-body {
  ...
  .lp-card-throw-box {
    ...
    .card {
      ...
    }
    .animation {
      transition: height .4s ease-out, width .4s ease-out, left .4s ease-out, top .4s ease-out;
    }
  }
}
</style>

到这里,基础功能已经实现了,还剩下根据条件判断将卡片飞出了,如果拖拽距离太短,则让其回到原点,否则将卡片飞出去,我们先看看现在的效果。

01

卡片飞出

判断飞出条件:根据勾股定理,a^2 + b^2 = c^2,其中,a代表了this.left,b代表了this.top,c指弹性指数,超过这个指数则飞出,否则还原,所以飞出的临界条件就是Math.pow(this.left, 2) + Math.pow(this.top, 2) < Math.pow(throwRatio, 2),throwRatio可以由父组件传入。

另外就是顶部卡片飞出的距离,我们可以也给他一个系数,这个系数越大,飞出的也就越远。

export default {
  props: {
    // 触发飞出的系数
    throwRatio: {
      type: Number,
      default: 150
    },
    // 飞出的距离系数
    throwDistance: {
      type: Number,
      default: 500
    }
    ...
  },
  ...
  // 触摸结束
  touchEnd (event) {
    // 如果没有超过一定距离,则还原,否则飞出
    // 公式利用了数学中的勾股定理
    if (Math.pow(this.left, 2) + Math.pow(this.top, 2) < Math.pow(this.throwRatio, 2)) {
      // 将顶部的盒子恢复原位
      this.top = 0;
      this.left = 0;
    } else {
      // 将盒子飞出
      // 也可以用changedTouches.clientX
      this.left = this.left * this.throwDistance;
      this.top = this.top * this.throwDistance;
      this.list.splice(0, 1);
    }
    // 添加动画效果,让效果看起来更加丝滑
    this.animation = true;
    setTimeout(() => {
      this.left = 0;
      this.top = 0;
      this.animation = false;
    }, 60);
  }
}

至此,这个功能也就完成了90%,来看看效果吧!

02

优化

基本的完成的差不多了,但是想要将其制作成一个组件,还是有一些地方需要优化的

  • 比如重复性代码太多,多处出现了this.left = 0;this.top = 0;的代码
  • 子组件不能直接对this.list进行删除,否则会报错,所以我们需要触发父级的函数

开撸:首先我们对touchEnd函数中的代码进行优化。

// 触摸结束
touchEnd (event) {
  this.animation = true;
  // 如果没有超过一定距离,则还原,否则飞出
  // 公式利用了数学中的勾股定理
  if (Math.pow(this.left, 2) + Math.pow(this.top, 2) >= Math.pow(this.throwRatio, 2)) {
    // 将盒子飞出
    // 也可以用changedTouches.clientX
    this.left = this.left * this.throwDistance;
    this.top = this.top * this.throwDistance;
    this.onThrowDone();
  }
  // 添加动画效果,让效果看起来更加丝滑
  setTimeout(() => {
    this.resetCard();
  }, 60);
},
// 使盒子回到原处
resetCard () {
  this.left = 0;
  this.top = 0;
  this.animation = false;
},
// 卡片成功飞出后触发父组件的方法,让父组件自己删除list数组
onThrowDone () {
  this.$emit('onThrowDone');
}

优化后我们解决了哪些问题:

  • 首先,我将判断条件中的小于换成了大于或等于,因为我们从原来的代码中可以看到,无论是否经过判断,最终都会经过this.left = 0; this.top = 0; this.animation = false;,只要换一个判断条件,我可以减少多余的重复性代码,何乐不为呢?
  • 第二,将重复性代码抽离出来,用resetCard函数进行封装,这样后期我们可以多次调用的时候我们自需要调用这个函数就行了
  • 第三,当卡片成功飞出后,触发父组件的方法,让父组件自己删除数组的元素,这样父组件的调用就可以这样写
<template>
  <div class="lp-card-throw-demo">
    <lp-card-throw @onThrowDone='onThrowDone' :list='list'></lp-card-throw>
  </div>
</template>

<script>
import { lpCardThrow } from '@/index.js';
export default {
  components: {
    lpCardThrow
  },
  data () {
    return {
      list: [1, 2, 3, 4, 5, 6, 7]
    };
  },
  methods: {
    // 卡片成功飞出后触发
    onThrowDone () {
      // 每次删除顶部的元素
      this.list.splice(0, 1);
    }
  }
};
</script>

至此,探探卡片飞出效果才算是真的完成了,到此为止真正的逻辑代码还不到40行,我们还可以对其进行补充,比如触摸按下时的事件,移动时的事件,取消的事件等等,下面是本次案例的完整代码。

完整代码

<!-- 卡片组件 -->
<template>
  <div class="lp-card-throw-body">
    <!-- 为了使卡片内容居中 -->
    <div class="lp-card-throw-box" :style="{
      width: cardWidth + 'px',
      height: cardHeight + 'px'
    }">
      <!-- 拖拽卡片 -->
      <div class="card" :style="{
        'z-index': 10 - index,
        width: cardWidth - index * offset + 'px',
        height: cardHeight - index * offset + 'px',
        top: (index === 0 ? top : index * offset * 1.5) + 'px',
        left: (index === 0 ? left : index * (offset / 2)) + 'px',
        background: '#fff',
        opacity: index < cardLevels ? 1 : 0
      }"
      :class="{ animation: animation }"
      @touchstart.stop.prevent='touchStart'
      @touchmove='touchMove'
      @touchend='touchEnd'
      v-for="(item, index) in list" :key="index">{{item}}</div>
      <div v-show="list.length === 0">没有卡片了</div>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    // 卡片宽度
    cardWidth: {
      type: Number,
      default: 250
    },
    // 卡片高度
    cardHeight: {
      type: Number,
      default: 250
    },
    // 卡片间的间隔,数值越大,卡片之间间隔越大
    offset: {
      type: Number,
      default: 25
    },
    // 触发飞出的系数
    throwRatio: {
      type: Number,
      default: 150
    },
    // 飞出的距离
    throwDistance: {
      type: Number,
      default: 500
    },
    // 传入的数据
    list: {
      type: Array,
      default: () => {
        return [];
      }
    },
    // 卡片显示的层数
    cardLevels: {
      type: Number,
      default: 3
    }
  },
  data () {
    return {
      // 顶部盒子相对于lp-card-throw-box的偏移量,可以是负值
      left: 0,
      top: 0,
      // 触摸开始的坐标
      startPoint: {
        x: 0,
        y: 0
      },
      // 是否加载动画
      animation: false
    };
  },
  methods: {
    // 触摸开始
    touchStart (event) {
      const touch = event.touches[0];
      // 记录开始触摸时的坐标,方便计算盒子的偏移量
      this.startPoint.x = touch.clientX;
      this.startPoint.y = touch.clientY;
    },
    // 触摸移动
    touchMove (event) {
      const touch = event.touches[0];
      // 顶部盒子移动的距离 = 当前坐标 - 触摸开始时的坐标
      // 即顶部盒子相对于`lp-card-throw-box`的偏移量
      this.left = touch.clientX - this.startPoint.x;
      this.top = touch.clientY - this.startPoint.y;
    },
    // 触摸结束
    touchEnd (event) {
      this.animation = true;
      // 如果没有超过一定距离,则还原,否则飞出
      // 公式利用了数学中的勾股定理
      if (Math.pow(this.left, 2) + Math.pow(this.top, 2) >= Math.pow(this.throwRatio, 2)) {
        // 将盒子飞出
        // 也可以用changedTouches.clientX
        this.left = this.left * this.throwDistance;
        this.top = this.top * this.throwDistance;
        this.onThrowDone();
      }
      // 添加动画效果,让效果看起来更加丝滑
      setTimeout(() => {
        this.resetCard();
      }, 60);
    },
    // 使盒子回到原处
    resetCard () {
      this.left = 0;
      this.top = 0;
      this.animation = false;
    },
    onThrowDone () {
      this.$emit('onThrowDone');
    }
  }
};
</script>

<style lang="scss" scoped>
.lp-card-throw-body {
  position: relative;
  width: 100%;
  height: 100%;
  .lp-card-throw-box {
    position: absolute;
    top: 50%;
    left: 50%;
    text-align: center;
    line-height: 250px;
    transform: translate(-50%, -50%);
    .card {
      position: absolute;
      width: 100%;
      height: 100%;
      box-shadow: 0px 4px 20px rgba(0,0,0,.3);
    }
    .animation {
      transition: opacity .4s ease-out, left .4s ease-out, top .4s ease-out;
    }
  }
}
</style>

<!-- 父组件调用代码 -->
<template>
  <div class="lp-card-throw-demo">
    <lp-card-throw @onThrowDone='onThrowDone' :list='list'></lp-card-throw>
  </div>
</template>

<script>
import { lpCardThrow } from '@/index.js';
export default {
  components: {
    lpCardThrow
  },
  data () {
    return {
      list: [1, 2, 3, 4, 5, 6, 7]
    };
  },
  methods: {
    onThrowDone () {
      this.list.splice(0, 1);
    }
  }
};
</script>

<style lang="scss" scoped>
.lp-card-throw-demo {
  width: 100vw;
  height: 100vh;
}
</style>

更多相关文档,请见:

线上地址 【前端橘子君】

GitHub仓库【前端橘子君】