文系プログラマによるTIPSブログ

文系プログラマ脳の私が開発現場で学んだ事やプログラミングのTIPSをまとめています。

angular-materialでスクリーンサイズによって自動的にサイドナビを開閉させる

exampleなのです〜

f:id:treeapps:20170918135756p:plain

初のangular記事です。

完全なサーバサイド人間だった私が、去年の冬頃にせっせとreact, react-router, reduxを勉強し、それら技術を使ってtree-mapsというSPAサイトを作りました。

www.tree-maps.com

reactは最近ライセンスの問題で揺れていたり、Microsoftのtypescriptが世界的にシェアを集めている事から、ちょっとangularやってみるか!という事で、最近angularをやっています。

http://qiita.com/exli3141/items/a36b9bc88d818efb3331qiita.com

実はreact + typescriptも試したのですが、元々typescriptベースに作られているわけではないので、非常に難しく、挫折するには十分な初見殺しがありました。

www.bunkei-programmer.net

一方angular4は最初からtypescriptベースなので、ただangularするだけで自然にtypescriptを触る事になります。

そこで現在angularでSPAサイトを作っており、その過程でサイドナビの自動開閉が必要で、ちょっと作ってみたのでサンプルを公開してみようと思います。

どういうこと?

言葉で説明しても解りにくいと思うので、以下をご覧下さい。つまりこういう事です。

f:id:treeapps:20170918140752g:plain

モバイルのスクリーンに横幅の広いサイドナビが表示されると邪魔なので、モバイルの場合はサイドナビを閉じる事で狭い画面を有効活用します。

逆にPCの場合は閉じているとメニュークリックの手間が増えるので、常時サイドナビは開かせ、PCならではの広い画面を有効活用します。

それらの挙動をスクリーンサイズによって自動で行おう!というのが今回の記事の趣旨となります。

システム要件

  • yarnかnpm必須。
  • angular v4
  • angular-material v2.0.0-beta.10

ソースコード

例によって事前に動くサンプルをgithubに用意してあります。

github.com

サイドナビの自動開閉サンプルではありますが、ついでにルーターでpushStateされるようにしてあります。

あと、stylusに未来を感じなかったので、sassにしています。(それもどうかとは思いますが)

angular-cliでsassを使う場合、以下のようにng new時に指定する事ができます。

ng new angular-material-sidenav-example --style=sass --routing=true --source-dir=src/client

簡単な解説

利用するモジュールは、angular-materialのSidenavです。

Angular Material

サイドナビのステータス

サイドナビの開閉ステータスはopened、モードはmode、で設定できます。

<md-sidenav #sidenav mode="{{sidenavMode}}" opened="{{sidenavOpened}}">
hoge
</md-sidenav>

サイドナビのモード

サイドナビのモードは3種類あり、それぞれ以下のようになっています。

side 常時サイドナビが表示されている状態です。
over 常時サイドナビが非表示で、表示するとコンテンツを覆い被せるように表示されます。
push 常時サイドナビが非表示で、表示するとコンテンツを右に押し出して表示されます。

スクリーンサイズが広い(PCとか)場合はside、スクリーンサイズが狭い(モバイルとか低解像度ディスプレイとか)場合はoverがいいと思います。pushは個人的に好まないですね。コンテンツを押し出すアニメーション、要ります?


基本的にスクリーンサイズの変更を検知して、openedとmodeを変更する事で、スクリーンサイズによるサイドナビの自動開閉を実現します。

コアとなるスクリーンサイズ検知部分

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

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.sass'],
})
export class AppComponent implements OnInit {

  sidenavOpened: boolean = true;
  sidenavMode: string = 'side';

  constructor(private ngZone: NgZone) {
    window.onresize = (e) => {
      ngZone.run(() => {
        this.handleResizeWindow(window.innerWidth);
      });
    };
  }

  ngOnInit() {
    this.handleResizeWindow(window.innerWidth);
  }

  private handleResizeWindow(width: number) {
    if (800 < width) {
      this.sidenavOpened = true;
      this.sidenavMode = 'side';
    } else {
      this.sidenavOpened = false;
      this.sidenavMode = 'over';
    }
  }
}

「800」の部分が、モバイルかそうでないかを判定するwidthとなります。

angularでウインドウのりサイズイベントを検知するには、テンプレートで「(window:resize)="resize($event)"するか、ngZoneで拾うか、@HostListenerで拾うか、等のやり方があります。

「(window:resize)」だとテンプレート側に依存してしまうし、「@HostListener」より「ngZone」の方が細かく制御できそうだったので、今回のサンプルではngZoneを使用しています。

もしリサイズイベントを間引くためにdebounceしたい場合は、RxJsのObservableを使ってService化する等の手法もあるようです。

画面初期表示時

ngZoneだと、ウインドウがリサイズされないと実行されないので、これだと初期表示時のスクリーンサイズを判定できません。

そこでngOnInitでコンポーネントの初期化時に以下のように直接windowから横幅を取得してサイドナビの状態を変更しています。

ngOnInit() {
  this.handleResizeWindow(window.innerWidth);
}


簡単にまとめると、これだけです。

後は「$sidenav」でサイドナビオブジェクトが取得できるので、各コンポーネントに「@Input」でサイドナビオブジェクトをバケツリレーで渡して操作するだけとなります。

<app-appbar [sidenav]="sidenav"></app-appbar>

<md-sidenav-container>
  <md-sidenav #sidenav mode="{{sidenavMode}}" opened="{{sidenavOpened}}">
    <app-sidenav [sidenav]="sidenav"></app-sidenav>
  </md-sidenav>

  <section class="main-container">
    <router-outlet></router-outlet>
  </section>
</md-sidenav-container>

sidenav mode = over時のメニュー

モバイルスクリーンサイズでサイドナビが無事自動収納できました。

しかしここで問題があります。そう、メニューを表示するボタン等がどこにも無いのです。

これを解決するため、モバイルスクリーンサイズになった際に、Toolbarの左端にハンバーガーメニューを出す必要があります。

これは簡単で、@Inputでバケツリレーで渡されたsidenavオブジェクトのモードから、*ngIfでハンバーガーメニューの表示・非表示を制御するだけです。

<header>
  <md-toolbar color="primary" class="mat-elevation-z6">
    <span *ngIf="sidenav.mode === 'over'">
      <md-icon (click)="toggleSidenav($event)">menu</md-icon>&nbsp;
    </span>
    <span>Auto toggle sidenav example</span>
  </md-toolbar>
</header>

後はそのハンバーガーメニューにクリックイベントを設定し、以下のようにトグルさせます。

import { Component, OnInit, Input, Output } from '@angular/core';
import {MdSidenav} from "@angular/material";

@Component({
  selector: 'app-appbar',
  templateUrl: './appbar.component.html',
  styleUrls: ['./appbar.component.sass']
})
export class AppbarComponent implements OnInit {

  @Input()
  sidenav: MdSidenav;

  constructor() { }

  ngOnInit() {
  }

  toggleSidenav(event) {
    this.sidenav.mode = 'over';
    this.sidenav.toggle();
  }
}

ここで「this.sidenav.mode = 'over';」とモードを静的に指定してしまってますが、そもそもハンバーガーメニューが表示されている=モバイルスクリーンなので、モードは常にoverなのです。ハンバーガーメニュークリックでサイドナビを表示する事自体はtoggleメソッドがsidenavオブジェクトにあるので、これを実行するだけです。

サイドナビのメニュークリック時にサイドナビを隠す

折角モバイルスクリーン時に自動的にサイドナビを隠す事ができたのですが、このままだとサイドナビのメニューリンクをクリックしてもサイドナビが開きっぱなしです。

これを解決するため、@Inputでsidenavを受取、サイドナビコンポーネント側でクリックイベントを設定し、sidenavを操作する事で閉じる事ができます。

以下の「(click)="handleClickMenu($event)"」の部分がそれに当たります。

<md-list>
  <md-list-item>
    <md-icon md-list-icon>folder</md-icon>
    <h4 md-line>
      <a routerLink="/" (click)="handleClickMenu($event)" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">top</a>
    </h4>
  </md-list-item>

  <md-divider></md-divider>

  <md-list-item>
    <md-icon md-list-icon>folder</md-icon>
    <h4 md-line>
      <a routerLink="/hello" (click)="handleClickMenu($event)" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">hello</a>
    </h4>
  </md-list-item>
</md-list>

クリックイベントでは、サイドナビのモードが「over」だったらclose()し、サイドナビを閉じます。(今見ると this.sidenav.mode === 'over' よりも this.sidenav.mode !== 'side' の方が良かったかも)

import { Component, Input } from '@angular/core';
import {MdSidenav} from "@angular/material";

@Component({
  selector: 'app-sidenav',
  templateUrl: './sidenav.component.html',
  styleUrls: ['./sidenav.component.sass']
})
export class SidenavComponent {

  @Input()
  sidenav: MdSidenav;

  handleClickMenu(event) {
    if (this.sidenav.mode === 'over')
      this.sidenav.close();
  }
}

以上がおおまかなサイドナビの自動開閉の仕組みの説明となります。

この書籍で大体angularできる

実際に私は以下の書籍を購入し、せっせと読み込みました。

Angularアプリケーションプログラミング

Angularアプリケーションプログラミング

この書籍は非常によくできており、これを読み終える頃には大体の事ができるようになっていると思います。他にもangularの書籍はありますが、直近ではこちらが良いかと思います。angular-cliの項目が最後の方に少ししか無かったのと、サーバサイドレンダリングについての項が無かったのは残念でしたが、それ以外は凄くよくできている本だと思いました。

2017/11/6には更に以下のangular本も出るようで、こちらには「サーバーサイドレンダリング」の項もあるようで、もしかしたらangular-universalの話が載っている可能性もあり、要注目です!

Angularデベロッパーズガイド  高速かつ堅牢に動作するフロントエンドフレームワーク

Angularデベロッパーズガイド 高速かつ堅牢に動作するフロントエンドフレームワーク

  • 作者: 宇野陽太,奥野賢太郎,金井健一,林優一,吉田徹生,稲富駿,丸山弘詩
  • 出版社/メーカー: インプレス
  • 発売日: 2017/12/15
  • メディア: 単行本(ソフトカバー)
  • この商品を含むブログを見る

雑感

angular4をやるにあたり、angular-cliを使いましたが、中々いいものですね。これでscaffoldすれば構成も崩されにくいですし、超絶面倒で難易度の高いwebpackを直接触らなくていいというメリットがあります。(詳細に触れないというデメリットにもなりますが

reactにもcreate-react-appがあり、今後簡単なものはcliでやっていきたいと思いました。webpackを生でゴリゴリするのは非常に疲弊するので、正直やりたくないですね。。。

今回react v15 -> angular4と触ったわけですが、angularをやってみて良かった事、良くなかった事をちょっと書いてみます。(私はフロントエンドエンジニアではないので素人意見です)

angularの良かった点

  • IntelliJ IDEA UltimateとVisual Studio Codeのコード補完がreactよりも強力だった。(IntelliJの方が強力です)
  • テンプレート(html)とロジック(component)の分離が良かった。
  • typescriptは非常にシェアを伸ばしている重要な技術要素で、今後もしangularが廃れてもtypescriptの経験は他で活かせる。
  • reactで言うところのpropsバケツリレーがangularでは@Input() @Output()で、これは凄く解りやすいと思った。
  • angularはフルスタックで学習コストが〜、と騒がれていたので心配していたが、事前にreactの知識があったせいか、大した勉強もせずに書けた。昔と比較して結構まとまってきたのでしょうかね。

angularの良くなかった点

  • angular公式のangular-materialのコンポーネントの量と質が、react公式ではないmaterial-uiに圧倒的に負けていること。
  • (click)="resize($event)" 等の書き方がちょっと嫌だった。最初は「(click)」なのか「[click]」なのか覚えられなかった。
  • *ngIf 等がキモすぎて引くレベル。変数を渡す際に髭{{}}で囲む時と囲まない時があって解りにくい。「mode="{{sidenavMode}}"」「[sidenav]="sidenav"」
  • moduleの存在が有難いような、面倒くさいような。
  • Google先生、絶対angularに全力を出してない・・・

こんな感じです。意外と学習コストは高くなかったです。IntelliJでコード補完がバリバリ効きますし(html上でcomponent内のメソッドも補完可能)、思ったよりは書きやすいと思いました。補完も効きにくく、どんな書き方をすればいいかはリファレンスを調べないと解らないreactよりは遥かに好感が持てました。

しかし、イベントバインディングの「(click)="resize($event)"」やプロパティバインディングの「value="{{val}}"」といった記述に関しては「う〜ん?」と唸ってしまいますね。two way bindingなせいでこういう記述にならざるをえないのだと思いますが、こればっかりはreactの方がスッキリ簡潔に記述できます。

また、ngFor等のディレクティブの構文もちょっと「う〜ん?」でした。例えば以下のような感じのシンタックスなのですが、以下のように唐突に「let」が登場したりするのが最っ高にキモいですね。。。

<ul>
  <li *ngFor="let item of items">{{item}}</li>
</ul>

一方reactは以下のような形で、単に配列をmapでループさせるという、所謂「見たまんま」の解りやすいコードです。

<ul>
  {this.state.items.map((item) => {
    return <li>{item}</li>;
  })}
</ul>

この辺を考えると、私はjsxの方が好みだな〜、と感じました。人それぞれだとは思いますけどね。


こんな感じでangularを始めたわけですが、とりあえずangular4を使ったSPAサイトを新規開発中で、またGoogle App Engineで公開しようと画策中です。

今回GAE/Golangにするか迷っていて、今GAE/Javaにv1.8系がベータ版で動くようで、GAEでspark frameworkが動かせるようなのです。その辺は先日以下の記事の最後の方に書きましたので合わせてどうぞ。

www.bunkei-programmer.net

kotlin + sparkでjavaなのに高速にスピンアップ+FW初期化できちゃうかな〜!?と期待しているので、上手くいけばkotlin + sparkで、ダメそうなら嫌々Golangでやる事になりそうです。

おまけ:@ngrx/store版も用意しました

今回用意したgithubのソースはReactで言うところのstateで状態を保存していました。

しかしAngularには@ngrx/storeというRx.jsベースのストア管理ができるので、@ngrx/store版のサイドナビ開閉コードも用意しました。

github.com

Google Chromeのアドオンであるredux-devtools/でstateを確認できるよう仕込んであります。

内容としてはこちらの方が絶対いいのですが、ストアの概念が追加されるので、学習目的では@ngrx/storeを使わない方から見ていく方が良いと思われます。

後日談

GAE/Java8 + kotlin + Spark Framework, angular4, angular-material v2 betaの組み合わせでサイトを公開してみました!

ちゃっかりServiceWorkerも有効にしているので、chromeやfirefox等のServiceWorker対応ブラウザの場合は非常に高速に動きます。
www.string-utility.com