likes
comments
collection
share

用JS实现钉钉官网的一部分动画效果

作者站长头像
站长
· 阅读数 16

前言

最近在抖音刷到渡一前端袁老师的一个视频,视频中袁老师用原生的 js 实现了钉钉官网的一个动画效果,我把这个视频看了很多遍,觉得实现这个效果特别能锻炼人的思维,在不依赖框架、第三方库的情况下,自己使用原生的 js 去实现一遍,对自己的提升会非常大,因此我也试着去实现该动画效果。

用JS实现钉钉官网的一部分动画效果

钉钉官网的动画效果如上,当然我这个只是实现了其中的一部分,其实其他的部分用一种思维都是可以实现的,由于时间关系,我只实现了下面的这部分:

用JS实现钉钉官网的一部分动画效果

固定动画区域

我们仔细观察页面的滚动时可以发现,当列表的动画在播放的时候,我一直在滑动滚动条,但是列表区域是固定的,并不会往下滑,而当动画播放结束之后,列表区域才可以被带走,也就是要固定一块区域用于播放动画,我们先来看下页面的布局结构:

用JS实现钉钉官网的一部分动画效果

HTML结构:

<template>
  <div class="main-conteiner">
    <div class="logo-title">
      <div class="logo">
        <img src="../static/images/logo.svg" alt="" />
      </div>
      <div class="title">
        <img src="../static/images/title.png" alt="" />
      </div>
    </div>
    <div class="playground" ref="playground">
      <div class="animation-container" ref="animationRef">
        <div class="list" ref="listRef">
          <div
            class="list-item"
            :data-order="orderMap[index]"
            v-for="(item, index) in list"
            :key="index"
            :class="[
              index == 6 || index == 13 ? 'mr-0' : '',
              index > 6 ? 'mt-100' : '',
            ]"
          >
            <img :src="item.url" alt="" />
          </div>
        </div>
      </div>
    </div>
    <div class="last-area">
      <img
        src="	https://gw.alicdn.com/imgextra/i4/O1CN013OYoqp1foK6tig7lJ_!!6000000004053-2-tps-864-168.png"
        alt=""
      />
      <span>企业数字化一个钉钉就解决</span>
    </div>
  </div>
</template>

playground (蓝色区域)的高度要尽可能大些,要留有足够的时间给列表区域播放动画;同时 animation-container (红色区域)要设置为粘性定位,当它的上边沿碰到其父元素 playground 时开始固定住,并播放动画,当它的下边沿碰到其父元素 playground 时的下边沿时动画播放结束,固定取消,随着滚动条的滚动,它会被父元素带走。样式代码如下:

<style lang="scss" scoped>
.main-conteiner {
  width: 100%;
  height: 100%;
}
.logo-title {
  display: flex;
  flex-direction: column;
  align-items: center;
  width: 100%;
  height: 800px;
  margin-top: 150px;
  .logo {
    width: 200px;
    height: 200px;
    img {
      display: block;
      width: 100%;
    }
  }
  .title {
    width: 800px;
    height: 100px;
    margin-top: 50px;
    img {
      display: block;
      width: 100%;
      background: #040506;
    }
  }
}
.playground {
  width: 100%;
  height: 3000px;
  .animation-container {
    position: sticky;
    top: 0;
    display: flex;
    justify-content: center;
    align-items: center;
    width: 100%;
    background: #040506;
    height: 710px;
    .list {
      display: flex;
      flex-wrap: wrap;
      width: 1200px;
      height: 460px;
      background: #15171b;
      border-radius: 20px;
      padding: 100px;
      box-sizing: border-box;
    }
    .list-item {
      width: 80px;
      height: 80px;
      margin-right: 73.333px;
      img {
        display: block;
        width: 80px;
        height: 80px;
        border-radius: 20px;
      }
    }
    .mr-0 {
      margin-right: 0;
    }
    .mt-100 {
      margin-top: 100px;
    }
  }
}
.last-area {
  display: flex;
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 800px;
  background: #0a0610;
  img {
    display: block;
    width: 200px;
    margin-right: 20px;
  }
  span {
    display: inline-block;
    line-height: 60px;
    font-size: 23px;
    font-weight: 700;
    color: #fff;
  }
}
</style>

实现动画

动画区域以及布局结构、样式处理完之后,接下来该思考如何去实现动画

首先,我们要理解动画的本质,我个人的理解是 坐标系,横坐标是时间,纵坐标是某个值,也就是随着时间的变化,某个样式的值(透明度、偏移量...)在发生均匀的改变,可以看下下面的这张图,当然我这里的横坐标是 scroll (滚动的距离)

用JS实现钉钉官网的一部分动画效果

这里可以写一个函数来计算对应的值

// 动画曲线 - 根据传入的横坐标计算对应的纵坐标(value)
function createAnimation(
  scrollstart: number,
  scrollEnd: number,
  valueStart: number,
  valueEnd: number
) {
  return function (scroll: number) {
    if (scroll <= scrollstart) {
      return valueStart;
    }
    if (scroll >= scrollEnd) {
      return valueEnd;
    }
    return (
      valueStart +
      ((valueEnd - valueStart) * (scroll - scrollstart)) /
        (scrollEnd - scrollstart)
    );
  };
}

由于列表中的每个元素的动画程度都不一样,因此需要准备一个 map 结构做映射:其中 key 为 dom 元素,value 为一个对象,里面是一些需要变化的样式,而且每个样式值都是一个函数,根据当前滚动条的位置设置不同的值:

用JS实现钉钉官网的一部分动画效果

监听页面的滚动事件,更新列表中每个 dom 元素的样式

// 映射 - dom => {}
const animationMap = new Map();

// 更新dom的样式,遍历map给每个dom元素设置样式(透明度、偏移量、放缩....)
function updatestyles() {
  const scroll = window.scrollY;
  for (let [dom, value] of animationMap) {
    for (const cssProp in value) {
      dom.style[cssProp] = value[cssProp](scroll);
    }
  }
}

// 初始时调用一次
updatestyles();

// 监听滚动条事件
window.addEventListener("scroll", updatestyles);

关键是要搞定 animationMap,另外写个函数来进行处理,在页面挂载的时候调用一次

onMounted(() => {
  updateMap();
});

scrollStart、scrollEnd 分别表示滚动距离为多少时开始动画、结束动画,这两个值都需要进行一些计算,涉及到的距离比较多,大家可以自行画图来进行分析

// 更新map
function updateMap() {
  // 每次调用时先将之前的清除掉,在窗口大小等发生改变时重新处理
  animationMap.clear();
  const playGroundRect = playground.value.getBoundingClientRect();
  const scrollStart = playGroundRect.top + window.scrollY;
  const scrollEnd = playGroundRect.bottom + window.scrollY - window.innerHeight;
  for (const item of listRef.value.children) {
    animationMap.set(item, getDomAnimation(scrollStart, scrollEnd, item));
  }
}

getDomAnimation 方法也就是实现上面所说的 map 结构中的 value,其返回两个 函数,用于计算对于的样式属性值,整个过程中透明度(opacity)由 0 -> 1,放缩比例(scale)由 0.5 -> 1,这里比较难处理的是偏移量(translate),每个元素都会往中间靠(水平、垂直方向上);由于每个元素这些属性值的变化不完全一样,因此需要给每个 dom 元素加个自定义属性 data-order,同时使动画具有对称性。

const orderMap = {
  0: 0,
  1: 1,
  2: 2,
  3: 3,
  4: 2,
  5: 1,
  6: 0,
  7: 0,
  8: 1,
  9: 2,
  10: 3,
  11: 2,
  12: 1,
  13: 0,
};
function getDomAnimation(scrollStart: number, scrollEnd: number, dom: any) {
  scrollStart = scrollStart + dom.dataset.order * 600;
  const opacityAimation = createAnimation(scrollStart, scrollEnd, 0, 1);
  const opacity = function (scroll: number) {
    return opacityAimation(scroll);
  };

  const xAnimation = createAnimation(
    scrollStart,
    scrollEnd,
    animationRef.value.clientWidth / 2 - dom.offsetLeft - dom.clientWidth / 2,
    0
  );
  const yAnimation = createAnimation(
    scrollStart,
    scrollEnd,
    animationRef.value.clientHeight / 2 - dom.offsetTop - dom.clientHeight / 2,
    0
  );
  const scaleAnimation = createAnimation(scrollStart, scrollEnd, 0.5, 1);

  const transform = function (scroll: number) {
    return `translate(${xAnimation(scroll)}px, ${yAnimation(
      scroll
    )}px) scale(${scaleAnimation(scroll)})`;
  };

  return {
    opacity,
    transform,
  };
}

实现效果

代码的话基本就完成了,下面我们看下最终的效果:

用JS实现钉钉官网的一部分动画效果

完整代码

DingTalk.vue

<template>
  <div class="main-conteiner">
    <div class="logo-title">
      <div class="logo">
        <img src="../static/images/logo.svg" alt="" />
      </div>
      <div class="title">
        <img src="../static/images/title.png" alt="" />
      </div>
    </div>
    <div class="playground" ref="playground">
      <div class="animation-container" ref="animationRef">
        <div class="list" ref="listRef">
          <div
            class="list-item"
            :data-order="orderMap[index]"
            v-for="(item, index) in list"
            :key="index"
            :class="[
              index == 6 || index == 13 ? 'mr-0' : '',
              index > 6 ? 'mt-100' : '',
            ]"
          >
            <img :src="item.url" alt="" />
          </div>
        </div>
      </div>
    </div>
    <div class="last-area">
      <img
        src="	https://gw.alicdn.com/imgextra/i4/O1CN013OYoqp1foK6tig7lJ_!!6000000004053-2-tps-864-168.png"
        alt=""
      />
      <span>企业数字化一个钉钉就解决</span>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from "vue";
const listRef = ref();
const playground = ref();
const animationRef = ref();

onMounted(() => {
  updateMap();
});

const list = [
  {
    name: "即时沟通",
    url: "https://img.alicdn.com/imgextra/i3/O1CN01TVWH501KYCEaUxPgv_!!6000000001175-0-tps-480-480.jpg",
  },
  {
    name: "组织",
    url: "https://img.alicdn.com/imgextra/i1/O1CN01vqBxIP1L5d98G9xJD_!!6000000001248-0-tps-480-480.jpg",
  },
  {
    name: "智能人事",
    url: "https://img.alicdn.com/imgextra/i3/O1CN01AKJU3T1tSi2NAFHFP_!!6000000005901-0-tps-480-480.jpg",
  },
  {
    name: "组织大脑",
    url: "https://img.alicdn.com/imgextra/i3/O1CN01AKJU3T1tSi2NAFHFP_!!6000000005901-0-tps-480-480.jpg",
  },
  {
    name: "OA审批",
    url: "	https://img.alicdn.com/imgextra/i1/O1CN01sphiAp1Fj7bOKEppz_!!6000000000522-2-tps-480-480.png",
  },
  {
    name: "邮箱",
    url: "https://img.alicdn.com/imgextra/i2/O1CN015T9BdZ28Ns5n1A82W_!!6000000007921-0-tps-480-480.jpg",
  },
  {
    name: "Teambition",
    url: "	https://img.alicdn.com/imgextra/i3/O1CN01bc6FKX1OCuHkDC2J7_!!6000000001670-0-tps-480-480.jpg",
  },
  {
    name: "文档",
    url: "https://img.alicdn.com/imgextra/i1/O1CN01Y936lS1whluhvos2E_!!6000000006340-0-tps-480-480.jpg",
  },
  {
    name: "音视频会议",
    url: "https://img.alicdn.com/imgextra/i3/O1CN017Wk2Cp1lp8VaSd22U_!!6000000004867-0-tps-480-480.jpg",
  },
  {
    name: "开放平台",
    url: "https://img.alicdn.com/imgextra/i4/O1CN016JIoXg1lMHYbmErdR_!!6000000004804-0-tps-240-240.jpg",
  },
  {
    name: "宜搭",
    url: "https://img.alicdn.com/imgextra/i3/O1CN011GyvMe1of0bAveV4u_!!6000000005251-0-tps-480-480.jpg",
  },
  {
    name: "钉闪会",
    url: "https://img.alicdn.com/imgextra/i2/O1CN01zFSNcP26wYrM09A4S_!!6000000007726-0-tps-480-480.jpg",
  },
  {
    name: "连接器",
    url: "https://img.alicdn.com/imgextra/i2/O1CN0142F9wc23s261dItxf_!!6000000007310-0-tps-480-480.jpg",
  },
  {
    name: "酷应用",
    url: "https://img.alicdn.com/imgextra/i4/O1CN012wi8vZ1xt3HbO0ttd_!!6000000006500-0-tps-480-480.jpg",
  },
];

const orderMap = {
  0: 0,
  1: 1,
  2: 2,
  3: 3,
  4: 2,
  5: 1,
  6: 0,
  7: 0,
  8: 1,
  9: 2,
  10: 3,
  11: 2,
  12: 1,
  13: 0,
};

// 动画曲线 - 根据传入的横坐标计算对应的纵坐标(value)
function createAnimation(
  scrollstart: number,
  scrollEnd: number,
  valueStart: number,
  valueEnd: number
) {
  return function (scroll: number) {
    if (scroll <= scrollstart) {
      return valueStart;
    }
    if (scroll >= scrollEnd) {
      return valueEnd;
    }
    return (
      valueStart +
      ((valueEnd - valueStart) * (scroll - scrollstart)) /
        (scrollEnd - scrollstart)
    );
  };
}

// 映射 - dom => {}
const animationMap = new Map();

function getDomAnimation(scrollStart: number, scrollEnd: number, dom: any) {
  scrollStart = scrollStart + dom.dataset.order * 600;
  const opacityAimation = createAnimation(scrollStart, scrollEnd, 0, 1);
  const opacity = function (scroll: number) {
    return opacityAimation(scroll);
  };

  const xAnimation = createAnimation(
    scrollStart,
    scrollEnd,
    animationRef.value.clientWidth / 2 - dom.offsetLeft - dom.clientWidth / 2,
    0
  );
  const yAnimation = createAnimation(
    scrollStart,
    scrollEnd,
    animationRef.value.clientHeight / 2 - dom.offsetTop - dom.clientHeight / 2,
    0
  );
  const scaleAnimation = createAnimation(scrollStart, scrollEnd, 0.5, 1);

  const transform = function (scroll: number) {
    return `translate(${xAnimation(scroll)}px, ${yAnimation(
      scroll
    )}px) scale(${scaleAnimation(scroll)})`;
  };

  return {
    opacity,
    transform,
  };
}

// 更新map
function updateMap() {
  // 每次调用时先将之前的清除掉,窗口大小等发生改变时重新处理
  animationMap.clear();
  const playGroundRect = playground.value.getBoundingClientRect();
  const scrollStart = playGroundRect.top + window.scrollY;
  const scrollEnd = playGroundRect.bottom + window.scrollY - window.innerHeight;
  for (const item of listRef.value.children) {
    animationMap.set(item, getDomAnimation(scrollStart, scrollEnd, item));
  }
}

// 更新dom的样式,遍历map给每个dom元素设置样式(透明度、偏移量、放缩....)
function updatestyles() {
  const scroll = window.scrollY;
  for (let [dom, value] of animationMap) {
    for (const cssProp in value) {
      dom.style[cssProp] = value[cssProp](scroll);
    }
  }
}

// 初始时调用一次
updatestyles();

// 监听滚动条事件
window.addEventListener("scroll", updatestyles);
</script>

<style lang="scss" scoped>
.main-conteiner {
  width: 100%;
  height: 100%;
}
.logo-title {
  display: flex;
  flex-direction: column;
  align-items: center;
  width: 100%;
  height: 800px;
  margin-top: 150px;
  .logo {
    width: 200px;
    height: 200px;
    img {
      display: block;
      width: 100%;
    }
  }
  .title {
    width: 800px;
    height: 100px;
    margin-top: 50px;
    img {
      display: block;
      width: 100%;
      background: #040506;
    }
  }
}
.playground {
  width: 100%;
  height: 3000px;
  .animation-container {
    position: sticky;
    top: 0;
    display: flex;
    justify-content: center;
    align-items: center;
    width: 100%;
    background: #040506;
    height: 710px;
    .list {
      display: flex;
      flex-wrap: wrap;
      width: 1200px;
      height: 460px;
      background: #15171b;
      border-radius: 20px;
      padding: 100px;
      box-sizing: border-box;
    }
    .list-item {
      width: 80px;
      height: 80px;
      margin-right: 73.333px;
      img {
        display: block;
        width: 80px;
        height: 80px;
        border-radius: 20px;
      }
    }
    .mr-0 {
      margin-right: 0;
    }
    .mt-100 {
      margin-top: 100px;
    }
  }
}
.last-area {
  display: flex;
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 800px;
  background: #0a0610;
  img {
    display: block;
    width: 200px;
    margin-right: 20px;
  }
  span {
    display: inline-block;
    line-height: 60px;
    font-size: 23px;
    font-weight: 700;
    color: #fff;
  }
}
</style>