🧮

Vue3 + Vuetify3で電卓を作ってみた

2023/03/13に公開

概要

普段はVue3を使用しないのですが、1日でVue3 と Vuetify3を使って電卓を作ってみました。
現在VuejsではVue2からVue 3に移行していて、Vue 3では従来のVue2の書き方と異なる部分が多々あり、インターネット上の情報がかなりややこしいです。
今回は、基本的にVuetify3のドキュメントを参考にしながら、電卓を作ってみました。

なお、setup scriptを使うことで、従来の書き方に比べて可読性が上がり、コード量も減りました。

デザイン・外観

Vuetify のコンポーネントはモダンでかっこよく、そのまま使用することにしました。
ボタンは頻繁に使うため、ラッパーを作って、デザインを共通化しました。
テーマは、ダークモードとライトモードを切り替えられるようにしました。やり方は以前の記事に書いています。

コンポーネント 使用した場所
v-text-field 入力値, 履歴の表示
v-col, v-row 電卓のレイアウト
v-btn ボタン
v-switch テーマの切り替え

picture 1

Vuetify3インストールからプロジェクト作成

Vue3では、Vue CLIを使わずに、Viteを使うことが推奨されていますので、今回はViteを使って作成しました。
インターネット上では、Vue CLIを使っている記事が多いので、注意しましょう。
とは言っても、Vuetifyのドキュメントに書いてあるインストール方法を使えば、ひな型を生成してくれます。

npm install -g yarn
yarn create vuetify
# 後は画面に従って進めるだけ
# piniaはあってもなくても良い
cd calculator-vuetify # プロジェクト名
yarn dev # ローカルで確認できます

プロジェクト作成後、デフォルトで付いてきている不要物を処理します。

  • logoやfaviconは不要または、自分の好きなものに置き換える
  • HelloWorld.vueは不要
  • Home.vueではHelloWorld.vueを表示しているので、CalculatorPage.vueを表示するように変更

計算機能

各ボタンが押されたときに適切な関数を呼び出し、入力値を更新するようにしました。
入力値をstringとして保存しておき、評価するときに、parserで数値に変換してから計算するようにしました。こうすることで、入力値のバリデーションチェックが簡単になります。また、拡張性も高くなります。

具体的な内容は、実装を見ていただくとわかりやすいかもしれません。

後から思ったのですが、javascriptのevalやFunctionを使えばもっと簡単に実装できたと思います。

GitHub pagesで公開

GitHub pagesで公開するためには、baseのさす場所をプロジェクト名に変更する必要があります。
具体的には、vite.config.jsのbaseを変更し、src\router\index.jsのHome.vueのパスを変更します。

実装

ソースコードはGitHubで公開しています。Unlicenseなので、自由に使ってください。
https://github.com/yunkai1841/calculator-vuetify

電卓本体
CalculatorPage.vue
<template>
  <div class="calculator-page">
    <!-- display area -->
    <v-col cols="12" class="display-area">
      <v-row>
        <v-col cols="12">
          <v-text-field
            variant="plain"
            hide-details="auto"
            v-model="history"
            class="display-area__history"
            readonly
          />
        </v-col>
      </v-row>
      <v-row>
        <v-col cols="12">
          <v-text-field
            variant="outlined"
            hide-details="auto"
            v-model="input"
            :error="!validateInput(input)"
            :error-messages="validateInput(input) ? [] : ['Invalid input']"
            class="display-area__input"
            readonly
          />
        </v-col>
      </v-row>
    </v-col>
    <!-- buttons area -->
    <v-col cols="12" class="buttons-area">
      <v-row>
        <v-col cols="3">
          <custom-button class="buttons-area__button" @click="clear">C</custom-button>
        </v-col>
        <v-col cols="3">
          <custom-button class="buttons-area__button" @click="backspace">
            <v-icon icon="mdi-backspace" />
          </custom-button>
        </v-col>
        <v-col cols="3">
          <custom-button class="buttons-area__button" @click="plusMinus">
            <v-icon icon="mdi-plus-minus" />
          </custom-button>
        </v-col>
        <v-col cols="3">
          <custom-button class="buttons-area__button" @click="addOperation('/')">
            <v-icon icon="mdi-slash-forward" />
          </custom-button>
        </v-col>
      </v-row>
      <v-row>
        <v-col cols="3">
          <custom-button class="buttons-area__button" :color="numberColor" @click="addNumber(7)">
            7
          </custom-button>
        </v-col>
        <v-col cols="3">
          <custom-button class="buttons-area__button" :color="numberColor" @click="addNumber(8)">
            8
          </custom-button>
        </v-col>
        <v-col cols="3">
          <custom-button class="buttons-area__button" :color="numberColor" @click="addNumber(9)">
            9
          </custom-button>
        </v-col>
        <v-col cols="3">
          <custom-button class="buttons-area__button" @click="addOperation('*')">
            <v-icon icon="mdi-close" />
          </custom-button>
        </v-col>
      </v-row>
      <v-row>
        <v-col cols="3">
          <custom-button class="buttons-area__button" :color="numberColor" @click="addNumber(4)">
            4
          </custom-button>
        </v-col>
        <v-col cols="3">
          <custom-button class="buttons-area__button" :color="numberColor" @click="addNumber(5)">
            5
          </custom-button>
        </v-col>
        <v-col cols="3">
          <custom-button class="buttons-area__button" :color="numberColor" @click="addNumber(6)">
            6
          </custom-button>
        </v-col>
        <v-col cols="3">
          <custom-button class="buttons-area__button" @click="addOperation('-')">
            <v-icon icon="mdi-minus" />
          </custom-button>
        </v-col>
      </v-row>
      <v-row>
        <v-col cols="3">
          <custom-button class="buttons-area__button" :color="numberColor" @click="addNumber(1)">
            1
          </custom-button>
        </v-col>
        <v-col cols="3">
          <custom-button class="buttons-area__button" :color="numberColor" @click="addNumber(2)">
            2
          </custom-button>
        </v-col>
        <v-col cols="3">
          <custom-button class="buttons-area__button" :color="numberColor" @click="addNumber(3)">
            3
          </custom-button>
        </v-col>
        <v-col cols="3">
          <custom-button class="buttons-area__button" @click="addOperation('+')">
            <v-icon icon="mdi-plus" />
          </custom-button>
        </v-col>
      </v-row>
      <v-row>
        <v-col cols="6">
          <custom-button class="buttons-area__button" :color="numberColor" @click="addNumber(0)" :width="2">
            0
          </custom-button>
        </v-col>
        <v-col cols="3">
          <custom-button class="buttons-area__button" @click="decimal">.</custom-button>
        </v-col>
        <v-col cols="3">
          <custom-button class="buttons-area__button" @click="equals">
            <v-icon icon="mdi-equal" />
          </custom-button>
        </v-col>
      </v-row>
    </v-col>
  </div>
</template>

<script setup>
import CustomButton from "./CustomButton.vue";
import { ref } from "vue";

const numberColor = "amber";

const input = ref("");
const history = ref("");

const addNumber = (number) => {
  input.value += number.toString();
};

const clear = () => {
  input.value = "";
  history.value = "";
};

const backspace = () => {
  input.value = input.value.slice(0, -1);
};

const addOperation = (operation) => {
  if (hasOperation(input.value)) {
    equals();
  }
  input.value += operation;
};

const decimal = () => {
  const lastNumber = input.value.split(/\+|-|\/|\*/).reverse()[0]
  if (lastNumber === "") {
    input.value += "0.";
  } else if (!lastNumber.includes(".")) {
    input.value += ".";
  }
};

const plusMinus = () => {
  if (hasOperation(input.value)) {
    equals();
  }
  input.value = (parseInt(input.value) * -1).toString();
};

function hasOperation(input) {
  const operations = ["+", "-", "*", "/"];
  for (let i = 0; i < input.length; i++) {
    if (operations.includes(input[i])) {
      return true;
    }
  }
  return false;
}

const equals = () => {
  if (!validateInput(input.value)) {
    return;
  }
  history.value = input.value;
  input.value = parser(input.value).toString();
};

function parser(input) {
  let result = 0;
  let currentNumber = "";
  let currentOperation = null;
  let minusFlag = false;
  // 先頭がマイナスの場合は、最初の数字をマイナスにする
  if (input[0] === "-") {
    minusFlag = true;
    input = input.slice(1);
  }

  for (let i = 0; i < input.length; i++) {
    const char = input[i];
    if (isNumber(char) || char === ".") {
      if (minusFlag && currentNumber === "") {
        currentNumber = "-";
        minusFlag = false;
      }
      currentNumber += char;
    } else if(isOperation(char)) {
      if (currentOperation === null) {
        result = parseFloat(currentNumber);
      } else {
        result = operate(currentOperation, result, parseFloat(currentNumber));
      }
      currentOperation = char;
      currentNumber = "";
    }
  }
  // 最後の数字を計算に含める
  // オペレーターがない場合は、最初の数字のみ
  if (currentOperation === null) {
    result = parseFloat(currentNumber);
  } else if (currentNumber !== "") {
    result = operate(currentOperation, result, parseFloat(currentNumber));
  }
  // 小数点以下5桁まで
  // 0を削除
  return result.toFixed(5).replace(/\.?0+$/, "");
}

function isNumber(char) {
  const numbers = /[0-9]/;
  return numbers.test(char);
}

function isOperation(char) {
  const operations = /[+*/-]/;
  return operations.test(char);
}

function operate(operation, a, b) {
  switch (operation) {
    case "+":
      return a + b;
    case "-":
      return a - b;
    case "*":
      return a * b;
    case "/":
      return a / b;
    default:
      return 0;
  }
}

function validateInput(input) {
  // 0-9+*/.-のみ
  const regex = /^[0-9+*/.-]+$/;
  return input === "" || regex.test(input);
}

</script>

<style lang="scss" scoped>
.calculator-page {
  align-items: center;
  height: 100%;
  width: fit-content;
  margin: 0 auto;

  .display-area {
    width: 100%;

    .display-area__history :deep(.v-field__input) {
      font-size: 1rem;
      padding: 0;
    }

    .display-area__input :deep(.v-field__input) {
      font-size: 2rem;
      text-align: right;
    }
  }

  .buttons-area {
    width: 100%;

    .buttons-area__button {
      width: 100%;
      height: 100%;
      font-size: 2rem;
    }
  }
}
</style>
ボタンコンポーネント
<template>
  <v-btn
    flat
    :color="color"
    :width="calcWidth"
    :height="calcHeight"
    class="custom-button"
    @click="$emit('click')"
  >
    <span v-if="text" class="custom-button__text">{{ text }}</span>
    <slot v-else />
  </v-btn>
</template>

<script setup>
import { computed } from 'vue'

const props = defineProps({
  text: {
    type: String,
  },
  color: {
    type: String,
    default: 'primary',
  },
  // ボタンのサイズを決める基準となる値
  baseSize: {
    type: Number,
    default: 72,
  },
  // baseSizeの倍数で指定する
  width: {
    type: Number,
    default: 1,
  },
  height: {
    type: Number,
    default: 1,
  },
})

defineEmits(['click']);

const calcWidth = computed(() => {
  return `${props.baseSize * props.width}px`
})

const calcHeight = computed(() => {
  return `${props.baseSize * props.height}px`
})

</script>
GitHubで編集を提案

Discussion