【Vue3でwebアプリをつくろう5】スライドさせながら画面を切り替えるアニメーションをつくる

この連載はプログラミングに興味を持つ学生を対象とした学習用コンテンツです。今回はVue3で英語学習アプリを作ってみます。

スライドさせながら画面を切り替える

アプリは上のような画面になっています。今回は問題を解き終わって次の問題に進むときにスライドさせながら画面が切り替わるアニメーションを加えます。

See the Pen switch-with-slide-effect by Masato Takamaru (@masato-takamaru) on CodePen.

簡単なサンプルを作ったので,実際の動きを確かめてみましょう。ボタンをクリックすると枠で囲まれたボックスが左にスライドして,新しいボックスが右から入ってきます。

Animate.cssで簡単アニメーション

Animate.cssを用いるとアニメーションが簡単に行えます。

ホームページにアクセスして画面の右側をクリックすると,実際にどのような動き方をするのかが簡単に確認できます。

Animate.cssを導入するには,htmlのヘッダーに

  <link rel="stylesheet"
    href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css"/>

を書き込むだけです。記事執筆時点のバージョンは4.1.1です。

これをVueで操作していきましょう。

transitionで操作する

<html lang="ja">
<head>
  <meta charset="utf-8">
  <script src="https://unpkg.com/vue@next"></script>
  <link rel="stylesheet"
    href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css"/>
</head>
<body>
<div id="root"></div>
</body>
</html>

HTMLです。ヘッダーでanimate.cssを読み込みます。

const RootComponent = {
  data() { return {
    index: 0,
    show: true
  }},
  template: `
    <transition name="slide" @after-leave="afterLeave">
      <div class="question" v-if="show">
        <p>Question No. {{index}}</p>
      </div>
    </transition>
    <button @click="goNext">next</button>`,
  methods: {
    goNext() {
      this.show = false;
    },
    afterLeave() {
      this.index++;
      this.show = true;
    }
  }
}
const app = Vue.createApp(RootComponent);
app.mount('#root');

JavaScriptのコードです。アニメーションさせたい部分を<transition></transition>の中に書きます。また,name=”slide”として名前をつけておきます。

.question {
  border: 1px solid blue;
}

.slide-enter-active { animation: fadeInRight; animation-duration: 0.3s;}
.slide-leave-active { animation: fadeOutLeft; animation-duration: 0.3s;}

cssのコードです。枠線で囲まれたボックスを表示するときに.slide-enter-activeが適用され,非表示のときに.slide-leave-activeが適用されます。このように,nameで指定した名前のあとに-enter-active-leave-activeを加えます。

たとえば,<transition name="hoge-hoge">なら,.hoge-hoge-enter-activeと書きます。

ボックスを表示するときはanimation: fadeInRightとして,右方向からスライドさせます。非表示にするときはanimation: fadeOutLeftとして左方向に出ていきます。また,animation-duration: 0.3sはアニメーションの時間です。ここでは0.3秒間でスライドするようにしています。

      <div class="question" v-if="show">

JavaScriptのコードに戻ると,ボックスの部分にv-if="show"が設定されています。これは要素の表示・非表示を操作するもので,show=trueなら表示,show=falseなら非表示となります。

要素の表示・非表示はtransitionと関連付けられていて,showfalseからtrueに変化したときに,.slide-enter-activeが適用され,trueからfalseに変化したときに.slide-leave-activeが適用される仕組みです。

    <button @click="goNext">next</button>`,

ボックスの下に表示されているボタンです。ボタンを押すとメソッドgoNext()が呼び出されます。

methods: {
    goNext() {
      this.show = false;
    },

メソッドgoNext()です。ここでthis.show=falseとして.slide-leave-activeを適用します(data()の中でつくった変数をメソッド内で使うときはthis.を付け忘れずに)。

すこしイメージがわかりにくいかもしれませんが,要するにtruefalseの切り替えがアニメーションを動かすためのスイッチの役割を果たしているということです。

@after-leaveでアニメーションの終わりを検知する

    <transition name="slide" @after-leave="afterLeave">

transitionにはもう一つ,@after-leave="afterLeave"が設定されています。これはアニメーションの終わりを検知するもので, .slide-leave-active によってボックスがスライドするアニメーションが終了したときに,メソッドafterLeave()を呼び出します。

 methods: {
    afterLeave() {
      this.index++;
      this.show = true;
    }

メソッドafterLeave()です。this.index++とすると,画面のQuestion No. 1Question No. 2に切り替わります。ボタンを押したときにボックスはいったん非表示となり,その段階でボックスの中身を書き換え,this.show = trueで再び表示します。このとき, .slide-enter-activeが適用され,右方向からスライドしながらボックスが表示されます。

@after-leaveを使う意味

わざわざ@after-leaveを用いるのは少し面倒に感じるかもしれません。

しかしこのようにしないと .slide-leave-active のアニメーションが終わる前に .slide-enter-active が即座に動いてしまいます。結果的に,ボックスが左方向にスライドするアニメーションは画面にはまったく現れません。

そこで,@after-leaveを用いていったんアニメーションが終了してから次のアニメーションを実行するようにしているのです。

まとめ

ここでは,Animate.cssを用いて画面をスライドさせながら遷移させる方法を学びました。Animate.cssには様々なアニメーションが用意されているので,アプリの様々な場面で使うことで,よりゲーム感覚の動きを加えることができるでしょう。

最後に,今回作成している英語学習アプリにこれを当てはめたコードを示します。以前と少し構成が変わっていますが,これまでの連載を振り返れば理解できるでしょう。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>fasgram 英文法トレーニングwebアプリ</title>
  <script src="https://unpkg.com/vue@next"></script>
  <script src="main.js" type="module"></script>
  <link rel="stylesheet"
    href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css"/>
  <link rel="stylesheet" href="fasgram2.css"/>
</head>
<body>
  <div id="header" class="size-4">fasgram 英文法トレーニングwebアプリ動作サンプル</div>
  <div id="container">
    <div id="root"></div>
  </div>
</body>
</html>
/*全体の構成
index.html
  + fasgram2.css              //スタイルシート
  + main.js                   //ルートコンポーネント
    + selectPhrase.js         //語句選択問題
    + vueComponents.js        //その他のコンポーネント
      + componentExpository   //解説文
      + instructionSpeech     //読み上げ機能の指示文
    + functions.js            //関数
    + allData.js              //問題文データ
*/

import {selectPhrase} from './selectPhrase.js'
import {allData} from './allData.js'

//ルートコンポーネント
const RootComponent = {
  data() { return {
    texts: allData  //問題文
  }},
  //テンプレート
  template: '<selectPhrase :texts="dataSelectPhrase"></selectPhrase>',
  //算出プロパティ
  computed: {
    dataSelectPhrase() {  //選択肢問題のデータを抽出
      return this.texts.filter((elem) => elem.select);
    }
  },
  //コンポーネントの登録
  components: {
    'selectPhrase': selectPhrase
  },
  created() { //捨てgetVoicesの実行
    const uttr = new SpeechSynthesisUtterance();
    const voices = window.speechSynthesis.getVoices();
  }
}
//Vueのインスタンスとマウント
export const app = Vue.createApp(RootComponent);
app.mount('#root');
import {shuffleOptions, correctBox, incorrectBox} from './functions.js'
import {highlightEnSentence, speechEnSentence} from './functions.js'
import {componentExpository, instructionSpeech} from './vueComponents.js'

export const selectPhrase = {
  props: ['texts'],
  data() { return {
    index: 0,               //問題番号
    correct_incorrect: '',  //正解不正解の判定
    show: {                 //表示・非表示の制御
      question: true,       //問題文
      expository: false,    //解説文
      options: true,        //選択肢のボタン
      next: false           //「次へ」ボタン
    } 
  }},
  template: `
    <transition name="slide" @after-leave="afterLeave">
      <div class="frame-round-white" v-if="show.question">
        <p class="instruction">適切な英文になるように,( )に当てはまる語句を1つクリックしなさい。</p>
        <p class="ja-st">{{ jaSentence }}</p>
        <p class="en-st" v-html="enSentence()" @click="execSpeech()"></p>
        <instruction-speech></instruction-speech>
      </div>
    </transition>
    <div id="btn-options" v-if="show.options">
      <button class="btn-option"
        v-for="opt in opts"
        @click="judgement(opt)">
        {{ opt }}
      </button>
    </div>
    <div class="btn-wrapper">
      <button id="btn_next" class="btn-option" v-if="show.next" @click="goNext">次へ</button>
    </div>
    <transition name="fade">
      <expository :data="expositoryObj" v-if="show.expository"></expository>
    </transition>`,
  computed: {
    jaSentence() { return this.texts[this.index].ja },
    opts() { return shuffleOptions(this.texts[this.index].select.opt) },
    expositoryObj() { return this.texts[this.index].expository }
  },
  methods: {
    //英文の表示 正解のとき・不正解のとき・初期状態で分ける
    enSentence() {
      let replacement;
      if(this.correct_incorrect == 'correct') {
        replacement = correctBox(this.texts[this.index].select.answer);
      } else if(this.correct_incorrect == 'incorrect') {
        replacement = incorrectBox(this.texts[this.index].select.answer);
      } else {
        replacement = '(    )';
      }
      return this.texts[this.index].select.sentence.replace('#', replacement);
    },
    //正解・不正解の判定
    judgement(opt) {
      this.show.options = false;  //選択肢ボタンを消す
      this.show.next = true;  //「次へ」ボタンを表示
      if(opt == this.texts[this.index].select.answer) {
        this.correct_incorrect = 'correct';
      } else {
        this.correct_incorrect = 'incorrect';
        this.show.expository = true;  //解説文を表示
      }
    },
    //次の問題に進む
    goNext() {
      this.show.question = false; //問題文を消す
    },
    //フェードアウトが終了した時点で問題文の切り替えを行う
    afterLeave() {
      if(this.index >= this.texts.length - 1) { //次の問題がなければ終了
        this.show.next = false;         //「次へ」ボタンを消す
        this.show.expository = false;   //解説文を消す
        console.log('終了');
      } else {                          //次の問題があれば表示を切り替える
        this.show.next = false;         //「次へ」ボタンを消す
        this.show.expository = false;   //解説文を消す
        this.correct_incorrect = '';    //正解不正解の判定をリセット
        this.index++;                   //次の問題に進む
        this.show.question = true;      //問題文を表示
        this.show.options = true;       //選択肢を表示
      }
    },
    execSpeech() {
      highlightEnSentence();    //英文をハイライトする
      speechEnSentence(this.texts[this.index].en); //読み上げ
    }
  },
  components: {
    'expository': componentExpository,        //解説文
    'instruction-speech': instructionSpeech   //音声
  }
}
export const componentExpository = {
  props: ['data'],  //パラメータとして問題文データのexpositoryの部分を受け取る
  template: `
  <div class="expository">
    <p class="expository-item">
      <span class="material-icons expository-icon">hdr_strong</span>
      <span v-html="this.data.item" />
    </p>
    <p class="expository-phrase-wrapper">
      <span class="expository-en" v-html="this.data.en" />
      <span class="expository-ja" v-html="this.data.ja" />
    </p>
    <p class="expository-detail" v-html="this.data.detail" />
  </div>`
};

export const instructionSpeech = {
  template: `
    <p class="text-to-speech">
      <span class="material-icons speech-icon">campaign</span>
      <span class="speech-body">英文をクリックして音声を聞きましょう。</span>
    </p>`
};
//選択肢のボタンが自動的にシャッフルされ,ボタンが表示される順番が変わる
//必要がなければ削除してよい
export function shuffleOptions(opts) {
  for(let i = 0; i < 100; i++) {
    let a = Math.floor(Math.random() * opts.length);
    let b = Math.floor(Math.random() * opts.length);
    [opts[a], opts[b]] = [opts[b], opts[a]];
  }
  return opts;
}
//正解部分のボックス表示をするHTMLコードを返す
export function correctBox(elem) {
  return `
    <span class="answer-correct">
      <span class="answer-correct-body">${elem}</span>
      <span class="answer-correct-index">
        <span class="material-icons correct-icon">thumb_up</span>
        <span>good</span>
      </span>
    </span>`;
}
//不正解部分のボックス表示をするHTMLコードを返す
export function incorrectBox(elem) {
  return `
    <span class="answer-incorrect">
      <span class="answer-incorrect-body">${elem}</span>
      <span class="answer-incorrect-index">
        <span>正しくは</span>
      </span>
    </span>`;
}
//英文をハイライトする
export function highlightEnSentence() {
	let elem = document.querySelector('.en-st');
	elem.classList.add('highlight-en-sentence');
	elem.addEventListener('animationend', () => {
		elem.classList.remove('highlight-en-sentence');
	}, false);
}
//英文を再生する
export function speechEnSentence(elem) {
  speechSynthesis.cancel();     //再生中の音声があればキャンセル
  let uttr = new SpeechSynthesisUtterance();  //インスタンス
  const voices = speechSynthesis.getVoices(); //音声リストの取得
  uttr.voice  = voices[5];      //女性の声
  uttr.volume = 1.0;            //音量
  uttr.rate   = 0.8;            //読み上げ速度
  uttr.pitch  = 1.05;           //音程
  uttr.lang   = 'en-US';        //言語
  uttr.text   = elem;           //読み上げテキスト
  speechSynthesis.speak(uttr);  //再生
}
export const allData = [
{
  category: "不定詞",
  expository: {
    item: "不定詞の名詞的用法",
    en: "want to",
    ja: "~したい",
    detail: "" },
  en: "He wants to be independent of his parents.",
  ja: "彼は自分の親から自立したいと思っている。",
  fill_sentence: "He # be independent of his parents.",
  fill_answer: "wants to" },
{
  category: "不定詞",
  expository: {
    item: "不定詞の名詞的用法",
    en: "to ~",
    ja: "~するための",
    detail: `
      something cold to drink 飲むための冷たいもの(冷たい飲み物)。
      something,anythingは形容詞をうしろに置きます。間違えてcold something
      としやすいので注意しましょう。` },
  en: "After a long walk in the fields I wanted something cold to drink.",
  ja: "野外での長い徒歩のあと,私は冷たい飲み物が欲しかった。",
  fill_sentence: "After a long walk in the fields I wanted something cold #.",
  fill_answer: "to drink",
  select: {
    sentence: "After a long walk in the fields I wanted #.",
    opt: ["something cold to drink", "cold something to drink",
         "cold to drink something"],
    answer: "something cold to drink" }},
{
  category: "不定詞",
  expository: {
    item: "不定詞の形容詞的用法",
    en: "to ~",
    ja: "~するための",
    detail: "a good way to lose weight 体重を減らすための良い方法" },
  en: "The doctor explained to his patient that jogging is a good way to lose weight.",
  ja: "医者は患者にジョギングが体重を減らすのに良い方法であると説明した。",
  fill_sentence: "The doctor explained to his patient that jogging is a good way # weight.",
  fill_answer: "to lose" },
{
  category: "不定詞",
  expository: {
    item: "不定詞の否定",
    en: "not to ~",
    ja: "~しないために",
    detail: `不定詞の否定の形は not to 動詞原形 となります。
      間違えて to not ~ としやすいので注意しましょう。` },
  en: "My father told me not to work too hard.",
  ja: "私の父は私にあまりに働きすぎないように言った。",
  fill_sentence: "My father told me # work too hard.",
  fill_answer: "not to",
  select: {
    sentence: "My father told me # work too hard.",
    opt: ["not to","don't","not","without"],
    answer: "not to" }},
{
  category: "不定詞",
  expository: {
    item: "人の性質を表す語に用いるof",
    en: "It was careless <span class='color-blue'>of</span> you to leave the door unlocked.",
    ja: "ドアに鍵をかけないままにするとはあなたは不注意だ。",
    detail: `通常は It is ~ <span class='color-blue'>for</span> (人) to
      ・・・ ですが,kind,clever,careless など人の性質を表す語では
      <span class='color-blue'>of</span> が用いられます。` },
  en: "It was careless of you to leave the door unlocked.",
  ja: "ドアに鍵をかけないままにするとはあなたは不注意だ。",
  fill_sentence: "It was careless # to leave the door unlocked.",
  fill_answer: "of you",
  select: {
    sentence: "It was careless # you to leave the door unlocked.",
    opt: ["for", "with", "to", "of"],
    answer: "of" }},
{
  category: "不定詞",
  expository: {
    item: "expect ~ to ・・・",
    en: "expect ~ to ・・・",
    ja: "~が・・・すると予想,期待する。",
    detail: "" },
  en: "I liked his new house, but I hadn't expected it to be so small.",
  ja: "私は彼の新しい家を気に入ったが,それがあれほど小さいとは予想していなかった。",
  fill_sentence: "I liked his new house, but I hadn't expected it # so small.",
  fill_answer: "to be",
  select: {
    sentence: "I liked his new house, but I hadn't expected it # so small.",
    opt: ["be","of being","to be","to being"],
    answer: "to be" }}
]
@charset "UTF-8";
@import url("https://fonts.googleapis.com/css2?family=Noto+Serif&display=swap");
@import url("https://fonts.googleapis.com/css2?family=Kosugi+Maru&display=swap");
@import url("https://fonts.googleapis.com/css2?family=Material+Icons&display=swap");
body { font-family: 'Noto Serif', 'Kosugi Maru', serif;
  font-size: 18px; background-color: #fafafa; margin: 0px; padding: 0px; }
p { margin: 0.5em; }
span { display: inline-block; }
#header { padding: 4px; background-color: #51ab9f; color: #fff;
  box-shadow: 0px 2px 3px rgba(0, 0, 0, 0.16); }
#container { max-width: 680px; margin-right: auto; margin-left: auto;
  padding: 10px; }
.answer-correct {
  display: inline-flex; flex-direction: column-reverse;
  border: 1px solid #51ab9f; margin: 0 0.25em; border-radius: 5px;
  box-shadow: 2px 2px 2px 0px #cccccc; }
.answer-correct-index {
  display: inline-flex; align-items: center; background-color: #51ab9f;
  padding: 0 0.5em 0.25em 0.5em; font-size: 0.5em;
  color: white; font-weight: 700;}
.answer-correct-body { text-align: center; padding: 0 0.25em; }
.answer-incorrect {
  display: inline-flex; flex-direction: column-reverse;
  border: 1px solid #c6292c; margin: 0 0.25em; border-radius: 5px;
  box-shadow: 2px 2px 2px 0px #cccccc; }
.answer-incorrect-index {
  display: inline-flex; align-items: center; background-color: #c6292c;
  padding: 0 0.5em 0.25em 0.5em; font-size: 0.5em;
  color: white; font-weight: 700;}
.answer-incorrect-body { text-align: center; padding: 0 0.25em; }
.btn-wrapper {
  display: flex; justify-content: center; align-items: center;
  flex-wrap: wrap; width: auto; margin-top: 2em; }
.btn-option {
  font-family: 'Noto Serif', 'Kosugi Maru', serif;
  font-size: 1.25em; font-weight: 400; margin: 5px 2px;
  padding: 0.75rem 1.25em; border: 1px solid #ccc;
  border-radius: 0.5em; background: #fff;
  box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.16);
  outline: none; }
.correct-icon { padding-right: 0.5em; font-size: 0.75em; color: white;}
.en-st { padding-left: 8px; font-size: 1.25em; font-weight: 400; }
.expository {
  margin-top: 16px; padding: 8px; background: #fff;
  box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.16);
  border: 1px solid #ccc; border-radius: 8px; }
.expository-item {
  display: flex; vertical-align: center;
  font-size: 1em; border-bottom: 2px solid #ccc; padding: 4px; }
.expository-phrase-wrapper {
  display: flex; align-items: center; }
.expository-en {
  color: #51ab9f; font-size: 1.5em; font-weight: 700; }
.expository-ja {
  font-size: 1em; font-weight: 700; padding-left: 1em; }
.expository-detail { font-size: 1em; }
.expository-icon { margin-right: 4px; color: #51ab9f; }
.slide-enter-active { animation: fadeInRight; animation-duration: 0.3s;}
.slide-leave-active { animation: fadeOutLeft; animation-duration: 0.3s;}
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease;}
.fade-enter-from, .fade-leave-to { opacity: 0;}
.frame-round-white { margin: 5px 2px; padding: 1rem 1em;
  border: 1px solid #ccc; border-radius: 0.5em; background: #fff;
  box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.16); outline: none; }
.highlight-en-sentence { animation: highlight-animation 0.8s ease-out; }
@keyframes highlight-animation {
  0% { background-color: #fff; }
  10% { background-color: #51ab9f; }
  100% { background-color: #fff; }}
.instruction { font-size: 0.75em; padding-bottom: 1em; }
.ja-st { font-size: 1em; font-weight: 400; }
.size-4 {font-size: 0.75em;}
.speech-icon { color:#51ab9f; margin-right: 4px;}
.speech-body { color: #51ab9f;} 
.text-to-speech {
  display: inline-flex; align-items: center; font-size: 0.75em;
  margin-top: 1.5em;
}