ネイティブアプリ風ウェブアプリを Ionic で作ってみる(完全入門編)その1

ネイティブアプリ風ウェブアプリを Ionic で作ってみる(完全入門編)その1

ここで作成しているアプリは、

こちら

で公開しています。

また、ソースコードは、こちらで公開しています。

Contents

概要

Ionic を使ってネイティブ風のウェブアプリ(ToDoアプリ)を作ります。Ionic自体は、フレームワークフリーですが、罠が少なそうな Angular で作っています。Angular の状態管理は、NgRx を使いますが、Redux-toolkit を使って開発を簡単にしています。

このアプリを作成します。こんなことをします。

  • ピュアなHTML/JavaScript で、Ionic を試す
  • Angular の中で出てくる rxjs の概要を理解する
  • Angular プロジェクトとして Ionic 立ち上げる
  • 簡単なToDoアプリの画面を作ってみる
  • ToDo の状態管理を Serviceクラスの BehaviorSubject でやってみる
  • NgRx を導入して状態管理してみる
  • Redux toolkit で NgRx を使うのを簡単にしてみる
  • まとめるとこんな開発フローになるんちゃうかと考えてみる

コードはこちらで公開しています。

※ 採用の応募者の方へ、弊社では、ここに記載されている技術を運用環境で利用していないので、事前に勉強しないで大丈夫です。

モチベーション

ネイティブアプリのような動作をするウェブアプリを HTML/JavaScript で開発します。

例えば ios であれば上にナビゲーションバーがあり、リスト表示されていてその中のひとつをクリックすれば次の画面がPushされて表示されます。ユーザーは、この使い心地にすごく慣れているので違和感なくいろんなアプリを使うことができます。

こういう使い心地のアプリを提供したいとなれば、選択肢は、

  • ネイティブアプリ(iOS / Android / Windows など)を開発する
  • ウェブアプリで頑張る

の大きくふたつかなと思います。前者のネイティブアプリを開発する方は、素朴にそれぞれの開発環境で開発する方法とクロスプラットフォーム開発する方法があります。クロスプラットフォーム開発で有名なのはいくつかあります。

  • Flutter (Googleが開発、言語はDartでネイティブコードは全く不要)
  • React Native (使える人が多いReactフレームワークで開発可能)
  • Xamarine (Microsoftが買収、C#で開発できるので Visual Studio が使える)
  • Unity (ゲームなのでちょっと文脈違いますが一応)
  • Ionic (WebアプリをWebViewを使って表示、Capcitor などでネイティブAPIに連携)

この中で、ウェブアプリで頑張る時にも使えるものがあります。Flutter for Web や、React Native for Web、Ionicは、そもそもWebアプリなのでそのまま使えます。

では、ウェブアプリで頑張る時にどれを使うのが良いのか。わかりません。ただ、すでに出来上がっているコンポーネント(画面を作る部品)をみると Flutter と Ionic は使いやすそうだなと感じます。Flutterを触ってみるとかなりいい感じでしたが、ウェブでしか使わないことを考えると既存のJavaScriptのライブラリを使い回したいですが、これが結構面倒そうで諦めました。また、ページを切り替えてもURLが遷移しないのでもしかしたら不便かもと思いました。Ionicは、めちゃくちゃ簡単です。というわけで Ionic を触ってみます。

想定読者は、HTML5 や JavaScript について理解しているものとしていますが、フレームワークの知識等は不要です。

Ionic 公式サイト

Pure な Ionic を触ってみる

index.html を作る

Ionic は、Pureな(プレーンな?) HTML/JavaScript で使うことができます。まずは、index.html を作成してみましょう。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>My To Do</title>
</head>
<body>
  
</body>
</html>

CDNから Ionic を読み込む

こちらのページに追加するタグが書いてあるので追加しておきます。
https://ionicframework.com/docs/intro/cdn

<script type="module" src="https://cdn.jsdelivr.net/npm/@ionic/core/dist/ionic/ionic.esm.js"></script>
<script nomodule src="https://cdn.jsdelivr.net/npm/@ionic/core/dist/ionic/ionic.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@ionic/core/css/ionic.bundle.css"/>

これを index.html に追加します。

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <script type="module" src="https://cdn.jsdelivr.net/npm/@ionic/core/dist/ionic/ionic.esm.js"></script>
  <script nomodule src="https://cdn.jsdelivr.net/npm/@ionic/core/dist/ionic/ionic.js"></script>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@ionic/core/css/ionic.bundle.css" />
  <title>My To Do</title>
</head>

<body>

</body>

</html>

コンポーネントを追加していく

こちらに使えるコンポーネントがたくさん掲載されているので使えそうなものをとってきます。注意深く読むと、<ion-app> というtagは、必ずひとつ必要ということで、それを入れて、ツールバーなどを追加していきます。

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <script type="module" src="https://cdn.jsdelivr.net/npm/@ionic/core/dist/ionic/ionic.esm.js"></script>
  <script nomodule src="https://cdn.jsdelivr.net/npm/@ionic/core/dist/ionic/ionic.js"></script>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@ionic/core/css/ionic.bundle.css" />
  <title>My To Do</title>
</head>

<body>
  <ion-app>
    <ion-header>
      <ion-toolbar>
        <ion-title>
          My To Do
        </ion-title>
      </ion-toolbar>
    </ion-header>
  </ion-app>
</body>

</html>

この時点で index.html を Chrome で開いてみます。

ツールバーが表示されました。

まずは、ToDoを適当に配置してみましょう。ion-list というコンポーネントが使えそうなので使っていきます。

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <script type="module" src="https://cdn.jsdelivr.net/npm/@ionic/core/dist/ionic/ionic.esm.js"></script>
  <script nomodule src="https://cdn.jsdelivr.net/npm/@ionic/core/dist/ionic/ionic.js"></script>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@ionic/core/css/ionic.bundle.css" />
  <title>My To Do</title>
</head>

<body>
  <ion-app>
    <ion-header>
      <ion-toolbar>
        <ion-title>
          My To Do
        </ion-title>
        <ion-buttons slot="end">
          <ion-button>追加</ion-button>          
        </ion-buttons>
      </ion-toolbar>
    </ion-header>

    <ion-content>
      <ion-list>
        <ion-list-header>
          まだやってないもの
        </ion-list-header>
        <ion-item>
          <ion-label>
            <h2>部屋の掃除</h2>
            <h3>2021年10月30日まで</h3>
          </ion-label>
          <ion-checkbox color="primary" slot="start"></ion-checkbox>
        </ion-item>
        <ion-item>
          <ion-label>
            <h2>ゴミ捨て</h2>
            <h3>2021年10月30日まで</h3>
          </ion-label>
          <ion-checkbox color="primary" slot="start"></ion-checkbox>
        </ion-item>
        <ion-item>
          <ion-label>
            <h2>風呂の掃除</h2>
            <h3>2021年10月30日まで</h3>
          </ion-label>
          <ion-checkbox color="primary" slot="start"></ion-checkbox>
        </ion-item>
        <ion-item>
          <ion-label>
            <h2>屋根の掃除</h2>
            <h3>2021年10月30日まで</h3>
          </ion-label>
          <ion-checkbox color="primary" slot="start"></ion-checkbox>
        </ion-item>
        <ion-item>
          <ion-label>
            <h2>窓の掃除</h2>
            <h3>2021年10月30日まで</h3>
          </ion-label>
          <ion-checkbox color="primary" slot="start"></ion-checkbox>
        </ion-item>
        <ion-item>
          <ion-label>
            <h2>犬小屋の掃除</h2>
            <h3>2021年10月30日まで</h3>
          </ion-label>
          <ion-checkbox color="primary" slot="start"></ion-checkbox>
        </ion-item>

        <ion-list-header>
          もうやったもの
        </ion-list-header>

        <ion-item>
          <ion-label>
            <h2>朝飯を食う</h2>
            <h3>2021年10月30日まで</h3>
          </ion-label>
          <ion-checkbox color="primary" checked slot="start"></ion-checkbox>
        </ion-item>

        <ion-item>
          <ion-label>
            <h2>昼飯を食う</h2>
            <h3>2021年10月30日まで</h3>
          </ion-label>
          <ion-checkbox color="primary" checked slot="start"></ion-checkbox>
        </ion-item>

        <ion-item>
          <ion-label>
            <h2>晩飯を食う</h2>
            <h3>2021年10月30日まで</h3>
          </ion-label>
          <ion-checkbox color="primary" checked slot="start"></ion-checkbox>
        </ion-item>

      </ion-list>

    </ion-content>

  </ion-app>
</body>

</html>

だいぶアプリっぽくなってきました。

この追加ボタンをタップするとモーダル画面(何かして元の画面にもどる用の画面)が出てきてToDoの中身を作れるような感じにしたいですが、今は何も起こりません。次でやってみます。

Pure な Ionic に JavaScript のコードを追加していく

JavaScript 用のファイルを作成し読み込ませる

まず、新規追加用のモーダル画面を作っていきましょう。index.html のbodyタグの閉じタグのすぐ上でスクリプトを読み込みます。

  <script src="main.js"></script>
</body>

main.js を作っていきます。まず、ボタンがクリックされた時のイベントを拾いたいのでボタンにidを指定して、 EventListner を追加していきます。

<body>
  <ion-app>
    <ion-header>
      <ion-toolbar>
        <ion-title>
          My To Do
        </ion-title>
        <ion-buttons slot="end">
          <ion-button id="addButton">追加</ion-button>          
        </ion-buttons>
      </ion-toolbar>
    </ion-header>

これをmain.js の方でイベントを拾っていきましょう。まず、index.html と同じ階層にmain.jsというファイルを作成しておいてください。追加ボタンをタップしたらアラートを表示するようにしてみます。

const addButton = document.querySelector('#addButton');
addButton.addEventListener('click', () => {
  alert('add button clicked');
});

documentというのは、このhtml文書のことです。querySelectorというのは、idやclassを使って(これをCSSセレクターと言います)ひとつの要素を取得する時に使うメッセージです。この例では、#addButton というセレクターを渡しています。先程、ボタンに id=”addButton” としたので、これは、ボタンの要素をとってくる命令になります。とってきたボタン要素は、直接テキストを操作したり色を変えたりすることができるようになります。ここでは、この要素がクリックされた時(イベントと言います)の挙動を addEventListener を使って設定しています。

さて、追加ボタンをタップしてみましょう。そうするとブラウザの上の方にアラートが表示されると思います。

これでJavaScriptファイルを登録できました。

追加ボタンがタップされたらモバイル用のアラートを表示する。

ブラウザのアラートではなく、モバイル用のアラートを表示してみましょう。index.htmlに要素をプログラム的に追加していきます。

コードだけで表示を作成していきます。ion-alert というコンポーネントが利用できます。

const addButton = document.querySelector('#addButton');
addButton.addEventListener('click', () => {
  showAlert('追加ボタンクリック', 'これで確認できました。', 'もう閉じて良いですよ');
});

function showAlert(header, subheader, message) {
  const alert = document.createElement('ion-alert');
  alert.header = header;
  alert.subHeader = subheader;
  alert.message = message;
  alert.buttons = ['OK'];

  document.body.appendChild(alert);
  return alert.present();
}

showAlertという関数を用意して、alertを表示させています。ion-alert は、配置されるとメソッドが用意されていて、present()を実行すれば表示されます。他の仕様は、こちらに記載があります。

モバイルっぽいいい感じになりました。ここでToDoの中身を登録できるようにしてみましょう。ion-alert の説明に、inputs という属性が使えるとあるのでinputs に todo を設定してみます。

const addButton = document.querySelector('#addButton');
addButton.addEventListener('click', () => {
  showAlert('タスク追加', '', 'やらなきゃいけないことは何?');
});

function showAlert(header, subheader, message) {
  const alert = document.createElement('ion-alert');
  alert.header = header;
  alert.subHeader = subheader;
  alert.message = message;
  alert.buttons = ['OK'];
  alert.inputs = [{
    name: 'todo',
    id: 'new-todo',
    placeholder: 'やらなきゃいけないことは何?'
  }];

  document.body.appendChild(alert);
  return alert.present();
}

inputs 属性は、配列で、name, id, placeholder, type などさまざまな設定が可能です。今回は、値を取り出す必要もありますので、idを指定しました。nameは、指定するとどうなるのかよくわかりません。ドキュメントにもなさそうなので、ソースコードを読むか、もしかしたら、TypeScript を使えば与えるべき値がわかりそうです。

入力を追加できました。

ブラウザのインスペクタで、エミュレートするデバイスを Pixcel に変えてみましょう。

アンドロイド風の見た目に変わりました。このように、Ionic が用意したコンポーネントを使うとAndroid / iOS での見た目の変かを自動的にやってくれるようになります。

ToDoの中身をプログラムで設定する

さて、これでOKボタンを押したところで、画面の表示は変わりません。データを更新しなければならないのですが、今のところHTMLにべたがきされてしまっています。これを分離してion-list にappendChildしていくような実装にしてみましょう。

まず、「まだやってないもの」と「もうやったもの」でリストを分けておき、異なるidをつけておきましょう。

      <ion-list id="todos">
        <ion-list-header>
          まだやってないもの
        </ion-list-header>
        // 省略 
      </ion-list>
      <ion-list id="done">
        <ion-list-header>
          もうやったもの
        </ion-list-header>

        // 省略
      </ion-list>

todoを描画をさせます。index.htmlからtodoのitemを綺麗にしちゃいましょう。

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <script type="module" src="https://cdn.jsdelivr.net/npm/@ionic/core/dist/ionic/ionic.esm.js"></script>
  <script nomodule src="https://cdn.jsdelivr.net/npm/@ionic/core/dist/ionic/ionic.js"></script>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@ionic/core/css/ionic.bundle.css" />
  <title>My To Do</title>
</head>

<body>
  <ion-app>
    <ion-header>
      <ion-toolbar>
        <ion-title>
          My To Do
        </ion-title>
        <ion-buttons slot="end">
          <ion-button id="addButton">追加</ion-button>
        </ion-buttons>
      </ion-toolbar>
    </ion-header>

    <ion-content>
      <ion-list id="todos">
        <ion-list-header>
          まだやってないもの
        </ion-list-header>
      </ion-list>
      <ion-list id="done">
        <ion-list-header>
          もうやったもの
        </ion-list-header>
      </ion-list>

    </ion-content>

  </ion-app>
  <script src="main.js"></script>
</body>

</html>

main.js の方で、 todoを配列としてデータ化しておきます。

const todos = [{
    title: '部屋の掃除',
    due: new Date(2021, 9, 31),
    done: false
  },
  {
    title: 'ゴミ捨て',
    due: new Date(2021, 9, 31),
    done: false
  },
  {
    title: '風呂の掃除',
    due: new Date(2021, 9, 31),
    done: false
  },
  {
    title: '屋根の掃除',
    due: new Date(2021, 9, 31),
    done: false
  },
  {
    title: '窓の掃除',
    due: new Date(2021, 9, 31),
    done: false
  },
  {
    title: '犬小屋の掃除',
    due: new Date(2021, 9, 31),
    done: false
  },
  {
    title: '朝飯を食う',
    due: new Date(2021, 9, 31),
    done: true
  },
  {
    title: '昼飯を食う',
    due: new Date(2021, 9, 31),
    done: true
  },
  {
    title: '晩飯を食う',
    due: new Date(2021, 9, 31),
    done: true
  },
];

new Date(2021, 9, 31) となっていると、「9月に31日なんてない!」って思っちゃいますが、1月が0なので、10月は9となっていてひとつずれています。続いて、todoを描画させます。createElementでdom要素を生成して、中身は、innerHTMLで一気にt追加します。


function writeToDos(todos) {
  const todoList = document.querySelector('#todos');
  const doneList = document.querySelector('#done');
  for (let todo of todos) {
    const todoItem = document.createElement('ion-item')
    todoItem.innerHTML = `
      <ion-label>
        <h2>${todo.title}</h2>
        <h3>${todo.due.toDateString()}</h3>
      </ion-label>
      <ion-checkbox ${( todo.done ? 'checked' : '' )} color="primary" slot="start"></ion-checkbox>
    `
    if (todo.done) {
      // 完了している時は、doneListへ
      doneList.appendChild(todoItem);
    } else {
      // 完了していないものは、todoListへ
      todoList.appendChild(todoItem);
    }
  }
}

writeToDos(todos); // ← 実行

日付表示が英語になっちゃいましたが描画できました。

ToDo追加を実装する

さて大分それっぽくなってきました。ここでToDo追加を実装しましょう。効率よくないですが、とりあえず、データが変わったら writeToDo(todos) を実行させれば画面は更新されてくれそうですね。

先程、アラートを実装したところで、 alert.buttons = ['OK'] という風にOKというボタンを表示させただけでした。OKをタップしたタイミングでプログラムを走らせたいのです。ionicのドキュメントを読むと、buttonsには、文字列の他、AlertButton という型のものも入れられるようです。TypeScript前提になっているのか、この AlertButton はなんなのかっていうのが公式サイトでよくわからないのですが、注意深く観察すると、text, role, handler というのを入れられるようになっています。これを使ってみましょう。

  alert.buttons = [
    {
      text: 'Cancel',
      role: 'cancel'
    },
    {
      text: 'OK',
      handler: () => {
        console.log('ok tapped')
      }
    }
  ];

handler のところで、ログに 「ok tapped」と表示されるようにしてみました。さてこれでどうなるでしょうか。はい。ログに表示されましたね。これが使えそうです。

では、inputから値を取得してtodoに追加、writeToDosを呼び出してみましょう。

  alert.buttons = [
    {
      text: 'Cancel',
      role: 'cancel'
    },
    {
      text: 'OK',
      handler: () => {
        const todo = {};
        todo.title = document.querySelector('#new-todo').value;
        todo.due = new Date(2021, 9, 31);
        todo.done = false;
        todos.push(todo);
        writeToDos(todos);
      }
    }
  ];

入力してみます。

おっと、ちゃんと新しいTODOが追加されていますが、すごく増えてしましました。元あった要素に追加 append しているからです。ひとつのToDoを追加する関数を別に用意しましょう。

function writeAllToDos(todos) {
  for (let todo of todos) {
    addToDo(todo);
  }
}

function addToDo(todo) {
  const todoList = document.querySelector('#todos');
  const doneList = document.querySelector('#done');
  const todoItem = document.createElement('ion-item')
  todoItem.innerHTML = `
    <ion-label>
      <h2>${todo.title}</h2>
      <h3>${todo.due.toDateString()}</h3>
    </ion-label>
    <ion-checkbox ${( todo.done ? 'checked' : '' )} color="primary" slot="start"></ion-checkbox>
  `
  if (todo.done) {
    // 完了している時は、doneListへ
    doneList.appendChild(todoItem);
  } else {
    // 完了していないものは、todoListへ
    todoList.appendChild(todoItem);
  }
}

writeToDos も writeAllToDos として明確にしました。addToDoは、writeToDos の繰り返しをしていないだけで、ほぼ同じ内容です。

OKをクリックした時の handler の方も修正しておきましょう。

  alert.buttons = [
    {
      text: 'Cancel',
      role: 'cancel'
    },
    {
      text: 'OK',
      handler: () => {
        const todo = {};
        todo.title = document.querySelector('#new-todo').value;
        todo.due = new Date(2021, 9, 31);
        todo.done = false;
        todos.push(todo); 
        addToDo(todo); // ← addToDo(todo)に
      }
    }
  ];

ちゃんと追加されるようになりました。

今、締切日はコード上で固定値を与えていました。これを設定できるようにしてみましょう。アラートのinputを使ってみます。アラートインプットのタイプにdateが指定できるようです。早速試します。

  alert.inputs = [{
      name: 'todo',
      id: 'new-todo',
      placeholder: 'やらなきゃいけないことは何?'
    },
    {
      name: 'due',
      id: 'new-due',
      type: 'date',
    }
  ];

早速やってみると、カレンダーまで使えて便利そうです。モバイルで表示させれば

さて、この値を使うようにしましょう。

  alert.buttons = [{
      text: 'Cancel',
      role: 'cancel'
    },
    {
      text: 'OK',
      handler: () => {
        const todo = {};
        todo.title = document.querySelector('#new-todo').value;
        todo.due = new Date(document.querySelector('#new-due').value);
        todo.done = false;
        todos.push(todo);
        addToDo(todo);
      }
    }
  ];

dueを #new-due というidを使って取得してきています。ここのvalueにはあくまで普通の文字列として日付が入っているので new Date(…) してDate型に変換しています。

ToDoの削除を実装する

todoを削除してみましょう。どうやって削除させるかですが、よくあるスワイプ(左か右にスライドさせる動作)で削除できるようにしてみましょう。

ドキュメントを読むと ion-item-sliding というものが使えそうです。この中に、ion-item と ion-item-options また、ion-item-option を入れていくということのようです。早速やってみましょう。

addToDo の実装の変更が必要です。

function addToDo(todo) {
  const todoList = document.querySelector('#todos');
  const doneList = document.querySelector('#done');

  const todoItemWithSliding = document.createElement('ion-item-sliding');
  todoItemWithSliding.innerHTML = `
  <ion-item-sliding>
    <ion-item>
      <ion-label>
        <h2>${todo.title}</h2>
        <h3>${todo.due.toDateString()}</h3>
      </ion-label>
      <ion-checkbox ${( todo.done ? 'checked' : '' )} color="primary" slot="start"></ion-checkbox>
    </ion-item>
    <ion-item-options side="end">
      <ion-item-option color="danger" expandable>
        削除
      </ion-item-option>
    </ion-item-options>
  </ion-item-sliding>
  `

  if (todo.done) {
    // 完了している時は、doneListへ
    doneList.appendChild(todoItemWithSliding);
  } else {
    // 完了していないものは、todoListへ
    todoList.appendChild(todoItemWithSliding);
  }
}

早速実行してみます。

余裕でできました。

では、削除を実装しましょう。その前に、データに id を割り当てておきます。IDは他のtodoと同じになってはいけません。削除するtodoがどれか同定するために使います。

const todos = [{
    id: 'todo1',
    title: '部屋の掃除',
    due: new Date(2021, 9, 31),
    done: false
  },
  {
    id: 'todo2',
    title: 'ゴミ捨て',
    due: new Date(2021, 9, 31),
    done: false
  },
  {
    id: 'todo3',
    title: '風呂の掃除',
    due: new Date(2021, 9, 31),
    done: false
  },
  {
    id: 'todo4',
    title: '屋根の掃除',
    due: new Date(2021, 9, 31),
    done: false
  },
  {
    id: 'todo5',
    title: '窓の掃除',
    due: new Date(2021, 9, 31),
    done: false
  },
  {
    id: 'todo6',
    title: '犬小屋の掃除',
    due: new Date(2021, 9, 31),
    done: false
  },
  {
    id: 'todo7',
    title: '朝飯を食う',
    due: new Date(2021, 9, 31),
    done: true
  },
  {
    id: 'todo8',
    title: '昼飯を食う',
    due: new Date(2021, 9, 31),
    done: true
  },
  {
    id: 'todo9',
    title: '晩飯を食う',
    due: new Date(2021, 9, 31),
    done: true
  },
];

追加するときもidを割り当てましょう。

  alert.buttons = [{
      text: 'Cancel',
      role: 'cancel'
    },
    {
      text: 'OK',
      handler: () => {
        const todo = {};
        todo.title = document.querySelector('#new-todo').value;
        todo.due = new Date(document.querySelector('#new-due').value);
        todo.done = false;
        todo.id = 'todo' + (new Date).getTime().toString();
        todos.push(todo);
        addToDo(todo);
      }
    }
  ];

(new Date).getTime().toString() というところは、idが被らないように現在時刻のミリ秒単位を使っています。uuidなどを使えばより安全なidが割り当てられますが、ここではこれでいきましょう。ion-itemにもidを割り当てましょう。

  todoItemWithSliding.id = todo.id;

ion-item-optionがクリックされた時の挙動を登録します。

<ion-item-option color="danger" expandable onClick="deleteToDo('${todo.id}')">
  削除
</ion-item-option>

deleteToDoという関数を呼び出します。

deleteToDo は、todosから、削除されるtodoを探して削除。画面から削除された要素を削除。というt二つをやれば良いですね。

function deleteToDo(todoId) {
  todos.splice(todos.findIndex(el => el.id === todoId), 1);
  document.querySelector(`#${todoId}`).remove();
}

実行してみます。

できました。

ToDo を done する

さていよいよ最後の方になりました。ToDoをDoneにする機能を実装します。これは、リストをタップしてチェックマークがつけばやったことに移動して、やったことの中からチェックを外せばやってないことに移動することができれば良いですね。

チェックボックスにイベントを登録しましょう。チェックボックスは、変化のイベントを取得できますが、今回は、リストをタップした時に値も変わる特性を利用して実装してみます。

  todoItemWithSliding.innerHTML = `
    <ion-item onClick="onItemClicked('${todo.id}')">
      <ion-label>
        <h2>${todo.title}</h2>
        <h3>${todo.due.toDateString()}</h3>
      </ion-label>
      <ion-checkbox ${( todo.done ? 'checked' : '' )} color="primary" slot="start"</ion-checkbox>
    </ion-item>
    <ion-item-options side="end">
      <ion-item-option color="danger" expandable onClick="deleteToDo('${todo.id}')">
        削除
      </ion-item-option>
    </ion-item-options>
  `

ion-item に onClick=”onItemClicked(‘${todo.id}’)” を追加しただけです。

さて、onItemClickedは、todoの状態を変える、画面上のリストを入れ替えるこのふたつができれば良いですが、今あるリストから削除してaddToDoするだけでもできそうです。やってみましょう。

function onItemClicked(todoId) {
  const todo = todos.find(el => el.id === todoId); // 元のtodoデータを取得
  todo.done = !todo.done; // todo の状態を変更
  document.querySelector(`#${todoId}`).remove(); // 今表示されているところを削除
  addToDo(todo); // addToDoを呼び出し
}

addToDo が自動的に追加するリストを選んでくれるので条件分岐は不要です。され実行してみましょう。

一通りできました。

RxJS を使ったイベント処理を理解する

この章では、先程の Pure な JavaScript での実装に rxjs というイベント処理を楽にしてくれるパッケージを追加して rxjs を理解します。rxjs は、Angular で使われており、これが理解できているとあとはシンプルです。

この例では、シンプルなアプリなので、rxjs の恩恵はほぼないですが、大きなアプリになってくるとイベントをいろんなところで飛ばして処理しなければならないので大変役に立ちます。

RxJS を使えるようにする

npmなどでインストールすれば使えるのですが、pureなhtmlをやってきたので、ここでもCDNからインストールするようにします。

  <script type="module" src="https://cdn.jsdelivr.net/npm/@ionic/core/dist/ionic/ionic.esm.js"></script>
  <script nomodule src="https://cdn.jsdelivr.net/npm/@ionic/core/dist/ionic/ionic.js"></script>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@ionic/core/css/ionic.bundle.css" />
  <script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/6.6.3/rxjs.umd.min.js"></script> // ← これ

これで rxjs が使えるようになります。rxjs という名前でスクリプトから読み込めるようになっています。rxjs を試すだけのコードを try-rxjs.js など別ファイルにしておきましょう。これもbody閉じタグのすぐ上に追加します。

  <script src="main.js"></script>
  <script src="try-rxjs.js"></script>
</body>

try-rxjs.js の中を次のように書いてみます。

const observable = rxjs.of(1, 2, 3);
observable.subscribe(e => console.log(e));

実行する(index.htmlをブラウザで再読み込み)と、デバッグコンソールに 1, 2, 3 と表示されたかと思います。

Observable と Subject

まず、一行目の rxjs.of(1, 2, 3) というコードですが、CDNで読み込んだので、rxjs がグローバルで呼び出せるようになっています。of というのは、Observable というものを生成します。この場合、可変長の値を読み取って左側から順番に Observable に流し込みます。Observable は、その名の通り観察可能なものになっており、Observable を購読する(Subscribe)するとObservable 経由の値を全て取得することができます。

上記の例ですと、1, 2, 3 が来ることがわかっているのでくだらないですが、ユーザーの操作やAPIからのデータの取得などいつ起こるかわからないイベントに対して取扱いしやすくしてくれるものになっています。

次に、observable で2回 subscribe してみましょう。

const sampleData = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const observable = rxjs.from(sampleData);
observable.subscribe(e => console.log('first', e));
observable.subscribe(e => console.log('second', e));

この場合は、firstのイベントが全て終わった後に、secondが続くようになります。observableは、全てに一気に発火することはありません。全て同時に発火させたい時には、Subjectを使います。

const sampleData = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const observable = rxjs.from(sampleData);
const subject = new rxjs.Subject();
subject.subscribe(e => console.log('first', e));
subject.subscribe(e => console.log('second', e));

observable.subscribe(subject);

今度は、発火したらfirst と second が順番に繰り返すようになったと思います。Subjectを使うと、Subject 経由で、イベントを全てに発火させることができるようになります。

Operator

filter

次にオペレーターを使ってみましょう。先程の、sampleData を奇数と偶数で処理を分けたい時を考えてみます。その時にfilterが使えます。

const sampleData = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const observable = rxjs.from(sampleData);
const subject = new rxjs.Subject();
const { filter } = rxjs.operators;

subject.pipe(
  filter(e => e % 2 === 1)
).subscribe(e => console.log('first', e));

subject.pipe(
  filter(e => e % 2 === 0)
).subscribe(e => console.log('second', e));

observable.subscribe(subject);

今度は、firstが、奇数を、secondが偶数をそれぞれ処理しているコンソールになったかと思います。

map

次にそれぞれを二乗した値をコンソールに出現させてみましょう。mapは、配列のmapと同じように使えます。

const sampleData = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const observable = rxjs.from(sampleData);
const subject = new rxjs.Subject();
const { filter, map } = rxjs.operators;

subject.pipe(
  filter(e => e % 2 === 1),
  map(e => e * e)
).subscribe(e => console.log('first', e));

subject.pipe(
  filter(e => e % 2 === 0),
  map(e => e * e)
).subscribe(e => console.log('second', e));

observable.subscribe(subject);

この結果は、次のようになると思います。

first 1
second 4
first 9
second 16
first 25
second 36
first 49
second 64
first 81
second 100

scan

さらにそれぞれを合計してみましょう。scan は、reduceと同じように使えます。

const sampleData = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const observable = rxjs.from(sampleData);
const subject = new rxjs.Subject();
const { filter, map, scan } = rxjs.operators;

subject.pipe(
  filter(e => e % 2 === 1),
  map(e => e * e), 
  scan((acc, cur) => acc + cur, 0)
).subscribe(e => console.log('first', e));

subject.pipe(
  filter(e => e % 2 === 0),
  map(e => e * e),
  scan((acc, cur) => acc + cur, 0)
).subscribe(e => console.log('second', e));

observable.subscribe(subject);

この結果はこうなります。

first 1
second 4
first 10
second 20
first 35
second 56
first 84
second 120
first 165
second 220

tap

console.logを出力している処理を、副作用を起こすtapオペレーターで処理させてみましょう。

const sampleData = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const observable = rxjs.from(sampleData);
const subject = new rxjs.Subject();
const { filter, map, scan, tap } = rxjs.operators;

subject.pipe(
  filter(e => e % 2 === 1),
  map(e => e * e), 
  scan((acc, cur) => acc + cur, 0),
  tap(e => console.log('first', e))
).subscribe();

subject.pipe(
  filter(e => e % 2 === 0),
  map(e => e * e),
  scan((acc, cur) => acc + cur, 0),
  tap(e => console.log('second', e))
).subscribe();

observable.subscribe(subject);

tapの中でconsole.logを行い、subscribeの引数は、空になりました。これでも同じ結果を確認できたかと思います。このようにオペレーターを繋げて書くことで、全体の見通しがスッキリします。

RxJS を使って ToDo アプリの実装を書き直す

さてまずは、初回の todos の描画を rxjs を使って制御してみましょう。まず、index.htmlからtry-rxjs.jsを読み込んでいるスクリプトタグを外しておきましょう。元々は、main関数を実行させていましたが、eventとして処理してみます。

// イベントを処理するsubjectを作成
const subject = new Subject();

subject.pipe(
  tap(todo => addToDo(todo))
).subscribe()

const observable = from(todos);

observable.subscribe(subject);

Subjectを作成して、todoを流し込んでいます。このままだと、Subjectを使い回すことができないので(使い回す必要があるかは別ですが)、Subjectを使いまわせるようにしてみましょう。

このために、処理させる流れを判定する type と処理に使うデータを入れた payload をもつActionという型(JavaScriptにはない概念ですが)を使ってみましょう。

const subject = new Subject();

const WRITE_TODOS = '[ToDo Page] Load ToDos';

subject.pipe(
  filter(action => action.type === WRITE_TODOS),
  mergeMap(action => from(action.payload)),
  tap(todo => addToDo(todo))
).subscribe()

const observable = of({
  type: WRITE_TODOS,
  payload: todos
});

observable.subscribe(subject);

mergeMap は、mapと基本的に同じですが、Observable / Subject を返却します。なので、この場合は、action.payload に todo の配列が入っていますので、from とすれば、配列から生成されたObservable が返却されます。次の tap では、この戻り値を一つずつ利用しています。

さて実行してみましょう。問題なく描画されました。writeAllToDos 関数は不要なので削除しましょう。

ToDoを追加するボタンのリスナーも変更しておきましょう。これには、fromEvent が使えます。

// ToDo 追加用のアラート表示
const addButton = document.querySelector('#addButton');
fromEvent(addButton, 'click')
  .pipe(
    tap(e => showAlert('タスク追加', '', 'やらなきゃいけないことは何?'))
).subscribe()

これで書き換えられました。

deleteToDo と onItemClicked もイベント処理させてみましょう。deleteToDo については、画面がわイベント自体は、onClick属性に登録しているので、イベントを発火させることで処理させてみます。onItemClicked も同様にしてみましょう。処理を拾った後に、Actionを生成してnextで流し込みます。

// イベントを処理するsubjectを作成
const subject = new Subject();

const WRITE_TODOS = '[ToDo Page] Load ToDos';
const DELETE_TODOS = '[ToDo Page] Delete ToDo';
const TOGGLE_STATUS = '[ToDo Page] Toggle ToDo Status';

// 追加用のイベント処理
subject.pipe(
  filter(action => action.type === WRITE_TODOS),
  mergeMap(action => from(action.payload)),
  tap(todo => addToDo(todo))
).subscribe()

// 削除用のイベント処理
subject.pipe(
  filter(action => action.type === DELETE_TODOS),
  map(action => action.payload),
  tap(todo => document.querySelector(`#${todo.id}`).remove()),
).subscribe()

// トグル用のイベント処理
subject.pipe(
  filter(action => action.type === TOGGLE_STATUS),
  map(action => action.payload),
  tap(todo => subject.next({
    type: DELETE_TODOS,
    payload: todo,
  })),
  tap(todo => subject.next({
    type: WRITE_TODOS,
    payload: [todo]
  }))
).subscribe()

// ToDo 追加用のアラート表示
const addButton = document.querySelector('#addButton');
fromEvent(addButton, 'click')
  .pipe(
    tap(e => showAlert('タスク追加', '', 'やらなきゃいけないことは何?'))
  ).subscribe()

// todo 削除
function onDeleteClicked(todoId) {
  const todo = todos.find(el => el.id === todoId);
  todos.splice(todos.findIndex(el => el.id === todoId), 1);
  const action = {
    type: DELETE_TODOS,
    payload: todo
  };
  subject.next(action);
}

// ToDo の Done 状態をトグルするイベント、リスナは onClick 属性で登録
function onItemClicked(todoId) {
  const todo = todos.find(el => el.id === todoId);
  todo.done = !todo.done;
  const action = {
    type: TOGGLE_STATUS,
    payload: todo
  };
  subject.next(action)
}

const action = {
  type: WRITE_TODOS,
  payload: todos
};

subject.next(action);

もはや、何が嬉しいのかわかりませんが、完全に同じように動きます。全ての処理をイベントにして、Actionを使って状態と描画を変えています。これで RxJS について理解が深まったのではないでしょうか。

この例ですと同期的な処理だけを行っているので嬉しさがわからないのですが、非同期処理でも同期処理でも全く同じような実装が可能なのが嬉しいところです。サーバーとの通信が発生したりすると嬉しさがわかってきます。

TypeScript で書き換える

さて、ここまでくると形式の決まっている Action など引数に与える「型」がすぐわかるようにしたくなってきます。Action は単なるオブジェクトですが、形式が異なると動作しません。これが、実行するまでわからないのも辛いです。これを解決してくれるのが静的型付けをもった言語なのですが、JavaScriptでそれをやってくれるものに、DartとTypeScriptがあります。TypeScriptの方がメジャーなのでこちらを利用してみましょう。

まず、必要なパッケージをインストールする

この段階で、cdnでの利用が難しくなってきました。nodeをインストールしていない場合は、インストールしておいてください。こちらに詳しいです。

パッケージ管理をスタートしましょう。

npm init

色々聞かれますが、とりあえず、Enterを連打でOKです。

続いて、必要なパッケージをインストールします。ここでコマンドラインが必要になります。

npm install --save-dev typescript

これだけでOKです。

終わったら、tsconfig.json というファイルを作成するために次のコマンドを打ちます。

./node_modules/typescript/bin/tsc --init

できましたか。

TypeScriptのファイルを、src/main.ts にして、コンパイル後のファイルを dist/main.js に配置することにしましょう。その設定は、次のようにすればOKです。

{
  "compilerOptions": {
    "target": "es6",
    "module": "esnext",
    "sourceMap": true,
    "outDir": "dist",
    "strict": true,
    "esModuleInterop": true,
    "moduleResolution": "node",
    "importHelpers": true,
    "declaration": false,
    "experimentalDecorators": true,
    "isolatedModules": true,
    "lib": [
      "es2018",
      "dom"
    ]
  },
  "include": [
    "src/**/*"
  ]
}

さて、早速 TypeScript を書いてみましょう。src/hello.ts を作成して次のコードで試します。

function hello(): void {
  console.log('Hello!');
}

これをブラウザで実行できるように JavaScript にコンパイルします。

./node_modules/typescript/bin/tsc 

dist フォルダが生成され、hello.js ができていると思います。

"use strict";
function hello() {
    console.log('Hello!');
}
//# sourceMappingURL=hello.js.map

これで実行できるようになりました。

TypeScript で書き換える

src/main.ts を作成して書き換えます。TypeScript にすると、rxjs もプロジェクトにインストールする必要がありますので、インストールします。また、ionic の型もインストールしておく必要があります。

npm install --save rxjs

さて、書き換えて行きましょう。

import { Subject, from, fromEvent } from 'rxjs';
import { filter, tap, mergeMap, map } from 'rxjs/operators';

type ToDo = {
  id: string;
  title: string;
  due: Date;
  done: boolean;
}

interface IonAlert extends HTMLElement {
  header: string;
  subHeader: string;
  message: string;
  buttons: { [key: string]: string | Function }[];
  inputs: { [key: string]: string | Function }[];
  present(): void;
}

const todos: ToDo[] = [
  {
    id: 'todo1',
    title: '部屋の掃除',
    due: new Date(2021, 9, 31),
    done: false
  },
  {
    id: 'todo2',
    title: 'ゴミ捨て',
    due: new Date(2021, 9, 31),
    done: false
  },
  {
    id: 'todo3',
    title: '風呂の掃除',
    due: new Date(2021, 9, 31),
    done: false
  },
  {
    id: 'todo4',
    title: '屋根の掃除',
    due: new Date(2021, 9, 31),
    done: false
  },
  {
    id: 'todo5',
    title: '窓の掃除',
    due: new Date(2021, 9, 31),
    done: false
  },
  {
    id: 'todo6',
    title: '犬小屋の掃除',
    due: new Date(2021, 9, 31),
    done: false
  },
  {
    id: 'todo7',
    title: '朝飯を食う',
    due: new Date(2021, 9, 31),
    done: true
  },
  {
    id: 'todo8',
    title: '昼飯を食う',
    due: new Date(2021, 9, 31),
    done: true
  },
  {
    id: 'todo9',
    title: '晩飯を食う',
    due: new Date(2021, 9, 31),
    done: true
  }
];

// Todo追加をする
function addToDo(todo: ToDo): void {
  const todoList = document.querySelector('#todos');
  if (!todoList) throw new Error('ToDo List Dom not found');
  const doneList = document.querySelector('#done');
  if (!doneList) throw new Error('Done List Dom not found');

  const todoItemWithSliding = document.createElement('ion-item-sliding');
  todoItemWithSliding.id = todo.id;
  todoItemWithSliding.innerHTML = `
  <ion-item onClick="onItemClicked('${todo.id}')">
    <ion-label>
      <h2>${todo.title}</h2>
      <h3>${todo.due.toDateString()}</h3>
    </ion-label>
    <ion-checkbox ${(todo.done ? 'checked' : '')} color="primary" slot="start"</ion-checkbox>
  </ion-item>
  <ion-item-options side="end">
    <ion-item-option color="danger" expandable onClick="onDeleteClicked('${todo.id}')">
      削除
    </ion-item-option>
  </ion-item-options>
`

  if (todo.done) {
    // 完了している時は、doneListへ
    doneList.appendChild(todoItemWithSliding);
  } else {
    // 完了していないものは、todoListへ
    todoList.appendChild(todoItemWithSliding);
  }
}

// アラートを表示する
function showAlert(header: string, subheader: string, message: string) {
  const alert = document.createElement('ion-alert') as IonAlert;
  alert.header = header;
  alert.subHeader = subheader;
  alert.message = message;
  alert.buttons = [{
    text: 'Cancel',
    role: 'cancel'
  },
  {
    text: 'OK',
    handler: () => {
      const newToDo = document.querySelector('#new-todo') as HTMLInputElement;
      const newDue = document.querySelector('#new-due') as HTMLInputElement;

      const todo: ToDo = {
        title: newToDo.value,
        due: new Date(newDue.value),
        done: false,
        id: 'todo' + (new Date).getTime().toString()
      }
      todos.push(todo);
      addToDo(todo);
    }
  }
  ];
  alert.inputs = [{
    name: 'todo',
    id: 'new-todo',
    placeholder: 'やらなきゃいけないことは何?'
  },
  {
    name: 'due',
    id: 'new-due',
    type: 'date',
  }
  ];

  document.body.appendChild(alert);
  return alert.present();
}

type Action = {
  type: string;
  payload: ToDo [] | ToDo;
}
// イベントを処理するsubjectを作成
const subject: Subject<Action> = new Subject();

const WRITE_TODOS = '[ToDo Page] Load ToDos';
const DELETE_TODOS = '[ToDo Page] Delete ToDo';
const TOGGLE_STATUS = '[ToDo Page] Toggle ToDo Status';

// 追加用のイベント処理
subject.pipe(
  filter(action => action.type === WRITE_TODOS),
  mergeMap(action => from(action.payload as ToDo [])),
  tap(todo => addToDo(todo))
).subscribe()

// 削除用のイベント処理
subject.pipe(
  filter(action => action.type === DELETE_TODOS),
  map(action => action.payload as ToDo),
  tap(todo => document.querySelector(`#${todo.id}`)?.remove()),
).subscribe()

// トグル用のイベント処理
subject.pipe(
  filter(action => action.type === TOGGLE_STATUS),
  map(action => action.payload as ToDo),
  tap(todo => subject.next({
    type: DELETE_TODOS,
    payload: todo,
  })),
  tap(todo => subject.next({
    type: WRITE_TODOS,
    payload: [todo]
  }))
).subscribe()

// ToDo 追加用のアラート表示
const addButton = document.querySelector('#addButton') as HTMLElement;
fromEvent(addButton, 'click')
  .pipe(
    tap(e => showAlert('タスク追加', '', 'やらなきゃいけないことは何?'))
  ).subscribe()

// todo 削除
function onDeleteClicked(todoId: string):void {
  const todo = todos.find(el => el.id === todoId) as ToDo;
  todos.splice(todos.findIndex(el => el.id === todoId), 1);
  const action: Action = {
    type: DELETE_TODOS,
    payload: todo
  };
  subject.next(action);
}

// ToDo の Done 状態をトグルするイベント、リスナは onClick 属性で登録
function onItemClicked(todoId: string): void {
  const todo = todos.find(el => el.id === todoId) as ToDo;
  todo.done = !todo.done;
  const action: Action = {
    type: TOGGLE_STATUS,
    payload: todo
  };
  subject.next(action)
}

const action = {
  type: WRITE_TODOS,
  payload: todos
};

subject.next(action);

はい、書き換えられました。コンパイルします。

./node_modules/typescript/bin/tsc

すると、distというフォルダに、hello.js, hello.js.map, main.js, main.js.map というファイルができます。今回関係あるのは、main.js ですので、これを index.html から読み込みます。


  </ion-app>
  <script src="./dist/main.js"></script>
</body>

読み込みました。さてこのまま実行しても、悲しいことにエラーになってしまします。出力された、 main.js のライブラリをインポートしているところを書き換えます。

// import { Subject, from, fromEvent } from 'rxjs';
// import { filter, tap, mergeMap, map } from 'rxjs/operators';
// これではなく

const { Subject, from, fromEvent } = rxjs;
const { filter, tap, mergeMap, map } = rxjs/operators;

ここだけ、CDNから読み込んだ rxjs を利用しているのでなんだかおかしいですが、ローカルだとこれが一番簡単な方法かと思います。わかる方いたら教えてください。。。

これで TypeScript で書き換えて実行できました!

Angular プロジェクトとして Ionic を立ち上げる

さて、いよいよ Angular のプロジェクトとして立ち上げることになりました。Ionic / RxJS / TypeScript それぞれやってきたのでこれがいかに簡単か、フレームワークの恩恵を感じるかと思います。

ionic をインストールする

ionic をインストールするには、先程、TypeScript をインストールする時と同様にやります。コマンドラインで使えるソフトも入っている便利なのでグローバルでインストールしておきます。

npm install -g @ionic/cli

これでionicがインストールできました。

Projectを作成する

ではプロジェクトを作成して行きます。

ionic start

すると色々聞かれます。

まずは、フレームワークを聞かれますので、Angular を選んで(↑↓キー)Returnキーで決定します。

ick a framework! 😁

Please select the JavaScript framework to use for your new app. To bypass this prompt next time, supply a value for the
--type option.

? Framework: (Use arrow keys)
❯ Angular | https://angular.io 
  React   | https://reactjs.org 
  Vue     | https://vuejs.org 

次にProject名を入力するようになりますので、My ToDo としておきましょう。

Every great app needs a name! 😍

Please enter the full name of your app. You can change this at any time. To bypass this prompt next time, supply name,
the first argument to ionic start.

? Project name: My ToDo

そうしますと、次にテンプレートを選ぶ画面になります。テンプレートは特に要りませんので、blankを選択しましょう。

Let's pick the perfect starter template! 💪

Starter templates are ready-to-go Ionic apps that come packed with everything you need to build your app. To bypass this
prompt next time, supply template, the second argument to ionic start.

? Starter template: 
  tabs         | A starting project with a simple tabbed interface 
  sidemenu     | A starting project with a side menu with navigation in the content area 
❯ blank        | A blank starter project 
  list         | A starting project with a list 
  my-first-app | An example application that builds a camera with gallery 
  conference   | A kitchen-sink application that shows off all Ionic has to offer 

Capacitor を使ってネイティブアプリをターゲットするか?と聞かれるので、No (N) を送信します。

✔ Preparing directory ./my-to-do in 1.33ms
✔ Downloading and extracting blank starter in 280.97ms
? Integrate your new app with Capacitor to target native iOS and Android? (y/N) N

大文字の方がデフォルトで選択されるオプションになりますので、単にReturnするだけでもOKです。これでプロジェクトの準備ができたので自動的にAngularのインストールなどが始まります。

Ionicのアカウントを作成するかどうか聞かれるので、Nにします。

Join the Ionic Community! 💙

Connect with millions of developers on the Ionic Forum and get access to live events, news updates, and more.

? Create free Ionic account? (y/N) N

これで完了です。

プロジェクトのファイル構成を確認する


├── angular.json                 <- angularの設定ファイル
├── e2e                          <- 自動テスト用のファイルが入っているフォルダ
│   ├── protractor.conf.js
│   ├── src
│   └── tsconfig.json
├── ionic.config.json            <- ionic の設定ファイル
├── karma.conf.js                <- 単体テストで使われる karma の設定ファイル
├── node_modules                 <- 依存する外部ライブラリがインストールされているところ
├── package-lock.json            <- 依存する外部ライブラリのバージョンを固定している
├── package.json                 <- パッケージの情報や依存する外部ライブラリの情報が入っている
├── src                          <- これがアプリケーション本体、もっとも長く滞在するフォルダ
│   ├── app                         <- プログラムやHTMLが入っているフォルダ
│   ├── assets                      <- 画像などのファイルを格納しておくフォルダ
│   ├── environments                <- 環境(開発、本番)ごとの設定を入れるフォルダ
│   ├── global.scss                 <- アプリ全体に適用したいデザイン
│   ├── index.html                  <- ブラウザに一番初めに読み込まれるファイル
│   ├── main.ts                     <- AppModuleを呼び出すファイル
│   ├── polyfills.ts                <- 多くのブラウザをサポートするためのファイル
│   ├── test.ts                     <- テストファイルを読み込んで実行するファイル
│   ├── theme                       <- ionic のカラーリングなどを変更する時にいじるファイル
│   └── zone-flags.ts               <- Angular が使うファイル
├── tsconfig.app.json            <- TypeScript の設定ファイル
├── tsconfig.json                <- TypeScript の設定ファイル
├── tsconfig.spec.json           <- TypeScript の設定ファイル
└── tslint.json                  <- エディタでエラー表示をするためのファイル

この中でsrcというフォルダがもっともよく使うフォルダになります。また、その中でもappというフォルダにコンポーネントやクラスが格納されていくのでほぼこのフォルダに滞在していると考えて良いでしょう。

src/app フォルダの中身をみてみる

src/app
├── app-routing.module.ts          <- URLと表示するページを設定しています。
├── app.component.html             <- もっとも上位に表示されるコンポーネント
├── app.component.scss             <- そのコンポーネントのスタイル定義
├── app.component.spec.ts          <- そのコンポーネントのユニットテストファイル
├── app.component.ts               <- そのコンポーネントの振る舞いを定義したコード
├── app.module.ts                  <- コンポーネントをモジュール化
└── home                           <- ホーム画面のファイル
    ├── home-routing.module.ts       <- ホーム画面関連のURLと表示の設定
    ├── home.module.ts               <- ホーム画面をモジュール化
    ├── home.page.html               <- ホーム画面のコンポーネントの HTML ファイル
    ├── home.page.scss               <- ホーム画面のスタイル定義
    ├── home.page.spec.ts            <- ホーム画面のユニットテストファイル
    └── home.page.ts                 <- ホーム画面の振る舞いを定義したファイル

app.component.ts のように component というものが画面を構成する要素の基本です。画面を構成するものは全てコンポーネントです。home以下をみると、page となっているところがありますが、これ自体も component です。pageという概念は、ionic 側で component をラップする形で作っています。ページはルーティングをもつものなので、 ionic generate として page を生成するとルーティングのファイルも自動的に生成されます。

実行してみる

さて、実行してみましょう。次のコマンドを入力してください。

ionic serve

これで自動的にブラウザが立ち上がりました。

テストも実行してみる

Angular は、フルスタックなフレームワークとなっており、テストもフレームワーク内に含まれています。テストを実行してみましょう。

npm run test

そうすると、Chromeが立ち上がってくるかと思います。デザインの抜けたトップ画面が表示され、テストが完了しました。DEBUGというボタンをクリックすると結果をブラウザ上で確認することができます。

コンソールにも表示されています。

> ng test

⠙ Generating browser application bundles (phase: building)...02 02 2021 18:33:58.367:WARN [karma]: No captured browser, open http://localhost:9876/
02 02 2021 18:33:58.370:INFO [karma-server]: Karma v5.1.1 server started at http://localhost:9876/
02 02 2021 18:33:58.371:INFO [launcher]: Launching browsers Chrome with concurrency unlimited
02 02 2021 18:33:58.374:INFO [launcher]: Starting browser Chrome
✔ Browser application bundle generation complete.
02 02 2021 18:34:01.544:WARN [karma]: No captured browser, open http://localhost:9876/
02 02 2021 18:34:01.595:INFO [Chrome 88.0.4324.96 (Mac OS 11.1.0)]: Connected on socket mQItIojXa9FloaqGAAAA with id 93063689
Chrome 88.0.4324.96 (Mac OS 11.1.0): Executed 2 of 2 SUCCESS (0.067 secs / 0.057 secs)
TOTAL: 2 SUCCESS

e2eの方は、省略します。

ToDo アプリを Angular に移植する

さて、いよいよ ToDo アプリを移植してみましょう。Angular は、HTMLがそのまま使えるのである程度コピーアンドペーストで出来上がってきます。

ToDo ページを追加してコピペする

そのままコピペしてみましょう。と、その前に ToDo 用のページを追加します。

$ ionic generate
 ? What would you like to generate? page
 ? Name/path of page: todo
   ng generate page todo --project=app
   CREATE src/app/todo/todo-routing.module.ts (339 bytes)
   CREATE src/app/todo/todo.module.ts (458 bytes)
   CREATE src/app/todo/todo.page.scss (0 bytes)
   CREATE src/app/todo/todo.page.html (123 bytes)
   CREATE src/app/todo/todo.page.spec.ts (633 bytes)
   CREATE src/app/todo/todo.page.ts (248 bytes)
   UPDATE src/app/app-routing.module.ts (716 bytes)
   [OK] Generated page! 

これでToDoページが追加できました。todo-routing を覗いてみましょう。

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';

import { TodoPage } from './todo.page';

const routes: Routes = [
  {
    path: '',
    component: TodoPage
  }
];

@NgModule({
  imports: [RouterModule.forChild(routes)],
  exports: [RouterModule],
})
export class TodoPageRoutingModule {}

これだけを見るとルートパスがTodoPage を表示するように見えますが、app-routing の方も実は書き換えられています。

  {
    path: 'todo',
    loadChildren: () => import('./todo/todo.module').then( m => m.TodoPageModule)
  },

この記述が追加されたので、TodoPage を表示されるのは、 localhost:8100/todo になります。

何もないページが表示されました。

さて、コピペしてみましょう。ion-app は、app.component.html に記述されているので、その中だけコピペします。

なんとエラーもなくここまでできました。(ってほぼからなので当たり前ですが。)

ToDo 一覧データを表示してみる

todoデータを読み込む

まず、todo のデータを todo.page.ts に記述します。これも main.ts から該当箇所をコピペしてみましょう。そのままコピペするとこんな感じで、エラーが表示されます。TodoPage というクラスなので、constをもつメンバーは定義できないというのと、ToDoという型の定義が見つからないと言われます。これを修正して行きます。

まずは、todo から const を外します。そしてToDo という型を定義します。先程、型は type で宣言して登録しましたが、class として宣言してみます。class にすると new ToDo(...) とやるのが面倒そうですが、うまいことやってくれます。まずは、ファイルを作りましょう。

$ touch todo/todo.model.ts

次に中身を記述します。

export class ToDo {
  id?: string;
  title: string;
  due: Date;
  done: boolean = false;
}

読み込んでみましょう。

import { ToDo } from './todo.model';

これでエラーが消えました。

ngFor を使って配列データを元に描画する

次に配列データを使ってリストを描画してみましょう。まずは、todoList と doneList を todo.page.ts に記述して行きましょう。

...
  get todoList(): ToDo [] {
    return this.todos.filter(todo => !todo.done);
  }

  get doneList(): ToDo [] {
    return this.todos.filter(todo => todo.done);
  }
...

get todoList(): ToDo [] は、ゲッターと呼び参照するときに todoList() と呼び出さなくても良いようになっています。ちょっとした違いですが、通常のプロパティのように使えるので便利です。

さて、リストのHTMLの方を確認しましょう。これは、もともと main.ts に無理くり入れ込んでいたものですが、これを todo.page.html の方に移動させます。

独自に定義した変数などがあるので、そのままでは使えません。

そこで次のように修正します。

    <ion-item-sliding *ngFor="let todo of todoList">
      <ion-item (click)="onItemClicked(todo)">
        <ion-label>
          <h2>{{ todo.title }}</h2>
          <h3>{{ todo.due | date: 'yyyy年MM月dd日まで' }}</h3>
        </ion-label>
        <ion-checkbox [checked]="todo.done" color="primary" slot="start"></ion-checkbox>
      </ion-item>
      <ion-item-options side="end">
        <ion-item-option color="danger" expandable (click)="onDeleteClicked(todo)">
          削除
        </ion-item-option>
      </ion-item-options>
    </ion-item-sliding>

まず、 *ngFor というのを使っています。ngForを使うと let todo fo todoList とあるので、todoList から todo という名前で一つずつ取り出して繰り返し描画します。そして、onClick は、(click) に置き換えられています。イベントや値を画面からとってきてプログラムにひもづける場合は、この () の書き方が利用されます。

次に、${} は、{{}} に置き換わっているのがわかると思います。これも同様で Angular の書き方になります。todo.page.ts で定義されたものを文字列として出力することができます。また、scriptやhtml を壊しかねない危険な文字列は自動的にエスケープ(無毒化)されます。

そして、{{ todo.due | date: 'yyyy年MM月dd日まで' }} という部分ですが、これも基本的には同じですが、 | この縦棒をパイプと行って、前の値を引き継いで処理をつなげる役目を果たします。この場合は、todo.due が Date クラスでですので、それを文字列に変換する処理を後ろに書いています。デフォルトで使えるパイプもたくさんありますので調べてみてください。

最後に [checked] と記述されているところがあります。この [] は、プログラム側から値を設定する時に使われるものです。 {{}} と似ていますが、タグの属性に対して使えます。chekced=’true’ とか checked=’false’ という形に展開されます。

さて、doneList の方も同様にして確認してみましょう。

表示されました。しかしリストをクリックすると、RROR TypeError: ctx_r3.onItemClicked is not a function というエラーが表示されてしまいます。onItemClickedを定義していないからです。

処理を定義する

クリック時の処理を定義する

さて、onItemClicked を定義してみましょう。todo.page.ts でやってみます。

  onItemClicked(todo: ToDo): void {
    todo.done = !todo.done;
  }

  onDeleteClicked(todo: ToDo): void {
    this.todos.splice(this.todos.indexOf(todo), 1);
  }

これで実際に動かしてみましょう。

todos に入っているデータを処理しただけで描画にまで反映されています。PureなJavaScript ではいちいちデータが変更された時に描画を修正していましたが、これが不要になるのがフレームワークの強いところです。

アラート表示を追加する

さて、次は新規作成ができるようになると嬉しいです。アラート表示はどのように追加するでしょうか。こちらのドキュメントをみると、Angular という使用例がありますのでそこをみてみましょう。そうすると、alertController というものを使って create を呼び出せば全く同じように使えるようです。

まず、AlertController を読み込みます

ドキュメントをみると、AlertController は、依存性注入(Dependency Injection、以下 DI)という Angular の機能で読み込むようです。DI は、非常に強力で簡単に機能をコンポーネントに持たせることができます。また、注入する依存性を変えるだけで外部システムへの依存性を変えられるなどさまざまな使い方ができます。

DI は、constructor で設定します。

  constructor(
    private alertController: AlertController
  ) { }

このように入力すると、自動的に import 文も追加されたと思います。追加されていなければ、手動で追加しておいてください。

import { AlertController } from '@ionic/angular';

では、設定して行きます。まずは、追加ボタンのクリックイベントを紐づけておきましょう。

    <ion-buttons slot="end">
      <ion-button (click)="onAddButtonClicked()">追加</ion-button>
    </ion-buttons>

先程と同じ容量で、(click) として呼び出す関数を紐付けます。

async onAddButtonClicked(): Promise<void> {
    const alert = await this.alertController.create({
      header: 'タスク追加',
      subHeader: '',
      message: 'やらなきゃいけないことは何?',
      buttons: [{
        text: 'Cancel',
        role: 'cancel'
      },
      {
        text: 'OK',
        handler: (data) => {
          console.log(data)
        }
      }],
      inputs: [{
        name: 'todo',
        id: 'new-todo',
        placeholder: 'やらなきゃいけないことは何?'
      },
      {
        name: 'due',
        id: 'new-due',
        type: 'date',
      }]
    });

    await alert.present();
  }

alertController.create は、非同期の関数ですので onAddButtonClicked は、非同期関数の呼び出しにawait を使うために、 async をつけて定義しています。alert.present() も同様に非同期ですので、await をつけています。

main.ts では、alert.header = ... という書き方をしていましたが、alertController.create は、AlertOptionsという型のオブジェクトを引数に受け取るのでオブジェクトに書くような方法に変更しています。

さて「追加」ボタンをクリックしてみましょう。アラートが表示されたかと思います。また、OKボタンをクリックすれば、コンソールに ok clicked と出たのではないでしょうか。

実際に ToDo を追加する処理を入れる

OKボタンがクリックされた時の処理を addToDo に記述して行きましょう。しかしどうやったらアラートのインプットに入力したデータを取得できるのかがよくわからないですね。ドキュメントを読んでも console.log しかやっていない使えない例です。試しに handler で何かデータが受け取れるか記述してみます。

handler: (data) => {
  console.log(data)
}

アラートを表示して、インプットに入力してからOKをタップしましょう。

なんと、インプットの name 属性でデータを受け取れましたあとは簡単ですね。

        handler: (data) => {
          const todo = new ToDo();
          todo.due = new Date(data.due);
          todo.title = data.todo;
          todo.id = 'todo' + (new Date).getTime().toString();
          this.addToDo(todo);
        }

ハンドラーでデータを作って addToDo に渡します。todo.title と data.todo が属性名がズレていて気持ち悪いですが、このまま行きます。

addToDo(todo: ToDo): void {
  this.todos.push(todo);
}

addToDo の方は、シンプルに配列に push するだけです。

さて動かしてみましょう。。。動きましたね!こんなに簡単にできるなんてフレームワークは、素晴らしいですね。複雑な処理もできるようになりそうです。

BehaviorSubject を利用して状態を管理してみる

誰がデータを管理するのか

今の状態で、Pure な JavaScript で書いたアプリと同等のことができるようになりました。少し整理してみましょう。

todoフォルダには、今、todo.model.ts, todo.module.ts, todo.page.* というファイルがあります。model その名の通り、 ToDo の情報もモデル化(形式化)したものになります。module は、Angular の基本概念でさまざまな機能提供を定義しています。todo.page.* では、というものは、これはユーザーインターフェース(画面そのもの)を定義しています。この状態でも良いのですが、データを取り扱うものは別に用意しておきたいですね。

Angular では、このような役割をするものを Service と呼んでいます。Serviceは、通常状態を持たないイメージがありますが、今回はデータそのものも持たせてみます。

BehaviorSubject というのは何か

以前に rxjs で、Subject や Observable を使っていつ起こるかわからないイベントを処理しました。データもそのようにいつ変更があるかわからないものとして管理してみます。また、複数のサブスクライバーにブロードキャストするために Subject を利用しました。

しかし、Subject は、状態を持ちません。状態の変化が発火しないと Subject は値を流しません。そこで、BehaviorSubject の使い所です。BehaviorSubject は、subscribe すると最後に発火された値を同期的に送信します。状態を持つような振る舞いをさせることができます。同期的というのはどういうことか例えば次のコードをみてみましょう。

const bSubject = new rxjs.Subject();
const observable2 = bSubject.asObservable();
bSubject.next(1);

let someValue = 0;
setTimeout(() => {
  observable2.subscribe(value => someValue = value)
  console.log(someValue);
}, 1000)

この場合、nextが呼び出されないのでsubscribeが発火しません。ですので、console.log(someValue) では、0が表示されます。

次のコードはどうでしょうか。

const bSubject = new rxjs.BehaviorSubject();
const observable2 = bSubject.asObservable();
bSubject.next(1);

let someValue = 0;
setTimeout(() => {
  observable2.subscribe(value => someValue = value)
  console.log(someValue);
}, 1000)

この場合は、BehaviorSubject を使っているので、すぐに発火します。なんとなく非同期に発火しそうなので、console.log(someValue) は、0になることもありそうですが、subscribe の中は、bSubject の最後の発火した 1 が入っていますので、同期的に実行され console.log(someValue) は、確実に 1 になります。

ですので、BehaviorSubject に状態を発火させていけば、後から利用する場合でも現在の状態を同期的に取得することが可能になりますし、将来の変更も取得できるようになります。

Service クラスを作成する

では、実装して行きましょう。まずは、サービスクラスを作成します。

$ ionic generate 
 ? What would you like to generate? service
 ? Name/path of service: todo/todo
   ng generate service todo/todo --project=app
   CREATE src/app/todo/todo.service.spec.ts (347 bytes)
   CREATE src/app/todo/todo.service.ts (133 bytes)
   [OK] Generated service! 

出来上がったServiceクラスをみてみましょう。

import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root'
})
export class TodoService {

  constructor() { }
}

@Injectable というデコレーターが使われています。デコレーターは、さまざまな機能を提供してくれますが、Injectableデコレーターは、DI して利用できるようにするデコレーターです。

DI で providedIn: ‘root’ でインジェクトされるとアプリケーション全体でキャッシュされ、シングルトンとしてサービスを利用可能になります。

Service クラスを実装していく

実装して行きましょう。Service クラスに ToDo のデータを持たせるので、Service クラスは、ToDo のデータの一覧取得、追加、削除、変更を行うクラスにしましょう。

早速実装してみます。まず、todos と言うデータを BehaviorSubject で持っておきましょう。そうすれば、TodoService は、アプリケーション全体でシングルトンとなりますので、最終状態を保存し、必要であれば同期的に取得できます。

todo の追加は、どうでしょうか。addToDo(todo: ToDo) と言う関数を定義しておけば良さそうです。deleteToDo(todo: ToDo | string) も同様ですね。string 型でもOKとしているのは、todo.id で受け取っても同じように処理して欲しいからです。その可能性がなければ string は省いても大丈夫です。

todos の初期データを分離しておきましょう。

import { ToDo } from "./todo.model";

const todos: ToDo [] = [
  {
    id: 'todo1',
    title: '部屋の掃除',
    due: new Date(2021, 9, 31),
    done: false
  },
  {
    id: 'todo2',
    title: 'ゴミ捨て',
    due: new Date(2021, 9, 31),
    done: false
  },
  {
    id: 'todo3',
    title: '風呂の掃除',
    due: new Date(2021, 9, 31),
    done: false
  },
  {
    id: 'todo4',
    title: '屋根の掃除',
    due: new Date(2021, 9, 31),
    done: false
  },
  {
    id: 'todo5',
    title: '窓の掃除',
    due: new Date(2021, 9, 31),
    done: false
  },
  {
    id: 'todo6',
    title: '犬小屋の掃除',
    due: new Date(2021, 9, 31),
    done: false
  },
  {
    id: 'todo7',
    title: '朝飯を食う',
    due: new Date(2021, 9, 31),
    done: true
  },
  {
    id: 'todo8',
    title: '昼飯を食う',
    due: new Date(2021, 9, 31),
    done: true
  },
  {
    id: 'todo9',
    title: '晩飯を食う',
    due: new Date(2021, 9, 31),
    done: true
  }
];

export default todos;

ToDo クラスをコンストラクタで初期化できるようにしておきます。Partial を使うと Object.assign(this, param); みたいにして Object から生成できるようになります。Partial<T> とすると、型T の全ての要素を含んでいるが、余分なものは捨ててくれます。

export class ToDo {
  id?: string;
  title: string;
  due: Date;
  done: boolean = false;

  constructor(todoParams: Partial<ToDo>) {
    Object.assign(this, todoParams);
  }
}

Todo.page.ts の alert のハンドラーの部分も修正しておきます。

handler: (data) => {
  const todoParams = {
    id: 'todo' + (new Date).getTime().toString(),
    title: data.todo,
    due: new Date(data.due)
  }
  const todo = new ToDo(todoParams);
  this.addToDo(todo);
}

TodoService を実装します。

import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { ToDo } from './todo.model';
import initialToDos from './todo.data';

@Injectable({
  providedIn: 'root'
})
export class TodoService {

  constructor() { }

  private todos: ToDo [] = initialToDos.map(el => new ToDo(el));
  private todosStore$ = new BehaviorSubject<ToDo[]>(this.todos);
  todos$ = this.todosStore$.asObservable();

  addToDo(todo: ToDo): void {
    this.todos.push(todo);
    this.todosStore$.next(this.todos);
  }

  deleteToDo(todo: ToDo | string): void {
    let id = '';
    if (todo instanceof ToDo) {
      id = todo.id;
    } else {
      id = todo;
    }
    console.log(id)
    this.todos.splice(this.todos.findIndex(el => el.id === id), 1);
    this.todosStore$.next(this.todos);
  }

  updateToDo(todo: ToDo): void {
    const existingTodo = this.todos.find(el => el.id === todo.id);
    for (let key of Object.keys(existingTodo)) {
      existingTodo[key] = todo[key]
    }
    this.todosStore$.next(this.todos);
  }
}

ここで、todoStore$ につけた $ マークは、Observable として振る舞うことができるプロパティにつける慣習があるのでつけています。つけなくても動作します。todos$ は、わざわざ this.todosStore$.asObservable() として、Subject としての機能を持たせない形で公開しています。Subject としての機能を持たせると直接 this.todosStore$.next(…) と言う書き方ができるようになり状態管理が一元化されないからです。

さて、todo.page.ts の実装を変えて行きます。DI で、todoService を呼び出し、todoService 経由で、todo データを操作します。

import { Component, OnInit } from '@angular/core';
import { AlertController } from '@ionic/angular';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { ToDo } from './todo.model';
import { TodoService } from './todo.service';
@Component({
  selector: 'app-todo',
  templateUrl: './todo.page.html',
  styleUrls: ['./todo.page.scss'],
})
export class TodoPage implements OnInit {

  constructor(
    private alertController: AlertController,
    private todoService: TodoService
  ) { }

  ngOnInit() {
    this.todoList$.subscribe();
    this.doneList$.subscribe();
  }

  todoList$: Observable<ToDo []> = this.todoService.todos$.pipe(
    map(todos => todos.filter(todo => !todo.done))
  );

  doneList$: Observable<ToDo []> = this.todoService.todos$.pipe(
    map(todos => todos.filter(todo => todo.done))
  );

  onItemClicked(todo: ToDo): void {
    const newTodo = { ...todo, done: !todo.done };
    this.todoService.updateToDo(newTodo);
  }

  onDeleteClicked(todo: ToDo): void {
    this.todoService.deleteToDo(todo);
  }

  addToDo(todo: ToDo): void {
    this.todoService.addToDo(todo);
  }

  async onAddButtonClicked(): Promise<void> {
    const alert = await this.alertController.create({
      header: 'タスク追加',
      subHeader: '',
      message: 'やらなきゃいけないことは何?',
      buttons: [{
        text: 'Cancel',
        role: 'cancel'
      },
      {
        text: 'OK',
        handler: (data) => {
          const todoParams = {
            id: 'todo' + (new Date).getTime().toString(),
            title: data.todo,
            due: new Date(data.due)
          }
          const todo = new ToDo(todoParams);
          this.addToDo(todo);
        }
      }],
      inputs: [{
        name: 'todo',
        id: 'new-todo',
        placeholder: 'やらなきゃいけないことは何?'
      },
      {
        name: 'due',
        id: 'new-due',
        type: 'date',
      }]
    });

    await alert.present();
  }

}

todo.page.html の方も、todoList を todoList$ と、doneList を doneList$ に書き換えましょう。この状態で、画面をみると、 todo が何も表示されていません。Observable が実行されるのは subscribe した後だからです。Angular には、Observable を表示するためのパイプが用意されています。Async Pipe と言うものです。

例えば、 *ngFor="let todo of todoList$ としているところは、*ngFor="let todo of todoList$ | async として、Observable を async パイプ通します。doneList$ も同様にしてください。

再度ページを開くと動作しているかと思います。

サービスクラスを通すとだいぶ見通しがよくなったのでは無いでしょうか。

NgRx を利用して状態を管理してみる

先程、BehaviorSubject を利用してかなりシンプルに状態管理をすることができるようになりました。状態管理ができる原理は、DI を通して、サービスクラスをシングルトン化すること、また、BehaviorSubject が最後の状態を保持し同期的に情報を取得できる仕組みになっていることを利用したものです。

このままでも何も問題ありません。非同期処理が発生しても、Observable になっているので、Service クラスの一部を書き換えるだけでアプリケーション全体を書き換える必要が全くありません。

しかし、状態を変更する際に、明示的に next をサービスクラスから呼び出していたり、サービスクラスなのに状態を持っていたりと違和感があるところもあります。そこで状態管理を別の仕組みで中央管理させてしまうことを考えてみましょう。

NgRx と Redux

Angular の NgRx は、Redux と言う状態管理のライブラリの影響を強く受けています。相互運用も可能なほど近しいものになっています。Redux は、主に React で使われる状態管理のためのライブラリですが、実際にはフレームワークに依存しない純粋な JavaScript のライブラリです。

Angular では、Redux ではなく NgRx を利用することが多いです。なぜ、Redux では無いのかと言うと、NgRx が RxJS をフル活用しているためものすごく Angular と相性が良いのです。例えば、状態を管理するクラスを Store と言いますが、Store は、DI できますし、状態は、基本的に Observable として取得されるのでそれが非同期なのか同期的かを気にすることなく取り扱うことができます。

と言うわで、ここでは NgRx を利用して状態管理を導入してみましょう。

基本概念

Redux に関する詳細な記事は多数ありますので、詳細説明はそういった記事に任せることにしましょう。ここでは、簡単に全体を把握することを目指します。基本概念は、Action、Reducer、State、dispatch の4つです。

Action

Action は、シンプルなオブジェクトです。type と言う文字列で命令名が記述された属性と、payload と言う任意の情報を受け取る属性を持っています。状態を変更するには、Action を使ってメッセージを送るようにします。

Reducer

Reducer は、state と action を受け取って、新しい state を返却する関数です。Aciton に格納する type によって呼び出される関数は変わります。

State

State は、初期状態があり、保持しているのは直近の状態になります。これもシンプルなオブジェクトです。

dispatch

dispatch は、何かの名称と言うことではなく、Action を命令として Store に渡す際に呼び出す関数でです。メッセージを送信するのと同じです。

NgRx を 使って状態管理する流れ

  1. Store は、初期状態を State に持っています
  2. 状態が変わる場合は、dispatch に Action を渡します
  3. Store は、Action の type によって処理する reducer を選択します
  4. reducer は、Action の payload と State を元に、状態変化を発生させ新しい State を返します

これだけです。では実装して行きましょう。

Action を定義する

さて、早速実装してみましょう。まずは、状態を変えるメッセージである Action を定義して行きます。このときに、createAction と言う NgRx で用意されている関数を用いて Action を定義して行きます。まずは、ngrx をインストールしましょう。

npm install --save @ngrx/store

インストールできました。

Action の定義の方法は、こうです。todo.actions.ts を作成して

import { createAction, props } from '@ngrx/store';
import { ToDo } from './todo.model';

export const addToDo = createAction(
  '[Todo page] Add ToDo',
  props<{ todo: ToDo }>()
);

export const deleteToDo = createAction(
  '[Todo page] Delete ToDo',
  props<{ todo: ToDo }>()
);

export const updateToDo = createAction(
  '[Todo page] Update ToDo',
  props<{ todo: ToDo }>()
);

createAction と言う関数で定義しています。第一引数の文字列は、なんでもいいですが、メッセージが、わかりやすい文字列にしておきましょう。第二引数の props と言うのは、こういう方で payload に渡すと言うのを定義しています。実は、 createAction は、そのままの NgRx を簡単にするための関数を使っています。興味のある人は、そのままの NgRx の書き方でもやってみましょう。だいぶコード量が増えるのがわかると思います。

Reducer を定義する

次に reducer を定義して行きます。reducer の定義には、createReducer を使います。

import { Action, createReducer, on } from '@ngrx/store';
import * as ToDoActions from './todo.actions';
import { ToDo } from './todo.model';
import initialToDos from './todo.data';

export interface State {
  todos: ToDo [];
};

export const initialState: State = {
  todos: initialToDos
};

const todoReducer = createReducer(
  initialState,
  on(ToDoActions.addToDo, (state, { todo }) => {
    return { ...state, todos: state.todos.concat(todo) };
  }),
  on(ToDoActions.deleteToDo, (state, { todo }) => {
    return { ...state, todos: state.todos.filter(el => el.id !== todo.id) };
  }),
  on(ToDoActions.updateToDo, (state, { todo }) => {
    let todos = state.todos.slice();
    todos.splice(todos.findIndex(el => el.id === todo.id), 1, todo);
    return { ...state, todos: todos }
  })
)

export function reducer(state: State | undefined, action: Action) {
  return todoReducer(state, action);
}

export const todoFeatureKey = 'todo';

まず、State を定義して、State の型をもつ initialState を定義しています。initialState には初期値を入れておきましょう。ここでは、先程作成した todo.data.ts から読み込んでいます。

次に createReducer 関数を使って、第一引数に初期値を入れます。続いて、 on 関数を使うと、switch 文などで分岐させずに reducer を定義することができます。 on 関数は、第一引数に、Actionを受け取り、第二引数に reducer それ自体を関数として与えます。

最後に、reducer 関数を export しています。reducer 関数は、必ず関数として export しなければなりません。

ついでに、状態を取得するための todoFeatureKey を定義しています。これは、なんでも良いですが、ここに定義しておけば TypeScript の機能で補完してくれるのでだいぶ楽になります。

Service クラスを書き直す

さて、Store の機能を一通り定義できたので、Service クラスを Store を使うように書きかえておきましょう。

import { Injectable } from '@angular/core';
import { ToDo } from './todo.model';
import { createFeatureSelector, createSelector, Store } from '@ngrx/store';
import * as fromTodo from './todo.reducer';
import * as todoActions from './todo.actions';

@Injectable({
  providedIn: 'root'
})
export class TodoService {

  constructor(
    private readonly store: Store<typeof fromTodo>
  ) { }

  todos$ = this.store.select(
    createSelector(
      createFeatureSelector<fromTodo.State>(fromTodo.todoFeatureKey),
      (state) => state.todos
    )
  )

  addToDo(todo: ToDo): void {
    this.store.dispatch(todoActions.addToDo({ todo }))
  }

  deleteToDo(todo: ToDo): void {
    this.store.dispatch(todoActions.deleteToDo({ todo }))
  }

  updateToDo(todo: ToDo): void {
    this.store.dispatch(todoActions.updateToDo({ todo }))
  }
}

かなりシンプルになりました。ほぼほぼ、Store の状態を書き換える、呼び出すだけのクラスになりました。実際に状態を持たないのでとてもしっくりきます。

また、APIなどの非同期処理が入っても Store と連携しているのでデータがいつでも引き出せるようになります。

Module をインポートする

あとは、Store を使えるようにモジュールを読み込んでいきます。まずは、AppModule から

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { RouteReuseStrategy } from '@angular/router';

import { IonicModule, IonicRouteStrategy } from '@ionic/angular';

import { AppComponent } from './app.component';
import { AppRoutingModule } from './app-routing.module';
import { StoreModule } from '@ngrx/store';                              <- これ
import { TodoPageModule } from './todo/todo.module';

@NgModule({
  declarations: [AppComponent],
  entryComponents: [],
  imports: [
    BrowserModule,
    IonicModule.forRoot(),
    AppRoutingModule,
    StoreModule.forRoot({}),                                                      <- これ
    TodoPageModule
  ],
  providers: [{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy }],
  bootstrap: [AppComponent],
})
export class AppModule { }

次に、TodoModule。

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import * as fromTodo from './todo.reducer';

import { IonicModule } from '@ionic/angular';

import { TodoPageRoutingModule } from './todo-routing.module';

import { TodoPage } from './todo.page';
import { StoreModule } from '@ngrx/store';

@NgModule({
  imports: [
    CommonModule,
    FormsModule,
    IonicModule,
    TodoPageRoutingModule,
    StoreModule.forFeature(fromTodo.todoFeatureKey, fromTodo.reducer)         <- これ
  ],
  declarations: [TodoPage]
})
export class TodoPageModule {}

では実行してみましょう。動きますね!

おっと、リストをたっぷした時にエラーになってしまいます。todo.page.ts の onItemClicked で、状態を直接書きかえているからです。State は、コピーして書き換える必要があります。

  onItemClicked(todo: ToDo): void {
    const newTodo = { ...todo, done: !todo.done };
    this.todoService.updateToDo(newTodo);
  }

さて実行してみましょう。

色々触って、全てうまくいっています!

Redux toolkitを活用して、NgRxの実装を楽にする

NgRx の実装は、結構大変ですね。これを非常に楽にしてくれるツールがあります。Redux-toolkit です。Redux toolkit は、Redux 用のライブラリですが、NgRx でも動きます。非同期処理が入る部分でも全く同じように(Effects という機能を使いますが)、 Redux toolkit の機能が使えます。

Redux toolkit は、状態管理の実装パターンを実装したもので、Slice と呼ばれる Store の興味の対象の範囲で State を管理し Slice の中に Action と Reducer そして Selector を同時に定義できるようになっています。createSlice と言う関数がその機能を提供してくれるのですが、これがなかなか便利ですので書きかえてみましょう!

actions / reducer を消して、slice を作成する

せっかく、 actions と reducer を作成しましたが消してしまいます。もちろん動かなくなりますが、代わりに、 todo.slice.ts を作成します。

redux/toolkit をインストールしましょう。

npm install --save @reduxjs/toolkit

これでインストール完了です。では、slice を作成して行きましょう。

import { createFeatureSelector } from '@ngrx/store';
import { createSlice } from '@reduxjs/toolkit';
import todos from './todo.data';

const todoSlice = createSlice({
  name: 'todo',
  initialState: {
    todos: todos
  },
  reducers: {
    addToDo: (state, action) => {
      state.todos.push(action.payload.todo);
    },
    deleteToDo: (state, action) => {
      const todo = action.payload.todo;
      const todoIndex = state.todos.findIndex(el => el.id === todo.id)
      state.todos.splice(todoIndex, 1);
    },
    updateToDo: (state, action) => {
      const todo = action.payload.todo;
      const oldTodo = state.todos.find(el => el.id === todo.id);
      state.todos.splice(state.todos.indexOf(oldTodo), 1);
      state.todos.push({...oldTodo, ...todo});
    }
  }
});

const {
  reducer,
  actions: {
    addToDo,
    deleteToDo,
    updateToDo
  },
  name
} = todoSlice;

export default reducer;
export {
  name,
  addToDo,
  deleteToDo,
  updateToDo,
};

export const selectFeature = createFeatureSelector<ReturnType<typeof reducer>>(
  name
);

createSlice に、name / initialState / recuders というプロパティをもつオブジェクトを渡します。すると、自動的に Actions と reducers を作成してくれます。reducers は、新しい state を返却しても良いし、しなくても良いです。

次に、export する値を指定して行きます。export するのは、デフォルトが reducer、それから name とアクションをそれぞれ export しましょう。先程、createFeatureSelector を Service クラスで作成していましたが、Slice にうつしました。<ReturnType<typeof reducer>> と言うのは、「reducer と言う関数が返却する型」を指定する方法です。ですので、typeof の後ろは、関数型のみ指定できます。

さて、todo.module.ts がエラーになっていると思います。先程作成した、todo.reducer.ts がなくなっているからです。これも書きかえましょう。

import fromTodo, { name as todoFeatureKey } from './todo.slice';
//消す import * as fromTodo from './todo.reducer'; 

...

StoreModule.forFeature(todoFeatureKey, fromTodo)

reducer を fromTodo として slice から呼び出し、name を todoFeatureKey として取得して設定しています。

次に Service クラスを変更しましょう。

import { Injectable } from '@angular/core';
import { ToDo } from './todo.model';
import { createSelector, Store } from '@ngrx/store';
import fromTodo, * as todoSlice from './todo.slice';
// import * as fromTodo from './todo.reducer';
// import * as todoActions from './todo.actions';

@Injectable({
  providedIn: 'root'
})
export class TodoService {

  constructor(
    private readonly store: Store<typeof fromTodo>
  ) { }

  todos$ = this.store.select(
    createSelector(
      todoSlice.selectFeature, (state) => state.todos
    )
  )

  addToDo(todo: ToDo): void {
    this.store.dispatch(todoSlice.addToDo({ todo }))
  }

  deleteToDo(todo: ToDo): void {
    this.store.dispatch(todoSlice.deleteToDo({ todo }))
  }

  updateToDo(todo: ToDo): void {
    this.store.dispatch(todoSlice.updateToDo({ todo }))
  }
}

こちらについても書きかえただけです。

さて実行しましょう。。。。うまくいったでしょうか?NgRx だけでもそれほど苦になりませんが、Slice にまとまっているとなんとなくわかりやすいような気もしないでしょうか?

しかし、これには罠があって、Redux Toolkit の非同期処理用の createAsyncThunk などは NgRx では使えません。NgRx では、createEffect を使う必要があるからです。うげって思う方もいるかもしれませんが、Effect は、同期・非同期関係なく使えますし、副作用専用のよくできた仕組みです。非同期処理の場合でも、上記の createSlice で全く同じように設定すれば良いのです。

ログイン画面を追加して、ログイン必須のアプリにする

さて、本格的なアプリケーションにするためには、ログイン機能が必要です。ログイン画面を実装して、ログインしていないユーザーは、全てログイン画面に遷移するようにしましょう。

今回は、データベースを使った認証は入れずに、認証の手続きを行う AuthService クラス、状態管理を行う authSlice と機能提供を行う AuthStoreModule、ログイン画面への強制リダイレクトを行う AuthGuard、擬似認証システムを提供する FakeAuthService を実装して行きましょう。

FakeAuthService は、後で入れ替え可能にするように、AuthProvider interface を実装したクラスとして定義してみます。

ログイン画面を実装する

まずは、ログイン画面を実装して行きましょう。ionic cli を使います。

$ ionic generate
 ? What would you like to generate? page
 ? Name/path of page: login
   ng generate page login --project=app
   CREATE src/app/login/login-routing.module.ts (343 bytes)
   CREATE src/app/login/login.module.ts (465 bytes)
   CREATE src/app/login/login.page.scss (0 bytes)
   CREATE src/app/login/login.page.html (124 bytes)
   CREATE src/app/login/login.page.spec.ts (640 bytes)
   CREATE src/app/login/login.page.ts (252 bytes)
   UPDATE src/app/app-routing.module.ts (716 bytes)
   [OK] Generated page! 

これでログイン画面の雛形ができました。早速、表示してみましょう。 http://localhost:8100/login へアクセスしてみてください。

何も表示されませんね。画面を作って行きます。

<ion-header [translucent]="true">
  <ion-toolbar>
    <ion-title>
      ログイン
<ion-header [translucent]="true">
  <ion-toolbar>
    <ion-title>
      My ToDo
    </ion-title>
  </ion-toolbar>
</ion-header>

<ion-content [fullscreen]="true">
  <ion-header collapse="condense">
    <ion-toolbar>
      <ion-title size="large">My ToDo</ion-title>
    </ion-toolbar>
  </ion-header>

  <div id="container">
    <strong>Login</strong>
    <p>さあ始めましょう。</p>

</ion-content>

ブランクページから、 html を拝借してログイン画面の元を作ります。scss も拝借するのを忘れないでください。

さて、これから email と password を入力する input を設定して行きましょう。

Email と Password を入力するフォームを作成する

早速実装して行きます。フォームの値を取得してくるには、取得してくる値に名前(name)をつけて、ngModel でデータを紐付けます(データバインディング)。

ionic のドキュメントから使えそうな、ion-input を使いましょう。

<ion-header [translucent]="true">
  <ion-toolbar>
    <ion-title>
      My ToDo
    </ion-title>
  </ion-toolbar>
</ion-header>

<ion-content [fullscreen]="true">
  <ion-header collapse="condense">
    <ion-toolbar>
      <ion-title size="large">My ToDo</ion-title>
    </ion-toolbar>
  </ion-header>

  <div id="container">
    <strong>Login</strong>
    <p>さあ始めましょう。</p>
    <ion-list class="ion-margin-vertical">
      <ion-item>
        <ion-input placeholder="Email" name="email" [(ngModel)]="email"></ion-input>
      </ion-item>
      <ion-item>
        <ion-input placeholder="Password" type="password" name="password" [(ngModel)]="password"></ion-input>
      </ion-item>
    </ion-list>
    <ion-button color="primary" expand="full" class="ion-margin-vertical" (click)="onLoginClicked()">
      Login
    </ion-button>
  </div>
</ion-content>

Login ボタンの (click) は、もう大丈夫でしょうか。これでクリックした時の動作を login.page.ts に送信します。まだ実装していないのでエラーになります。[(ngModel)] の方は、双方向バインディングで、email と言う変数にインプットの値が同期されます。name="email" が無いと動作しないので注意してください。パスワードも同じで、type="password" とすることで画面上では、・・・になります。

画面からの値を取得してみましょう。login.page.ts に email / password と言う変数と、onLoginClicked() と言う関数を実装します。

import { Component, OnInit } from '@angular/core';

@Component({
  selector: 'app-login',
  templateUrl: './login.page.html',
  styleUrls: ['./login.page.scss'],
})
export class LoginPage implements OnInit {

  email: string = '';
  password: string = '';

  constructor() { }

  ngOnInit() {
  }

  onLoginClicked() {
    console.log(this.email, this.password);
  }

}

ionic が気を利かせて、OnInit インターフェースを実装するようにしていますが、特に何もしません。onLoginClicked() で、this.email と this.password の値を確認しています。

さて、クリックしてみましょう。

コンソールは、次のように表示されます。

さて、これで値を取得できるようになりました。

AuthSlice を作成する

認証状態を管理する AuthSlice を作成しましょう。

import { createFeatureSelector } from '@ngrx/store';
import { createSlice } from '@reduxjs/toolkit';

const authSlice = createSlice({
  name: 'auth',
  initialState: {
    currentAuthenticatedSession: null
  },
  reducers: {
    setLoginStatus: (state, action) => {
      state.currentAuthenticatedSession = action.payload
    },
  }
});

const {
  reducer,
  actions: {
    setLoginStatus
  },
  name
} = authSlice;

export default reducer;
export {
  name,
  setLoginStatus
};

export const selectFeature = createFeatureSelector<ReturnType<typeof reducer>>(
  name
);

こんな感じでしょうか。この辺は、関連するファイルを作成仕切るまで動かないので我慢して作り続けましょう。また、作っていく中で足りないものは付け加えて行きます。

状態を変更する reducer は、setLoginStatus 飲みです。また、状態には、currentAuthenticatedSession と言うものを持つようにしています。

続いて、auth-store.module.ts を作成して、機能を公開します。

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { StoreModule } from '@ngrx/store';
import fromAuth, { name as authFeatureKey } from './auth.slice';

@NgModule({
  declarations: [],
  imports: [
    CommonModule,
    StoreModule.forFeature(authFeatureKey, fromAuth),
  ]
})
export class AuthStoreModule { }

これも先程やったことと同じですね。これをAppModule に読み込んでおきましょう。

import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { RouteReuseStrategy } from '@angular/router';

import { IonicModule, IonicRouteStrategy } from '@ionic/angular';

import { AppComponent } from './app.component';
import { AppRoutingModule } from './app-routing.module';
import { StoreModule } from '@ngrx/store';
import { TodoPageModule } from './todo/todo.module';
import { AuthStoreModule } from './auth/auth-store.module';
import { environment } from 'src/environments/environment';

@NgModule({
  declarations: [AppComponent],
  entryComponents: [],
  imports: [
    BrowserModule,
    IonicModule.forRoot(),
    AppRoutingModule,
    StoreModule.forRoot({}),
    AuthStoreModule,
    StoreDevtoolsModule.instrument({
      maxAge: 25,
      logOnly: environment.production
    }),
    TodoPageModule
  ],
  providers: [{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy }],
  bootstrap: [AppComponent],
})
export class AppModule { }

StoreDevtoolsModule と言うものは、Redux のデバッガーを利用するためのものです。入れておいて損は無いので利用しましょう。Redux devtools は、こちらです。

StoreDevtoolsModule は、npm でインストールします。

npm install --save @ngrx/store-devtools

AuthProvider インターフェースを実装した、FakeAuthService を用意

認証機能に必要最低限あって欲しい機能はなんでしょうか。

  • 現在の認証情報を取得する
  • 現在、認証できているかを検証する
  • ログインを実行する
  • ログアウトを実行する

とりあえず、このくらいあれば試しに実装できそうです。では、これをインターフェースに記述して行きます。

import { Observable } from "rxjs";

export interface AuthProvider { 
  currentAuthenticatedSession$: Observable<{}>;
  login(authentication: {}): void;
  logout(): void;
}

できました。この実装である FakeAuthService を fake-auth.service.ts に実装して行きましょう。

import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { User } from './user.model';
import { AuthProvider } from './auth-provider';

@Injectable({
  providedIn: 'root'
})
export class FakeAuthService implements AuthProvider {

  currentAuthenticatedSession$: Observable<User>;

  constructor() { }

  login(authentication): void {
  }

  logout(): void {
  }
}

とりあえず空の状態でファイルを用意しました。currentAuthenticatedSession$ は、今のところ BehaviorSubject としています。

ではこれを実装して行きます。まず、authSlice を使いたいのでこれを呼び出しましょう。

login() の実装を考える

FakeAuth (偽の認証)なので、適当な認証にします。email と password を決め打ちにしてやってみましょう。


const EMAIL = 'john@example.com';
const PASSWORD = 'validpassword';

...

  login(authentication: { email: string, password: string }): void {
    console.log(authentication);
    if (authentication.email === EMAIL && authentication.password === PASSWORD) {
      console.log('password is valid')
      this.store.dispatch(authSlice.setLoginStatus({ email: authentication.email }));
    } else {
      this.store.dispatch(authSlice.setLoginStatus(null));
    }
  }

これでいって見ていきましょう。fake-auth が、authSlice を呼び出して、setLoginStatus の状態を変化させています。

フロントエンドに埋め込んでいるので全く認証として機能しないですが、fake-auth を何かに置き換えれば良いだけになっていますので気にしないようにしましょう。パスワード認証が失敗したら setLoginStatus を null にしています。

logout の実装を考える

ログアウトも同じです。


  logout(): void {
    this.store.dispatch(authSlice.setLoginStatus(null));
  }

さて、AuthProvider インターフェースの currentAuthenticatedSession$ はどうするかですが、これも authSlice から取得しましょう。createSelector で、 state.currentAuthenticatedSession を取得します。

  currentAuthenticatedSession$ = this.store.select(
    createSelector(authSlice.selectFeature, (state) => state.currentAuthenticatedSession)
  );

全体のコードはこのようになります。

import { Injectable } from '@angular/core';
import { AuthProvider } from './auth-provider';
import { Store } from '@ngrx/store';
import fromAuth, * as authSlice from './auth.slice';
import { createSelector } from '@reduxjs/toolkit';


const EMAIL = 'john@example.com';
const PASSWORD = 'validpassword';

@Injectable({
  providedIn: 'root'
})
export class FakeAuthService implements AuthProvider {

  currentAuthenticatedSession$ = this.store.select(
    createSelector(authSlice.selectFeature, (state) => state.currentAuthenticatedSession)
  );

  constructor(private store: Store<typeof fromAuth>) { }

  login(authentication: { email: string, password: string }): void {
    console.log(authentication);
    if (authentication.email === EMAIL && authentication.password === PASSWORD) {
      console.log('password is valid')
      this.store.dispatch(authSlice.setLoginStatus({ email: authentication.email }));
    } else {
      this.store.dispatch(authSlice.setLoginStatus(null));
    }
  }

  logout(): void {
    this.store.dispatch(authSlice.setLoginStatus(null));
  }
}

これで認証の仕組みができました。これを直接読み込んでも良いのですが、ひとつ単純なクラスを挟んで依存度を下げてみましょう。

import { Injectable } from '@angular/core';
import { createSelector, Store } from '@ngrx/store';
import { map } from 'rxjs/operators';
import fromAuth, * as authSlice from './auth.slice';
import { FakeAuthService } from './fake-auth.service';

@Injectable({
  providedIn: 'root'
})
export class AuthFacade {
  constructor(
    private readonly store: Store<typeof fromAuth>, 
    private authProvider: FakeAuthService
  ) { }

  currentAuthenticatedSession$ = this.store.select(
    createSelector(authSlice.selectFeature, (state) => state.currentAuthenticatedSession)
  );

  isAuthenticated$ = this.currentAuthenticatedSession$.pipe(
    map(session => session ? true : false)
  )

  login(authentication): void {
    this.authProvider.login(authentication);
  }

  logout(): void {
    this.authProvider.logout();
  }

}

AuthFacade (オース・ファサード)となっていますが、中身はサービスクラスと同じような実装になっています。

isAuthenticated$ の実装だけ確認しておきましょう。this.currentAuthenticatedSession$ は、Observable で、User 型({ email: string })が流れてきます。それを boolean に変換したいので、rxjs/operators の map オペレーターが利用できますね。しかし一方で、facade が具体的な内容を知っている実装になってしまっています。認証されているのかされていないのかは、認証プロバイダによって異なる確認方法をするはずです。ですので、これは auth-provider インターフェースで実装すべき実装にうつしておきましょう。

import { Observable } from "rxjs";

export interface AuthProvider { 
  currentAuthenticatedSession$: Observable<{}>;
  isAuthenticated$: Observable<boolean>;  // <- 追加
  login(authentication: {}): void;
  logout(): void;
}

次に fake-auth の方にも実装を移動させます。

  isAuthenticated$ = this.currentAuthenticatedSession$.pipe(
    map(session => session ? true : false)
  )

最終的に AuthFacade は、このようになりました。依存度が低いですね。

import { Injectable } from '@angular/core';
import { createSelector, Store } from '@ngrx/store';
import { map } from 'rxjs/operators';
import fromAuth, * as authSlice from './auth.slice';
import { FakeAuthService } from './fake-auth.service';

@Injectable({
  providedIn: 'root'
})
export class AuthFacade {
  constructor(
    private readonly store: Store<typeof fromAuth>, 
    private authProvider: FakeAuthService
  ) { }

  currentAuthenticatedSession$ = this.store.select(
    createSelector(authSlice.selectFeature, (state) => state.currentAuthenticatedSession)
  );

  isAuthenticated$ = this.authProvider.isAuthenticated$;

  login(authentication): void {
    this.authProvider.login(authentication);
  }

  logout(): void {
    this.authProvider.logout();
  }

}

login画面からログイン機能を呼び出す

ここまで読んだ読者であれば、次にやることはわかっているかと思います。login.page.ts で AuthFacade を DI して、onLoginClicked で login を呼び出せば良いわけです。

import { Component, OnInit } from '@angular/core';
import { AuthFacade } from '../auth/auth.facade';

@Component({
  selector: 'app-login',
  templateUrl: './login.page.html',
  styleUrls: ['./login.page.scss'],
})
export class LoginPage implements OnInit {

  email: string = '';
  password: string = '';
  isAuthenticated$ = this.authService.isAuthenticated$;

  constructor(
    private authService: AuthFacade
  ) { }

  ngOnInit() {
  }

  onLoginClicked() {
    this.authService.login({
      email: this.email,
      password: this.password
    })
  }

}

さていよいよ実装が完了し始めました。ログイン状態の変化(isAuthenticated$)の値を表示するものを一時的に含めておきましょう。

  <div id="container">
    <strong>Login</strong>
    <p>さあ始めましょう。</p>
    <ion-list class="ion-margin-vertical">
      <ion-item>
        <ion-input placeholder="Email" name="email" [(ngModel)]="email"></ion-input>
      </ion-item>
      <ion-item>
        <ion-input placeholder="Password" type="password" name="password" [(ngModel)]="password"></ion-input>
      </ion-item>
    </ion-list>
    <ion-button color="primary" expand="full" class="ion-margin-vertical" (click)="onLoginClicked()">
      Login
    </ion-button>
    <p>
      {{ isAuthenticated$ | async }}
    </p>
  </div>

下の方に、 isAuthenticated$ | async で Async Pipe しています。

さて実行してみましょう。

isAuthenticated$ が true になりましたね。成功しているようです。念の為、まちがったパスワードでも確認しておいてください。

ルーティングの仕組みを確認する

では、ログイン状態に応じてページ遷移させましょう。ここでルーティングについて説明する必要がありますね。app-routing.module.ts を開いてみます。

import { NgModule } from '@angular/core';
import { PreloadAllModules, RouterModule, Routes } from '@angular/router';

const routes: Routes = [
  {
    path: 'home',
    loadChildren: () => import('./home/home.module').then( m => m.HomePageModule)
  },
  {
    path: '',
    redirectTo: 'home',
    pathMatch: 'full'
  },
  {
    path: 'todo',
    loadChildren: () => import('./todo/todo.module').then( m => m.TodoPageModule)
  },
  {
    path: 'login',
    loadChildren: () => import('./login/login.module').then( m => m.LoginPageModule)
  },
];

@NgModule({
  imports: [
    RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules })
  ],
  exports: [RouterModule]
})
export class AppRoutingModule { }

ionic generate でページを生成すると、app-routing.module.ts にも URL を追加してくれます。これをみると home と言うものがあり /home/ 以下は、HomePageModule で読み込まれるようになっています。

HomePageModule をみてみましょう。

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { IonicModule } from '@ionic/angular';
import { FormsModule } from '@angular/forms';
import { HomePage } from './home.page';

import { HomePageRoutingModule } from './home-routing.module';


@NgModule({
  imports: [
    CommonModule,
    FormsModule,
    IonicModule,
    HomePageRoutingModule     // <- これ
  ],
  declarations: [HomePage]
})
export class HomePageModule {}

ここでも複数のモジュールを読み込んでいますが、HomePageRoutingModule が読み込まれています。

HomePageRoutingModule を見てみましょう。

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { HomePage } from './home.page';

const routes: Routes = [
  {
    path: '',
    component: HomePage,
  }
];

@NgModule({
  imports: [RouterModule.forChild(routes)],
  exports: [RouterModule]
})
export class HomePageRoutingModule {}

ここでも Routes を設定しています。Routes のルートパス ” は、HomePage コンポーネントを指定しています。AppModule では、HomePageModule を loadChildren の中で呼び出しているので、ルートパスは、実際には、/home/ と言うことになります。

では再び、AppRoutingModule に戻りましょう。todo や、login についても同様になっています。

今は、home 画面は使っていないので home のパスを削除して、todo をルートにしましょう。

import { NgModule } from '@angular/core';
import { PreloadAllModules, RouterModule, Routes } from '@angular/router';

const routes: Routes = [
  {
    path: 'todo',
    loadChildren: () => import('./todo/todo.module').then( m => m.TodoPageModule)
  },
  {
    path: '',
    redirectTo: 'todo',
    pathMatch: 'full'
  },
  {
    path: 'login',
    loadChildren: () => import('./login/login.module').then( m => m.LoginPageModule)
  },
];

@NgModule({
  imports: [
    RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules })
  ],
  exports: [RouterModule]
})
export class AppRoutingModule { }

これで、 http://localhost:8100/ にアクセスしてみてください。/todo にリダイレクトされたと思います。

Guard を利用して、ログイン状態によってページ遷移させる

では、Guard を使って遷移させる方法を考えてみましょう。Guard は、Router に設定することでさまざまな機能を提供することができます。詳細な機能は、こちらで確認できます。

ここでは、canActivate と言う機能を使います。とりあえず雛形を作成してみましょう。

$ ionic generate
 ? What would you like to generate? guard
 ? Name/path of guard: auth/auth
   ng generate guard auth/auth --project=app
   ? Which interfaces would you like to implement? CanActivate
   CREATE src/app/auth/auth.guard.spec.ts (331 bytes)
   CREATE src/app/auth/auth.guard.ts (457 bytes)
   [OK] Generated guard! 

ionic generate と打つだけで対話的に作成したいファイルを聞いてくれるので便利ですね。途中でなんの機能を実装するかを聞かれたと思いますので、canActivate を選択してください。

生成された Guard をみていきましょう。

import { Injectable } from '@angular/core';
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree } from '@angular/router';
import { Observable } from 'rxjs';

@Injectable({
  providedIn: 'root'
})
export class AuthGuard implements CanActivate {
  canActivate(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
    return true;
  }
  
}

CanActivate を実装した、AuthGuard クラスがあり、canActivate の実装が書いてあります。雛形では、return true となっており全てアクティベート可能な状態です。return false とすればアクティベートできないと言うことです。

canActivate にマウスカーソルを合わせて canActivate のインターフェースを確認してみます。

これをみると、canActivate の戻り値として期待されているのは、boolean | UrlTree の2タイプであり、それぞれ、Observable でも Promise でも良いと言うことになっていますね。rxjs に慣れているので基本的に Observable で返すような実装を考えておけば良いでしょう。

では、実装していきます。先程、AuthFacade を作ったので、これを利用しましょう。

import { Injectable } from '@angular/core';
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, Router, UrlTree } from '@angular/router';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { AuthFacade } from './auth.facade';

@Injectable({
  providedIn: 'root'
})
export class AuthGuard implements CanActivate {

  constructor(
    private authService: AuthFacade,
    private router: Router
  ) { }
  canActivate(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): Observable<boolean | UrlTree> {
    return this.authService.isAuthenticated$.pipe(
      map(loggedIn => {
        if (loggedIn) {
          return true;
        } else {
          return this.router.parseUrl('/login');
        }
      })
    )
  }
}

実装を確認しましょう。まず、canActivate が、Observable<boolean | UrlTree> を返却すると限定しました。その中で、isAuthenticated$ のパイプの中で map を呼び出し処理を分岐しています。loggedIn が false の場合、UrlTree 型で ‘/login’ を返却してページ遷移させています。this.router.parseUrl とすることで、文字列を UrlTree に変換できます。ここで、 this.router.navigate を使っても良いです。これは、プログラム的にページ遷移させる方法です。つまりログインしていない場合は、 ‘/login’ に遷移させると言う処理もかけます。

これを todo ページに追加しましょう。実は、TodoRoutingModule に canActivate を追加するだけです。

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { AuthGuard } from '../auth/auth.guard'; // 追加

import { TodoPage } from './todo.page';

const routes: Routes = [
  {
    path: '',
    component: TodoPage,
    canActivate: [AuthGuard] // 追加
  }
];

@NgModule({
  imports: [RouterModule.forChild(routes)],
  exports: [RouterModule],
})
export class TodoPageRoutingModule {}

これだけです。早速、試してみましょう。ブラウザを再読み込みして、 http://localhost:8100/todo にアクセスしてみてください。リダイレクトされたのでは無いでしょうか。

ログインしてから todo にアドレスバーに打ち込んで遷移するのは残念ながらうまくいきません。状態をメモリ上にしか保存していないので、ページが再読み込みされるとログイン状態がクリアされてしまいログインしていない状態として始まるからです。

ログインに成功したら todo ページに遷移させる

では、ログインに成功したらセッションを切らずに todo ページに自動的に遷移するようにしてみましょう。Guard は、CanActivate(ページへの遷移) / CanDeactivate(ページからの離脱) などページ遷移のタイミングで実行されますので、ログインした場合の動作を別で定義しておく必要があります。

ボタンのアクションに設定しても良いのですが、ログイン後の画面遷移は、一括して振る舞いを変更したいこともありますので、どこかにまとめて記述したいですね。なので、loginSuccess と言うイベントを作って、loginSuccess した時に、onLoginSuccess を実行させましょう。今回は、とりあえず、ログイン画面の方でイベントを拾うようにしてみます。

loginSuccess は、状態変化と言えますので Slice に追加します。

  • initialState に新しい状態を追加
  • reducer に状態変化の仕方を定義
  • 追加した action を export

この手順で良いですね。

import { createFeatureSelector } from '@ngrx/store';
import { createSlice } from '@reduxjs/toolkit';

const authSlice = createSlice({
  name: 'auth',
  initialState: {
    currentAuthenticatedSession: null,
    loginSuccess: false,
  },
  reducers: {
    setLoginStatus: (state, action) => {
      state.currentAuthenticatedSession = action.payload
    },
    setLoginSuccess: (state, action) => {
      state.loginSuccess = action.payload
    }
  }
});

const {
  reducer,
  actions: {
    setLoginStatus,
    setLoginSuccess,
  },
  name
} = authSlice;

export default reducer;
export {
  name,
  setLoginStatus,
  setLoginSuccess,
};

export const selectFeature = createFeatureSelector<ReturnType<typeof reducer>>(
  name
);

次に、FakeAuthService の方から状態変化を呼び出します。ログイン成功のタイミングで、setLoginSuccess を true にそれ以外の時は、false にしておきましょう。

import { Injectable } from '@angular/core';
import { AuthProvider } from './auth-provider';
import { Store } from '@ngrx/store';
import { map } from 'rxjs/operators';
import fromAuth, * as authSlice from './auth.slice';
import { createSelector } from '@reduxjs/toolkit';


const EMAIL = 'john@example.com';
const PASSWORD = 'validpassword';

@Injectable({
  providedIn: 'root'
})
export class FakeAuthService implements AuthProvider {

  currentAuthenticatedSession$ = this.store.select(
    createSelector(authSlice.selectFeature, (state) => state.currentAuthenticatedSession)
  );

  isAuthenticated$ = this.currentAuthenticatedSession$.pipe(
    map(session => session ? true : false)
  )

  constructor(private store: Store<typeof fromAuth>) { }

  login(authentication: { email: string, password: string }): void {
    console.log(authentication);
    if (authentication.email === EMAIL && authentication.password === PASSWORD) {
      console.log('password is valid')
      this.store.dispatch(authSlice.setLoginStatus({ email: authentication.email }));
      this.store.dispatch(authSlice.setLoginSuccess(true));  // 追加
    } else {
      this.store.dispatch(authSlice.setLoginStatus(null));
      this.store.dispatch(authSlice.setLoginSuccess(false));  // 追加
    }
  }

  logout(): void {
    this.store.dispatch(authSlice.setLoginStatus(null));
    this.store.dispatch(authSlice.setLoginSuccess(false)); // 追加
  }
}

続いて、AuthFacade の方で、loginSuccess が true になった時の挙動を定義しておきます。まず、loginSuccess$ をセレクターでセットします。

import { Injectable } from '@angular/core';
import { createSelector, Store } from '@ngrx/store';
import fromAuth, * as authSlice from './auth.slice';
import { FakeAuthService } from './fake-auth.service';

@Injectable({
  providedIn: 'root'
})
export class AuthFacade {
  constructor(
    private readonly store: Store<typeof fromAuth>, 
    private authProvider: FakeAuthService,
  ) { }

  currentAuthenticatedSession$ = this.store.select(
    createSelector(authSlice.selectFeature, (state) => state.currentAuthenticatedSession)
  );

  loginSuccess$ = this.store.select(
    createSelector(authSlice.selectFeature, (state) => state.loginSuccess)
  )

  isAuthenticated$ = this.authProvider.isAuthenticated$;

  login(authentication): void {
    this.authProvider.login(authentication);
  }

  logout(): void {
    this.authProvider.logout();
  }

}

まだ動かせませんが、いい感じですね。だいぶわかってきた気がします。

続いて、ログイン画面で subscribe します。一点注意なのですが、subscribe すると明示的に subscribe をとめなければメモリリークの原因になりますので、OnDestroy で一括して subscribe を止めるようにします。

そのためには、Subject と OnDestroy を利用しましょう。OnDestroy つまりページが破棄される時に Subject でイベントを発火させて、それを起点に購読を止めると言うやり方です。

import { Component, OnDestroy, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { Subject } from 'rxjs';
import { filter, takeUntil, tap } from 'rxjs/operators';
import { AuthFacade } from '../auth/auth.facade';

@Component({
  selector: 'app-login',
  templateUrl: './login.page.html',
  styleUrls: ['./login.page.scss'],
})
export class LoginPage implements OnDestroy, OnInit {

  email: string = '';
  password: string = '';
  isAuthenticated$ = this.authService.isAuthenticated$;

  // subscription を止めるための Subject
  destroy$= new Subject<void>();

  constructor(
    private authService: AuthFacade,
    private router: Router  // Router を DI
  ) { }

  // onInit で、購読開始、takeUntil で destroy$ が発火するまで購読、それ以降は、破棄
  ngOnInit() {
    this.authService.loginSuccess$.pipe(
      takeUntil(this.destroy$),
      filter(success => success),
      tap(success => this.router.navigate(['/todo']))
    ).subscribe();
  }

  // onDestory で、destroy$ を発火させて終了
  ngOnDestroy() {
    this.destroy$.next();
    this.destroy$.complete();
  }

  onLoginClicked() {
    this.authService.login({
      email: this.email,
      password: this.password
    })
  }

}

さあやってみましょう。(日没の関係でダークモードになっています)

Coooooooool! So Cool じゃないでしょうか!デバッグ用の isAuthenticated$ の表示は、消しておきましょう。

締め切り日がわかるカレンダー表示を追加する

だいぶ素敵なアプリケーションになってきました。せっかく実装したものを何度も書き換えさせてすいません。同じ機能を提供しているけれども実装が異なるって言うのをわかってほしかったので色々やりました。

ここで、機能追加をしましょう。例えば、Todo の期限日がカレンダーでわかったら嬉しいのでは無いでしょうか。やってみましょう。

Calendar 表示用のライブラリをインストールする

Calendar 表示を自前で作るとなると結構大変なので出来合いのものを探します。今使っているフレームワークが、Angular なので Angular 向けのライブラリか、フレームワークに依存しない Pure な JavaScript のものかが選択できます。一番良いのは、コンポーネントとしてデザインも考慮された Ionic 向けに作成されたものがあればそれがいいですね。

Ionic Calendar などと検索すると、Ionic 2-Calendar と言うライブラリがすぐに見つかります。この記事で扱っている Ionic が Version 5.5 なのでこれは使えないと思いきや、Document を読むと Ionic 5 に対応する変更が含まれているでは無いですか。なんというミスリーディングなパッケージ名。ありがたく使わせてもらうことにしましょう。

早速インストールします。

npm install --save ionic2-calendar

ではインストールできたので、使い方をみていこうかと思います。こちらによると Basic Usage と言うところで、次のような書き方をしていました。

 <calendar [eventSource]="eventSource"         [calendarMode]="calendar.mode"         [currentDate]="calendar.currentDate"         (onCurrentDateChanged)="onCurrentDateChanged($event)"         (onRangeChanged)="reloadSource(startTime, endTime)"         (onEventSelected)="onEventSelected($event)"         (onTitleChanged)="onViewTitleChanged($event)"         (onTimeSelected)="onTimeSelected($event)"         step="30">              </calendar>

何やら難しそうですが、calendar と言うオブジェクトを作成しておき、{ mode, currentDate } を設定しておけばとりあえず大丈夫そうです。

また、各種イベント用のメソッドも用意が必要ですね。なので、早速 calendar ページを作成しましょう。

ionic generate page calendar

今回は、対話式のインターフェースを使わずに一気に作成してみました。calendar と言うフォルダに各種ファイルが生成されているのがわかると思います。

それぞれすっからかんなので実装を追加していきましょう。

ionic2-calendar を実装していく

まずは、html から作っていきましょう。

あ、ここで注意ですが、この記事では、ionic2-calendar の詳細には踏み込みません(僕も知りません)ので、実装の仕方を淡々と説明していきます。

例を頼りにまずは、前月に行くボタン、次月に行くボタン、今月のタイトル表示を含めます。

  <ion-row>
    <ion-col size="2">
      <ion-button fill="clear" (click)="back()">
        <ion-icon name="arrow-back" slot="icon-only"></ion-icon>
      </ion-button>
    </ion-col>

    <ion-col size="8" class="ion-text-center">
      <h2>{{ viewTitle }}</h2>
    </ion-col>

    <ion-col size="2">
      <ion-button fill="clear" (click)="next()">
        <ion-icon name="arrow-forward" slot="icon-only"></ion-icon>
      </ion-button>
    </ion-col>
  </ion-row>

  <calendar [eventSource]="eventSource$ | async" [calendarMode]="calendar.mode" [currentDate]="calendar.currentDate"
    [locale]="locale" (onCurrentDateChanged)="onCurrentDateChanged($event)"
    (onRangeChanged)="reloadSource(startTime, endTime)" (onEventSelected)="onEventSelected($event)"
    (onTitleChanged)="onViewTitleChanged($event)" step="30">
  </calendar>

この状態のままだとエラーが出てしまうので、calendar.page.ts を修正します。eventSource は、Observable で取得してくる前提で、Async Pipe に渡しておきます。

ですが、公式の説明をみると、Locale の設定を手動でしないといけないらしいので、まずは、calendar.module.ts を修正しましょう。

import { LOCALE_ID, NgModule } from '@angular/core';  // LOCALE_ID を使えるようにする
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';

import { IonicModule } from '@ionic/angular';

import { CalendarPageRoutingModule } from './calendar-routing.module';

import { CalendarPage } from './calendar.page';
import { NgCalendarModule  } from 'ionic2-calendar';     // <- パッケージの読み込み

// Locale の読み込み設定(これ面倒だな)
import { registerLocaleData } from '@angular/common';
import localeJa from '@angular/common/locales/ja';      // この例では、日本をロケールに
registerLocaleData(localeJa);

@NgModule({
  imports: [
    CommonModule,
    FormsModule,
    IonicModule,
    CalendarPageRoutingModule,
    NgCalendarModule,  // <- Import する
  ],
  declarations: [CalendarPage],
  providers: [
    { provide: LOCALE_ID, useValue: 'ja-JP' } // <- Inject できるように修正
  ]
})
export class CalendarPageModule {}

ふぅ。これで大丈夫なはずです。さて、calendar.page.ts に移りましょう。

import { Component, Inject, LOCALE_ID, OnDestroy, OnInit, ViewChild, ViewEncapsulation } from '@angular/core';
import { CalendarComponent } from 'ionic2-calendar';  // package の読み込み
import { TodoService } from '../todo/todo.service';
import { Subject } from 'rxjs';

@Component({
  selector: 'app-calendar',
  templateUrl: './calendar.page.html',
  styleUrls: ['./calendar.page.scss'],
})
export class CalendarPage implements OnInit, OnDestroy {
  calendar = {
    mode: 'month',
    currentDate: new Date(),
  };
  viewTitle: string;

  constructor(
    @Inject(LOCALE_ID) private locale: string,   // locale をインジェクト
    private todoService: TodoService                  // todo の情報を使うのでインジェクト
  ) { }

  // 子コンポーネントを読み込んで参照できるようにする
  @ViewChild(CalendarComponent) calView: CalendarComponent;

  next() {
    this.calView.slideNext();   // ドキュメントから
  }

  back() {
    this.calView.slidePrev();  // ドキュメントから
  }

  onViewTitleChanged(title) {
    this.viewTitle = title;       //  ドキュメントから
  }

  onCurrentDateChanged($event) {}       // とりあえず空
  reloadSource(startTime, endTime) {}  // とりあえず空
  onEventSelected($event) {}                  // とりあえず空

  ngOnInit() {
  }

}

さて実行してみましょう。

だ。。。だっせぇ。。。インベーダーゲームか。

カプセル化された子コンポーネントのスタイルを修正する

CSSをいじってましにしたいと思います。ですが、子コンポーネントは、カプセル化されていて普通にはさわれません。なので、カプセル化を解除します。

@Component({
  selector: 'app-calendar',
  templateUrl: './calendar.page.html',
  styleUrls: ['./calendar.page.scss'],
  encapsulation: ViewEncapsulation.None      // <-   これ!
})

encapsulation を None にセットすればカプセル化が解除できます。便利ですね。それでは、calendar.page.scss にオーバーライドするスタイルを定義していきます。

.monthview-datetable {
  border: 0 !important;

  td,
  th {
    border: 0 !important;
    border-radius: 10px !important;
  }

  .monthview-selected {
    font-weight: bold;
    background-color: rgb(157, 193, 240) !important;
  }

  .monthview-current {
    background: #c03737 !important;
  }
}

さて、どうなるでしょうか。(CSSの詳細は説明しません。公式を参考にしてください)

ましになりましたね(汗。

では、続けていきましょう。

ionic2-calendar の求めるプロパティを実装する

続いて、カレンダーに表示する todo を ionic2-calendar の eventSouce と言うものと同期させる必要があります。eventSouce は、Observable にしたので、次のように取得してみましょう。eventSource と ToDo の変換をしています。

一番厄介なのは、allDay が true の時、startTime は、0時0分0秒0ミリセックでないと表示されなかったところです。気がつくのに時間がかかりましたが、皆さんはこのまま変換すれば大丈夫です。

  eventSource$ = this.todoService.todos$.pipe(
    map(todos => todos.map(todo => {
      todo.due.setHours(0);
      todo.due.setMinutes(0);
      todo.due.setSeconds(0);
      todo.due.setMilliseconds(0);
      return { title: todo.title, allDay: true, startTime: todo.due, endTime: todo.due }
    }))
  );

さて、答えはこれですね。todoService から受け取って、map で変換していきます。

これだけでイベント(ToDo)が締切日に合わせて見つけられるようになりました。

ついでに、今月にもどるボタンを実装しましょう。ここまで来た読者ならば余裕ですね。

  <ion-row class="ion-justify-content-center ion-margin-top">
    <ion-button (click)="backToCurrent()" fill="clear">今月に戻る</ion-button>
  </ion-row>

そして、backToCurrent() を実装します。

  backToCurrent() {
    this.calView.currentDate = new Date();
  }

これで今月にもどるボタンができました。

タブを使ってToDo一覧とカレンダーを行き来できるようにする

こんな感じにしたいですよね。

下の方にタブバーで ToDo 一覧と Calendar 表示を行き来できるようになっています。タブバーを実装しましょう。

まずは、タブバーの元になるページを作成します。

ionic generate page tabs

これでタブの親となるページを作成します。中身は、タブバーを表示するだけの単純なものです。ですが、Ionic 側で賢くやってくれるのでこれで良いのです。

<ion-tabs>
  <ion-tab-bar slot="bottom">
    <ion-tab-button tab="todo">
      <ion-icon name="list"></ion-icon>
      <ion-label>ToDo</ion-label>
    </ion-tab-button>

    <ion-tab-button tab="calendar">
      <ion-icon name="calendar"></ion-icon>
      <ion-label>Calendar</ion-label>
    </ion-tab-button>
  </ion-tab-bar>
</ion-tabs>

Todo ページと Calendar ページのタブを追加しました。tab=”todo” となっているところは、ルーティングにも利用されるので気をつけてください。

タブのルーティングを設定する

タブのルーティングは、タブページを親ページにして子ページとして定義していきます。今回の場合、タブページから todo それから calendar を呼び出したいので、app-routing.module.ts から該当箇所をカットアンドペーストします。

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';

import { TabsPage } from './tabs.page';

const routes: Routes = [
  {
    path: '',
    component: TabsPage,
    children: [
      {
        path: 'todo',
        loadChildren: () => import('../todo/todo.module').then( m => m.TodoPageModule)
      },
      {
        path: 'calendar',
        loadChildren: () => import('../calendar/calendar.module').then( m => m.CalendarPageModule)
      }
    ]
  }
];

@NgModule({
  imports: [RouterModule.forChild(routes)],
  exports: [RouterModule],
})
export class TabsPageRoutingModule {}

これで OK のはずです。tabs は、” つまりルートパスを使って、/todo と /calendar にURLを分岐させています。login についても同様なのですが、tabs が先に処理しているので /login のものしかやってきません。

同様に app-routing.module.ts の方は、というと、

import { NgModule } from '@angular/core';
import { PreloadAllModules, RouterModule, Routes } from '@angular/router';
import { TabsPage } from './tabs/tabs.page';

const routes: Routes = [
  {
    path: '',
    loadChildren: () => import('./tabs/tabs.module').then( m => m.TabsPageModule )
  },
  {
    path: 'login',
    loadChildren: () => import('./login/login.module').then( m => m.LoginPageModule)
  },
  {
    path: 'tabs',
    loadChildren: () => import('./tabs/tabs.module').then( m => m.TabsPageModule)
  },
];

@NgModule({
  imports: [
    RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules })
  ],
  exports: [RouterModule]
})
export class AppRoutingModule { }

こんな感じでシンプルになっています。あとは特に修正するところはありません。みてみましょう。

todo ページには、AuthGuard が効いているのでログインしてから試します。

CoooooooooL!

できていますね!

カレンダーのカスタマイズ

カレンダーに予定がある時 allDay | [ToDoのタイトル] と表示されるのが少し違和感ありますね。これを修正する方法をみていきましょう。ionic2-calendar のドキュメントを読むと Template Customization と言う項目があります。これを使ってできそうです。

まず、ng-template でテンプレートを定義して、プロパティや設定項目を入れていきます。

例えば、次のようにしてみましょう。


  <ng-template #template let-showEventDetail="showEventDetail" let-selectedDate="selectedDate">
    <ion-list class="event-detail-container" has-bouncing="false" *ngIf="showEventDetail" overflow-scroll="false">
      <ion-item *ngFor="let event of selectedDate?.events">
        <span class="event-detail"> {{event.title}}</span>
      </ion-item>
      <ion-item *ngIf="selectedDate?.events.length === 0">
        <div class="no-events-label">{{ selectedDate.date | date: 'yy年MM月dd日'}}予定の ToDo はありません。</div>
      </ion-item>
    </ion-list>
  </ng-template>

  <calendar [eventSource]="eventSource$ | async" [calendarMode]="calendar.mode" [currentDate]="calendar.currentDate"
    [locale]="locale" (onCurrentDateChanged)="onCurrentDateChanged($event)"
    (onRangeChanged)="reloadSource(startTime, endTime)" (onEventSelected)="onEventSelected($event)"
    (onTitleChanged)="onViewTitleChanged($event)" step="30" [monthviewEventDetailTemplate]="template">
  </calendar>

いろんな値を受け取れるようにんあっていますね。ここでは、深入りせずにいきます。興味があれば色々試してみてください。

今回は、allDay と言う表示と、ToDoがない時に No Events となっているところを変えました。

No Events と言う表示を変えることができました。

こちらでも、allDay と言う表示を非表示にすることができました。

ToDoにメモを追加できるようにする(フォームを使う)

さてさて、ToDoには、タイトルと締切日だけのシンプルな実装でした。ですが、現実的にはメモを貼り付けておきたい時もあるかと思います。そこで、メモが確認編集できるように ToDo の詳細画面兼編集画面を追加したいと思います。

先程、開発した AlertController は、捨て去ります。え、ええええ、また捨て去るんですかぁ。そうです。一瞬で開発できるので捨て去ることに躊躇することはありません。より良い選択に向けて古いコードは削除していきましょう。

モーダル画面に編集画面を実装する

ToDo の追加時にアラートのようなちっちゃい画面ではなく、モーダルビュー(ユーザーがタスクを完了するともとの画面に戻るような一時的な画面)で表示させてみましょう。

そのためにまず、Ionic でモーダルを表示する方法を調べます。1秒くらい探すと ion-modal というコンポーネントが見つかります。これを見ると、モーダルにデータを渡す方法、モーダルが閉じる時の処理について詳しく書かれています。

とりあえずモーダルを表示してみましょう。この手順によると、

  • モーダルを呼び出す側のコンポーネントに ModalController を DI する
  • 表示するときは、modalController.create でコンポーネントを描画する
  • データを渡すときは componentProps 属性にデータを渡す
  • データを受け取るときは、呼び出される側のコンポーネントの dismiss の中でデータを返す

という流れになっています。

実装の方針を考える

たとえば、ToDo の一つをクリックしたときは、モーダル編集画面に対して ToDo のデータを componentProps で渡せば良いでしょう。もしくは、Store を使って props は使わないという考え方もあるかと思います。モーダルを閉じる時も同様です。

ここでは、Store は、もうやったので componentProps でデータを渡して、モーダルの dismiss のタイミングでデータを受け取り、モーダルは Store あるいは、サービスにアクセスしないという方針でやってみましょう。これは、どれがベストかということではなく単にここではそうしてみましょうという意味で捉えてください。引き出しを増やすのが目的です。

まず OnItemClicked の挙動を変更する

ToDo 一覧(久しぶりに登場しましたw)のリストの一つをクリックすると、ToDo を done = true に変更する処理をしていました。これをまず変更します。クリックするとコンソールに clicked と表示するだけにしてみましょう。その前に、毎回ログインするのが面倒なので、Guard を外しておくのをお勧めします。

List のクリックで checkbox の値に影響を与えないようにする

これは結構面倒です。ion-list 内に checkbox が配置されると自動的にクリックできる要素として認識してくれるのですが、リストのクリックとチェックボックスのクリックを分けたい時に簡単にできる方法がありません。この場合、要素を明確に分割しておく必要があります。

ion-grid を使いましょう。ion-grid は、縦(行方向)と横(列方向)の分割を制御できるものです。ion-grid のなかに、ion-row を設置すると行が作成され ion-row の中に ion-col を入れると列を作成してくれます。サイズの指定などは、公式ドキュメントを参照してください。size は、同じ値が入っていると同じサイズになります。わかりやすくするために、行は、横に12サイズあると考えてください。ion-col size=”3″ を4つ指定すると均等に入ります。ion-col size=”1″ を3つ ion-col size=”9″ を一つ指定すると、ion-col size=”9″ が圧倒的に大きいサイズになります。

ion-itemを次のように書き換えてみましょう。

      <ion-item (click)="onItemClicked(todo)">
        <ion-grid>
          <ion-row>
            <ion-col size="2">
              <ion-checkbox [checked]="todo.done" color="primary" (click)="onCheckBoxClicked(todo)"></ion-checkbox>
            </ion-col>
            <ion-col size="10">
              <ion-label for="this">
              <h2>{{ todo.title }}</h2>
              <h3>{{ todo.due | date: 'yyyy年MM月dd日まで' }}</h3>
              </ion-label>
            </ion-col>
          </ion-row>
        </ion-grid>
      </ion-item>

リストの線が横まで伸びてしまいましたが、いい感じにチェックボックスだけチェックが効くようになりました。

もうやったものの方も変更しておいてください。OnCheckBoxClicked には、今までの OnItemClicked と同じ挙動を指定しておきます。

  onCheckBoxClicked(todo: ToDo): void {
    const newTodo = { ...todo, done: !todo.done };
    this.todoService.updateToDo(newTodo);
  }

OnItemClicked を指定する

OnItemClicked の方は、モーダル画面を表示するようにしましょう。モーダル画面は、ion-modal と言うコンポーネントを活用します。ここでは、list がクリックされたかどうかを確認するだけにしておきましょう。

  onItemClicked(todo: ToDo): void {
    console.log('item clicked');
  }

確認できました。

モーダル画面を作成する

モーダル画面を作成していきましょう。モーダルはページとして作成しますので、ionic generate でページを作成してください。

$ ionic generate
 ? What would you like to generate? page
 ? Name/path of page: todo/todo-modal
   ng generate page todo/todo-modal --project=app
   CREATE src/app/todo/todo-modal/todo-modal-routing.module.ts (360 bytes)
   CREATE src/app/todo/todo-modal/todo-modal.module.ts (495 bytes)
   CREATE src/app/todo/todo-modal/todo-modal.page.scss (0 bytes)
   CREATE src/app/todo/todo-modal/todo-modal.page.html (129 bytes)
   CREATE src/app/todo/todo-modal/todo-modal.page.spec.ts (669 bytes)
   CREATE src/app/todo/todo-modal/todo-modal.page.ts (271 bytes)
   UPDATE src/app/todo/todo-routing.module.ts (552 bytes)
   [OK] Generated page! 

これで作成できました。ページに遷移してみましょう。

表示できました。これを todo 一覧画面の onItemClicked でモーダルとして表示させます。まず、モーダルページを todo.page.ts から import が必要です。さらに DI で ModalController を利用します。

DI に ModalController を追加


  constructor(
    private alertController: AlertController,
    private todoService: TodoService,
    private modalController: ModalController
  ) { }

モーダルコントローラーを呼び出し


  onItemClicked(todo: ToDo): void {
    this.showTodoModal(todo);
  }

  async showTodoModal(todo: ToDo) {
    const modal = await this.modalController.create({
      component: TodoModalPage      
    });
    return await modal.present();
  }

できました。

モーダル画面を閉じられるようにする

モーダルは簡単に表示することができました。今度は、モーダルを閉じられるようにしておきましょう。

閉じるには、呼び出されたコントローラー側で dismiss することができます。

Close ボタンを配置

<ion-header>
  <ion-toolbar>
    <ion-title>ToDo 編集</ion-title>
    <ion-button slot="end" fill="clear" (click)="dismiss()">
      Close
    </ion-button>
  </ion-toolbar>
</ion-header>

<ion-content>

</ion-content>

dismiss を実装

import { Component, OnInit } from '@angular/core';
import { ModalController } from '@ionic/angular';

@Component({
  selector: 'app-todo-modal',
  templateUrl: './todo-modal.page.html',
  styleUrls: ['./todo-modal.page.scss'],
})
export class TodoModalPage implements OnInit {

  constructor(
    private modalController: ModalController
  ) { }

  ngOnInit() {
  }

  dismiss() {
    this.modalController.dismiss()
  }

}

これで確認してみましょう。

できました。

フォームを実装する

モーダル画面にフォームを実装していきましょう。項目としては、

  • タイトル
  • 期限日
  • メモ

の3つくらいで良いでしょうか。フォームを実装する時に、以前はアラートの中では、ngModel と name 属性を使って素朴に実装しました。しかし、バリデーションを入れたり、動的にフォームを生成したい場合には困難があります。Angular では、そういった場合には、ReactiveFormsModule を利用することをお勧めしています。

ReactiveFormsModule を使ってフォームを実装する

さて、やっていきましょう。まず、todo-modal.module.ts で ReactiveFormsModule を import します。

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';

import { IonicModule } from '@ionic/angular';

import { TodoModalPageRoutingModule } from './todo-modal-routing.module';

import { TodoModalPage } from './todo-modal.page';

@NgModule({
  imports: [
    CommonModule,
    FormsModule,
    IonicModule,
    TodoModalPageRoutingModule,
    ReactiveFormsModule
  ],
  declarations: [TodoModalPage]
})
export class TodoModalPageModule {}

ついでに、Form を簡単に作れるようにする FormBuilder も todo-modal.page.ts に import しておきます。

import { Component, OnInit } from '@angular/core';
import { FormBuilder } from '@angular/forms'; // <- 追加
import { ModalController } from '@ionic/angular';

@Component({
  selector: 'app-todo-modal',
  templateUrl: './todo-modal.page.html',
  styleUrls: ['./todo-modal.page.scss'],
})
export class TodoModalPage implements OnInit {

  constructor(
    private modalController: ModalController,
    private fb: FormBuilder // <- 追加
  ) { }

  ngOnInit() {
  }

  dismiss() {
    this.modalController.dismiss()
  }

}

フォームをゴリゴリ実装していきます。

import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ModalController } from '@ionic/angular';

@Component({
  selector: 'app-todo-modal',
  templateUrl: './todo-modal.page.html',
  styleUrls: ['./todo-modal.page.scss'],
})
export class TodoModalPage implements OnInit {

  constructor(
    private modalController: ModalController,
    private fb: FormBuilder
  ) { }

  today = new Date();

  todoForm: FormGroup = this.fb.group({
    title: ['', [Validators.required, Validators.minLength(3)]],
    due: [this.todayFormat(), Validators.required],
    memo: [''],
  });

  readonly errorMessages = {
    tittle: [
      { type: 'required', message: 'タイトルを入力してください。' },
      { type: 'minLength', message: 'タイトルは、3文字以上で入力してください。' }
    ],
    due: [
      { type: 'required', message: '期限を入力してください。' }
    ]
  }

  ngOnInit() {
  }

  onSubmitClicked() {
    console.log(this.todoForm.get('title').value, this.todoForm.get('due').value, this.todoForm.get('memo').value);
  }

  todayFormat() {
    const dateStr = this.today.toISOString().split('T')[0];
    return dateStr;
  }

  dismiss() {
    this.modalController.dismiss()
  }

}

Form builder をインポートして FormGroup を生成しています。formのインプット属性名をキーとして、値には、配列を指定しています。配列の初めの値は、初期値です。二番目の値には、検証を入れます。ひとつならそのまま。配列で複数指定することも可能です。

次に、errorMessages を定義しています。これはまだ使いませんが、どのエラーが出たのかがわかるように表示する際などに使います。こちらについては、ionic や angular の機能ではなく適当に作ったものです。

OnSubmitClicked では、フォームからの値の取得できているかわかるように console に出力させています。todayFormat() は、date 型の input には、 yyyy-mm-dd で入力する必要があるのでそれを作っているだけです。

さて、html をみていきます。

<ion-header>
  <ion-toolbar>
    <ion-title>ToDo 編集</ion-title>
    <ion-button slot="end" fill="clear" (click)="dismiss()">
      Close
    </ion-button>
  </ion-toolbar>
</ion-header>

<ion-content>
  <form [formGroup]="todoForm">
    <ion-item class="ion-margin">
      <ion-label position="floating">Title</ion-label>
      <ion-input type="text" formControlName="title"></ion-input>
    </ion-item>
    <ion-item class="ion-margin">
      <ion-label position="floating">期日</ion-label>
      <ion-input type="date" formControlName="due"></ion-input>
    </ion-item>
    <ion-item class="ion-margin">
      <ion-label position="floating">メモ</ion-label>
      <ion-textarea auto-grow="true" rows="3"  formControlName="memo"></ion-textarea>
    </ion-item>
    <ion-button color="primary" expand="full" [disabled]="!todoForm.valid" (click)="onSubmitClicked()">
      登録
    </ion-button>
  </form>
</ion-content>

formタグの formGroup 属性で、どの FormGroup とバインディングするのかを指定しています。各インプットには、 formControlName として、どの値と紐づけるのかを指定しています。登録ボタンでは、onSubmitClicked を呼び出し、また、 todoForm.valid になるまで disabled に true が入るようにしてエラーがある状態ではフォームを送信できないように制御しています。さて実際にみてみましょう。

編集のバリデーションを実装する

タイトルが3文字以下の時にボタンがクリックできなくなりました。しかしなんでクリックできないのかユーザーにはわかりません。

エラー文を表示してユーザーにフィードバックを与えましょう。フォームの下にエラーメッセージを表示してみます。

<ion-header>
  <ion-toolbar>
    <ion-title>ToDo 編集</ion-title>
    <ion-button slot="end" fill="clear" (click)="dismiss()">
      Close
    </ion-button>
  </ion-toolbar>
</ion-header>

<ion-content>
  <form [formGroup]="todoForm">
    <ion-item class="ion-margin">
      <ion-label position="floating">Title</ion-label>
      <ion-input type="text" formControlName="title"></ion-input>
    </ion-item>
    <ion-item class="ion-margin">
      <ion-label position="floating">期日</ion-label>
      <ion-input type="date" formControlName="due"></ion-input>
    </ion-item>
    <ion-item class="ion-margin">
      <ion-label position="floating">メモ</ion-label>
      <ion-textarea auto-grow="true" rows="3" formControlName="memo"></ion-textarea>
    </ion-item>
    <ion-button color="primary" expand="full" [disabled]="!todoForm.valid" (click)="onSubmitClicked()">
      登録
    </ion-button>
  </form>

  <p class="ion-margin" *ngFor="let errorMessage of errorMessages.title">
    <ion-text color="danger"
      *ngIf="todoForm.get('title').hasError(errorMessage.type) && (todoForm.get('title').dirty || todoForm.get('title').touched)">
      {{ errorMessage.message }}
    </ion-text>
  </p>

  <p class="ion-margin" *ngFor="let errorMessage of errorMessages.due">
    <ion-text color="danger"
      *ngIf="todoForm.get('due').hasError(errorMessage.type) && (todoForm.get('due').dirty || todoForm.get('due').touched)">
      {{ errorMessage.message }}
    </ion-text>
  </p>

</ion-content>

todoForm.get(‘due’) などとして、get の中に、formControlName を入れると formControlが取得できます。 hasErrorで、どのタイプのエラーがあるかを判定しています。dirty と言うのは、値が変更されたか、どうかを判定しています。

エラーの出方が、若干アクロバティックですが、何がエラーになっているのかわかるようになりましたね。

モーダルにデータを渡す

さて、詳細画面を兼ねたものにしたいので、タップした todo の中身を初めから表示できるようにしておきましょう。

コンポーネントにデータを渡す時には、 @Input デコレーターが使えます。

その前に、ToDo モデルを変更しておきましょう。

export class ToDo {
  id?: string;
  title: string;
  due: Date = new Date();
  memo?: string;
  done: boolean = false;

  constructor(todoParams: Partial<ToDo>) {
    Object.assign(this, todoParams);
  }

  get stringDate() {
    return this.due.toISOString().split('T')[0];
  }

  set stringDate(val) {
    this.due = new Date((new Date(val)).setHours(0));
  }
}

memoをオプショナルで追加し、stringDate と言うゲッタとセッタをつけました。これは、フォームでの日付表示が、2021-02-07 のような文字列になってしまうのですが、文字列を直接渡せば Date 型に変換して、due に値を入れたり、stringDate を参照すれば、Date 型を文字列にしておくことができます。

todo.data.ts の方でも、しっかりと ToDo モデルに変換する処理を入れておきましょう。

  }
].map(todo => new ToDo(todo));

export default todos;

これでOKです。

さて、ToDoモーダルの方に @Input を定義していきましょう。

export class TodoModalPage implements OnInit {

  @Input() todo: ToDo;

  ...

これだけです。todo ページの方から値を渡しましょう。

  async showTodoModal(todo: ToDo) {
    this.currentModal = await this.modalController.create({
      component: TodoModalPage,
      componentProps: {
        todo: todo,  // <- 追加
      }
    });
    return await this.currentModal.present();
  }

これで追加できました。他にエラーが出てしまっているので、それを修正します。

  onCheckBoxClicked(todo: ToDo): void {
    const newTodo = new ToDo({ ...todo, done: !todo.done }); // ToDo を生成するように
    this.todoService.updateToDo(newTodo);
  }

モーダルがイニシャライズされた時に、取得したデータをフォームに反映させます。

  ngOnInit() {
    this.todoForm.get('title').setValue(this.todo.title);
    this.todoForm.get('due').setValue(this.todo.stringDate);
    this.todoForm.get('memo').setValue(this.todo.memo);
  }

さてこれで値が表示されるかと思います。

できていますね。

ToDo を更新する

次にモーダルで入力した値で ToDo を更新していきましょう。いくつか考え方がありますが、ここでは、モーダル側に更新処理を入れずに、モーダルは完了したデータを戻すだけにしておきます。

そして、完成したデータを元に ToDo 一覧の方でデータを更新させましょう。モーダルの中でやっても良いのですが、なんとなく分けておきます。なので、モーダルの OnSubmitClicked では、ToDo データを渡すだけにして、モーダルを呼び出した側で処理させましょう。


  onSubmitClicked() {
    const todo = new ToDo({});
    todo.id = this.todo.id;
    todo.title = this.todoForm.get('title').value;
    todo.stringDate = this.todoForm.get('due').value;
    todo.memo = this.todoForm.get('memo').value;
    this.modalController.dismiss(todo);
  }

これで、モーダルが閉じた後にデータを更新させましょう。 OnDidDismiss で返却される Promise 内で処理を入れます。

  async showTodoModal(todo: ToDo) {
    this.currentModal = await this.modalController.create({
      component: TodoModalPage,
      componentProps: {
        todo: todo,
      }
    });

    this.currentModal.onDidDismiss().then( data  => {
      if (data.data instanceof ToDo) {
        this.todoService.updateToDo(data.data);
      }
    })

    return await this.currentModal.present();
  }

やってみます。

順番が入れ替わっちゃっているけど、できました。

ToDo を追加する

アラート画面で入力していた新規 ToDo を廃止してモーダルから入力させるようにしましょう。

  async onAddButtonClicked() {
    this.currentModal = await this.modalController.create({
      component: TodoModalPage,
      componentProps: {
        todo: new ToDo({}),
      }
    });

    this.currentModal.onDidDismiss().then( data  => {
      const todo = data.data;
      if (todo instanceof ToDo) {
        todo.id = 'todo' + (new Date).getTime().toString();
        this.todoService.addToDo(todo);
      }
    })

    return await this.currentModal.present();
  }

既存の AlertController を使っていたところをまんま書き換えます。更新とほぼ同じですが、IDを割り当て、addToDo しています。

やってみましょう。

呼び出しがわで処理を決めるようにすると、このような使いまわせそうなモーダル自体に変更を加える必要がなくなります。

ToDo 一覧の並び順を制御する

ToDo 一覧を追加、変更した時に順番が変わってしまいました。そもそもどんな順番になっているべきでしょうか。ここでは、順番を制御することをしたいと思います。難しいことは全くやりません。単なる JavaScript です。

まずは、順番をこうしましょう。

  • Done ではないものが上、Done が下
  • 期日が近いものが上、期日が遠いものが下

この条件でソートしてみましょう。

Array.prototype.sort(compareFunction)

ソートする場合は、compareFunction に二つの要素 (a, b) が渡ってきます。これで二つの要素を比較し、結果が 0 未満であれば、a が前にきます。結果が 0 より大きい場合は、b が前にきます。

todos をソートしてみましょう。

sortToDo (a, b) => {
  const compare = a.done - b.done;
  if (compare === 0) {
    return a.due.getTime() - b.due.getTime();
  } else {
    return compare;
  }
};

こんな感じでどうでしょうか。もともと別々にtodoリストを作成していましたが、これをやめます。一つのリストで並べ替えながらやってみます。

  todos$: Observable<ToDo[]> = this.todoService.todos$.pipe(
    map(todos => todos.slice().sort(this.sortToDo))
  );

  sortToDo(a: ToDo, b: ToDo): number {
    const compare = Number(a.done) - Number(b.done);
    if (compare === 0) {
      return a.due.getTime() - b.due.getTime();
    } else {
      return compare;
    }
  };

さて、html の方も、

<ion-header>
  <ion-toolbar>
    <ion-title>
      My To Do
    </ion-title>
    <ion-buttons slot="end">
      <ion-button (click)="onAddButtonClicked()">追加</ion-button>
    </ion-buttons>
  </ion-toolbar>
</ion-header>

<ion-content>
  <ion-list id="todos">
    <ion-item-sliding *ngFor="let todo of (todos$ | async)">
      <ion-item (click)="onItemClicked(todo)">
        <ion-grid>
          <ion-row>
            <ion-col size="2">
              <ion-checkbox [checked]="todo.done" color="primary" (click)="onCheckBoxClicked(todo)"></ion-checkbox>
            </ion-col>
            <ion-col size="10">
              <ion-label for="this">
              <h2>{{ todo.title }}</h2>
              <h3>{{ todo.due | date: 'yyyy年MM月dd日まで' }}</h3>
              </ion-label>
            </ion-col>
          </ion-row>
        </ion-grid>
      </ion-item>
      <ion-item-options side="end">
        <ion-item-option color="danger" expandable (click)="onDeleteClicked(todo)">
          削除
        </ion-item-option>
      </ion-item-options>
    </ion-item-sliding>
  </ion-list>
</ion-content>

こんな感じでだいぶシンプルになりました。

できました。ToDo を更新すると二度目のモーダルで日時表示が崩れてしまいました。

Slice の updateToDo で確実に ToDo 型になるようにしておきましょう。

    updateToDo: (state, action) => {
      const todo = action.payload.todo;
      const oldTodo = state.todos.find(el => el.id === todo.id);
      state.todos.splice(state.todos.indexOf(oldTodo), 1);
      state.todos.push(new ToDo({...oldTodo, ...todo}));
    }

これで一通りできましたね。

ToDo一覧、カレンダーの両方からモーダルを呼び出す

さて、カレンダー表示の方からもモーダルを呼び出します。カレンダーの ToDo 一覧をクリックした時のイベントを取得します。

  <ng-template #template let-showEventDetail="showEventDetail" let-selectedDate="selectedDate">
    <ion-list class="event-detail-container" has-bouncing="false" *ngIf="showEventDetail" overflow-scroll="false">
      <ion-item *ngFor="let event of selectedDate?.events" (click)="eventSelected(event)">
        <span class="event-detail"> {{event.title}}</span>
      </ion-item>
      <ion-item *ngIf="selectedDate?.events.length === 0">
        <div class="no-events-label">{{ selectedDate.date | date: 'yy年MM月dd日'}}予定の ToDo はありません。</div>
      </ion-item>
    </ion-list>
  </ng-template>

これを calendar.page.ts で受け取って、

  constructor(
    @Inject(LOCALE_ID) private locale: string,
    private modalController: ModalController, // DI する
    private todoService: TodoService
  ) { }

  currentModal?: HTMLIonModalElement = null; // 入れる

  ...... 中略 ......

  async showTodoModal(todo: ToDo) {
    this.currentModal = await this.modalController.create({
      component: TodoModalPage,
      componentProps: {
        todo: todo,
      }
    });

    this.currentModal.onDidDismiss().then( data  => {
      const todo = data.data;
      if (todo instanceof ToDo) {
        this.todoService.updateToDo(todo);
      }
    })

    return await this.currentModal.present();
  }

  eventSelected(event) {
    const todo = new ToDo({});
    todo.id = event.id;
    todo.title = event.title;
    todo.due = new Date(event.startTime);
    todo.memo = event.memo;
    todo.done = event.done;
    this.showTodoModal(todo);
  }

eventSelected で、event から ToDo に変換しています。showTodoModal は、全く同じコードなのでちょっと違和感ありますが、このままいきいましょう。

EventSource に ID を割り当てておきましょう。

  eventSource$ = this.todoService.todos$.pipe(
    map(todos => todos.map(todo => {
      todo.due.setHours(0);
      todo.due.setMinutes(0);
      todo.due.setSeconds(0);
      todo.due.setMilliseconds(0);
      return { title: todo.title, allDay: true, startTime: todo.due, endTime: todo.due, id: todo.id, memo: todo.memo, done: todo.done }
    }))
  );

これで、カレンダーから ToDoの 編集までできるようになりました。

日付ずれるバグを修正

フォームで更新を繰り返すだけで日付が一日ずつ前に行ってしまうバグがありました。これを直します。todo.modelを見ると、toISOString が使われていますので、これが日本時間をUTCに直してUTC午後3時になってしまっているようです。汚いですが、とりあえず直します。

  get stringDate() {
    return `${this.due.getFullYear()}-${('0' + (this.due.getMonth() + 1)).slice(-2)}-${('0' + this.due.getDate()).slice(-2)}`
  }

yyyy-mm-dd 形式の文字列を作っているのですが、元の Date オブジェクトから作成しています。’0′ を足して、右から2文字を取得して言うのは、1月などを 01 とするためです。また、getMonth() に+1 しているのは、1月が 0 になるためです。

はい、これでなおりました。

Formの機能を使ってログインフォームを洗練させる

先程の FormBuilder を使ってログインフォームを ReactiveFormsModule を使って書き直しましょう。

手順をおさらいしましょう。

  • まず、FormBuilder を login.page.ts に DI する
  • loginForm と言う FormGroup を fb.group で作成する
  • HTML にformControlName と form [formGroup]=”loginForm” を追加する

まず、login.module.ts に ReactiveFormsModule をインポートします。

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule } from '@angular/forms';

import { IonicModule } from '@ionic/angular';

import { LoginPageRoutingModule } from './login-routing.module';

import { LoginPage } from './login.page';

@NgModule({
  imports: [
    CommonModule,
    ReactiveFormsModule,
    IonicModule,
    LoginPageRoutingModule
  ],
  declarations: [LoginPage]
})
export class LoginPageModule {}

そして、フォームグループを生成しましょう。

  loginForm: FormGroup = this.fb.group({
    email: ['', [Validators.required, Validators.email]],
    password: ['', Validators.required]
  });

これでOKです。

フォームの検証で正しくないときは、ログイン処理をスキップします。

  onLoginClicked() {
    if (!this.loginForm.valid) return;
    
    this.authService.login({
      email: this.email,
      password: this.password
    })
  }

フロントを調整します。

<ion-header [translucent]="true">
  <ion-toolbar>
    <ion-title>
      My ToDo
    </ion-title>
  </ion-toolbar>
</ion-header>

<ion-content [fullscreen]="true">
  <ion-header collapse="condense">
    <ion-toolbar>
      <ion-title size="large">My ToDo</ion-title>
    </ion-toolbar>
  </ion-header>

  <div id="container">
    <strong>Login</strong>
    <p>さあ始めましょう。</p>
    <form [formGroup]="loginForm">
      <ion-list class="ion-margin-vertical">
        <ion-item>
          <ion-input placeholder="Email" formControlName="email"></ion-input>
        </ion-item>
        <ion-item>
          <ion-input placeholder="Password" type="password" formControlName="password"></ion-input>
        </ion-item>
      </ion-list>
    </form>
    <ion-button color="primary" expand="full" class="ion-margin-vertical" (click)="onLoginClicked()" [disabled]="!loginForm.valid">
      Login
    </ion-button>
  </div>
</ion-content>

form で formGroup を指定し、各インプットには、formControlName で値を指定します。ngModel は使わないので削除しておきます。ログインボタンも見えなくしておきます。

ログイン処理のところもフォームから値を取得するようにしましょう。

  onLoginClicked() {
    if (!this.loginForm.valid) return;

    this.authService.login({
      email: this.loginForm.get('email').value,
      password: this.loginForm.get('password').value
    })
  }

エラーメッセージも作成しておきましょう。

    <p class="ion-margin" *ngFor="let errorMessage of errorMessages.email">
      <ion-text color="danger"
        *ngIf="loginForm.get('email').hasError(errorMessage.type) && (loginForm.get('email').dirty || loginForm.get('email').touched)">
        {{ errorMessage.message }}
      </ion-text>
    </p>
  
    <p class="ion-margin" *ngFor="let errorMessage of errorMessages.password">
      <ion-text color="danger"
        *ngIf="loginForm.get('password').hasError(errorMessage.type) && (loginForm.get('password').dirty || loginForm.get('password').touched)">
        {{ errorMessage.message }}
      </ion-text>
    </p>

メッセージは、こうしますか。

  readonly errorMessages = {
    email: [
      { type: 'required', message: 'Emailは、必須です。' },
      { type: 'email', message: 'Email形式で入力してください。'}
    ],
    password: [
      { type: 'required', message: 'パスワードを入力してください。' }
    ]
  };

一通りできましたので、試します。

できましたね。これで一通り ToDo アプリが完成しました。

その2に続く