Skip to content

创建简单且可重用的过渡动画

Published:

前言

在 Vue.js 中使用过渡动画非常方便。毫无疑问的是,过渡的存在使得应用充满了动感,但问题是通常需要在每个项目中从头开始写,甚至可能引入一些CSS库(如animate.css)来使过渡动画更加的华丽,因此把过渡动画封装在一个组件中轻松地在各个项目中复用显得尤为重要。

过渡组件 Transition 和 CSS

定义过渡动画的最简单方式是使用 transition​ 或 transition-group​ 这两个内置组件。这需要为过渡定义一个名称和一些 CSS 规则。一个简单的例子如下:

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

  const show = ref(false);
</script>

<template>
  <button @click="show = !show">onClick</button>
  <Transition name="fade">
    <p v-if="show">Hello</p>
  </Transition>
</template>

虽然这看起来很简单,但这种方法存在一个问题。我们不能在另一个项目中真正地重用这个过渡动画。

封装的过渡组件

如果我们将代码封装到一个组件中,并将其作为一个组件来使用,会怎么样?

<template>
  <Transition name="fade">
    <slot />
  </Transition>
</template>

<style lang="scss">
  .fade-enter-active,
  .fade-leave-active {
    transition: opacity 0.3s;
  }

  .fade-enter,
  .fade-leave-to {
    opacity: 0;
  }
</style>

如代码所示,我们可以将过渡动画封装到一个组件中,并将其用作可重用的组件。通过在过渡组件中提供插槽 slot​,我们可以与基本的过渡组件(Transition)一样地使用它。这比前面的例子稍微好一些,但如果我们想传递其他过渡特定的属性,例如 mode​ 或者一些生命周期钩子,该怎么办呢?

提供 Props

Vue.js 为过渡组件提供了一个 duration​ prop,它主要用于更复杂的过渡动画。在我们这例子中,需要的是通过组件的 props​ 来控制 css 过渡动画。

我们可以通过在 css 中不指定显式的动画持续时间,而是将其作为样式应用来实现。我们可以利用过渡钩子,在过渡所需的元素之前和之后调用它们,这些钩子类似于组件生命周期钩子。

<!-- FadeTransition -->
<script setup lang="ts">
  interface Props {
    duration: number;
    mode: "in-out" | "out-in" | "default";
  }

  const onSetDuration = (el: Element) => {
    el.style.animationDuration = `${props.duration}ms`;
  };
  const cleanUpDuration = (el: Element) => {
    el.style.animationDuration = "";
  };
  const props = withDefaults(defineProps<Props>(), {
    duration: 300,
    mode: "out-in",
  });
</script>

<template>
  <Transition
    name="fade"
    :mode="mode"
    enter-active-class="fadeIn"
    leave-active-class="fadeOut"
    @before-enter="onSetDuration"
    @after-enter="cleanUpDuration"
    @before-leave="onSetDuration"
    @after-leave="cleanUpDuration"
  >
    <slot />
  </Transition>
</template>

<style lang="scss">
  @keyframes fadeIn {
    from {
      opacity: 0;
    }
    to {
      opacity: 1;
    }
  }
  .fadeIn {
    animation-name: fadeIn;
  }
  @keyframes fadeOut {
    from {
      opacity: 1;
    }
    to {
      opacity: 0;
    }
  }
  .fadeOut {
    animation-name: fadeOut;
  }
</style>
<!-- App.vue -->
<script setup lang="ts">
  import { computed, ref } from "vue";
  import FadeTransition from "./FadeTransition.vue";

  const show = ref(false);
  const duration = ref(0);
  const getDuration = computed(() => +duration.value);
</script>

<template>
  <div id="app">
    <button @click="show = !show">onClick</button>
    <input v-model="duration" type="range" min="100" max="3000" />
    <FadeTransition mode="out-in" :duration="getDuration">
      <p v-if="show" class="box">Show</p>
    </FadeTransition>
  </div>
</template>

props.gif

现在我们可以控制真正可见的过渡持续时间,使我们的可重用过渡更加灵活和易于使用。但是,如果要过渡多个元素,例如列表项,怎么办呢?

Transition Group

最直接的方式可能是创建一个新组件,例如 fade-transition-group​ ,并将当前的 transition​ 标签替换为 transition-group​ ,以实现组过渡。如果我们能在同一个组件中执行此操作,并暴露一个 group​ prop,该属性将切换到 transition-group​ 实现,那么就更好了。正好我们可以使用渲染函数或者借助 component​ 和 is​ 属性来实现这一点。

Vue.js 文档中有一个关于过渡组元素的警告:当元素离开时,必须将每个 item 的 position 设置为绝对位置,以实现其他 item 的平滑移动动画。

<!-- SingleOrGroup.vue -->
<script setup lang="ts">
  import { Transition, TransitionGroup, computed } from "vue";

  interface Props {
    group: boolean;
    duration: number;
    tag: string;
  }
  const props = withDefaults(defineProps<Props>(), {
    group: false,
    duration: 300,
    tag: "div",
  });
  const transitionType = computed(() =>
    props.group ? TransitionGroup : Transition
  );

  const onSetDuration = (el: Element) => {
    el.style.animationDuration = `${props.duration}ms`;
  };
  const cleanUpDuration = (el: Element) => {
    el.style.animationDuration = "";
  };
  const setAbsolutePosition = (el: Element) => {
    if (props.group) {
      el.style.position = "absolute";
    }
  };
</script>

<template>
  <component
    :is="transitionType"
    :tag="tag"
    enter-active-class="fadeIn"
    leave-active-class="fadeOut"
    move-class="fade-move"
    v-bind="$attrs"
    @before-enter="onSetDuration"
    @after-enter="cleanUpDuration"
    @before-leave="onSetDuration"
    @after-leave="cleanUpDuration"
    @level="setAbsolutePosition"
  >
    <slot />
  </component>
</template>

<style lang="scss">
  @keyframes fadeIn {
    from {
      opacity: 0;
    }

    to {
      opacity: 1;
    }
  }

  .fadeIn {
    animation-name: fadeIn;
  }

  @keyframes fadeOut {
    from {
      opacity: 1;
    }

    to {
      opacity: 0;
    }
  }

  .fadeOut {
    animation-name: fadeOut;
  }

  .fade-move {
    transition: transform 0.3s ease-out;
  }
</style>
<script setup lang="ts">
  import { ref } from "vue";
  import SingleOrGroup from "./SingleOrGroup.vue";

  const items = ref<number[]>([0, 1, 2, 3, 4, 5]);
  const onAdd = () => {
    let randomIndex = Math.floor(Math.random() * items.value.length);
    items.value.splice(randomIndex, 0, Math.random());
  };
  const onRemove = (index: number) => {
    items.value.splice(index, 1);
  };
</script>

<template>
  <div id="app">
    <button @click="onAdd">onClick</button>
    <SingleOrGroup group :duration="300">
      <div
        class="box"
        v-for="(item, index) in items"
        @click="onRemove(index)"
        :key="item"
      ></div>
    </SingleOrGroup>
  </div>
</template>

transition group.gif