hey / STORES advent calendar 2020 7 日目の記事です。
2020 年の振り返りとして、この記事では Nuxt.js で実装してきたアニメーションをまとめていきます。
Nuxt.js での開発で、Vue.js のコミュニティが活発だからか、自然と多くの UI ライブラリやアニメーションライブラリを見ます。しかし、自分はそれらを使わず、ほとんどのケースにおいて自分で実装していく派です。
理由としては、
<transition>
タグや <transition-group>
タグがある。というのがあります。
2020 年では、UI・UX 向上のため Web サイトのデザインリニューアルを担当していました。多くのアニメーション実装が必要でしたが、上の理由から、適宜要件に合うように自分で実装してきました。多くの学びがあったため、振り返りを兼ねて、それらの一部をまとめます。Vue.js の <transition>
を使ったケースと使わなかったケースがあるので、その観点でグループ化しました。
開発環境は以下のものになります。
ssr
オプションは false
にして、SPA アプリケーションのモードにする。components
のオプションは true
にして、 コンポーネントの自動 import が効くようにしました。Vue.js でアニメーションを実装すると言えば、上であげた <transition>
タグを使うことがまず案として上がってきます。自分も多く使ってきたので、実装してきたアニメーションの中で <transition>
タグを使ったケースをあげます。
まずはスライドメニューです。使い方としては、ヘッダーメニューをスライドで開閉を切り替えるようにし、横からスライドで表示するというのがあります。
実装内容もシンプルで、Vuejs の <transition>
タグのドキュメントの一番最初にあるサンプルを参考にして実装したものです。サンプルは開くときに右から左にスライドし、閉じるときには左から右にスライドします。
開くときは、<transition>
タグの中身の dom がレンダリングされるのにフックして、
opacity: 0
で透明から、transition: opacity 0.15s
で少しずつ背景を黒に変化する。translateX(10%)
で右に少しずらした位置から、 transition: all 0.15s ease
で少しずつ、移動が終わったであろう位置までスライドしていく。逆に閉じるときは、<transition>
タグの中身の DOM がなくなることにフックして、
opacity: 0
を設定し、そのまま透明にする。そのまま DOM が破棄される最中から破棄されたあとは同じ状態を維持するので、leave-active
クラスは何も書かなくても問題ありません。transition: all 0.15s cubic-bezier(1, 0.5, 0.8, 1)
でスライドしていく。消える頃には右に少しずれた位置へ opacity: 0
で透明にされているため、ならめかにスライドメニューが消えるという挙動になっている。スライドメニューの中のメニューと背景をクリックしたときに、スライドメニューを閉じる処理を忘れないようにしましょう。
<template>
<div class="ume-slide-menu">
<transition name="background">
<div v-if="isOpened" class="background" @click="close">
<div class="close-button">×</div>
</div>
</transition>
<transition name="menu">
<div v-if="isOpened" class="menu">
<div class="menu-item-wrapper">
<div class="menu-item" @click="close">
<span>page1</span>
</div>
<div class="menu-item" @click="close">
<span>page2</span>
</div>
<div class="menu-item" @click="close">
<span>page3</span>
</div>
</div>
</div>
</transition>
</div>
</template>
<script lang="ts">
import Vue from 'vue'
export default Vue.extend({
model: {
prop: 'isOpened',
event: 'close',
},
props: {
isOpened: {
type: Boolean,
required: true,
},
},
methods: {
close() {
this.$emit('close')
},
},
})
</script>
<style lang="scss" scoped>
.ume-slide-menu {
.background-enter,
.background-leave-to {
opacity: 0;
}
.background-enter-active {
transition: opacity 0.15s;
}
.menu-enter,
.menu-leave-to {
transform: translateX(10%);
opacity: 0;
}
.menu-enter-active {
transition: all 0.15s ease;
}
.menu-leave-active {
transition: all 0.15s cubic-bezier(1, 0.5, 0.8, 1);
}
.background {
width: 100%;
height: 100%;
position: fixed;
z-index: 98;
top: 0;
right: 0;
overflow-x: hidden;
background-color: rgba(0, 0, 0, 0.6);
.close-button {
position: absolute;
top: 0;
left: 25px;
font-size: 36px;
color: #fff;
}
}
.menu {
height: 100%;
width: 64%;
max-width: 320px;
position: fixed;
z-index: 99;
top: 0;
right: 0;
background-color: #f3f3f3;
overflow-x: hidden;
.menu-item-wrapper {
background-color: #fff;
padding-top: 40px;
padding-bottom: 52px;
}
.menu-item {
display: block;
cursor: pointer;
margin: 0 20px;
padding: 17px 0;
font-size: 16px;
font-weight: bold;
border-bottom: thin solid #c7c7cc;
color: #4a4a4a;
text-decoration: none;
line-height: 1;
span {
vertical-align: middle;
}
}
}
}
</style>
ここでの注意点では、v-if
などで制御せずに、呼び出しているコンポーネントをマウントしているときには、スライドメニューのコンポーネントもレンダリングされている必要がある。
<template>
<div class="index-page">
<ume-slide-menu v-model="showSlideMenu" />
</div>
</template>
次は、ポップアップです。ページが表示されたあとにキャンペーンの告知として表示したり、条件によってボタンをクリックをしたにフックして表示します。スライドメニューと同じで、背景をクリックしたときにも、ポップアップを閉じる処理を入れるのを忘れないようにしましょう。
実装も上のスライドメニューとほぼ同じです。Vue.js の <transition>
タグを使って、DOM がレンダリングされるときと破棄されるときまでの挙動を CSS クラスでなめらかにポップアップを表示できます。
<template>
<div class="ume-popup">
<transition name="background">
<div v-if="showPopup" class="background" @click.prevent="$emit('change-popup', false)" />
</transition>
<transition name="popup">
<div v-if="showPopup" class="popup-wrapper">
<ume-close class="icon icon-close" @click.prevent="$emit('change-popup', false)" />
<div class="image-wrapper">
<img src="https://picsum.photos/seed/picsum/400/600" />
</div>
</div>
</transition>
</div>
</template>
<script lang="ts">
import Vue from 'vue'
import UmeClose from '~/assets/fonts/close.svg?inline'
export default Vue.extend({
components: {
UmeClose,
},
model: {
prop: 'showPopup',
event: 'change-popup',
},
props: {
showPopup: {
type: Boolean,
required: true,
},
},
})
</script>
<style lang="scss" scoped>
.ume-popup {
.background-enter-active {
transition: opacity 0.15s;
}
.background-enter,
.background-leave-to {
opacity: 0;
}
.popup-enter-active {
transition: all 0.25s ease;
}
.popup-leave-active {
transition: all 0.25s cubic-bezier(1, 0.5, 0.8, 1);
}
.popup-enter,
.popup-leave-to {
opacity: 0;
}
.background {
width: 100%;
height: 100%;
position: fixed;
z-index: 1;
top: 0;
right: 0;
overflow-x: hidden;
background-color: rgba(35, 24, 21, 0.35);
}
.popup-wrapper {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
position: fixed;
padding: 0.5em 1em;
z-index: 2;
display: flex;
flex-direction: column;
.icon-close {
height: 36px;
fill: black;
margin: 0 0 10px auto;
}
.image-wrapper {
display: block;
height: 350px;
width: 330px;
img {
height: 100%;
width: 100%;
border-radius: 10px;
}
}
}
}
</style>
ここでも、呼び出し側のほうで v-if
で制御せずに、事前にポップアップのコンポーネントもレンダリングされている必要があります。
<template>
<div class="index-page">
<ume-popup v-model="showPopup" />
</div>
</template>
狭い限定された枠の中で、メニューをクリックしてコンテンツを開閉したいというケースがあり、その対応でアコーディオンを実装しました。これも OSS のライブラリで要求されるデザインや仕様を満たせるかが不安だったので、自分で実装しました。今回も Vue.js の<transition>
タグを使っていますが、上 2 つとは少し違います。
どのように違うかというと、今まではトランジション状態を css で任せていましたが、今回はJavaScriptのほうで制御しています。
コンテンツが開かれたあとのアコーディオンの高さがコンテンツの量に依存し、css のクラスで言うところのxxx-enter-to
で指定する高さが動的になることから、css で静的に決め打ちできません。今回はサンプルなので、コンテンツは静的に決まっていますが、実際は API のレスポンスに依存するので、高さが動的になっています。
動的な高さをどう与えるかというと、トランジションが終わるとき、つまりxxx-enter-to
のときに、そのコンテンツのラッパーの scrollHeight
を渡すようにすれば解決できます。 Vue.js の <transition>
タグには、JavaScriptフックがあります。ここでは @enter
のフックで、アコーディオンのコンテンツのラッパーの高さを scrollHeight
と同じにすれば、動的に高さを決めることができます。
ここで注意すべきなのは、アコーディオンを開く前に、一度アコーディオンの高さを 0
にしないとアニメーションが動きません。0
と決め打ちしないとheight: auto
が割り振られてしまい、height: ${height}px
へのケースでは、transition
が効かなくなってしまいます。
逆もしかりで、コンテンツを閉じるときの height: ${height}px
から height: auto
へのケースでも、transition
が効かなくなってしまいます。なので、コンテンツが開かれる前と閉じたあとの height
は 0
にしましょう。この 0
にするというのも、JavaScript のフックで実現可能です。(下の例で言うと、@before-enter
と @leave
になります。)
<template>
<div class="ume-accordion">
<div class="header" @click="$emit('expand')">
<slot name="header" />
<down-arrow v-if="expandable" class="icon" :class="{ rotate: expanded }" />
</div>
<transition name="accordion" @before-enter="beforeEnter" @enter="enter" @before-leave="beforeLeave" @leave="leave">
<div v-if="expanded" ref="content" class="content">
<slot name="content" />
</div>
</transition>
</div>
</template>
<script lang="ts">
import Vue from 'vue'
import DownArrow from '~/assets/fonts/down-arrow.svg?inline'
export default Vue.extend({
components: {
DownArrow,
},
model: {
prop: 'expanded',
event: 'expand',
},
props: {
expanded: {
type: Boolean,
required: true,
},
expandable: {
type: Boolean,
required: true,
},
},
mounted() {
if (this.$refs.content) {
(this.$refs.content as HTMLElement).style.height = `${this.$refs.content.clientHeight}px`
}
},
methods: {
beforeEnter(el: HTMLElement) {
el.style.height = '0'
},
enter(el: HTMLElement) {
el.style.height = el.scrollHeight + 'px'
},
beforeLeave(el: HTMLElement) {
el.style.height = el.scrollHeight + 'px'
},
leave(el: HTMLElement) {
el.style.height = '0'
},
},
})
</script>
<style lang="scss" scoped>
.ume-accordion {
border-radius: 6px;
padding-top: 16px;
.header {
color: #fff;
display: flex;
align-items: center;
justify-content: space-between;
line-height: 1;
padding-bottom: 16px;
border-bottom: solid 1px #d1d1d6;
.icon {
display: block;
fill: #c7c7cc;
height: 14px;
width: 14px;
transform: rotate(0deg);
transition-duration: 0.3s;
}
.rotate {
transform: rotate(180deg);
transition-duration: 0.3s;
}
}
.content {
padding: 0 12px;
overflow: hidden;
transition: 0.2s ease-out;
}
}
</style>
呼び出し側はこのように書いています。
<template>
<ume-accordion
v-for="(group, index) in groups"
:key="group.id"
:expanded="accordionExpanded[index]"
:expandable="group.children.length > 0"
class="accordion"
@click-header-arrow="toggleExpandAccordion($event, index)"
>
<template v-slot:header>
<p>{{ group.name }}</p>
</template>
<template v-slot:content>
<ul v-if="group.children.length > 0">
<li v-for="groupChild in group.children" :key="`${group.id}-${groupChild.id}`">
<div class="child-img">
<img :src="groupChild.src" />
</div>
</li>
</ul>
</template>
</ume-accordion>
</template>
<script lang="ts">
import Vue from 'vue'
import { ImageGroup } from '~/types/image'
type Data = {
groups: ImageGroup[]
accordionExpanded: boolean[]
}
export default Vue.extend({
data(): Data {
const groups = [
{
id: 1,
name: 'scenes',
children: [
{ id: 1015, src: 'https://picsum.photos/id/1015/200/300' },
{ id: 1016, src: 'https://picsum.photos/id/1016/200/300' },
{ id: 1018, src: 'https://picsum.photos/id/1018/200/300' },
{ id: 1019, src: 'https://picsum.photos/id/1019/200/300' },
{ id: 102, src: 'https://picsum.photos/id/102/200/300' },
],
},
{
id: 2,
name: 'scenes',
children: [
{ id: 244, src: 'https://picsum.photos/id/244/200/300' },
{ id: 237, src: 'https://picsum.photos/id/237/200/300' },
{ id: 200, src: 'https://picsum.photos/id/200/200/300' },
{ id: 219, src: 'https://picsum.photos/id/219/200/300' },
{ id: 169, src: 'https://picsum.photos/id/169/200/300' },
],
},
]
return {
groups,
accordionExpanded: [true, ...groups.slice(1, groups.length).map(() => false)],
}
},
computed: {
accordionClass() {
return (opened: boolean) => {
return opened ? 'opened' : ''
}
},
},
methods: {
toggleExpandAccordion(expanded: boolean, ingredientCategoryIndex: number) {
this.accordionExpanded = this.accordionExpanded.map((_) => false)
this.accordionExpanded[ingredientCategoryIndex] = expanded
},
},
})
</script>
上では <transition>
タグを使ったケースを使わなかったものも紹介します。
これは個人的見解ですが、<transition>
タグはDOMの表示/非表示の切り替え時のアニメーションには有効ですが、要素の位置や高さを変更するアニメーションは向いていないのかなと思っています。
下の 2 つのケースだと、要素の表示と非表示はせず、単純に要素の高さを変えるだけだったり、X 軸の位置の変更だけで要件を満たせることができました。どのように実装したかをまとめていきます。
UI・UX リニューアルに伴い、類似サービスとの差別化を目指して、下から長さを伸ばすことができ、かつ z-index が効かして画面から浮かび上がっているモーダルを用意しようとなりました。上はサンプルですが、実際にはサービス上にある大量のコンテンツから欲しいものだけを絞り込みできるボタンが複数並べられており、それらをクリックすることで、欲しいものを絞り込みできるものとなっています。
アニメーションが入っている部分としては、モーダルの高さ変更のときのtransition
です。モーダルの上の帯の部分をクリックすることで、指定した高さまでモーダルの高さを広げることができます。逆に、広がったモーダルをデフォルトの高さまで縮めることができます。
今回では、要素の高さを調節するだけで要件を満たせるので、上記で書いた通り要素の表示/非表示をするわけではないため、Vue.js の <transition>
タグを使いませんでした。
<template>
<div ref="modal" class="ume-expandable-bottom-modal">
<div class="modal-content-title" @click="toggleExpand">
<p>タイトル</p>
<up-arrow v-if="!expanded" class="icon icon-arrow-up" />
<down-arrow v-else class="icon icon-arrow-down" />
</div>
<div class="content">
</div>
</div>
</template>
<script lang="ts">
import Vue, { PropType } from 'vue'
import { ImageGroup } from '~/types/image'
import UpArrow from '~/assets/fonts/up-arrow.svg?inline'
import DownArrow from '~/assets/fonts/down-arrow.svg?inline'
type Data = {
defaultHeight: number
transitionSeconds: number
}
export default Vue.extend({
components: {
UpArrow,
DownArrow,
},
model: {
prop: 'expanded',
event: 'toggleExpand',
},
props: {
expanded: {
type: Boolean,
required: true,
},
groups: {
type: Array as PropType<ImageGroup[]>,
required: true,
},
},
data(): Data {
return {
defaultHeight: 48,
transitionSeconds: 0.5,
}
},
watch: {
expanded(newValue) {
if (newValue) {
return
}
(this.$refs.modal as HTMLElement).style.height = `${this.defaultHeight}px`
},
transitionSeconds(newValue) {
(this.$refs.modal as HTMLElement).style.transition = `${newValue}s ease-out`
},
},
mounted() {
(this.$refs.modal as HTMLElement).style.transition = `${this.transitionSeconds}s ease-out`
},
methods: {
expandUpTo(height: number) {
(this.$refs.modal as HTMLElement).style.height = `${height}px`
},
toggleExpand() {
this.$emit('toggleExpand', !this.expanded)
},
},
})
</script>
<style lang="scss" scoped>
.ume-expandable-bottom-modal {
width: 100vw;
height: 48px;
position: fixed;
top: auto;
right: 0;
left: 0;
bottom: 0;
background: white;
cursor: pointer;
box-shadow: 0 -9px 10px 0 rgba(0, 0, 0, 0.1);
border-radius: 8px 8px 0 0;
z-index: 97;
.modal-content-title {
position: relative;
height: 48px;
background: 'red';
display: flex;
justify-content: center;
align-items: center;
border-radius: 8px 8px 0 0;
.category-title {
margin: 6px auto;
width: 80%;
text-align: center;
font-size: 14px;
font-weight: bold;
color: #000;
line-height: 1;
}
.icon {
display: block;
text-align: left;
fill: #000;
position: absolute;
right: 20px;
height: 14px;
}
}
}
</style>
もう 1 つ Vue.js の<transition>
タグを使わずに実装したアニメーションとして、写真を複数枚並べて、それをスライドさせるギャラリーがあります。使われるシーンとしては、トップページの上部で複数のバナー画像の表示だったり、ランキング表示や 1 つの商品を紹介する写真を複数する表示するときなどに利用されるケースが多いです。
上で書いたように、これも <transition>
タグを使わずに実装しています。矢印クリックで、要素のリストのラッパーのtranslateY
を要素の長さ分、加算したり減算することで、リストの位置をずらす仕組みになっています。リストをラップしている DOM は、指定した長さで固定されているので、実際に目に見えるリストのコンテンツを表示するという考えです。スライドの速度はリストの css にtransition
を書くことで調整できます。
<template>
<div class="ume-gallery">
<div @click="previous">
<left-arrow :class="`icon icon-arrow-left ${previousclickableClass}`" />
</div>
<div ref="slide-list-wrapper" class="slide-list-wrapper">
<ol ref="slide-list" class="slide-list">
<li v-for="(slideListElement, index) in slideListElements" :key="index">
<slot name="slide-list-element" :slide-list-element="slideListElement" />
</li>
</ol>
</div>
<div @click="following">
<right-arrow :class="`icon icon-arrow-right ${followingClickableClass}`" />
</div>
</div>
</template>
<script lang="ts">
import Vue, { PropType } from 'vue'
import { Image } from '~/types/image'
import RightArrow from '~/assets/fonts/right-arrow.svg?inline'
import LeftArrow from '~/assets/fonts/left-arrow.svg?inline'
const firstDisplayNum = 4
type Data = {
transformX: number
offset: number
largestDisplayedNum: number
}
export default Vue.extend({
components: {
RightArrow,
LeftArrow,
},
props: {
slideListElements: {
type: Array as PropType<Image[]>,
required: true,
},
width: {
type: Number,
required: true,
},
},
data(): Data {
return {
transformX: 0,
offset: 24,
largestDisplayedNum: firstDisplayNum,
}
},
computed: {
previousclickableClass(): string {
return this.canSlidePrevious ? 'clickable' : 'non-clickable'
},
followingClickableClass(): string {
return this.canSlideFollowing ? 'clickable' : 'non-clickable'
},
canSlidePrevious(): boolean {
return this.largestDisplayedNum > firstDisplayNum
},
canSlideFollowing(): boolean {
return this.largestDisplayedNum < this.slideListElements.length
},
slideElementStyle(): { 'min-width': string } {
return { 'min-width': `${this.width}px` }
},
},
mounted() {
(this.$refs['slide-list-wrapper'] as HTMLElement).style.width = `${(this.width + this.offset) * firstDisplayNum}px`
},
methods: {
previous(): void {
if (!this.canSlidePrevious) {
return
}
this.transformX += this.width + this.offset
(this.$refs.slide as HTMLElement).style.transform = `translate(${this.transformX}px, 0)`
this.largestDisplayedNum--
},
following(): void {
if (!this.canSlideFollowing) {
return
}
this.transformX -= this.width + this.offset
(this.$refs.slide as HTMLElement).style.transform = `translate(${this.transformX}px, 0)`
this.largestDisplayedNum++
},
},
})
</script>
<style lang="scss" scoped>
.ume-gallery {
display: flex;
justify-content: center;
align-items: center;
@media only screen and (max-width: 940px) {
margin: 0;
}
.icon {
height: 24px;
width: 28px;
}
.clickable {
fill: #c7c7cc;
}
.non-clickable {
fill: #d1d1d6;
}
.slide-list-wrapper {
overflow: hidden;
margin: 0 auto;
.slide-list {
transition: 0.5s;
display: flex;
}
}
}
</style>
ざっと書いてきましたが、2020 年に Nuxt.js で実装してきたアニメーションを Vue.js の <transition>
タグを使ったケースと使わなかったケースでまとめてみました。元々は OSS のライブラリを使うときのコストを下げたい、またデザインや仕様変更に対して柔軟に対応するため、自分で実装してきましたが手を動かして実装した分、多くのことを学んだと思っています。
振り返りをして思ったのは、アニメーションの実装って楽しいと再認識したことです。プログラミングに挑戦したいときっかけにもなった「作ったものが動く」という感動を思い出し、初心に返ることができました。 少しはできることが増えたのかなと思いつつも、まだまだリッチなアニメーションだとスラスラ実装できないレベルです。来年は自分のフロント力をもっと伸ばしていける年になるといいなと思っています。
最後まで読んでいただき、ありがとうございました。 (上で書いたコードは Github のレポジトリにまとめました。もし参考になったなどあれば、star してくれると嬉しいです。)
明日は STORES 予約の@yksihimoto さんによる、「next.js + Fullcalendar v5 を攻略する」です。お楽しみに。