中安拓也のブログ

プログラミングについて書くブログ

Angular + Reduxでストップウォッチを作る

f:id:l08084:20180228152503g:plain

はじめに

Angular + Reduxでストップウオッチを作りました

  • デモ

https://l08084.github.io/ng-stopwatch/

  • ソースコード

github.com

実装

開発環境

  • Angular: 5.2.3

  • angular-redux/store: 7.1.0

  • OS: darwin x64

  • Node: 8.1.4

概要

angular-redux/storeを使ったReduxのアーキテクチャを採用しました。 ストップウオッチについては、Observable.interval(1000)を使用して、STARTボタンを押下してから、何秒経過したかを計測した後、計測結果をmoment.jsとAngularのPipeで、HH : mm : ssフォーマットに変更するといった流れになっています。

f:id:l08084:20180228170559p:plain

Store

Storeでは、何秒経過したかを記録するtimeと、STOPボタンが押されているかどうかを記録するisStopの2つのプロパティを定義しています。

src/state/store.ts:

import { Action } from 'redux';
import { TimerActions } from '../state/action';

export interface IAppState {
    time: number;
    isStop: boolean;
}

export const INITIAL_STATE: IAppState = {
    time: 0,
    isStop: false,
};

Action

Actionでは、3つあるボタン(START, STOP, RESET)に対応した3メソッドを定義しています。

src/state/action.ts:

import { Injectable } from '@angular/core';
import { Action, Store } from 'redux';
import { FluxStandardAction } from 'flux-standard-action';
import { dispatch, NgRedux } from '@angular-redux/store';
import { IAppState } from './store';

export type VoidAction = FluxStandardAction<void, void>;

@Injectable()
export class TimerActions {
  static readonly COUNT = 'COUNT';
  static readonly STOP = 'STOP';
  static readonly RESET = 'RESET';

  @dispatch() count = (): VoidAction => ({
    type: TimerActions.COUNT,
    payload: undefined,
    meta: undefined
  })

  @dispatch() stop = (): VoidAction => ({
    type: TimerActions.STOP,
    payload: undefined,
    meta: undefined
  })

  @dispatch() reset = (): VoidAction => ({
    type: TimerActions.RESET,
    payload: undefined,
    meta: undefined
  })
}

Reducer

count()が呼び出されるたびに、Storeのtimeプロパティを+1ずつインクリメントしています。

src/state/reducer.ts:

import { IAppState } from './store';
import { Action } from 'redux';
import { TimerActions,
         VoidAction,
        } from './action';

export function rootReducer(
    lastState: IAppState,
    action: Action
): IAppState {
    switch (action.type) {
        case TimerActions.COUNT:
            return {
              time: lastState.time += 1,
              isStop: false,
            };
        case TimerActions.STOP:
            return {
              time: lastState.time,
              isStop: true,
            };
        case TimerActions.RESET:
            return {
              time: 0,
              isStop: false,
            };
            default:
                return lastState;
    }
}

Appcomponent

本アプリケーションのキモであるAppComponentです。 isStoptrueになっている状態(STOPボタンが押された)では、this.action.count()を呼び出さないようにしています。

src/app/app.component.ts:

import { Component, OnInit } from '@angular/core';
import { select } from '@angular-redux/store';
import { Observable } from 'rxjs/Observable';
import { TimerActions } from '../state/action';

import 'rxjs/add/observable/interval';
import { Subscription } from 'rxjs/Subscription';


@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit {
  @select() readonly time$: Observable<number>;
  @select() readonly isStop$: Observable<boolean>;

  subscription: Subscription;
  isStop: boolean;

  constructor(
    private action: TimerActions
  ) {}

  ngOnInit() {
    this.isStop$.subscribe(isStop => this.isStop = isStop);
  }

  countStart = (): void => {
    if (this.subscription) {
      this.subscription.unsubscribe();
    }
    this.isStop = false;
    this.subscription = Observable.interval(1000)
      .subscribe(() => {
        if (this.isStop) {
          return;
        }
        this.action.count();
      });
  }

  callReset = (): void => {
    if (this.subscription) {
      this.subscription.unsubscribe();
    }
    this.action.reset();
  }
}

概要で説明した通り、Observable.interval()でカウントした結果をPipeで変換することによって、デジタル形式で時刻を表示しています(例えば、カウント結果が70だとするとデジタル形式では、00: 01 : 10になる)。

src/app/app.component.html:

<div class="timer-box">
  <h1 class="timer">{{ (time$ | async) | digitalTimer }}</h1>
  <div>
    <button *ngIf="((isStop$ | async) || (time$ | async) === 0) else elseBlock"
    (click)="countStart()" class="start">START</button>
    <ng-template #elseBlock>
        <button (click)="action.stop()" class="stop">STOP</button>
    </ng-template>
    <button *ngIf="(isStop$ | async)"
      (click)="callReset()" class="reset">RESET</button>
  </div>
</div>

Pipe

JavaScriptのDateを素のままで使うと少々厳しいので、moment.jsライブラリーを使用しています。

# moment.jsをインストール
$ npm install --save moment

import * as moment from 'moment';の行でmoment.jsをインポートした後、 moment().hour(0).minute(0).second(time).format('HH : mm : ss')の行でフォーマット変換を行なっています。

src/app/digital-timer-pipe.ts:

import { Pipe, PipeTransform } from '@angular/core';
import * as moment from 'moment';

@Pipe({
  name: 'digitalTimer'
})
export class DigitalTimerPipe implements PipeTransform {

  transform(time: number): string {
    return moment().hour(0).minute(0).second(time).format('HH : mm : ss');
  }

}

参考サイト

jsfiddle.net