前端实现:页面滚动时,元素缓慢上升效果

6,440 阅读2分钟

效果

2024-08-11 13.58.04.gif

实现方式

  1. 自定义指令
  2. 封装组件

两种方式均可以在SSR页面中使用

方式1:自定义指令实现

import Vue from 'vue';

const DISTANCE = 100; // y轴移动距离
const DURATION = 1000; // 动画持续时间
const THRESHOLD_FOR_TRIGGERING_ANIMATION = 0.1; // 当元素一部分可见时触发动画

const animationMap = new WeakMap();

function handleIntersection(entries, observer) { // IntersectionObserver 回调函数, 处理元素的可见性变化
  for (const entry of entries) { // 遍历所有观察目标
    if (entry.isIntersecting) { // 如果目标可见
      const animation = animationMap.get(entry.target); // 获取动画对象
      if (animation) {
        animation.play(); // 播放动画
      } else {
        // 如果不支持 Web Animations API,则使用 CSS 动画回退方案
        entry.target.classList.add('active');
      }
      observer.unobserve(entry.target); // 播放一次后停止监听
    }
  }
}

let ob;
if ('IntersectionObserver' in window) { // 如果浏览器支持 IntersectionObserver
  ob = new IntersectionObserver(handleIntersection, { // 创建 IntersectionObserver 对象
    threshold: THRESHOLD_FOR_TRIGGERING_ANIMATION // 当元素一部分可见时触发动画
  });
} else {
  // 回退机制:如果不支持 IntersectionObserver
  ob = {
    observe(el) { // IntersectionObserver 接口的 observe 方法
      el.__onScroll__ = () => { // 监听元素的滚动事件
        if (isInViewport(el)) { // 如果元素在视窗内
          const animation = animationMap.get(el); // 获取动画对象
          if (animation) {
            animation.play();
          } else {
            // 如果不支持 Web Animations API,则使用 CSS 动画回退方案
            el.classList.add('active');
          }
          window.removeEventListener('scroll', el.__onScroll__); // 停止监听
        }
      };
      window.addEventListener('scroll', el.__onScroll__); // 监听元素的滚动事件
    },
    unobserve(el) { // IntersectionObserver 接口的 unobserve 方法
      if (el.__onScroll__) { // 如果元素有滚动事件监听
        window.removeEventListener('scroll', el.__onScroll__); // 停止监听
        delete el.__onScroll__; // 清理引用
      }
    }
  };
}

function isBelowViewport(el) { // 判断元素是否在视窗下方
  const rect = el.getBoundingClientRect();
  return rect.top > window.innerHeight;
}

function isInViewport(el) { // 判断元素是否在视窗内
  const rect = el.getBoundingClientRect();
  return rect.top < window.innerHeight && rect.bottom > 0;
}

const directive = {
  name: 'slide-in',
  inserted(el, binding) { // 元素插入到 DOM 时触发
    if (!isBelowViewport(el)) {  // 如果元素在视窗下方,则不执行动画
      console.log('Element is not below viewport');
      return;
    }

    const duration = binding.value && binding.value.duration ? binding.value.duration : DURATION; // 动画持续时间
    const animationOptions = { // 动画选项: 目标位置、持续时间、缓动函数
      duration: duration,
      easing: binding.value && binding.value.easing ? binding.value.easing : 'ease'
    };

    // 检查是否支持 Web Animations API
    let animation;
    if (el.animate) { // 如果支持 Web Animations API
      animation = el.animate([ // 创建动画对象
        {
          transform: `translateY(${DISTANCE}px)`,
          opacity: 0.5
        },
        {
          transform: 'translateY(0)',
          opacity: 1
        }
      ], animationOptions);
      animation.pause(); // 初始化时暂停动画
      animationMap.set(el, animation); // 保存动画对象
    } else {
      // 如果不支持 Web Animations API,则添加 CSS 动画回退类
      el.classList.add('animate-fallback'); // animate-fallback在下面SCSS中有定义
    }

    ob.observe(el); // 开始监听元素的可见性变化
  },
  unbind(el) { // 元素从 DOM 中移除时触发
    ob.unobserve(el); // 停止监听元素的可见性变化
  }
};

Vue.directive(directive.name, directive);

注册指令

image.png

directives/index.js

import './slide-in' // 元素缓慢上升效果

main.js

import './directives'

在页面中使用

<template>
    <div class="boxs .scroll-container">
        <div class="slide-box" v-slide-in="{ duration: 500, easing: 'ease-in-out' }">0 - slide-directives</div>
        <div class="slide-box" v-slide-in>1 - slide-directives</div>
        <div class="slide-box" v-slide-in>2 - slide-directives</div>
        <div v-slide-in>3 - slide-directives</div>
        <div v-slide-in="{ duration: 500, easing: 'linear' }">4 - slide-directives</div>
        <div v-slide-in>5 - slide-directives</div>
        <div v-slide-in="{ duration: 500 }">6 - slide-directives</div>
    </div>
</template>
<style lang="scss" scoped>
.boxs {
    div {
        text-align: center;
        width: 800px;
        height: 300px;
        background-color: #f2f2f2;
        margin: 0 auto;
        margin-top: 20px;
    }
}

<!-- 兼容性处理(可放到全局style中) -->
.animate-fallback {
    opacity: 0;
    transform: translateY(100px);
    transition: transform 1s ease, opacity 1s ease;
}

.animate-fallback.active {
    opacity: 1;
    transform: translateY(0);
}

@keyframes slideIn {
    from {
        opacity: 0;
        transform: translateY(100px);
    }

    to {
        opacity: 1;
        transform: translateY(0);
    }
}

.animate-fallback-keyframes {
    opacity: 0;
    animation: slideIn 1s ease forwards;
}
</style>

方式2: 封装为组件

<template>
  <div ref="animatedElement" :style="computedStyle">
    <slot></slot>
  </div>
</template>

<script>
export default {
  name: 'slideIn',
  props: {
    duration: { // 动画持续时间
      type: Number,
      default: 1000
    },
    easing: { // 动画缓动效果
      type: String,
      default: 'ease'
    },
    distance: { // 动画距离
      type: Number,
      default: 100
    }
  },
  data() {
    return {
      hasAnimated: false // 是否已经动画过
    }
  },
  computed: {
    computedStyle() {
      return {
        opacity: this.hasAnimated ? 1 : 0,
        transform: this.hasAnimated ? 'translateY(0)' : `translateY(${this.distance}px)`,
        transition: `transform ${this.duration}ms ${this.easing}, opacity ${this.duration}ms ${this.easing}`
      }
    }
  },
  mounted() {
    if (typeof window !== 'undefined' && 'IntersectionObserver' in window) { // 检测是否支持IntersectionObserver
      this.createObserver() // 创建IntersectionObserver
    } else {
      // 如果不支持IntersectionObserver,则使用scroll事件来实现动画
      this.observeScroll()
    }
  },
  methods: {
    createObserver() {
      const observer = new IntersectionObserver(entries => { // IntersectionObserver回调函数
        entries.forEach(entry => { // 遍历每个观察目标
          if (entry.isIntersecting && !this.hasAnimated) { // 如果目标进入视口并且没有动画过
            this.hasAnimated = true // 标记动画过
            observer.unobserve(entry.target) // 停止观察
          }
        })
      }, { threshold: 0.1 }) // 观察阈值,表示目标在视口的百分比
      observer.observe(this.$refs.animatedElement) // 观察目标
    },
    observeScroll() {
      const onScroll = () => { // scroll事件回调函数
        if (this.isInViewport(this.$refs.animatedElement) && !this.hasAnimated) { // 如果目标在视口并且没有动画过
          this.hasAnimated = true // 标记动画过
          window.removeEventListener('scroll', onScroll) // 停止监听scroll事件
        }
      }
      window.addEventListener('scroll', onScroll) // 监听scroll事件
    },
    isInViewport(el) { // 判断目标是否在视口
      const rect = el.getBoundingClientRect()
      return rect.top < window.innerHeight && rect.bottom > 0
    }
  }
}
</script>

页面使用

<div class="text-slide-in-vue">
      <slide-comp v-for="(s ,idx) in list" :key="idx">
        <p>{{ s.text }} - slide-comp</p>
      </slide-comp>
    </div>

<div class="level-slide">
  <slide-comp v-for="(s, idx) in list" :key="idx" :duration="500 * idx + 500">
    <p>{{ s.text }} - slide-comp</p>
  </slide-comp>
</div>

<style>
.text-slide-in-vue {
  p {
    text-align: center;
    width: 400px;
    height: 200px;
    background-color: goldenrod;
    margin: 0 auto;
    margin-top: 20px;
  }
}

.level-slide {
  display: flex;
  align-items: center;
  justify-content: flex-start;
  gap: 20px;
  p {
      text-align: center;
      width: 200px;
      height: 200px;
      background-color: blueviolet;
      margin: 0 auto;
      margin-top: 20px;
    }
}
</style>