メディア掲載: レバテックフリーランス様のサイトで当ブログが紹介されました

クイズ・問題集アプリを無料で作成する(スプレッドシート・GAS)

GoogleスプレッドシートとGoogle Apps Scriptを使って、クイズアプリ・問題集アプリを作成してみました。

Google Apps Script(HtmlService)の学習的な趣旨で作成しましたので、超シンプル・低機能なアプリになっています。
手っ取り早く高品質なアプリをお求めの方は、他のサイトを探していただく方がよいと思います。

また、HTML, CSS, JavaScript(Vue.js含む)についての詳細な解説は記載していませんので、ご了承ください。

作成するアプリ

この記事で作成するサンプルアプリはこんな感じです。
デザインにこだわると内容が難しくなってしまうため、最低限のスタイルのみ設定しています。

問題はGoogleスプレッドシート上で作成します。最大5択の選択式問題が作れます。

使い方

STEP1. スプレッドシートをコピーする

Google Sheets: Sign-in
Access Google Sheets with a personal Google account or Google Workspace account (for business use).

上記のリンク先でコピーを作成を押すと、マイドライブにコピー ~ My問題集が作成されます。

STEP2. 問題を作成する(後回しにしてもOK)

コピーしたスプレッドシートに、オリジナルの問題を記入します。

  • 解答(B列)は、半角数字で正解の選択肢の番号を入力してください。
  • 選択肢1〜5をすべて埋める必要はありません。
    例えば、3択問題にしたい場合は、選択肢1〜3まで入力し、選択肢4・5は空欄にしてください。

STEP3. ウェブアプリとしてデプロイする

拡張機能 > Apps Script をクリックし、スクリプトエディタを起動してください。

右上のデプロイボタンより、「新しいデプロイ」をクリック。

「種類の選択」の歯車マークから、「ウェブアプリ」を選択

説明文(任意)を入力し、ウェブアプリの実行ユーザーおよびアクセスできるユーザーを選択したら、デプロイボタンを押す。

デプロイが完了し、ウェブアプリのURLが表示されます。

表示されたURLにアクセスすれば、このクイズアプリを使用することができます。

ちなみに、デプロイの途中の手順で、アクセスできるユーザーを「自分のみ」にしていますが、ここを「全員」にすれば、誰でもクイズアプリを利用できるようになります。

問題の追加や修正は、スプレッドシートの内容を書き換えるだけでOKです。(デプロイ作業をやり直す必要はありません)

参考:ソースコード

参考に、今回のソースコードを掲載させていただきます。

/**
 * アプリにアクセスした際に実行される関数。index.htmlをもとに作成したhtmlを返す。
 */
function doGet() {
  // index.htmlからhtmlテンプレートを作成する
  const htmlTemplate = HtmlService.createTemplateFromFile("index");

  // htmlテンプレートを評価(「<?!= ... ?>」のコードを実行)して、htmlを返す。
  // ウェブページのタイトルはMy問題集とし、viewportも設定する。
  return htmlTemplate.evaluate()
    .setTitle('My問題集')
    .addMetaTag('viewport', 'width=device-width, initial-scale=1')
}

/**
 * "問題"シートから問題データを取得し加工して返す
 */
function getSheetData() {
  // このスプレッドシートの"問題"シートを取得する
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName("問題");
  
  // 問題シートからデータを文字列で取得する(2次元配列)
  const table = sheet.getDataRange().getDisplayValues();

  // 2行目以降を切り出して、rowsに格納する
  const rows = table.slice(1);

  // 各行をオブジェクトに変換し、responseに格納する
  const response = rows.map(row => {
    return {
      text: row[0],             // 問題文
      correctAnswer: row[1],    // 解答
      choises: row.slice(2, 7)  // 選択肢1〜5
    }
  })

  // 作成したresponseを返す
  return response;
}
<!DOCTYPE html>
<html>
<head>
  <base target="_top">
  <?!= HtmlService.createHtmlOutputFromFile('css').getContent(); ?>
</head>
<body>
  <div id="app">
    <template v-if="page === 'start'">
      <?!= HtmlService.createHtmlOutputFromFile('start').getContent(); ?>
    </template>
    <template v-else-if="page === 'practice'">
      <?!= HtmlService.createHtmlOutputFromFile('practice').getContent(); ?>
    </template>
    <template v-else-if="page === 'result'">
      <?!= HtmlService.createHtmlOutputFromFile('result').getContent(); ?>
    </template>
  </div>
  <?!= HtmlService.createHtmlOutputFromFile('js').getContent(); ?>
</body>
</html>
<h1>My 問題集</h1>

<!-- 問題データの取得が完了したらSTARTボタンを表示  -->
<template v-if="questions.length">
  <button @click="handleClickStart">START</button>
</template>

<!-- 問題データの取得が未完了なら、ローディングメッセージを表示  -->
<template v-else>
  <p>Loading ...</p>
</template>
<!-- 問題番号 -->
<h2>Q{{ activeQuestionIndex + 1 }}</h2>

<!-- 問題文 -->
<p>{{ activeQuestion.text }}</p>

<!-- 選択肢のラジオボタン + ラベル -->
<template v-for="(choise, i) in activeQuestion.choises">
  <label
    v-if="choise.text"
    :for="`choise${i}`" 
    :class="{ 'label-correct': choise.isCorrect && showAnswer }"
    style="display: block"
  >
    <input
      type="radio"
      :id="`choise${i}`"
      :value="i"
      v-model="userAnswer"
      :disabled="showAnswer"
    >
    {{ choise.text }}
  </label>
</template>

<!-- 正解 or 不正解の表示 -->
<p v-if="showAnswer">
  <span v-if="isCorrect" style="color: red;">{{ "正解" }}</span>
  <span v-else style="color: purple;">{{ "不正解" }}</span>
</p>

<!-- 回答する / 次へ / 終了 ボタン -->
<button v-if="!showAnswer" @click="handleClickSubmit">回答する</button>
<template v-if="showAnswer">
  <button v-if="activeQuestionIndex < questions.length - 1" @click="handleClickNext">次へ</button>
  <button v-else @click="handleClickQuit">終了</button>
</template>
<h2>result</h2>

<p>
  {{ questions.length }}問中 {{ correctCount }}問 正解しました。
  (正答率:{{ correctRate }} %)
</p>

<button @click="handleClickStart">再挑戦</button>
<style>
  /* 正解の選択肢をカラーリング */
  .label-correct {
    color: #0f5132;
    background-color: #d1e7dd;
  }
</style>
<!-- Vue.js -->
<script src="https://unpkg.com/vue@3.2.36"></script>

<script>
  const App = {
    // アプリで使うデータを定義する
    data() {
      return {
        page: "start",            // 表示中のページ
        questions: [],            // すべての問題データ
        activeQuestionIndex: 0,   // 出題中の問題(配列のインデックス)
        showAnswer: false,        // trueの場合は解答を表示する
        userAnswer: null,         // ユーザーが選んだ選択肢
        correctCount: 0,          // 現在の正解数
      }
    },

    // アプリ読み込み直後に実行される処理
    mounted() {
      // コード.gsのgetSheetDataを実行し、結果取得・加工する
      google.script.run.withSuccessHandler((response) => {
        this.questions = response.map(question => {
          // 扱いやすいように正答番号を配列のインデックスに変換
          const newCorrectAnswer = Number(question.correctAnswer) - 1

          // 選択肢の配列を、選択肢 + 正否のオブジェクトからなる配列へ変換
          const newChoises = question.choises.map((choise, index) => {
            return {
              text: choise,
              isCorrect: (index === newCorrectAnswer) ? true : false
            }
          });

          // correctAnswerとchoisesを加工後の値に変更した上でquestionを返す
          return {
            ...question, 
            correctAnswer: newCorrectAnswer,
            choises: newChoises
          };
        });
      }).getSheetData();
    },

    // 随時再計算されるプロパティを宣言
    computed: {
      // 出題中の問題データ
      activeQuestion() {
        return this.questions[this.activeQuestionIndex]
      },
      // ユーザーの回答が正しいかどうか(true / false)
      isCorrect() {
        return (this.userAnswer === this.activeQuestion.correctAnswer)
          ? true
          : false
      },
      // 現在の正答率
      correctRate() {
        return Math.round((this.correctCount / this.questions.length) * 100)
      }
    },

    // 関数
    methods: {
      // スタートボタン押下時の処理
      handleClickStart() {
        this.activeQuestionIndex = 0;
        this.showAnswer = false;
        this.userAnswer = null;
        this.correctCount = 0;
        this.page = "practice";
      },

      // 「回答する」ボタン押下時の処理
      handleClickSubmit() {
        if (this.isCorrect) this.correctCount += 1;
        this.showAnswer = true;
      },

      // 「次へ」ボタン押下時の処理
      handleClickNext() {
        this.userAnswer = null;
        this.showAnswer = false;        
        this.activeQuestionIndex += 1;
      },

      // 「終了」ボタン押下時の処理
      handleClickQuit() {
        this.page = "result";
      }
    }
  }
  Vue.createApp(App).mount('#app');
</script>

参考:コードの補足

ここからは、各コードのポイントについて補足していきます。

コード.gs

function doGet() {
  const htmlTemplate = HtmlService.createTemplateFromFile("index");
  return htmlTemplate.evaluate()
    .setTitle("My問題集")
    .addMetaTag("viewport", "width=device-width, initial-scale=1")
}

Google Apps Scriptで作成したウェブアプリ(URL)に利用者がアクセスすると、doGet関数が実行される仕組みになっています。

2行目:HtmlService.createTemplateFromFile("index");
ここでは、index.htmlをもとに、htmlテンプレートを作成しています。

htmlテンプレートは、通常のhtmlとは異なり、スクリプトレット(GASのコード)を記述することができます。

このアプリでは、index.html内の<?!= HtmlService.createHtmlOutputFromFile…(省略) ?>がスクリプトレットですね。

htmlテンプレートの.evaluate()メソッドを呼び出した時に(上記コードの3行目)、スクリプトレットが実行され、その結果を反映したhtml(HtmlOutputと呼びます)を取得することができます。

index.html

index.htmlでは、数多くのスクリプトレットが使われていますね。

スクリプレットの内容はすべて<?!= HtmlService.createHtmlOutputFromFile('ファイル名').getContent(); ?>になっています。

これは、createHtmlOutputFromFileメソッドで、当該ファイルからHtmlOutputを取得します。
HtmlOutputは、普通のhtmlファイルだと思っていただいて差し支えないと思います。)

そのHtmlOutput.getContentメソッドを呼び出すことで、htmlのソーステキストを取得しているイメージです。

つまりは、簡単に説明すると、<?!= HtmlService.createHtmlOutputFromFile('css').getContent(); ?>と記述しておけば、.evaluate()を呼び出した時に、css.htmlのソースがそこに差し込まれるという感じです。

<head>
  <base target="_top">
  <?!= HtmlService.createHtmlOutputFromFile('css').getContent(); ?>
</head>
<head>
  <base target="_top">
  <style>
    /* 正解の選択肢をカラーリング */
    .label-correct {
      color: #0f5132;
      background-color: #d1e7dd;
    }
  </style>
</head>

index.htmlのメインの部分は下記の部分です。

  <div id="app">
    <template v-if="page === 'start'">
      <?!= HtmlService.createHtmlOutputFromFile('start').getContent(); ?>
    </template>
    <template v-else-if="page === 'practice'">
      <?!= HtmlService.createHtmlOutputFromFile('practice').getContent(); ?>
    </template>
    <template v-else-if="page === 'result'">
      <?!= HtmlService.createHtmlOutputFromFile('result').getContent(); ?>
    </template>
  </div>

このアプリでは、Vue.jsを使用しています。

細かい解説は割愛しますが、Vue.jsのv-ifを使うことで、条件を満たした場合のみ要素をレンダリングすることが可能になります。

例えば、<template v-if="page === 'start'"> HTML要素 </template>とすれば、変数pageの値が “start” の時のみ、templateタグ内のHTML要素がレンダリングされます。

これを利用して・・・

  • 変数pageが start の時はstart.htmlの内容を表示
  • 変数pageが practice の時はpractice.htmlの内容を表示
  • 変数pageが result の時はresult.htmlの内容を表示

というページ切り替えを実現しています。

js.html

    // アプリ読み込み直後に実行される処理
    mounted() {
      // コード.gsのgetSheetDataを実行し、結果取得・加工する
      google.script.run.withSuccessHandler((response) => {
        this.questions = response.map(question => {

          // ... 省略 ... //

        });
      }).getSheetData();
    },

js.htmlにはこのアプリで使うJavaScriptが記述されています。

上記の抜粋部分では、アプリが読み込まれたら、google.script.runコード.gsに記述したgetSheetData関数を実行し、それが成功したら、その結果をresponseとして受け取った後、処理しています。

コメント

  1. MASAYA.H より:

    初めてコメントさせていただきます

    チームで勉強ができるよう、こちらのプログラムでアプリを作成してみました。
    機種に依存する部分があると思いますが、ご高齢の方がいる為、全体的にベースの文字サイズを大きくしたいと考えています。
    CSSの編集等で可能でしょうか?

    プログラムに関しては素人で申し訳ありません。

タイトルとURLをコピーしました