noxi雑記

.NET、Angularまわりの小ネタブログ

Angular MaterialとWijmoの入力コントロールを組み合わせる

Angularを使用するときにデザインとして何を組み込むかは重要な要素です。私は主に Angular Material を使用してマテリアルデザインな Web アプリを開発しますが、 Wijmo の入力コントロールをそのまま組み込もうとすると機能面、デザイン面共に不都合が発生します。

今回は Angular Material の FormField と Wijmo の数字入力コントロールとを組み合わせる方法を紹介します。


目的

この記事の目的は Angular Material でデザインコンポーネントを実装しているアプリに Wijmo の入力コントロールを組み入れる事です。 Wijmo は標準でマテリアルデザインに組み込むための CSS を提供していますが、 Material Design Lite を対象としたもので Angular4 以降の Angular Material に対応させるものはありません。 Angular Material は文字入力コンポーネントとして MatFormField があり、フロートラベルやエラーメッセージを表示する機能も統合されています。 Web アプリに Wijmo をスムーズに組み込むため MatFormFieldControl インターフェースを実装した Wijmo の入力コントロールMatFormField とを繋ぐ Directive を実装します。

今回のコードはこのレポジトリに保存されています。

github.com

検証環境

この記事執筆時の環境情報です。

     _                      _                 ____ _     ___
    / \   _ __   __ _ _   _| | __ _ _ __     / ___| |   |_ _|
   / △ \ | '_ \ / _` | | | | |/ _` | '__|   | |   | |    | |
  / ___ \| | | | (_| | |_| | | (_| | |      | |___| |___ | |
 /_/   \_\_| |_|\__, |\__,_|_|\__,_|_|       \____|_____|___|
                |___/


Angular CLI: 7.3.8
Node: 10.15.3
OS: win32 x64
Angular: 7.2.13
... animations, common, compiler, compiler-cli, core, forms
... language-service, platform-browser, platform-browser-dynamic
... router

Package                           Version
-----------------------------------------------------------
@angular-devkit/architect         0.13.8
@angular-devkit/build-angular     0.13.8
@angular-devkit/build-optimizer   0.13.8
@angular-devkit/build-webpack     0.13.8
@angular-devkit/core              7.3.8
@angular-devkit/schematics        7.3.8
@angular/cdk                      7.3.7
@angular/cli                      7.3.8
@angular/material                 7.3.7
@ngtools/webpack                  7.3.8
@schematics/angular               7.3.8
@schematics/update                0.13.8
rxjs                              6.3.3
typescript                        3.2.4
webpack                           4.29.0
  • wijmo@5.20183.568

プロジェクトの生成

まずは開発用のプロジェクトを生成します。 Angular CLI 経由でサクッと作ります。

ng new wijmo-material-sample --style=scss --routing=false
cd wijmo-material-sample
ng add @angular/material
npm i -P wijmo

@angular/material を追加した時のオプションは全て規定値で OK です。

モジュールインポートの追加

src/app/app.module.ts にある AppModule に以下5つのモジュールを追加でインポートします。

  • @angular/forms/FormsModule
  • @angular/forms/ReactiveFormsModule
  • @angular/material/MatFormFieldModule
  • wijmo/wijmo.angular2.core/WjCoreModule
  • wijmo/wijmo.angular2.input/WjInputModule

追加するとこのようになります。

src/app/app.module.ts

import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material';
import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { WjCoreModule } from 'wijmo/wijmo.angular2.core';
import { WjInputModule } from 'wijmo/wijmo.angular2.input';
import { AppComponent } from './app.component';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    BrowserAnimationsModule,
    FormsModule,
    ReactiveFormsModule,
    MatFormFieldModule,
    WjCoreModule,
    WjInputModule,
  ],
  providers: [],
  bootstrap: [ AppComponent ]
})
export class AppModule {
}

Wijmo 用の Directive 作成

Wijmo の入力コントロールMatFormField とを繋ぐための Directive を Angular CLI を使用して生成します。今回は数値系の WjInputNumber 向けです。

ng generate directive mat-wj-input-number

このコマンドを実行するとこのようなファイルが生成されます。

src/app/mat-wj-input-number.directive.ts

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

@Directive({
  selector: '[appMatWjInputNumber]'
})
export class MatWjInputNumberDirective {

  constructor() { }

}

インターフェースを追加する

出力されたディレクティブに実装すべきインターフェースを追加します。今回は以下の5つです。

  • MatFormFieldControl<any>
  • OnInit
  • OnDestroy
  • OnChanges
  • DoCheck
import { Directive, DoCheck, OnChanges, OnDestroy, OnInit } from '@angular/core';
import { MatFormFieldControl } from '@angular/material';

@Directive({
  selector: '[appMatWjInputNumber]'
})
export class MatWjInputNumberDirective implements MatFormFieldControl<any>, OnInit, OnDestroy, OnChanges, DoCheck {

  constructor() {
  }

}

provide の追加とセレクターの変更

MatFormField から今回実装している Directive を認識して貰うために、 provide で自身を MatFormFieldControl として提供します。また wj-input-number に対して設定するため、セレクターを正しく修正します。

@Directive({
  selector: 'wj-input-number[appMatWjInput]',
  providers: [
    { provide: MatFormFieldControl, useExisting: MatWjInputNumberDirective }
  ]
})

コンストラクタに依存を追加する

この Directive を実装するにあたって利用するものをコンストラクタに追加します。この Directive がバインドされる Wijmo のコントロール、 NgControl 、 FocusやAutoFill関連です。追加後のコンストラクタはこんな感じになります。

  constructor(
    @Self() private readonly wjControl: WjInputNumber,
    @Optional() @Self() public readonly ngControl: NgControl,
    private readonly focusMonitor: FocusMonitor,
    private readonly autofillMonitor: AutofillMonitor
  ) {
  }

実装する

noxi515.hateblo.jp

MatFormFieldControl 実装に必要なプロパティは上の記事で確認済みのため、MatInputの実装も参考にしつつ、ひとまず雰囲気で実装してみます。Wijmoのコントロールは必須属性である isRequired がありますが、このフラグを立てると値が削除できないという問題があるため、コントロールの必須とFormFieldとしての必須を分けることとしました。

src/app/mat-wj-input-number.directive.ts

import { FocusMonitor } from '@angular/cdk/a11y';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { AutofillMonitor } from '@angular/cdk/text-field';
import { Directive, DoCheck, Input, OnChanges, OnDestroy, OnInit, Optional, Self, SimpleChanges } from '@angular/core';
import { NgControl } from '@angular/forms';
import { MatFormFieldControl } from '@angular/material';
import { Subject } from 'rxjs';
import { WjInputNumber } from 'wijmo/wijmo.angular2.input';

let nextUniqueId = 0;

@Directive({
  selector: 'wj-input-number[appMatWjInput]',
  providers: [
    { provide: MatFormFieldControl, useExisting: MatWjInputNumberDirective }
  ]
})
export class MatWjInputNumberDirective implements MatFormFieldControl<any>, OnInit, OnDestroy, OnChanges, DoCheck {

  readonly controlType: string = 'mat-wj-input-number';
  readonly stateChanges = new Subject<void>();

  private readonly uid = `mat-wj-input-number-${nextUniqueId++}`;

  private _id!: string;
  private _required: boolean = false;

  autofilled: boolean = false;
  focused: boolean = false;


  @Input()
  placeholder!: string;

  get id(): string {
    return this._id || this.uid;
  }

  @Input()
  set id(id: string) {
    this._id = id || this.uid;
  }

  get required(): boolean {
    return this._required;
  }

  @Input()
  set required(required: boolean) {
    // Wijmoのコントロールはrequired指定すると必ず初期値が入る仕様なので、FormFieldとしてのrequiredを別途持つ
    this._required = coerceBooleanProperty(required);
  }

  get value(): number | null {
    const value = this.wjControl.value;
    return value == null ? null : value;
  }

  get disabled(): boolean {
    return this.wjControl.isDisabled;
  }

  get empty(): boolean {
    return this.value == null;
  }

  get shouldLabelFloat(): boolean {
    return this.focused || !this.empty;
  }

  get errorState(): boolean {
    return this.ngControl && this.ngControl.invalid === true;
  }

  constructor(
    @Self() private readonly wjControl: WjInputNumber,
    @Optional() @Self() public readonly ngControl: NgControl,
    private readonly focusMonitor: FocusMonitor,
    private readonly autofillMonitor: AutofillMonitor
  ) {
  }

  ngDoCheck(): void {
  }

  ngOnInit(): void {
    const el = this.wjControl.inputElement;
    this.focusMonitor.monitor(el, false).subscribe(state => {
      this.focused = state !== null;
      this.stateChanges.next();
    });
    this.autofillMonitor.monitor(el).subscribe(ev => {
      this.autofilled = ev.isAutofilled;
      this.stateChanges.next();
    });
  }

  ngOnChanges(changes: SimpleChanges): void {
    this.stateChanges.next();
  }

  ngOnDestroy(): void {
    this.stateChanges.complete();

    const el = this.wjControl.inputElement;
    this.focusMonitor.stopMonitoring(el);
    this.autofillMonitor.stopMonitoring(el);
  }


  onContainerClick(event: MouseEvent): void {
    this.wjControl.focus();
  }

  setDescribedByIds(ids: string[]): void {
    // Do nothing
  }

}

Directive の使用

今回実装した Directive は wj-input-number と一緒に使うことを想定しています。試しに使ってみます。 app.component.html を変更します。

src/app/app.component.html

<mat-form-field>
  <mat-label>WjInputNumber</mat-label>
  <wj-input-number appMatWjInput [formControl]="control" [isRequired]="false"></wj-input-number>
</mat-form-field>

そのまま出力してみるとこんな感じです。 CSS 当たってないのはわざとです。

f:id:noxi515:20190414181142p:plain
今回実装したDirectiveの試用

CSS の適用

Wijmo は2018.2のバージョンからから SCSS に対応したため、 CSS を構築するのに Angular Material との親和性がちょっとだけ良くなりました。。。が、結論を記すと Wijmo の CSS を SCSS で作るのはかなり難しいです。テーマ構成まで含めて作るのであれば普通の CSS をインポートしてちょこちょこカスタマイズする方が圧倒的に楽で早いです(私は Wijmo の SCSS カスタマイズに心が折れた)。
この記事の目的は MatFormField に Wijmo を組み込むことなので、 CSS についてはスキップします。。。

終わりに

Angular で開発しているアプリに Angular Material を導入すると手軽に見た目マテリアルデザインのアプリを実装することができますが、標準で用意されているコンポーネントだけでは不十分な事が多いです。既存の他のライブラリも今回の記事の様に Angular Material と共存して組み込むことができますので、色々工夫してみてはいかがでしょうか。