简单分析: h5实现某外卖APP的效果(3)--抛物线动画

147 阅读11分钟

抛物线动画.gif

前两篇文章

简单分析: h5实现外卖APP的效果 - 掘金 (juejin.cn)

简单分析: h5实现某外卖APP的效果(2) - 掘金 (juejin.cn)

原理

  • 抛物线轨迹,由水平运动作用力和垂直作用力,相互影响而来。(你可想象下,你将一个弹珠放在水平的桌面滚动,理论上弹珠是在水平方向运动,但弹珠滚离桌面后,就会以抛物线的轨迹,掉落在地面,原因就是,弹珠离开桌面后除了受到了水平方向的推力还受垂直方向地球重力的影响,所以最终的运动轨迹是个抛物线)
  • 使用css+js实现抛物线动画
  • css模拟水平作用力(translateX)和垂直作用力(translateY)
  • js用于设置运动的起点和终点

特别注意

  • 抛物线轨迹是由两个不同dom,分别执行translateX和translateY,相互影响之下得来。dom结构为嵌套结构
  • 只有内部的那个dom会走抛物线轨迹,外部那个dom依然走的是直线
  • translateX与translateY动效执行时间应该一致
<div class="outterThrowRect">
    <div class="innerThrowRect">只有内部这个div走的抛物线轨迹</div>
</div>

逐步实现

先实现基本的结构和点击事件

效果

image.png

代码

<!--

@author: pan
@createDate: 2022-12-06 16:10
-->
<script setup lang="ts">
import { ref } from 'vue'

const scrollDomRef = ref<HTMLElement>()
const splitDomRef = ref<HTMLElement>()
const headerNavDomRef = ref<HTMLElement>()
const headerPanelDomRef = ref<HTMLElement>()
const classifyPanelDomRef = ref<HTMLElement>()
const buyCarDomRef = ref<HTMLElement>()
const splitDomPositionClass = ref('')
const classifyPanelDomPositionClass = ref('')

function updateSplitDomPosition() {
  const scrollDom = scrollDomRef.value
  if (!scrollDom) return
  const headerPanelDom = headerPanelDomRef.value
  if (!headerPanelDom) return
  const headerNavDom = headerNavDomRef.value
  if (!headerNavDom) return
  const splitDom = splitDomRef.value
  if (!splitDom) return

  const { offsetHeight: headerPanelDomHeight } = headerPanelDom
  const { offsetHeight: headerNavDomHeight } = headerNavDom

  const { scrollTop } = scrollDom
  // 分割面板fixed定位scrollTop阈值 = 头部面板高度 - 头部导航高度
  const fixedHeight = headerPanelDomHeight - headerNavDomHeight
  if (scrollTop >= fixedHeight) {
    // 超过阈值,则使用fixed定位
    splitDomPositionClass.value = 'fixed top-8 left-0 right-0 h-8'
  } else {
    // 反之不使用特殊定位
    splitDomPositionClass.value = ''
  }
}
function updateClassifyPanelDomPosition() {
  const scrollDom = scrollDomRef.value
  if (!scrollDom) return
  const headerPanelDom = headerPanelDomRef.value
  if (!headerPanelDom) return
  const splitDom = splitDomRef.value
  if (!splitDom) return

  const { offsetHeight: headerPanelDomHeight } = headerPanelDom
  const { offsetHeight: splitDomHeight } = splitDom
  const { scrollTop } = scrollDom

  const fixedHeight = headerPanelDomHeight - splitDomHeight
  if (scrollTop >= fixedHeight) {
    // 超过阈值,则使用fixed定位
    classifyPanelDomPositionClass.value = 'fixed top-16 left-0 w-14'
  } else {
    // 反之不使用特殊定位
    classifyPanelDomPositionClass.value = ''
  }
}
function onScroll() {
  updateSplitDomPosition()
  updateClassifyPanelDomPosition()
}

function onClick() {
  console.log('点击')
}
</script>

<template>
  <!-- 最外层的滚动容器 -->
  <div
    ref="scrollDomRef"
    class="scroll-dom-height overflow-auto"
    @scroll="onScroll"
  >
    <!-- 头部面板包裹容器 -->
    <div ref="headerPanelDomRef" class="bg-pink-100 h-32 relative">
      <div ref="headerNavDomRef" class="h-8">
        <!-- 外面这层div的作用是使界面高度不发生变化 -->
        <div class="h-8 bg-blue-100 fixed top-0 left-0 right-0"></div>
      </div>
    </div>
    <!-- 分割面板包裹容器 -->
    <div class="h-8 bg-green-100">
      <!-- 外面这层div的作用是当splitDomRef为fixed定位时,界面高度不发生变化 -->
      <div
        ref="splitDomRef"
        class="bg-green-100 h-8"
        :class="splitDomPositionClass"
      ></div>
    </div>
    <!-- 主体内容 -->
    <main class="flex flex-row">
      <!-- 侧边导航包裹面板 -->
      <div class="bg-orange-100 w-14 flex-shrink-0">
        <!-- 外面这层div的作用是当classifyPanelDomRef为fixed定位时,界面宽度不发生变化 -->
        <div
          ref="classifyPanelDomRef"
          class="classify-panel-height bg-orange-100"
          :class="classifyPanelDomPositionClass"
        ></div>
      </div>
      <!-- 右侧列表面本 -->
      <div ref="mainListDomRef" class="flex-1 w-0">
        <div
          v-for="i in 100"
          :key="i"
          class="flex flex-row border-t-2 border-red-300 border-dashed first:border-t-0"
          @click="onClick"
        >
          <div class="flex-1 w-0 py-2">第{{ i }}</div>
          <div
            class="flex-shrink-0 w-3 bg-teal-200 flex flex-col justify-center items-center"
          >
            +
          </div>
        </div>
      </div>
    </main>
  </div>
  <div class="h-8 bg-lime-100">
    <!-- 这里可以加个fixed定位的容器,也可以不加,加了能一定程度的缓解滚动到底部之后,继续滚动的橡皮筋效果问题 -->
    <div
      class="h-8 bg-lime-100 fixed left-0 right-0 bottom-0 flex flex-row items-center"
    >
      <div ref="buyCarDomRef" class="bg-red-200 inline-block ml-2 px-2">购物车</div>
    </div>
  </div>
</template>

<style lang="scss" scoped>
// 分类面板高度
.classify-panel-height {
  /*
  为什么是6rem: 是`headerNavDomRef 的 h8` + `splitDomRef 的 h8` + 底部青色区域高度
  h8是什么? 是tailwindcss的一个高度值, 实际是2rem, 三个 2rem 就是 6rem
   */
  height: calc(100vh - 6rem);
}
.scroll-dom-height {
  // 滚动容器高度 = 视口高度 - 青色区域高度
  height: calc(100vh - 2rem);
}
</style>

点击加号创建被抛的小方块, 并计算抛物线起点和终点

效果

image.png

代码

<!--

@author: pan
@createDate: 2022-12-06 16:10
-->
<script setup lang="ts">
import { ref } from 'vue'

const scrollDomRef = ref<HTMLElement>()
const splitDomRef = ref<HTMLElement>()
const headerNavDomRef = ref<HTMLElement>()
const headerPanelDomRef = ref<HTMLElement>()
const classifyPanelDomRef = ref<HTMLElement>()
const buyCarDomRef = ref<HTMLElement>()
const splitDomPositionClass = ref('')
const classifyPanelDomPositionClass = ref('')

function updateSplitDomPosition() {
  const scrollDom = scrollDomRef.value
  if (!scrollDom) return
  const headerPanelDom = headerPanelDomRef.value
  if (!headerPanelDom) return
  const headerNavDom = headerNavDomRef.value
  if (!headerNavDom) return
  const splitDom = splitDomRef.value
  if (!splitDom) return

  const { offsetHeight: headerPanelDomHeight } = headerPanelDom
  const { offsetHeight: headerNavDomHeight } = headerNavDom

  const { scrollTop } = scrollDom
  // 分割面板fixed定位scrollTop阈值 = 头部面板高度 - 头部导航高度
  const fixedHeight = headerPanelDomHeight - headerNavDomHeight
  if (scrollTop >= fixedHeight) {
    // 超过阈值,则使用fixed定位
    splitDomPositionClass.value = 'fixed top-8 left-0 right-0 h-8'
  } else {
    // 反之不使用特殊定位
    splitDomPositionClass.value = ''
  }
}
function updateClassifyPanelDomPosition() {
  const scrollDom = scrollDomRef.value
  if (!scrollDom) return
  const headerPanelDom = headerPanelDomRef.value
  if (!headerPanelDom) return
  const splitDom = splitDomRef.value
  if (!splitDom) return

  const { offsetHeight: headerPanelDomHeight } = headerPanelDom
  const { offsetHeight: splitDomHeight } = splitDom
  const { scrollTop } = scrollDom

  const fixedHeight = headerPanelDomHeight - splitDomHeight
  if (scrollTop >= fixedHeight) {
    // 超过阈值,则使用fixed定位
    classifyPanelDomPositionClass.value = 'fixed top-16 left-0 w-14'
  } else {
    // 反之不使用特殊定位
    classifyPanelDomPositionClass.value = ''
  }
}
function onScroll() {
  updateSplitDomPosition()
  updateClassifyPanelDomPosition()
}

function onClick(e: MouseEvent) {
  // 获取事件触发元素
  const dom = e.target as HTMLElement
  if (!dom) return
  const buyCarDom = buyCarDomRef.value
  if (!buyCarDom) return

  // 获取事件触发元素相对浏览器视口的位置(这个startPoint.x,startPoint.y是dom元素左上顶点位置)[用于计算运动开始的位置]
  const startPoint = dom.getBoundingClientRect()
  const rectSize = 16
  // 被抛dom的x轴位置:与触发事件的对象一致
  const rectStartX = startPoint.x
  // 被抛dom的y轴位置:在y轴中心
  const rectStartY = startPoint.y + startPoint.height / 2 - rectSize / 2

  // 获取购物车dom相对于浏览器视口的位置(endPoint.x,endPoint.y是dom元素左上顶点位置)[用于计算运动结束的位置]
  const endPoint = buyCarDom.getBoundingClientRect()
  // 终点的x位置,由于该位置是绝对定位元素的内部元素,所以该值实际就等于两点之间x轴的距离
  const rectEndX = startPoint.x - (endPoint.x + endPoint.width / 2)
  // 终点的y位置,由于该位置是绝对定位元素的内部元素,所以该值实际就等于两点之间y轴的距离
  const rectEndY = endPoint.y - (startPoint.y + startPoint.height / 2)
  console.log(rectEndX, rectEndY)
  
  createRect(rectSize, rectStartX, rectStartY)
}

function createRect(rectSize: number, x: number, y: number) {
  const ballOuterDom = document.createElement('div')
  ballOuterDom.classList.add(...['throw-rect'])
  ballOuterDom.style.left = `${x}px`
  ballOuterDom.style.top = `${y}px`
  
  const ballInnerDom = document.createElement('div')
  ballOuterDom.appendChild(ballInnerDom)
  
  // 这里一定是设置内部这个div的背景色,只有这个div走的是抛物线轨迹,外部那个div走的还是直线轨迹
  ballInnerDom.classList.add('bg-blue-300')
  ballInnerDom.style.width = `${rectSize}px`
  ballInnerDom.style.height = `${rectSize}px`
  
  document.body.append(ballOuterDom)
}
</script>

<template>
  <!-- 最外层的滚动容器 -->
  <div
    ref="scrollDomRef"
    class="scroll-dom-height overflow-auto"
    @scroll="onScroll"
  >
    <!-- 头部面板包裹容器 -->
    <div ref="headerPanelDomRef" class="bg-pink-100 h-32 relative">
      <div ref="headerNavDomRef" class="h-8">
        <!-- 外面这层div的作用是使界面高度不发生变化 -->
        <div class="h-8 bg-blue-100 fixed top-0 left-0 right-0"></div>
      </div>
    </div>
    <!-- 分割面板包裹容器 -->
    <div class="h-8 bg-green-100">
      <!-- 外面这层div的作用是当splitDomRef为fixed定位时,界面高度不发生变化 -->
      <div
        ref="splitDomRef"
        class="bg-green-100 h-8"
        :class="splitDomPositionClass"
      ></div>
    </div>
    <!-- 主体内容 -->
    <main class="flex flex-row">
      <!-- 侧边导航包裹面板 -->
      <div class="bg-orange-100 w-14 flex-shrink-0">
        <!-- 外面这层div的作用是当classifyPanelDomRef为fixed定位时,界面宽度不发生变化 -->
        <div
          ref="classifyPanelDomRef"
          class="classify-panel-height bg-orange-100"
          :class="classifyPanelDomPositionClass"
        ></div>
      </div>
      <!-- 右侧列表面本 -->
      <div ref="mainListDomRef" class="flex-1 w-0">
        <div
          v-for="i in 100"
          :key="i"
          class="flex flex-row border-t-2 border-red-300 border-dashed first:border-t-0"
          @click="onClick"
        >
          <div class="flex-1 w-0 py-2">第{{ i }}</div>
          <div
            class="flex-shrink-0 w-3 bg-teal-200 flex flex-col justify-center items-center"
          >
            +
          </div>
        </div>
      </div>
    </main>
  </div>
  <div class="h-8 bg-lime-100">
    <!-- 这里可以加个fixed定位的容器,也可以不加,加了能一定程度的缓解滚动到底部之后,继续滚动的橡皮筋效果问题 -->
    <div
      class="h-8 bg-lime-100 fixed left-0 right-0 bottom-0 flex flex-row items-center"
    >
      <div ref="buyCarDomRef" class="bg-red-200 inline-block ml-2 px-2">购物车</div>
    </div>
  </div>
</template>

<style lang="scss" scoped>
// 分类面板高度
.classify-panel-height {
  /*
  为什么是6rem: 是`headerNavDomRef 的 h8` + `splitDomRef 的 h8` + 底部青色区域高度
  h8是什么? 是tailwindcss的一个高度值, 实际是2rem, 三个 2rem 就是 6rem
   */
  height: calc(100vh - 6rem);
}
.scroll-dom-height {
  // 滚动容器高度 = 视口高度 - 青色区域高度
  height: calc(100vh - 2rem);
}
</style>

加入x轴动效

效果

抛物线动画.gif

代码

<!--

@author: pan
@createDate: 2022-12-06 16:10
-->
<script setup lang="ts">
import { ref } from 'vue'

const scrollDomRef = ref<HTMLElement>()
const splitDomRef = ref<HTMLElement>()
const headerNavDomRef = ref<HTMLElement>()
const headerPanelDomRef = ref<HTMLElement>()
const classifyPanelDomRef = ref<HTMLElement>()
const buyCarDomRef = ref<HTMLElement>()
const splitDomPositionClass = ref('')
const classifyPanelDomPositionClass = ref('')

function updateSplitDomPosition() {
  const scrollDom = scrollDomRef.value
  if (!scrollDom) return
  const headerPanelDom = headerPanelDomRef.value
  if (!headerPanelDom) return
  const headerNavDom = headerNavDomRef.value
  if (!headerNavDom) return
  const splitDom = splitDomRef.value
  if (!splitDom) return

  const { offsetHeight: headerPanelDomHeight } = headerPanelDom
  const { offsetHeight: headerNavDomHeight } = headerNavDom

  const { scrollTop } = scrollDom
  // 分割面板fixed定位scrollTop阈值 = 头部面板高度 - 头部导航高度
  const fixedHeight = headerPanelDomHeight - headerNavDomHeight
  if (scrollTop >= fixedHeight) {
    // 超过阈值,则使用fixed定位
    splitDomPositionClass.value = 'fixed top-8 left-0 right-0 h-8'
  } else {
    // 反之不使用特殊定位
    splitDomPositionClass.value = ''
  }
}
function updateClassifyPanelDomPosition() {
  const scrollDom = scrollDomRef.value
  if (!scrollDom) return
  const headerPanelDom = headerPanelDomRef.value
  if (!headerPanelDom) return
  const splitDom = splitDomRef.value
  if (!splitDom) return

  const { offsetHeight: headerPanelDomHeight } = headerPanelDom
  const { offsetHeight: splitDomHeight } = splitDom
  const { scrollTop } = scrollDom

  const fixedHeight = headerPanelDomHeight - splitDomHeight
  if (scrollTop >= fixedHeight) {
    // 超过阈值,则使用fixed定位
    classifyPanelDomPositionClass.value = 'fixed top-16 left-0 w-14'
  } else {
    // 反之不使用特殊定位
    classifyPanelDomPositionClass.value = ''
  }
}
function onScroll() {
  updateSplitDomPosition()
  updateClassifyPanelDomPosition()
}

function onClick(e: MouseEvent) {
  // 获取事件触发元素
  const dom = e.target as HTMLElement
  if (!dom) return
  const buyCarDom = buyCarDomRef.value
  if (!buyCarDom) return

  // 获取事件触发元素相对浏览器视口的位置(这个startPoint.x,startPoint.y是dom元素左上顶点位置)[用于计算运动开始的位置]
  const startPoint = dom.getBoundingClientRect()
  const rectSize = 16
  // 被抛dom的x轴位置:与触发事件的对象一致
  const rectStartX = startPoint.x
  // 被抛dom的y轴位置:在y轴中心
  const rectStartY = startPoint.y + startPoint.height / 2 - rectSize / 2

  // 获取购物车dom相对于浏览器视口的位置(endPoint.x,endPoint.y是dom元素左上顶点位置)[用于计算运动结束的位置]
  const endPoint = buyCarDom.getBoundingClientRect()
  // 终点的x位置,由于该位置是绝对定位元素的内部元素,所以该值实际就等于两点之间x轴的距离
  const rectEndX = startPoint.x - (endPoint.x + endPoint.width / 2)
  // 终点的y位置,由于该位置是绝对定位元素的内部元素,所以该值实际就等于两点之间y轴的距离
  const rectEndY = endPoint.y - (startPoint.y + startPoint.height / 2)

  createRect(rectSize, rectStartX, rectStartY, rectEndX, rectEndY)
}

function createRect(
  rectSize: number,
  x: number,
  y: number,
  endX: number,
  endY: number
) {
  const ballOuterDom = document.createElement('div')
  ballOuterDom.classList.add(...['throw-rect'])
  ballOuterDom.style.left = `${x}px`
  ballOuterDom.style.top = `${y}px`

  const ballInnerDom = document.createElement('div')
  ballOuterDom.append(ballInnerDom)

  // 这里一定是设置内部这个div的背景色,只有这个div走的是抛物线轨迹,外部那个div走的还是直线轨迹
  ballInnerDom.classList.add('bg-blue-300')
  ballInnerDom.style.width = `${rectSize}px`
  ballInnerDom.style.height = `${rectSize}px`

  document.body.append(ballOuterDom)
  console.log(endY)
  requestAnimationFrame(() => {
    ballOuterDom.style.transform = `translateX(-${endX}px)`
  })
}
</script>

<template>
  <!-- 最外层的滚动容器 -->
  <div
    ref="scrollDomRef"
    class="scroll-dom-height overflow-auto"
    @scroll="onScroll"
  >
    <!-- 头部面板包裹容器 -->
    <div ref="headerPanelDomRef" class="bg-pink-100 h-32 relative">
      <div ref="headerNavDomRef" class="h-8">
        <!-- 外面这层div的作用是使界面高度不发生变化 -->
        <div class="h-8 bg-blue-100 fixed top-0 left-0 right-0"></div>
      </div>
    </div>
    <!-- 分割面板包裹容器 -->
    <div class="h-8 bg-green-100">
      <!-- 外面这层div的作用是当splitDomRef为fixed定位时,界面高度不发生变化 -->
      <div
        ref="splitDomRef"
        class="bg-green-100 h-8"
        :class="splitDomPositionClass"
      ></div>
    </div>
    <!-- 主体内容 -->
    <main class="flex flex-row">
      <!-- 侧边导航包裹面板 -->
      <div class="bg-orange-100 w-14 flex-shrink-0">
        <!-- 外面这层div的作用是当classifyPanelDomRef为fixed定位时,界面宽度不发生变化 -->
        <div
          ref="classifyPanelDomRef"
          class="classify-panel-height bg-orange-100"
          :class="classifyPanelDomPositionClass"
        ></div>
      </div>
      <!-- 右侧列表面本 -->
      <div ref="mainListDomRef" class="flex-1 w-0">
        <div
          v-for="i in 100"
          :key="i"
          class="flex flex-row border-t-2 border-red-300 border-dashed first:border-t-0"
          @click="onClick"
        >
          <div class="flex-1 w-0 py-2">第{{ i }}</div>
          <div
            class="flex-shrink-0 w-3 bg-teal-200 flex flex-col justify-center items-center"
          >
            +
          </div>
        </div>
      </div>
    </main>
  </div>
  <div class="h-8 bg-lime-100">
    <!-- 这里可以加个fixed定位的容器,也可以不加,加了能一定程度的缓解滚动到底部之后,继续滚动的橡皮筋效果问题 -->
    <div
      class="h-8 bg-lime-100 fixed left-0 right-0 bottom-0 flex flex-row items-center"
    >
      <div ref="buyCarDomRef" class="bg-red-200 inline-block ml-2 px-2">
        购物车
      </div>
    </div>
  </div>
</template>

<style lang="scss" scoped>
// 分类面板高度
.classify-panel-height {
  /*
  为什么是6rem: 是`headerNavDomRef 的 h8` + `splitDomRef 的 h8` + 底部青色区域高度
  h8是什么? 是tailwindcss的一个高度值, 实际是2rem, 三个 2rem 就是 6rem
   */
  height: calc(100vh - 6rem);
}
.scroll-dom-height {
  // 滚动容器高度 = 视口高度 - 青色区域高度
  height: calc(100vh - 2rem);
}
</style>
<style lang="scss">
// 这里的动效时间必须一致
$transitionTime: 0.7s;

.throw-rect {
  position: absolute;
  transition: transform $transitionTime linear;
  > div {
    opacity: 1;
    position: absolute;
    transition: all $transitionTime cubic-bezier(0.29, -0.48, 0.99, 0.19);
  }
}
</style>

加入y轴动效(此时仅内部的div走抛物线轨迹),并在动效执行完毕删除dom节点

效果

抛物线动画.gif

代码

<!--

@author: pan
@createDate: 2022-12-06 16:10
-->
<script setup lang="ts">
import { ref } from 'vue'

const scrollDomRef = ref<HTMLElement>()
const splitDomRef = ref<HTMLElement>()
const headerNavDomRef = ref<HTMLElement>()
const headerPanelDomRef = ref<HTMLElement>()
const classifyPanelDomRef = ref<HTMLElement>()
const buyCarDomRef = ref<HTMLElement>()
const splitDomPositionClass = ref('')
const classifyPanelDomPositionClass = ref('')

function updateSplitDomPosition() {
  const scrollDom = scrollDomRef.value
  if (!scrollDom) return
  const headerPanelDom = headerPanelDomRef.value
  if (!headerPanelDom) return
  const headerNavDom = headerNavDomRef.value
  if (!headerNavDom) return
  const splitDom = splitDomRef.value
  if (!splitDom) return

  const { offsetHeight: headerPanelDomHeight } = headerPanelDom
  const { offsetHeight: headerNavDomHeight } = headerNavDom

  const { scrollTop } = scrollDom
  // 分割面板fixed定位scrollTop阈值 = 头部面板高度 - 头部导航高度
  const fixedHeight = headerPanelDomHeight - headerNavDomHeight
  if (scrollTop >= fixedHeight) {
    // 超过阈值,则使用fixed定位
    splitDomPositionClass.value = 'fixed top-8 left-0 right-0 h-8'
  } else {
    // 反之不使用特殊定位
    splitDomPositionClass.value = ''
  }
}
function updateClassifyPanelDomPosition() {
  const scrollDom = scrollDomRef.value
  if (!scrollDom) return
  const headerPanelDom = headerPanelDomRef.value
  if (!headerPanelDom) return
  const splitDom = splitDomRef.value
  if (!splitDom) return

  const { offsetHeight: headerPanelDomHeight } = headerPanelDom
  const { offsetHeight: splitDomHeight } = splitDom
  const { scrollTop } = scrollDom

  const fixedHeight = headerPanelDomHeight - splitDomHeight
  if (scrollTop >= fixedHeight) {
    // 超过阈值,则使用fixed定位
    classifyPanelDomPositionClass.value = 'fixed top-16 left-0 w-14'
  } else {
    // 反之不使用特殊定位
    classifyPanelDomPositionClass.value = ''
  }
}
function onScroll() {
  updateSplitDomPosition()
  updateClassifyPanelDomPosition()
}

function onClick(e: MouseEvent) {
  // 获取事件触发元素
  const dom = e.target as HTMLElement
  if (!dom) return
  const buyCarDom = buyCarDomRef.value
  if (!buyCarDom) return

  // 获取事件触发元素相对浏览器视口的位置(这个startPoint.x,startPoint.y是dom元素左上顶点位置)[用于计算运动开始的位置]
  const startPoint = dom.getBoundingClientRect()
  const rectSize = 16
  // 被抛dom的x轴位置:与触发事件的对象一致
  const rectStartX = startPoint.x
  // 被抛dom的y轴位置:在y轴中心
  const rectStartY = startPoint.y + startPoint.height / 2 - rectSize / 2

  // 获取购物车dom相对于浏览器视口的位置(endPoint.x,endPoint.y是dom元素左上顶点位置)[用于计算运动结束的位置]
  const endPoint = buyCarDom.getBoundingClientRect()
  // 终点的x位置,由于该位置是绝对定位元素的内部元素,所以该值实际就等于两点之间x轴的距离
  const rectEndX = startPoint.x - (endPoint.x + endPoint.width / 2)
  // 终点的y位置,由于该位置是绝对定位元素的内部元素,所以该值实际就等于两点之间y轴的距离
  const rectEndY = endPoint.y - (startPoint.y + startPoint.height / 2)

  createRect(rectSize, rectStartX, rectStartY, rectEndX, rectEndY)
}

function createRect(
  rectSize: number,
  x: number,
  y: number,
  endX: number,
  endY: number
) {
  const ballOuterDom = document.createElement('div')
  ballOuterDom.classList.add(...['throw-rect'])
  ballOuterDom.style.left = `${x}px`
  ballOuterDom.style.top = `${y}px`

  const ballInnerDom = document.createElement('div')
  ballOuterDom.append(ballInnerDom)

  // 这里一定是设置内部这个div的背景色,只有这个div走的是抛物线轨迹,外部那个div走的还是直线轨迹
  ballInnerDom.classList.add('bg-blue-300')
  ballInnerDom.style.width = `${rectSize}px`
  ballInnerDom.style.height = `${rectSize}px`

  document.body.append(ballOuterDom)
  requestAnimationFrame(() => {
    ballOuterDom.style.transform = `translateX(-${endX}px)`
    ballInnerDom.style.opacity = '0'
    ballInnerDom.style.transform = `translateY(${endY}px)`
    setTimeout(() => {
      // 删除被抛dom
      ballOuterDom.parentNode?.removeChild(ballOuterDom)
    }, 700) // 这里的执行时间需要与抛物线动效的执行时间一致
  })
}
</script>

<template>
  <!-- 最外层的滚动容器 -->
  <div
    ref="scrollDomRef"
    class="scroll-dom-height overflow-auto"
    @scroll="onScroll"
  >
    <!-- 头部面板包裹容器 -->
    <div ref="headerPanelDomRef" class="bg-pink-100 h-32 relative">
      <div ref="headerNavDomRef" class="h-8">
        <!-- 外面这层div的作用是使界面高度不发生变化 -->
        <div class="h-8 bg-blue-100 fixed top-0 left-0 right-0"></div>
      </div>
    </div>
    <!-- 分割面板包裹容器 -->
    <div class="h-8 bg-green-100">
      <!-- 外面这层div的作用是当splitDomRef为fixed定位时,界面高度不发生变化 -->
      <div
        ref="splitDomRef"
        class="bg-green-100 h-8"
        :class="splitDomPositionClass"
      ></div>
    </div>
    <!-- 主体内容 -->
    <main class="flex flex-row">
      <!-- 侧边导航包裹面板 -->
      <div class="bg-orange-100 w-14 flex-shrink-0">
        <!-- 外面这层div的作用是当classifyPanelDomRef为fixed定位时,界面宽度不发生变化 -->
        <div
          ref="classifyPanelDomRef"
          class="classify-panel-height bg-orange-100"
          :class="classifyPanelDomPositionClass"
        ></div>
      </div>
      <!-- 右侧列表面本 -->
      <div ref="mainListDomRef" class="flex-1 w-0">
        <div
          v-for="i in 100"
          :key="i"
          class="flex flex-row border-t-2 border-red-300 border-dashed first:border-t-0"
          @click="onClick"
        >
          <div class="flex-1 w-0 py-2">第{{ i }}</div>
          <div
            class="flex-shrink-0 w-3 bg-teal-200 flex flex-col justify-center items-center"
          >
            +
          </div>
        </div>
      </div>
    </main>
  </div>
  <div class="h-8 bg-lime-100">
    <!-- 这里可以加个fixed定位的容器,也可以不加,加了能一定程度的缓解滚动到底部之后,继续滚动的橡皮筋效果问题 -->
    <div
      class="h-8 bg-lime-100 fixed left-0 right-0 bottom-0 flex flex-row items-center"
    >
      <div ref="buyCarDomRef" class="bg-red-200 inline-block ml-2 px-2">
        购物车
      </div>
    </div>
  </div>
</template>

<style lang="scss" scoped>
// 分类面板高度
.classify-panel-height {
  /*
  为什么是6rem: 是`headerNavDomRef 的 h8` + `splitDomRef 的 h8` + 底部青色区域高度
  h8是什么? 是tailwindcss的一个高度值, 实际是2rem, 三个 2rem 就是 6rem
   */
  height: calc(100vh - 6rem);
}
.scroll-dom-height {
  // 滚动容器高度 = 视口高度 - 青色区域高度
  height: calc(100vh - 2rem);
}
</style>
<style lang="scss">
// 这里的动效时间要一致
$transitionTime: 0.7s;

.throw-rect {
  position: absolute;
  transition: transform $transitionTime linear;
  > div {
    opacity: 1;
    position: absolute;
    transition: all $transitionTime cubic-bezier(0.29, -0.48, 0.99, 0.19);
  }
}
</style>

参考资料

制作抛物线小球效果 - 掘金 (juejin.cn)

这回试试使用CSS实现抛物线运动效果 « 张鑫旭-鑫空间-鑫生活 (zhangxinxu.com)

了解一下全新的CSS动画合成属性animation-composition - 掘金 (juejin.cn)