Goalist Developers Blog

Observableでクリックイベントを制御してドロップダウンメニューを作ってみる【Angular】

こんにちは。ゴーリスト開発の飯尾です。
AngularでWebアプリを作ったりしています。
世の賢人によるイルなコンポーネントだと微妙に機能が多すぎたり足りなかったりで
結局自作のワックコンポーネント生産したりしますよね。

作りたいもの

  • Airbnb一休の検索条件指定部分みたいなドロップダウンメニュー
  • 内側クリックで閉じず、外側クリックで閉じる
  • 一画面でひとつだけ開く

イメージとしてはこんなやつです。

f:id:y-iio:20180109180913g:plain

参考にしたもの

基本的にはこれパクっ参考にしましたが
ここのところがちょっとな〜だったので書き換えました。

@HostListener('click', ['$event']) onClick(event: Event) {
    if (!this.hideOnClick) {
        event.stopPropagation();
    }
}

変えたいポイント

その1

ドロップダウンを開いている時のみクリックイベントを拾いたい(@HostListenerでイベント登録するとリムーブできない)

github.com

その2

iOSだとdocumentをルート要素としてクリックイベントを登録しても拾ってくれない

qiita.com

解決法を考える

ドロップダウンを開いている時のみクリックイベントを拾いたい 問題

Observableでクリックイベントを制御する

qiita.com

// ドロップダウン開いてる時
const stream = Observable.fromEvent(document, 'click').subsucribe(() => {
  console.log('clicked!');
});

// 閉じたら購読をやめる
stream.unsubscribe();

iOSだとdocumentをルート要素としてクリックイベントを登録しても拾ってくれない 問題

Observable.fromEvent(document, 'touchend')ならOK

そうしてできたもの

import {
  ChangeDetectorRef, Component, ElementRef, HostBinding, Injectable, OnDestroy, OnInit, ViewEncapsulation
} from '@angular/core';
import { Observable, Subscriber } from 'rxjs/Rx';

@Injectable()
export class DropdownRegistry {
  private dropdownMenuComponents: any[] = [];

  constructor() { }

  public add(dropdownMenu: DropdownMenuComponent) {
    this.dropdownMenuComponents.push(dropdownMenu);
  }

  public remove(dropdownMenu: DropdownMenuComponent) {
    this.dropdownMenuComponents.slice(this.dropdownMenuComponents.indexOf(dropdownMenu), 1);
  }

  public hideAllExcept(dropdownMenu: DropdownMenuComponent) {
    this.dropdownMenuComponents.forEach((component) => {
      if (component !== dropdownMenu) {
        component.hide();
      }
    });
  }
}

@Component({
  selector: 'app-dropdown-menu',
  template: `<ng-content></ng-content>`,
  encapsulation: ViewEncapsulation.None // ここはカプセル化しない
})
export class DropdownMenuComponent implements OnInit, OnDestroy {
  @HostBinding('class.is-visible') isVisible: boolean = false;

  private clickEventStream: any; // Observable<Event>とかObservable<any>だと警告出る
  private subscription: Subscriber<any>;

  constructor(
    private changeDetectionRef: ChangeDetectorRef,
    private dropdownRegistry: DropdownRegistry,
    private elementRef: ElementRef,
  ) {
    this.dropdownRegistry.add(this);
  }

  ngOnInit() {
    // iOSだとボタンとかリンクの要素以外はタッチしてもクリックイベントとしてみなされない
    this.clickEventStream = Observable.merge(Observable.fromEvent(document, 'click'), Observable.fromEvent(document, 'touchend'))
      .catch(e => Observable.throw(e));
  }

  ngOnDestroy() {
    this.dropdownRegistry.remove(this);
  }

  public toggle(event: Event): void {
    if (this.isVisible) {
      this.hide();
    } else {
      this.show(event);
    }
  }

  public hide(): void {
    this.isVisible = false;
    if (this.subscription && !this.subscription.closed) { // stream流れてるなら止める
      this.subscription.unsubscribe();
    }
    this.changeDetectionRef.markForCheck();
  }

  public show(event: Event): void {
    event.stopPropagation();
    this.hideAll();
    this.isVisible = true;
    this.subscription = this.clickEventStream.subscribe((clickEvent: Event) => { // 外側クリック時に閉じる
      clickEvent.stopPropagation();
      if (this.isVisible && !this.elementRef.nativeElement.contains(clickEvent.target)) {
        this.hide();
      }
    });
  }

  private hideAll(): void {
    this.dropdownRegistry.hideAllExcept(this);
  }

}

あとはドロップダウン表示用のトグルボタンとCSSをいいかんじにする
いいかんじにする

こんな感じで動いているよ

f:id:y-iio:20180109181027g:plain

おわりに

もっとドープな書き方を教えてくれるマイメンを募集しています!!