9
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Angular MaterialのWrapper Componentを作る

Last updated at Posted at 2018-12-25

こんにちは。24日目、思いっきり遅刻してしまい申し訳ありません。Angularを使い始めて半年も経ってないですが、Angular Material ComponentのWrapper component(特にmat-radio-button)を作りながらいくつか気づいたところを書いていきたいと思います。

@Input

まず基本的なInputプロパティ。普通のやり方でdisabledで例えると

  • Wrapperを使うクライアントからのBinding
app.component.html
<app-radio-button [disabled]="true">Option 1</app-radio-button>
  • WrapperのInputプロパティを経由して
radio-button.component.ts
@Component({
...
})
export class RadioButtonComponent {
  @Input()
  public disabled: boolean;
...
  • WrapされるComponentプロパティにBinding
radio-button.component.html
<mat-radio-button [disabled]="disabled">
  <ng-content></ng-content>
</mat-radio-button>

の順番にデータが流れます。

HTML attribute対応

app.component.html
<app-radio-button disabled>Option 1</app-radio-button>

ここで上のようにdisabledをHTML attributeにも対応するには
(ここでdisalbedは空の文字列が入る)

radio-button.component.ts
...
export class RadioButtonComponent {
  private _disabled: boolean;
  @Input()
  get disabled(): boolean { return this._disabled; }
  set disabled(value) {
    this._disabled = (value === '' || !!value);
  }
...

のように強引にstring型にも対応した感じですが、実はAngular Material側ですでに対策済みであるため、ただプロパティを流すだけでHTML attributeにも対応ができてしまいます。(というか上の方法ではTSエラーになってしまいます。)

coerceBooleanProperty()

対策されているAngular Materialを覗くと下記のようにCDKのヘルパー関数が使われています。

radio-button.component.ts
import { coerceBooleanProperty } from '@angular/cdk/coercion';
...
  set disabled(value) {
    this._disabled = coerceBooleanProperty(value);
  }
...

HTML attributeで取得されるvalueはstring型になり、上記の関数はvalueが文字列なら'false'以外すべてをtrueに変換するようです。
もしプロパティの行き先がAngular MaterialのComponentじゃないときに使うと便利そうですね。

mat-radio-group

 Front-end開発において、Radio buttonはその性質上、複数のComponentで単一のフォームを持つ必要があります。ReactiveFormsだったらFormControl、Template-drivenだったらNgModelどのタグに持たせるか。Angular Materialでは<mat-radio-group>という親タグその役割を担っています。
 しかし、Radio buttonはまた、他のinputフォームと違って、name属性によってGroupingされる特殊な仕様があります。各radio-buttonタグでnameを指定してまとめる手もありますが、そうしてしまうと親のradio-groupタグの存在意義がなくなる。
 これをどう対処するか悩ましいところですが、そもそもwrapしようとするmat-radio-groupを覗くと、

material2/src/lib/radio/radio.html
    <input #input class="...
        ...
        [attr.name]="name"
material2/src/lib/radio/radio.ts
...
let nextUniqueId = 0;
...
@Directive({
...
})
export class MatRadioGroup extends ... {
  ...
  // nameはgroupコンポーネントで決まる
  private _name: string = `mat-radio-group-${nextUniqueId++}`;
  ...
  // content内の子コンポーネントのリストを取得
  @ContentChildren(forwardRef(() => MatRadioButton), { descendants: true })
  _radios: QueryList<MatRadioButton>;
  ...
  @Input()
  get name(): string { return this._name; }
  set name(value: string) {
    this._name = value;
    // nameが変わったら子コンポーネントにも反映する
    this._updateRadioButtonNames();
  }
  ...
  private _updateRadioButtonNames(): void {
    if (this._radios) {
      this._radios.forEach(radio => {
        radio.name = this.name;
      });
    }
  }

なるほど、@ContentChildrenで子コンポーネントを取得し、親と同じnameをセットする仕組みのようです。

 今度はMatRadioButtonを見ると、

material2/src/lib/radio/radio.ts
...
@Component({
...
})
export class MatRadioButton extends ... implements OnInit, ... {
  ...
  @Input() name: string;
  ...
  constructor(
    @Optional() radioGroup: MatRadioGroup,
    ...
  ) {
    ...
  }
  ...
  ngOnInit() {
    if (this.radioGroup) {
      ...
      // 親groupのnameを引き継ぐ
      this.name = this.radioGroup.name;
    }
  }
...

DIで親コンポーネントを取得してそのnameを引き継ぎます。

Circular dependency

 ソースを見て気づいた方も多いと思いますが、MatRadioButtonMatRadioGroupが自分のクラスの中で互いを参照する状態になっています。上記の例のようにradio.tsという一つのファイルの中ならforwardRefを使うだけでなんとかなっています。ずるい。
 しかし、Wrapper Componentを作る際はAngular流儀上、1コンポーネント/1ファイルになるため、そのまま真似してしまうと、

WARNING in Circular dependency detected:
src/app/radio-button/radio-button.component.ts -> src/app/radio-group/radio-group.component.ts -> src/app/radio-button/radio-button.component.ts

WARNING in Circular dependency detected:
src/app/radio-group/radio-group.component.ts -> src/app/radio-button/radio-button.component.ts -> src/app/radio-group/radio-group.component.ts

を食らってしまうし、一度決まったnameをプログラム途中で変更するユースケースは想定しにくいので、

  • ButtonからGroupのnameを取りに行く
  • Groupからは何もしない(Buttonを参照しない)

の方針で、依存をButton(子)→Group(親)のように一方向にするのが妥当かなと思います。

ControlValueAccessor

 Custom Form Controlを作るためにControlValueAccessorを実装します。ControlValueAccessorとはAngular Form API(FormControl)とDOMのNative(or Custom) Elementをつなぐinterfaceで、<input>などフォームタグ(Native element)に対応するDirectiveはすべてこのinterfaceを実装しており、以下のように定義されています。

Accessor Form Element
DefaultValueAccessor input, textarea
CheckboxControlValueAccessor input[type=checkbox]
NumberValueAccessor input[type=number]
RadioControlValueAccessor input[type=radio]
RangeValueAccessor input[type=range]
SelectControlValueAccessor select
SelectMultipleControlValueAccessor select[multiple]

ControlValueAccessorの各メソッドの実装が済んだら、あとはNG_VALUE_ACCESSORというDI tokenを持ってProviderに登録することで、FormControlが使えるようになります。

radio-group.component.ts
@Component({
  ...
  providers: [{
    provide: NG_VALUE_ACCESSOR,
    useExisting: forwardRef(() => RadioGroupComponent),
    multi: true
  }],
  ...
})
export class RadioGroupComponent implements ControlValueAccessor, ... {
  ...
  writeValue(obj: any) {...}
  registerOnChange(fn: any) {...}
  registerOnTouched(fn: any) {...}
  setDisabledState(isDisabled: boolean) {...}
}

DI tokenとかProviderとか調べるとどういう仕組になってるのか、あれこれ説明されているところは多いですが、いくらソースコードを読んでみても、そもそものAngularのDIコンテナの仕組み自体あまり理解できず、雰囲気だけで作ってる感があるので、詳しく説明していただける方がいらっしゃれば…

References

9
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?