GSAP实战仿荣耀官网的页面滚动效果

3,704 阅读14分钟

  在上一篇文章中,我们对GSAP的用法有了一个简单的了解,本文我们就结合GSAP的用法教程,仿照荣耀官网MagicOS的页面,实现一个酷炫的网页效果。

本文正在参加「金石计划」

整体样式布局

  我们先来欣赏一下页面的效果,每一幕如同电影开场一样缓缓的呈现效果,大家可以点击这个链接来欣赏效果:

整体效果

  在整体布局上,我们发现,它是通过多个section来划分每一屏的;这里的一屏,可以理解为一个动画效果的划分,每一屏的高度大致等于100vh。

  大多数的section再嵌套一层.section-wrapper来包裹内部的元素,同时使用margin: 0 auto;来让wrapper左右居中:

<div class="main magic-os">
  <!-- 第一屏 -->
  <section class="section-hero section-dark">
    <div class="section-wrapper"></div>
  </section>
  <!-- 第三屏 -->
  <section class="section-magic">
    <div class="section-wrapper"></div>
  </section>
  <!-- 省略其他屏... -->
</div>

  样式上,将很多屏公共的、通用样式抽离出来,放到.magic-os中,比如给section添加黑色的背景.section-dark.section-headline是主标题,.section-intro是介绍性的文字,.section-link是跳转链接等等;不同屏有相同的布局和呈现效果,样式上也可以通用,比如.section-start呈现svg画图和.section-card-view呈现卡片式布局等等。

.magic-os {
  background-color: #fff;
  section {
    position: relative;
    z-index: 1;
    background-color: #fff;
  }
  .section-dark {
    color: #fff;
    background-color: #000;
  }
}
// 第一屏
.section-hero {
}
// 第三屏
.section-magic {
}

  而每一屏特有的样式则在下面独立出来。

首屏适配

  首屏是整个网站的门面,体现出整个网站的特色与风格;我们看到首屏的设计还是比较简洁明了的,一个logo、主标题和slogan;随着屏幕宽度不断的缩放,文字的宽度和图片的大小也在随之缓慢的等比缩放,适配了各尺寸的屏幕。

  缓慢的效果主要是通过transition属性来实现的,常见的用法是:transition: 1s表示过渡效果需要1秒来完成;这里我们发现后面还带有一个时间值:transition: 1s 0.5s;我们回顾一下transition的语法:

transition: property duration timing-function delay;

  不难猜出来1s表示完成时间duration,0.5s表示延迟时间delay;因此上面的就相当于下面的省略写法:

transition: all 1s ease 0.5s

  不知道大家有没有遇到多个属性需要使用transition的情形,笔者一般会偷懒,使用all让它们的完成时间差不多;但是如果几个属性的完成时间差距较大,就需要使用逗号将多个属性复合使用:

.box {
  width: 100px;
  height: 100px;
  border: 3px solid black;
  margin: 30px;
  cursor: pointer;
  transition: width 0.5s, background-color 1s 0.5s, transform 2s;
  &:hover {
    width: 200px;
    background-color: red;
    transform: translateY(100px);
  }
}

  通过transition属性我们能够实现很多意想不到的动画效果。

复合transition属性

  我们发现在.section-wrapper外层还有一个比较特殊的类名,就是.aspect-ratio,这就涉及到了如何通过CSS来实现固定宽高比。

CSS实现固定宽高比

  首先,可替换元素(replaced element)实现固定宽高比就比较简单了,和其他元素不同,它们本身有像素宽度和高度的概念;这里说到了一个概念:可替换元素,其实就是浏览器根据元素的标签和属性,来决定元素的具体显示内容;可替换元素的内容不受当前文档的样式的影响。

CSS可以影响可替换元素的位置,但不会影响到可替换元素自身的内容

  比如iframe也是可替换元素,可能有自己的样式表,CSS不能影响其内部的样式;常见的可替换元素有iframe、video、img、embed;与之相对应的就是不可替换元素了,它们内容可以受CSS渲染控制;我们常见的div、p、span等大多数都是不可替换元素。

  我们就来看下img固定宽高比,只需要设置width或者height为一个具体值,另一个属性设置为auto即可:

<template>
  <div class="wrap">
    <img src="./images/1.jpg" class="img" /> 
  </div>
</template>
<style lang="scss">
.wrap {
  position: relative;
  width: 50vw;
  margin: 0 auto;
  .img {
    width: 100%;
    height: auto;
  }
}
</style>

img实现固定宽高比

  虽然上面的方式实现了可替换元素的固定宽高比,但是不适用于div、span等不可替换元素,因为它们本身是没有尺寸的,默认的高度都是0。

  对于不可替换元素,我们能想到一种方式是通过js来实现,页面加载时获取宽度,根据宽高比rate计算出高度然后赋值style属性即可;别忘了,还需要监听resize,这样的方式也能实现。

  另一种就是我们下面介绍的纯CSS的实现方式了,我们使用padding来撑大div的高度:

<template>
  <div class="wrap">
    <div class="cont"></div>
  </div>
</template>
<style lang="scss">
.wrap {
  position: relative;
  width: 50vw;
  margin: 0 auto;
  .cont {
    background-color: black;
    height: 0;
    padding: 0;
    padding-bottom: 75%;
  }
}
</style>

  我们看到div元素的宽高比也是固定的了,大致相当于4/3,也就是75%。

div实现固定宽高比

  很多小伙伴肯定会好奇,为什么加了padding就能实现这样的效果;我们从mdn上来找答案,看下mdn对于padding属性的解释,当取百分比值的时候,是相当于包含块的宽度来计算的:

mdn对于padding解释

  通过这种方式,div的高度实际上是被padding给撑开的;我们可以将上面的样式抽离成一个通用的样式.aspect-ratio;在需要用到固定宽高比的地方直接使用类名即可,给wrap元素设置一个padding-bottom样式。

<template>
  <div class="wrap aspect-ratio">
    <div class="cont"></div>
  </div>
</template>
<style lang="scss">
// 抽离出来的公共样式
.aspect-ratio {
  position: relative;
  &::before {
    display: block;
    content: "";
  }
  & > :first-child {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
  }
}
// 使用时给父级before加padding控制宽高比
.wrap {
  position: relative;
  width: 50vw;
  margin: 0 auto;
  &::before {
    padding-bottom: 75%;
  }
}
.cont {
  background-color: red;
}
</style>

  这样wrap盒子就被before元素撑开了,如果我们想要在里面放入内容,还需要将div内部元素使用绝对定位充满整个内容;这种方式虽然能够实现,但是只能高度随着宽度改变而改变,缺点是并不能反过来,宽度随着高度改变。

  W3C提出一个保持纵横比的规范属性:aspect-ratio,我们看到目前大部分主流的浏览器也已经支持了,支持率已经有90%;但是IE还是全版本不支持,如果你不需要考虑支持IE,可以考虑使用该属性。

aspect-ratio浏览器支持程度

  那么aspect-ratio如何使用呢?我们就不需要像上面的padding那样来套娃了,只需要在CSS添加一行代码:

<template>
  <div class="box"></div>
</template>
<style lang="scss">
.box {
  position: relative;
  width: 50vw;
  margin: 0 auto;
  // 直接添加宽高比
  aspect-ratio: 4 / 3;
  background-color: red;
}
</style>

  第二屏也是使用.aspect-ratio来实现视频元素宽高的固定比例,这里就不再赘述了。

滚动渐显

  我们往下继续看,第三屏是滚动渐显的效果,这里就用到了GSAP的滚动触发,我们先欣赏一下页面的效果:

滚动触发效果

  这一屏的页面布局也比较简单,一个section-headline标题,section-content内容包裹四个section-item模块展示。

<section class="section-magic">
  <div class="section-wrapper">
    <h2 class="section-headline fade-copy fade-trigger">4大技术加持 共筑新体验</h2>
    <div class="section-content fade-copy fade-trigger">
      <div class="section-item">
        <img class="section-icon" src="./images/icon-magic-ring.svg" alt="" />
        <h3 class="section-headline-reduced">MagicRing 信任环</h3>
        <p class="section-intro">跨系统可信互联</p>
      </div>
      <div class="section-item">
        <img class="section-icon" src="./images/icon-magic-ring.svg" alt="" />
        <h3 class="section-headline-reduced">Magic Live 智慧引擎</h3>
        <p class="section-intro">平台级AI能力</p>
      </div>
        <!-- 省略其他... -->
    </div>
  </div>
</section>

  我们发现,这里section-headline标题section-content内容都加了两个特殊的样式fade-copy和fade-trigger,fade-copy的样式比较简单,初始化通过opacity: 0进行隐藏,同时使用transform让它在原始位置Y轴偏下方;触发时,再加上active样式就可以实现从底部滑动上来,实现渐显的效果。

.fade-copy {
  transition: opacity 0.5s, transform 0.5s;
  transform: translateY(50px);
  opacity: 0;
  &.active {
    transform: translateY(0px);
    opacity: 1;
  }
}

  CSS的样式实现了,那么最最最关键的问题来了,如何在滚动时触发给fade-copy元素添加active类名呢?这里我们就用到了ScrollTrigger滚动触发了:

const triggerFn = () => {
  const triggerList = document.querySelectorAll(".fade-trigger");
  triggerList.forEach((item) => {
    const hook = item.getAttribute("data-hook") || "70%";
    gsap.timeline({
      scrollTrigger: {
        trigger: item,
        start: "top " + hook,
        toggleClass: "active",
        // markers: true,
      },
    });
  });
};

  这里hook参数用来设置滚动触发起始的位置,默认是在距离屏幕顶部70%的高度;我们在写代码的时候,很多时候不知道元素滚动到什么时候会触发,因此可以给ScrollTrigger添加markers: true添加页面上的标记,来调试滚动条触发的位置;还不了解ScrollTrigger用法的小伙伴可以点击这里

  我们通过forEach循环来遍历页面上所有的.fade-trigger元素,每个元素都绑定了滚动触发的事件;因此在下面的很多地方,我们发现都是使用该类名来实现的效果。

svg动画

  svg绘制的动画效果,图形可以进行无限缩放,也不会失真;相较于图片也比较灵活,本文不对svg的具体使用教程进行深入的探讨,我们简单看下gsap是如何结合svg实现强大的动画效果。

  在第四屏、十一屏、十五屏和十九屏都有类似的svg动画效果,我们以第四屏为例,首先欣赏一下页面的效果:

滚动触发效果

  页面上通过前面三个ellipse元素绘制椭圆形描边,设置transform让每个旋转一定的角度,形成对称的图案;最后一个ellipse是中心的圆形。

<g fill="none" stroke-dasharray="0 220% 0">
  <ellipse
    class="magic-path"
    cx="74.8447318"
    cy="68.4"
    rx="31.5406825"
    ry="68.2132305"
  ></ellipse>
  <ellipse
    class="magic-path"
    cx="74.8447318"
    cy="68.4"
    rx="31.4542843"
    ry="68.4"
  ></ellipse>
  <ellipse
    class="magic-path"
    cx="74.8447318"
    cy="68.4"
    rx="31.5406825"
    ry="68.2132305"
  ></ellipse>
  <ellipse
    class="magic-circle"
    fill="#D7A85B"
    cx="74.8447318"
    cy="68.4"
    rx="10.4847614"
    ry="10.5230769"
  ></ellipse>
</g>

  图形绘制后,现在需要的就是如何让他们动起来,这里借助stroke-dasharray样式,让其实现描边的效果:

ellipse {
  animation: magic 1.5s linear;
  animation-fill-mode: both;
  animation-delay: 3s;
}
@keyframes magic {
  0% {
    stroke-dasharray: 0 220% 0;
  }
  100% {
    // 如果写成220% 0% 0%就是顺时针
    stroke-dasharray: 0% 0% 220%;
  }
}

  我们的图案就像下面一样动起来了:

svg

  很多小伙伴对stroke-dasharray这个样式可能不是很了解,我们先看下mdn上的用法:

stroke-dasharray样式

  它是由数值或者百分比组成的一个数列,数列中的数值,第一个表示点的大小,第二值表示两个点之间的空隙大小;一般的写法如:stroke-dasharray:10, 2表示点10px,点空隙2px;上面样式中刚开始0 220% 0其实相当于0 220%,表示空隙占满全部的空间,也就是不显示了。

使用stroke-dashoffset也能实现类似的效果。

  现在图案有了也动起来了,我们就不用CSS的动画了;我们需要结合GSAP来让它和滚动条实现互动了,还记得我们之前说过,GSAP也能控制svg的属性,让svg动起来么?

gsap
  .timeline({
    scrollTrigger: {
      trigger: ".magic-svg",
      start: "top 60%",
      end: "bottom 100%",
      scrub: 0.5,
    },
  })
  // 让ellipse实现描边
  .to(".magic-path", {
    strokeDasharray: "0% 0% 220%",
  })
  // 让中心圆圈渐显
  .to(".magic-circle", {
    duration: 0.5,
    opacity: 1,
  })
  // 让ellipse从细到粗渐变
  .from(
    ".magic-path",
    {
      duration: 0.5,
      stroke: "#d7a85b",
      strokeWidth: 2,
    },
    "<",
  );

  我们发现,很多动画结束后,都有相同的效果,主标题headline渐隐展示、副标题subhead和链接link都从下方滚动展示出来;这里就需要介绍一个新的函数:gsap.registerEffect,可以让我们在全局注册想要的效果,直接调用,不用每次都重复造轮子。

gsap.registerPlugin(SplitText);

// 注册
gsap.registerEffect({
  name: "rainbow",
  effect: (target, config) => {
    let split = new SplitText(target, { type: "chars,words,lines" });
    return gsap.from(split.chars, { opacity: 0, y: -100, stagger: 0.05 });
  },
});

// 初始化调用
onMounted(() => {
  gsap.effects.rainbow(".h1");
  gsap.effects.rainbow(".h2");
});

registerEffect注册效果

  这样注册后我们每次都需要手动调用gsap.effects,或者我们还设置extendTimeline: true,在任意时间线之后都可以调用该效果。

gsap.registerEffect({
  name: "rainbow",
  // extendTimeline设置为true,可以直接在任何GSAP时间线上调用效果
  // 让结果立即插入到定义的位置(默认是在最后的位置)
  extendTimeline: true,
  // ...其他代码
});

// 调用
onMounted(() => {
  gsap.timeline()
    .rainbow(".h1")
    .rainbow(".h2");
});

  这样rainbow效果就会在时间线上顺序调用;我们回到荣耀的页面注册函数上来,发现在全局注册了一个tech4的效果。

gsap.registerEffect({
  name: "tech4",
  extendTimeline: true,
  effect: function (targets) {
    let tl = gsap
      .timeline()
      // 整个svg从放大效果回到正常
      .from(targets[0], {
        duration: 0.5,
        scale: 5,
        yPercent: 80,
      })
      // 标题逐渐显示
      .to(targets[1], {
        duration: 0.5,
        opacity: 1,
      })
      // 副标题从下向上滚动
      .fromTo(
        targets[2],
        {
          y: 60,
        },
        {
          y: 0,
          opacity: 1,
        },
      );
    // link链接从下向上滚动
    if (targets[3]) {
      tl.fromTo(
        targets[3],
        {
          y: 60,
        },
        {
          y: 0,
          autoAlpha: 1,
        },
      );
    }
    return tl;
  },
});

  因此在动画效果结束后,都会调用这个tech4效果来对标题、副标题等元素进行处理。

gsap
  .timeline()
  // 其他的效果
  .tech4([svg, headline, subhead, link, wrapper], "<");

卡片式布局

  我们前面介绍过卡片式布局的通用样式是.section-card-view,这种布局将两个或多个div如同卡片横向排列,随着滚动条而移动,首先也来欣赏一下页面的滚动效果:

卡片式布局效果

  页面结构看似很复杂,其实主要就三层结构:

<template>
  <section class="section-connect-4 section-card-view">
    <div class="sticky-wrapper">
      <div class="sticky-content">
        <div class="section-wrapper">
          <div class="section-card"> <!-- 卡片内容--> </div>
          <div class="section-card"> <!-- 卡片内容--> </div>
        </div>
      </div>
    </div>
  </section>
</template>
<style lang="scss">
.section-card-view {
  .sticky-wrapper {
    height: 108.333333vw;
  }
  .sticky-content {
    position: sticky;
    width: 100%;
    height: auto;
    top: 65px;
    overflow: hidden;
  }
  .section-wrapper {
    position: relative;
    display: flex;
    width: 70.833333vw;
    margin: 0 auto;
  }
  .section-card {
    position: relative;
    flex-shrink: 0;
    width: 100%;
  }
  .section-card + .section-card {
    margin-left: 3.125vw;
  }
}
</style>

  我们仔细来看下它的层级结构,首先是.sticky-wrapper设置高度108vw,用来撑开高度;中间的元素.sticky-content设置position:sticky,就是我们用来实现粘性定位的主要元素了,这样页面在滚动时就能保证内容始终距离顶部悬浮一定高度;.section-wrapper设置display:flex,是内部flex布局的容器。

  我们发现第二个元素刚开始会有缩小并且毛玻璃的效果,弱化内容的展示,随着滚动逐渐清晰;初始化时可以通过css设置blur,来达到毛玻璃的遮罩效果。

.section-card + .section-card .section-card-content {
  transform: scale(0.8);
  transform-origin: left;
  filter: blur(10px);
}

  页面有了,那如果让卡片滚动起来呢?又到了我们的GSAP开始大显身手的时候了;实现的逻辑其实也非常简单,粘性定位元素.sticky-content在滚动时保持悬浮位置不变,让其内部的flex布局元素.section-wrapper向右移动,这样就让我们有种错觉,滚动条向下时将卡片推着移动。

const cardViewFn = () => {
  const sections = document.querySelectorAll(".section-card-view");

  sections.forEach((section) => {
    const wrapper = section.querySelector(".section-wrapper");
    const stickyWrapper = section.querySelector(".sticky-wrapper");

    gsap.to(wrapper, {
      scrollTrigger: {
        trigger: stickyWrapper,
        start: "top 65",
        end: "bottom 100%",
        scrub: 0,
      },
      ease: "none",
      x: -swiperOffset,
    });
  })
}

  我们查找页面上所有的.section-card-view,遍历元素将其绑定ScrollTrigger事件;scrub属性将滚动条和.sticky-wrapper元素的x轴位移绑定;其实现的效果如下:

滚动位移

  我们看到上面的代码中有一个swiperOffset变量,猜测就是wrapper的位移距离,那它是如何来计算的呢?我们将整个flex布局元素.section-wrapper内部的所有卡片想象成一个整体的div,它向左移动的距离就是整体的宽度减去页面的宽度,因此我们主要的工作就是计算它的宽度。

位移距离

  计算方式直接上代码:

const screenWidth = document.documentElement.clientWidth;
const cardWidth = cards[0].clientWidth;
const cardMargin = Number(window.getComputedStyle(cards[1]).getPropertyValue("margin-left").slice(0, -2));
const cardsNumber = cards.length;

const swiperOffset =
  // 距离页面左侧的宽度 * 2
  wrapper.getBoundingClientRect().left * 2 
  // 每个卡片宽度 * 卡片数量
  + cardWidth * cardsNumber
  // 卡片的左侧距离 * (卡片数量 - 1)
  + cardMargin * (cardsNumber - 1) 
  // 屏幕的宽度
  - screenWidth;

gsap.to(wrapper, {
  // 省略其他代码
  x: -swiperOffset,
});

  那么现在wrapper也滚动起来了,我们就需要将第二个及其以后的卡片内容在滚动时逐渐放大清晰,去掉模糊效果。

const cardScroll = cardWidth + cardMargin;
const stickyTop = 65;

cards.forEach(function (card, index) {
  if (index > 0) {
    const startTrigger = stickyTop - cardScroll * (index - 1);

    gsap.to(card.querySelector(".section-card-content"), {
      scrollTrigger: {
        trigger: card,
        start: "top " + startTrigger,
        end: "+=" + cardScroll / 3,
        scrub: 0,
      },
      ease: "none",
      filter: "blur(0px)",
      scale: 1,
    });
  }
});

  最终实现的效果如下:

实现效果

总结

  通过本文,我们结合实际的案例,对GSAP的使用方式有了更进一步的了解;但是由于篇幅和精力的限制,本文主要分析了滚动渐显、svg动画和卡片式布局的几个效果,实际页面中有非常丰富的效果,本文只是窥探了其中的极少一部分的效果,大家如果感兴趣可以自行在荣耀官网查看。

本文正在参加「金石计划」

如果觉得写得还不错,敬请关注我的掘金主页。更多文章请访问谢小飞的博客

本文所有源码敬请关注公众号【前端壹读】,后台回复关键词【GASP荣耀官网】即可获取。