自学内容网 自学内容网

FLIP动画实现卡片到弹窗打开的动画效果(仿小红书)

最近在学习通过js实现对不定高的元素实现高度变化的过渡效果时,了解到了FLIP动画这个技术。

FLIP动画原理:

  1. First:在动画效果开始前,记录元素初始状态的位置和尺寸信息,可以通过getBoundingClientRect()方法获取
  2. Last:执行相应代码设置元素为动画的最终状态,并记录元素最终状态的位置和尺寸信息
  3. Invert:计算元素初始和最终状态之间的位置、大小变化,使用这些数字做一定的计算,通过transform属性让元素进行移动、缩放
  4. Play: 播放动画,首先让元素恢复到最初状态,再通过transform属性让元素从初始状态到最终状态,并通过transition给动画增加过渡效果实现平滑的动画效果

先浅浅实现一下FLIP动画实现不定高的高度渐变效果:
在这里插入图片描述
代码如下:

<template>
  <div class="home-box">
    <el-button
      type="primary"
      @mouseenter="handleMouseEnter"
      @mouseleave="handleMouseLeave"
      >展开按钮</el-button
    >
    <div class="text-box" ref="textBoxRef">
      <div class="text">FLIP动画的原理是通过计算元素在布局变化前后的位置和尺寸差异,然后应用反向变换来模拟元素的移动,最后播放动画,从而实现流畅的过渡效果。
        FLIP技术的优势在于它提供了一种高效的方式来动态改变DOM元素的位置和尺寸,同时赋予动效,从而增强用户体验。通过FLIP技术,开发者可以更专注于布局变化,而无需手动计算和应用变换,简化了动画开发的复杂度,提高了开发效率‌。
      </div>
    </div>
  </div>
</template>

<script lang="ts" setup>
import { onMounted, ref, getCurrentInstance } from "vue";

const instance = getCurrentInstance();

const handleMouseEnter = () => {
  const targetNode = instance?.refs.textBoxRef as HTMLElement;

  // F:记录初始状态的位置和尺寸 默认就是初始状态 height: 0
  // L: 执行代码让动画进入最终状态,并记录最终状态的位移和大小
  // 不定高,则设置高度为auto,并通过getBoundingClientRect获取最终高度
  targetNode.style.height = "auto";
  const lastInfo = targetNode.getBoundingClientRect();
  // I:计算最终状态和初始状态的位移和尺寸差异,并应用反向变换
  // P:执行动画,先让元素恢复到初始状态,再使用transition让元素从初始状态过渡到最终状态
  targetNode.style.height = "0";
  targetNode.style.transition = "height 0.3s";

  // 过渡到最终状态,下一帧执行,给动画一点缓冲时间
  requestAnimationFrame(() => {
    targetNode.style.height = lastInfo.height + "px";
  });
};

const handleMouseLeave = () => {
  const targetNode = instance?.refs.textBoxRef as HTMLElement;

  requestAnimationFrame(() => {
    targetNode.style.height = "0";
  });
};
</script>

<style lang="scss" scoped>
.home-box {
  .text-box {
    width: 300px;
    height: 0;
    overflow: hidden;
    border-radius: 10px;
    margin-top: 10px;
    background-color: #fff;
    box-sizing: border-box;
    box-shadow: 0 0 4px rgba(0, 0, 0, 0.4);
    .text {
      padding: 10px;
    }
  }
}
</style>

接下来实现小红书点击卡片弹出内容弹窗的效果:
在这里插入图片描述
dom结构代码如下:

<template>
  <div class="animation-page">
    <div class="left-side"></div>
    <div class="flip-animation">
      <div
        class="flip-card"
        @click="openDialog"
        v-for="(item, index) in list"
        :key="index"
      >
        <img src="@/assets/images/2.jpg" alt="" />
      </div>
    </div>
    <div class="dialog" v-if="showMask" @click="closeDialog" ref="dialogRef">
      <div class="dialog-content" ref="dialogContentRef" @click.stop>
        <div class="left-container" :style="{ width: mediaWidth + 'px' }">
          <img src="@/assets/images/2.jpg" alt="" />
        </div>
        <div class="right-container">
          <img src="@/assets/images/right.png" alt="" />
        </div>
      </div>
    </div>
  </div>
</template>

js部分的代码如下,注意查看代码中的注释部分哦,标注了需要注意的事项:

<script lang="ts" setup>
import { onMounted, ref, nextTick } from "vue";

const showMask = ref(false);
const loadImg = () => {
  const maxImageHeight = window.innerHeight - 64;
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.src = "/src/assets/images/2.jpg";
    img.onload = (e) => {
      const realWidth = (maxImageHeight / e.target.height) * e.target.width;
      resolve({
        realWidth,
        realHeight: maxImageHeight,
      });
    };
  });
};

const mediaWidth = ref();
const zoom = ref();
let firstInfo = {},
  lastInfo = {},
  dialogNode = null,
  maskNode = null,
  convertY = 0,
  convertX = 0;
const openDialog = async (e) => {
  // F:记录初始状态的位置,首先为实现该效果,弹窗的初始位置需与卡片重叠,且左侧图片的大小刚好与卡片中的图片大小一致
  firstInfo = e.target.getBoundingClientRect();
  // 点击弹窗,获取当前图片的实际宽高,此处是因为小红书内容弹窗左侧的图片的宽高是动态计算的,所以需要动态获取
  const { realWidth, realHeight } = await loadImg();
  mediaWidth.value = realWidth;
  // 展示弹窗,获取弹窗的最终状态的位置
  showMask.value = true;

  nextTick(() => {
    dialogNode = document.querySelector(".dialog-content");
    maskNode = document.querySelector(".dialog");
    // L:记录弹窗的最终态的位置和大小信息
    lastInfo = dialogNode.getBoundingClientRect();

    // I:计算弹窗初始态和最终态的位移差和缩放的比例,并设置到弹窗上
    // 卡片图片与弹窗图片保持一致,所以缩放比例就是卡片图片的宽度与弹窗图片宽度的比例
    // 然后通过计算位移将弹窗移动到卡片的位置
    zoom.value = firstInfo.width / mediaWidth.value;
    convertX = firstInfo.x - lastInfo.x;
    convertY = firstInfo.y - lastInfo.y;

    // P: 设置弹窗到初始状态
    // 注意这里的一个技巧:将缩放放置到translate的前面,这样计算位移就不需要考虑缩放后产生的位移差了
    dialogNode.style.transform = `translate(calc(-50% + ${convertX}px), calc(-50% + ${convertY}px)) scale(${zoom.value}) `;
    dialogNode.style.transformOrigin = "left top";

    // P:下一帧取消回到初始状态的动画效果,则恢复到最终态
    requestAnimationFrame(() => {
      // 最后一定要再次设置元素的宽度,否则关闭弹窗时的宽度过渡不生效
      dialogNode.style.transition = "transform 0.4s, width 0.4s";
      dialogNode.style.width = lastInfo.width + "px";
      dialogNode.style.transform = "";
      maskNode.style.backgroundColor = "rgba(0, 0, 0, 0.4)";
    });
  });
};

const closeDialog = () => {
  // F:记录当前弹窗的初始态信息,之前的lastInfo即为关闭弹窗时的初始态信息
  // 这里需要对初始态进行处理,即弹窗右侧的文字内容最终需要隐藏才可实现效果
  requestAnimationFrame(() => {
    // 设置弹窗的宽度为图片的宽度,右侧部分溢出后隐藏,动画效果集中在左边图片部分
    // 这里有个关键点就是在改变弹窗的宽度后,使用弹性布局的情况下,需要设置图片的flex-shrink:0,否则图片会随着弹窗的宽度变化而变化
    dialogNode.style.width = mediaWidth.value + "px";
    dialogNode.style.overflow = "hidden";

    // L:记录当前弹窗的最终态信息,之前的初始态firstInfo即为弹窗的最终态信息
    dialogNode.style.transition = "transform 0.4s, width 0.4s";
    // I:计算位移和缩放差距,这里跟打开时的计算方式一致
    // 多出的220px是因为右侧文字内容的宽度设置了定宽440px,在隐藏了右半部分后,会产生一半的位置偏移,需要减去
    // P: 设置弹窗从初始状态, 再过渡到最终态,这里与打开有点不一样,原因是打开的时候我们已知的是最终态,而这里我们已知的是初始态
    dialogNode.style.transform = `translate(calc(-50% + ${convertX}px - 220px), calc(-50% + ${convertY}px)) scale(${zoom.value})`;
    dialogNode.style.transformOrigin = "left top";
    maskNode.style.backgroundColor = "transparent";
  });

  dialogNode.addEventListener("transitionend", () => {
    // 动画结束后移除弹窗
    showMask.value = false;
  });
};

const list = ref([
  { width: 250, height: 300 },
  { width: 250, height: 420 },
  { width: 250, height: 280 },
  { width: 250, height: 320 },
  { width: 250, height: 360 },
  { width: 250, height: 300 },
  { width: 250, height: 400 },
  { width: 250, height: 340 },
]);
</script>

css样式代码如下:

<style lang="scss" scoped>
.animation-page {
  display: flex;
  align-content: flex-start;
  width: 1650px;
  margin: 0 auto;
  padding-top: 120px;
  .left-side {
    width: 260px;
  }
  .flip-animation {
    display: grid;
    grid-template-columns: repeat(5, 250px);
    grid-gap: 10px;
  }
}
.flip-card {
  > img {
    width: 100%;
    border-radius: 20px;
  }
}
.dialog {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: transparent;
  transition: background-color 0.4s;
  z-index: 9999;
  .dialog-content {
    display: flex;
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%) scale(1);
    height: calc(100% - 64px);
    background-color: #fff;
    border-radius: 20px;
    overflow: visible;
    .left-container {
      flex-shrink: 0;
      flex-grow: 0;
      height: 100%;
      border-radius: 20px 0 0 20px;
      overflow: hidden;
      img {
        max-width: 100%;
        max-height: 100%;
        object-fit: contain;
      }
    }
    .right-container {
      width: 440px;
      flex-shrink: 0;
      flex-grow: 1;
      border-radius: 0 20px 20px 0;
      overflow: hidden;
      padding: 10px;
      img {
        max-width: 100%;
        max-height: 100%;
        object-fit: contain;
      }
    }
  }
}
</style>


原文地址:https://blog.csdn.net/qiansitong/article/details/142878113

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!