Monacaを使いJavascriptでカレンダーアプリを作る方法①

2019年6月30日 
App.vueのcomputed内のiconRightを修正しました。

2019年7月17日 
説明追加。


こんにちは ひかです。

今年初めに急にスマホのアプリを作りたいと思い立ち勉強を始めました。

何から始めていいのかわからなかったのですが調べていく中でMonacaというハイブリットアプリを作成できるサービスがありそのサービスを使えばiOSとAndroid両方対応のアプリを開発できるということがわかりました。

ということで単純な理由なのですがMonacaを使ってアプリ制作を開始しました。

アプリの勉強していて、やっとカレンダーの作り方がわかってきましたのでnoteにしました。

無料noteです。

このnoteではMonacaを使用してカレンダーアプリのメインページの月間カレンダーを作成手順を書いていきます。

このnoteの内容で誰でもカレンダーアプリを作成できるようになります。

ぼくが初心者なのでアプリ開発初心者向けのnoteです。初心者の方に少しでもこのnoteが参考になりましたら幸いです。

完成イメージはこんな感じです。背景や文字色はぼくの好みなので変更して使ってください。



この第1回目で月間カレンダーの枠を作り第2回目でgoogleのFirebaseというサービスと連携して予定作成機能をつけていきます。

ここから第1回目の説明です。今回はMonacaのクラウドIDEというサービスを利用して開発していきますので、まずは下記の方法でクラウドIDEに自分のプロジェクトを作成しましょう。

Monacaのアカウント作成をしてログインします。

そしてMonaca Dashboardの新しいプロジェクトを作るをクリックし下記の選択をしていきます。

① テンプレートの種類:フレームワークテンプレート

② フレームワーク:Vue

③ フレームワーク:Onsen UI V2 Vue Navigation

プレジェクト名は任意の名前でOKです。説明は空白でOK。

この画面で作成をクリックします。

するとDashboardに今作成したプロジェクトが出てきますので選択し、クラウドIDEで開くをクリック

こんなこんな画面になりましたら成功です。

初期起動時は時間がかかりますので画面中央よりやや下の方のプレビュー画面が終わるまで気長に待ちましょう。

次に必要な機能をインストールしていきます。

画面中央よりやや下のデバッガー、プレビューと並んでますがその右の+ボタンをクリックし新規ターミナルをクリックします。

こんな感じです。

この/project $ の右のグレーの長方形のところにコマンドを入力して必要な機能をインストールしていきます。

まずはアプリ内のデータ管理をしやすいようにVuexをインストールします。下記をコピーしてターミナルに右クリックで貼り付けてください。

npm install vuex --save

貼り付けられたらキーボードのEnterでインストールです。

インストールに少し時間がかかりますがターミナルの画面に下の図のように/project $が出てきたらインストール完了です。

さらに今回はJavascriptで非同期処理のasync・awaitを使用したいのでbabelがうまく動くように下記をコピーしてbabel-polyfillを導入します。

npm install --save-dev babel-preset-es2015 babel-preset-stage-3 babel-polyfill

babelは簡単に説明すると新しいJavaScriptの機能をまだ新しい機能が対応していないブラウザでも使えるように変換させるツールです。
非同期処理はこの処理が終わってからこの処理するという感じの処理のことです。その非同期処理のasync・awaitを対応させるために上記をインストールしました。

Vuexの時と同じく/project $になったらインストール完了です。

インストールが終わりましたらクラウドIDEの左側package.jsonがあるフォルダに「.babelrc」というファイル作成します。「.」も必要ですので気を付けてください。

上の図のフォルダ、ファイル名、ファイル形式を作成しOKをクリック。

.babelrcが作成されましたね?

作成された.babelrcをダブルクリックして開いてください。
開くとクラウドIDE真ん中に開かれます。ファイルを作成したばかりですので何もかかれていないファイルになっていると思いますのでこの内容を書き込みます。

{
 "presets": ["es2015", "stage-3"]
}

書き込みましたらファイルをクリックし保存します。

保存しましたらもう.babelrcは触りませんので画面上側のタブの×ボタンで閉じてください。

これで初期設定は完了です。

次からメインのカレンダー作成をしていきます。

まず左側のsrcフォルダの中の「main.js」の内容を書き換えます。main.jsを開き、内容を下記のように書き換えます。

import 'onsenui/css/onsenui.css';
import 'onsenui/css/onsen-css-components.css';
import 'babel-polyfill';

import Vue from 'vue';
import VueOnsen from 'vue-onsenui';
import Vuex from 'vuex';
import storeLike from './store.js';
import App from './App.vue';
Vue.use(Vuex);
Vue.use(VueOnsen);

new Vue({
 el: '#app',
 template: '<app></app>',
 components: { App },
 store: new Vuex.Store(storeLike),
 beforeCreate () {
   Vue.prototype.md = this.$ons.platform.isAndroid();
   if (window.location.search.match(/iphonex/i)) {
     document.documentElement.setAttribute('onsflag-iphonex-portrait', '');
     document.documentElement.setAttribute('onsflag-iphonex-landscape', '');
   }
   this.$ons.disableAutoStatusBarFill()
 },
});

今回Vue.jsというフレームワークを使用していきますのでコンポーネントごとにファイルを分けていきます。

使用するファイルは下記の2つのファイルですのでsrcフォルダに新規作成で作成していきましょう。

・store.js

・Home.vue

store.jsは先ほどインストールしたVuex用のファイルです。あとApp.vueも使用しますが最初からファイルがありますので作成しなくてOKです。

App.vueを開き下記の内容に書き換えます。

<template>
 <v-ons-page>
   <v-ons-navigator
     swipeable
     swipe-target-width="300px"
     :page-stack="getPageStack"
   ></v-ons-navigator>

   <v-ons-toolbar
     id="toolbar"
     modifier="transparent"
     v-bind:style="visibility"
   >

<!--  note-2 -->

     <div class="center accent-color">
       <span>
         {{ title }}
       </span>
     </div>
     <div class="right right-padding">
       <v-ons-toolbar-button v-on:click="clickRight">
         <v-ons-icon
           v-bind:style="getAccentColor"
           size="lg"
           v-bind:icon="iconRight"
         ></v-ons-icon>
       </v-ons-toolbar-button>
     </div>
   </v-ons-toolbar>

<!--  note-2 -->

 </v-ons-page>
</template>

<script>

 export default {
   data() {
     return {
       isVisible: false,
       errorMessage: [],
     };
   },

   mounted () {
     this.createPage()
   },

   methods: {
     createPage: function () {
       const windowHeight = window.innerHeight;
       const toolbarHeight = document.querySelector("#toolbar").clientHeight;
       const frameHeight = windowHeight - (2 * toolbarHeight);
       
       this.$store.dispatch('createPage', frameHeight);
     },

     clickRight: function () {
       const nowPage = this.$store.getters.getNowPage
       if (nowPage === this.$store.getters.getPageData.home.title) {
         this.toToday();
       }
     },

     toToday: async function () {
       var carouselItem = document.getElementById('carouselId');
       const pageData = this.$store.getters.getPages[1];
       const pageTime = new Date(pageData.year, pageData.month - 1, 1).getTime();

       const todayData = this.$store.getters.getToday;
       const todayTime = new Date(todayData.year, todayData.month - 1, 1).getTime();

       if (pageTime === todayTime) {
         const todayElement = document.querySelector(".today .date-box")
         todayElement.classList.add('scale-up')
         setTimeout(() => {
           todayElement.classList.remove('scale-up')
         }, 500)
       } else {
         let index = '';
         if (pageTime > todayTime) {
           index = 0;
         } else {
           index = 2;
         }
         await this.$store.dispatch('toToday', index);
         carouselItem.setActiveIndex(index, {
           animationOptions: {duration: 0.3}
         });
       }
     },
   },

   computed: {
     getFirstApi: function () {
       return this.$store.getters.getFirstApi;
     },

     iconRight: function () {
       return 'md-calendar'
     },

     getAccentColor: function () {
       return this.$store.getters.getAccentColor;
     },

     title: function () {
       if (this.$store.getters.getNowPage === this.$store.getters.getPageData.home.title) {
         return this.$store.getters.getTitleOfHome;
       } else {
         return this.$store.getters.getNowPage
       }
     },

     getPageStack: function () {
       return this.$store.getters.getPageStack;
     },

     visibility: function () {
       let style = 'hidden';
       if (this.$store.getters.getFirstApi) {
         style = 'visible';
       }
       return {
         visibility: style
       }
     },
   },
 }
</script>

<style>

.page__background {
 background-color: #eaeae0;
}

.left.left-padding {
 padding-left: 2%;
}

.right.right-padding {
 padding-right: 2%;
}

.accent-color {
 color: #00336d;
}

.alert-dialog {
 background-color: #eaeae0;
}

.alert-dialog-button {
 color: #00336d;
}

.dialog-background {
 background-color: rgb(255, 255, 255, 0.7);
}

.dialog-list {
 background-color: #eaeae0;
}

.scale-up {
 animation: scale-up 0.5s linear infinite;
}

@keyframes scale-up {
 0%  {transform: scale(1, 1);}

 50%  {transform: scale(1.5, 1.5);}

 100%  {transform: scale(1, 1);}
}

</style>

App.vueの内容を説明します。

基本的にはVue.jsの書き方で書いていきます。3つのブロックに分かれており、
<template></template>の中にブラウザに表示される部分を書きます。この中はhtmlと同じ書き方です。
<script></script>の中にJavascriptを書きます。
<style></style>にCSSを書きます。

それでは<template></template>から説明します。

はじめのタグ<v-ons-page>はmonacaのonsen uiの独自タグです。
このタグはonsen uiの公式に詳しく書かれていますので参考にしてください。
https://ja.onsen.io/v2/api/vue/v-ons-page.html
次の<v-ons-navigator>内のswipeableとswipe-target-width="300px"もonsen ui独自の書き方です。
:page-stack="getPageStack"はonsen uiとvue.js独自のものが両方入っています。「:」はVue.jsの「v-bind:」の略です。v-bindを使うことにより動的な値を使うことができます。=で指定したgetPageStackはJavascript部分に書きます。この書き方はVue.jsの公式に詳しく書かれていますので参考にしてください。
https://jp.vuejs.org/v2/guide/class-and-style.html
少し下にいき{{ title }}もVue.js独自の書き方です。これによりカレンダーの月を変えた時に上の年月が動的に変わるようになっています。
v-on:click="clickRight"はクリックした時の動きをJavascriptで指定できます。今回はクリックした時に現在の月に移動するようにしています。現在の月になっている場合は当日の文字を大きくします。

次は<script></script>の説明です。ここからの説明は次回更新します。

続いてHome.vueを開き下記の内容に書き換えます。
長くて見づらいかもしれませんが。。

<template>
 <v-ons-page>

   <v-ons-bottom-toolbar
     id="bottom-toolbar"
     modifier="transparent"
   >
     <div class="bottom-center">
       <v-ons-toolbar-button>
         <v-ons-icon
           v-bind:style="getIconColor"
           size="2x"
           icon="fa-plus-square"
         ></v-ons-icon>
       </v-ons-toolbar-button>
     </div>
   </v-ons-bottom-toolbar>

 <div class="carousel-frame">
   <v-ons-carousel
     id="carouselId"
     swipeable auto-scroll
     auto-scroll-ratio="0.3" fullscreen
     v-bind:options="{animation: 'none'}"
     v-bind:index.sync="carouselIndex"
     v-on:postchange="postchange"
   >

     <v-ons-carousel-item
       v-for="(page, index) in getPages"
       v-bind:key="index"
     >
       <div class="container" v-bind:style="containerStyle(page)">
         <div
           class="box weekly-header"
           v-for="(day, index) in getWeekly"
           v-bind:class="getStyleDay('', index)"
         >
           {{ day }}
         </div>

         <div
           class="box"
           v-for="(day, index) in page.pageDates"
           v-bind:class="getStyleDay(day, index)"
           v-bind:id="index + '-' + day.year+ '-' + day.month + '-' + day.date"
         >

           <div class="date-box">
             {{ day.date }}
           </div>

         </div>
       </div>

     </v-ons-carousel-item>

   </v-ons-carousel>
   </div>
 </v-ons-page>
</template>

<script>

export default {
 data () {
   return {
     carouselIndex: 1,
   };
 },

 computed: {
   getIconColor: function () {
     return this.$store.getters.getIconColor;
   },

   getWeekly: function () {
     return this.$store.getters.getWeekly;
   },

   getPages: function () {
     return this.$store.getters.getPages;
   },
 },

 methods: {
   postchange: function (event) {
     setTimeout(() => { 
       if (event.activeIndex != 1) {
         this.carouselChange(event.activeIndex) 
       } else {
         this.$store.dispatch('chageTitleOfHome', event.activeIndex);
         this.$store.dispatch('updatePage', event.lastActiveIndex)
         if (this.$store.getters.getToTodayFlag) {
           this.$store.dispatch('toTodayCallback', 2 - event.lastActiveIndex)
         }
       }
     }, 0)
   },

   carouselChange: async function (index) {
     await this.$store.dispatch('movePage', index)
     this.carouselIndex = 1;
   },

   getStyleDay: function (day, index) {
     return {
       today: JSON.stringify(day) === JSON.stringify(this.$store.getters.getToday),
       sunday: index % 7 === 0,
       saturday: index % 7 === 6,
       }
   },

   containerStyle: function (page) {
     return {
       gridTemplateRows: page.calendarBoxStyle.gridTemplateRows
     }
   },
 }
};
</script>

<style scoped>
html {
 font-size: 16px;
}

.carousel-frame {
 height: 100%;
 width: 100%;  
 background: #eaeae0;
 padding: 0;
}

.bottom-left {
 position: absolute;
 display: -webkit-flex;
 display: flex;
 width: 33%;
 left: 0;
 height: 100%;
 -webkit-align-items: center;
 align-items: center;
 -webkit-justify-content: center;
 justify-content: center;
}

.bottom-center {
 position: absolute;
 display: -webkit-flex;
 display: flex;
 width: 34%;
 left: 33%;
 height: 100%;
 -webkit-align-items: center;
 align-items: center;
 -webkit-justify-content: center;
 justify-content: center;
}

.bottom-right {
 position: absolute;
 display: -webkit-flex;
 display: flex;
 width: 33%;
 left: 67%;
 height: 100%;
 -webkit-align-items: center;
 align-items: center;
 -webkit-justify-content: center;
 justify-content: center;
}

.container {
 display: grid;
 height: 100%;
 width: 100%;
 grid-template-columns: repeat(7, 1fr);
 overflow: hidden;
}

.box {
 position: relative;
 background: #eaeae0;
 border-left: 1px solid ghostwhite;
 border-top: 1px solid ghostwhite;
}

.weekly-header {
 font-size: 80%;
 text-align: center;
 border-top: none;
}

.sunday {
 border-left: none;
}

.date-box {
 margin: 0;
 color: grey;
 font-size: 12px;
 text-align: center;
 height: 16px;
 width: 100%;
}

.today {
 background: ghostwhite;
}

.today .date-box {
 color: #00336d
}

.weekly-header.sunday,
.sunday .date-box {
 color: #FF4F50;
}

.weekly-header.saturday,
.saturday .date-box {
 color: #4d9be8;
}

</style>


仕上げです。

store.jsを開き下記の内容に書き換えます。

import homePage from 'Home';

export default {
 state: {
   pageData: {
     home: {
       page: homePage,
       title: 'ホーム'
     },
   },
   firstApi: true,
   frameHeight: '',
   accentColor: {color: '#00336d'},
   iconColor: {color: '#4496d3'},
   titleOfHome: '',
   weekly: ['日', '月', '火', '水', '木', '金', '土'],
   today: { year: '', month: '', date: '' },
   toTodayFlag: false,
   todayPages: [],
   pages: [
       {calendarBoxStyle: {gridTemplateRows: ''}},
       {calendarBoxStyle: {gridTemplateRows: ''}},
       {calendarBoxStyle: {gridTemplateRows: ''}}
   ],
   pageStack: [homePage],
   nowPage: '',
 },

 getters: {
   getPageData(state) {
     return state.pageData;
   },

   getFirstApi(state) {
     return state.firstApi;
   },

   getAccentColor(state) {
     return state.accentColor;
   },

   getIconColor(state) {
     return state.iconColor;
   },

   getPageStack(state) {
     return state.pageStack;
   },

   getTitleOfHome(state) {
     return state.titleOfHome;
   },

   getNowPage(state) {
     return state.nowPage;
   },

   getWeekly(state) {
     return state.weekly;
   },

   getToday(state) {
     return state.today;
   },

   getPages(state) {
     return state.pages;
   },

   getToTodayFlag(state) {
     return state.toTodayFlag
   },
 },

 mutations: {
   addframeHeight(state, frameHeight) {
     state.frameHeight = frameHeight;
   },

   copyTodayPages(state) {
     for (let page of state.pages) {
       state.todayPages.push(page)
     }
     state.nowPage = 'ホーム'
   },

   push(state, {pageName, animation} ) {
     const pageStackData = {
       extends: state.pageData[pageName].page,
       onsNavigatorOptions: {
         animation: animation,
         animationOptions: { duration: 0.5 }
       }}

     state.pageStack.push(pageStackData);
     state.nowPage = state.pageData[pageName].title;
   },

   toTodayCallback(state, index) {
     state.toTodayFlag = false;
     state.pages.splice(index, 1, state.todayPages[index]);
   },

   toToday(state, index) {
     state.toTodayFlag = true;
     state.pages.splice(index, 1, state.todayPages[1]);
   },

   chageTitleOfHome(state, index) {
     state.titleOfHome = state.pages[index].year + '年' + state.pages[index].month + '月';
   },
   
   addToday(state, { year, month, date }) {
     state.today.year = year;
     state.today.month = month;
     state.today.date = date;
     state.titleOfHome = year + '年' + month + '月';
   },

   updatePage(state, { updateData, index }) {
     state.pages.splice(index, 1, updateData);
   },

   movePage(state, index) {
     const tempPages = state.pages
   
     if(index === 0) {
       state.pages.splice(2, 1, tempPages[1]);
       state.pages.splice(1, 1, tempPages[0]);

     } else if (index === 2) {
       state.pages.splice(0, 1, tempPages[1]);
       state.pages.splice(1, 1, tempPages[2]);
     }
   },

 },
 actions: {
   /*################
   actions Home.vue
   ################*/

   chageTitleOfHome({ commit }, index) {
     commit('chageTitleOfHome', index);
   },

   updateDate({ commit }, {startDateString, endDateString}) {
     commit('updateStartDate', startDateString)
     commit('updateEndDate', endDateString)
   },

   push({ commit }, {pageName, animation}) {
     commit('push', {pageName: pageName, animation: animation})
   },

   async movePage({ commit, dispatch }, index) {
     commit('movePage', index);
   },

   toTodayCallback({ commit }, index) {
       commit('toTodayCallback', index);
   },

   /*################
   actions App.vue
   ################*/

   async toToday({ commit }, index) {
     commit('toToday', index)        
   },

   async createPage({ commit, dispatch }, frameHeight) {
     commit('addframeHeight', frameHeight);

     const newDate = new Date();
     const year = newDate.getFullYear();
     const month = newDate.getMonth() + 1;
     const date = newDate.getDate();
     const dt = {year: year, month: month}

     commit('addToday', { year: year, month: month, date: date });

     let promises = [];
     for (let i = 0; i < 3; i++) {
       promises.push(dispatch('calMonth', {dt: dt, index: i}));
     }

     Promise.all(promises)
     .then( (updateData) => {
       for (let i = 0; i < 3; i++) {
         commit('updatePage', {updateData: updateData[i], index: i})
       }
     })
     .then( (updateData) => {
       commit('copyTodayPages')
     })
   },

   /*################
   actions 共通
   ################*/

   async updatePage({ commit, dispatch, state }, index) {
     const updateData = await dispatch('calMonth', {dt: state.pages[index], index: index})
     commit('updatePage', {updateData: updateData, index: index});
   },

   async calMonth({ commit, dispatch, state }, { dt, index }) {
       const pagedt = new Date(dt.year, dt.month + index -1, 0);
       const pageYear = pagedt.getFullYear();
       const pageMonth = pagedt.getMonth() + 1;

       const pagelastdt = new Date(pageYear, pageMonth, 0);
       const pagelastdate = pagelastdt.getDate();
       const pagelastday = state.weekly[pagelastdt.getDay()];
       const pagelastdayindex = state.weekly.indexOf(pagelastday)

       const pagefirstdt = new Date(pageYear, pageMonth - 1, 1);
       const pagefirstdate = pagefirstdt.getDate();
       const pagefirstday = state.weekly[pagefirstdt.getDay()];
       const pagefirstdayindex = state.weekly.indexOf(pagefirstday)

       pagefirstdt.setDate(pagefirstdate - pagefirstdayindex);

       const pageNumberOfDays = pagelastdate + pagefirstdayindex + 6 - pagelastdayindex;

       let updateData = { 
           year: '',
           month: '',
           numberOfLines: '',
           pageDates: [],
           calendarBoxStyle: {gridTemplateRows: ''}
       };

       updateData.year = pageYear;
       updateData.month = pageMonth;

       updateData.pageDates.push({
           year: pagefirstdt.getFullYear(),
           month: pagefirstdt.getMonth() + 1,
           date: pagefirstdt.getDate()
         });

       for (let i = 1; i < pageNumberOfDays; i++) {
           pagefirstdt.setDate(pagefirstdt.getDate() + 1);
           updateData.pageDates.push({
             year: pagefirstdt.getFullYear(),
             month: pagefirstdt.getMonth() + 1,
             date: pagefirstdt.getDate()
           });
       }

       const rows = pageNumberOfDays / 7

       updateData.calendarBoxStyle.gridTemplateRows = `4% repeat(${rows}, 1fr)`

       //height 16px
       updateData.numberOfLines = Math.floor(state.frameHeight * 0.96 / rows / 16) - 1

       return updateData
   },    
 },
};

こんな感じで完成です。

カレンダーがうまく動きましたか?

今はコードの説明はありませんが後日更新して加えていきます。

エラーが出る、またはわかりにくいところがありましたら質問してください!時間があるときに返信していきます。

質問があったところはnoteを更新していこうと思います。

それでは。長文でしたが読んでくださりありがとうございました。

続きは第2回で。