FE/Angular

[Angular] Popup Directive

monkeykim 2024. 11. 5. 15:16

Angular 애플리케이션을 개발하다 보면 종종 모달이나 팝업과 같은 사용자 인터페이스 요소를 필요로 할 때가 있습니다. 오늘은 클릭 이벤트로 열고 닫을 수 있으며, 화면 중앙에 나타나고 드래그로 위치를 이동할 수 있는 팝업을 만드는 PopupDirective를 구현해보겠습니다.


PopupDirective의 기본 구조

먼저, PopupDirective의 구조를 살펴보겠습니다. 이 디렉티브는 @Directive 데코레이터를 통해 정의되었으며, standalone 속성을 true로 설정하여 독립적인 컴포넌트로 사용할 수 있게 합니다.

@Directive({
  selector: '[appPopup]',
  standalone: true,
})
export class PopupDirective {
  @Input('appPopup') content!: TemplateRef<any>;
  private embeddedViewRef!: EmbeddedViewRef<any>;
  private isVisible = false;
  private offsetX: number = 0;
  private offsetY: number = 0;
  private isDragging: boolean = false;

  constructor(
    private appRef: ApplicationRef,
    private hostElement: ElementRef,
    private renderer: Renderer2
  ) {}
  • content: 팝업에 표시할 TemplateRef로, @Input 데코레이터를 통해 외부에서 템플릿을 전달받습니다.
  • embeddedViewRef: 팝업의 내용을 담는 EmbeddedViewRef로, 동적으로 생성된 뷰를 참조합니다.
  • isVisible: 팝업의 표시 여부를 관리하는 플래그입니다.
  • offsetX, offsetY: 팝업 드래그 시 마우스와 팝업 모서리 간의 거리(오프셋)를 저장하는 변수입니다.
  • isDragging: 팝업이 현재 드래그 중인지 여부를 나타냅니다.

팝업 열기와 닫기: togglePopup 메소드

팝업을 열고 닫는 togglePopup 메소드는 사용자가 클릭할 때마다 팝업이 표시되거나 사라지도록 합니다.

@HostListener('click', ['$event'])
togglePopup(event: Event): void {
  event.stopPropagation(); // 이벤트 전파 방지
  this.isVisible = !this.isVisible;

  if (this.isVisible) {
    this.showPopup();
  } else {
    this.closePopup();
  }
}
  • @HostListener 데코레이터는 click 이벤트를 감지하여 togglePopup 메소드를 실행합니다.
  • event.stopPropagation()은 부모 요소로의 이벤트 전파를 막아주므로, 다른 클릭 이벤트와의 충돌을 방지할 수 있습니다.
  • isVisible 플래그에 따라 showPopup 혹은 closePopup을 호출하여 팝업을 표시하거나 닫습니다.

팝업 외부 클릭 감지: onDocumentClick

팝업이 열려 있을 때, 사용자가 팝업 외부를 클릭하면 자동으로 팝업이 닫히도록 합니다.

@HostListener('document:click', ['$event.target'])
onDocumentClick(target: HTMLElement): void {
  const popupElement = this.embeddedViewRef?.rootNodes[0] as HTMLElement;

  if (this.isVisible && !this.hostElement.nativeElement.contains(target) && popupElement && !popupElement.contains(target)) {
    this.closePopup();
  }
}
  • document:click 이벤트를 감지하여 팝업 외부의 클릭을 확인합니다.
  • 팝업 외부를 클릭한 경우에만 closePopup 메소드를 호출하여 팝업을 닫습니다.

팝업 표시 및 초기 위치 설정: showPopup

showPopup 메소드는 팝업을 화면 중앙에 고정된 위치로 표시하고, 드래그 이벤트 리스너를 추가합니다.

public showPopup(): void {
  this.embeddedViewRef = this.content.createEmbeddedView({});
  this.appRef.attachView(this.embeddedViewRef);
  const domElem = this.embeddedViewRef.rootNodes[0] as HTMLElement;
  document.body.appendChild(domElem);

  domElem.style.position = 'fixed';
  domElem.style.top = '50%';
  domElem.style.left = '50%';
  domElem.style.transform = 'translate(-50%, -50%)';

  this.renderer.listen(domElem, 'mousedown', this.onMouseDown.bind(this));
}
  • createEmbeddedView 메소드를 사용해 팝업 내용을 DOM에 동적으로 추가합니다.
  • 중앙 위치를 설정하기 위해 position, top, left, transform 스타일 속성을 설정합니다.
  • 드래그를 위해 mousedown 이벤트 리스너를 추가하여, 사용자가 팝업을 클릭했을 때 onMouseDown 메소드가 호출되도록 설정합니다.

팝업 닫기: closePopup

closePopup 메소드는 팝업을 화면에서 제거하고, Angular 애플리케이션에서 이 뷰를 관리하지 않도록 분리합니다.

public closePopup(): void {
  if (this.embeddedViewRef) {
    this.appRef.detachView(this.embeddedViewRef);
    const domElem = this.embeddedViewRef.rootNodes[0] as HTMLElement;
    if (domElem && domElem.parentNode) {
      domElem.parentNode.removeChild(domElem);
    }
    this.isVisible = false;
  }
}
  • detachView 메소드는 Angular의 변경 감지에서 분리하여 메모리 누수를 방지합니다.
  • DOM에서 팝업 요소를 제거하여 화면에서 보이지 않게 합니다.

팝업 드래그 시작: onMouseDown

팝업을 드래그하기 위해 마우스 클릭 위치와 팝업의 좌표 차이를 계산하여 드래그 시 위치 이동이 부드럽게 되도록 합니다.

private onMouseDown(event: MouseEvent): void {
  event.preventDefault();
  const popupElement = this.embeddedViewRef?.rootNodes[0] as HTMLElement;

  const rect = popupElement.getBoundingClientRect();
  popupElement.style.top = `${rect.top}px`;
  popupElement.style.left = `${rect.left}px`;
  popupElement.style.transform = '';

  this.offsetX = event.clientX - rect.left;
  this.offsetY = event.clientY - rect.top;
  this.isDragging = true;

  document.addEventListener('mousemove', this.onMouseMove);
  document.addEventListener('mouseup', this.onMouseUp);
}
  • offsetX와 offsetY를 계산하여 드래그 시 팝업의 위치를 정확히 이동시킵니다.
  • isDragging 플래그를 true로 설정하여 드래그 상태를 활성화하고, mousemove와 mouseup 이벤트 리스너를 추가합니다.

팝업 드래그 이동: onMouseMove

마우스 이동에 따라 팝업 위치를 업데이트하여 사용자가 드래그할 때 팝업이 마우스를 따라 이동하게 합니다.

private onMouseMove: (event: MouseEvent) => void = (event: MouseEvent) => {
  if (this.isDragging) {
    const popupElement = this.embeddedViewRef?.rootNodes[0] as HTMLElement;

    if (popupElement) {
      popupElement.style.position = 'absolute';
      popupElement.style.left = `${event.clientX - this.offsetX}px`;
      popupElement.style.top = `${event.clientY - this.offsetY}px`;
    }
  }
};
  • onMouseMove 메소드는 마우스의 현재 좌표에서 offsetX, offsetY를 빼준 위치에 팝업을 위치시킵니다.

드래그 종료: onMouseUp

드래그 상태를 해제하고 이벤트 리스너를 제거하여 더 이상 팝업이 움직이지 않도록 합니다.

private onMouseUp: () => void = () => {
  this.isDragging = false;
  document.removeEventListener('mousemove', this.onMouseMove);
  document.removeEventListener('mouseup', this.onMouseUp);
};
  • isDragging을 false로 설정하여 드래그가 종료되었음을 표시합니다.
  • mousemove와 mouseup 이벤트 리스너를 제거하여 불필요한 이벤트 감지를 중단합니다.

전체 코드

import { Directive, Input, TemplateRef, ApplicationRef, EmbeddedViewRef, HostListener, ElementRef, Renderer2 } from '@angular/core';

@Directive({
  selector: '[appPopup]',
  standalone: true,
})
export class PopupDirective {
  @Input('appPopup') content!: TemplateRef<any>;
  private embeddedViewRef!: EmbeddedViewRef<any>;
  private isVisible = false;

  private offsetX: number = 0;
  private offsetY: number = 0;
  private isDragging: boolean = false;

  constructor(
    private appRef: ApplicationRef,
    private hostElement: ElementRef,
    private renderer: Renderer2
  ) {}

  /**
   * 클릭하여 팝업을 열거나 닫습니다.
   * @param event 클릭 이벤트
   */
  @HostListener('click', ['$event'])
  togglePopup(event: Event): void {
    // event.stopPropagation는 이벤트가 부모 요소나 다른 상위 요소로 전파되는 것을 막는 역할을 함. 즉, 해당 요소에서 이벤트가 중지되어 다른 이벤트 핸들러가 실행되지 않도록 하는 역할을 함
    // 이 디렉티브는 click 이벤트가 발생할 때 팝업을 토글을 함.
    // 그런데 팝업을 열었을 때, 만약 이벤트가 부모 요소로 전파된다면 다른 click 이벤트 핸들러가 의도하지 않게 실행될 수 있음.
    // 특히 팝업 외부를 클릭하면 팝업을 닫게 하는 기능이 있다면, 이 click 이벤트가 팝업 외부 클릭으로 인식될 수 있음.
    event.stopPropagation();
    this.isVisible = !this.isVisible;

    if (this.isVisible) {
      this.showPopup();
    } else {
      this.closePopup();
    }
  }

  /**
   * 팝업 외부의 클릭을 감지하여 팝업을 닫습니다.
   * @param target 클릭된 HTML 요소
   */
  @HostListener('document:click', ['$event.target'])
  onDocumentClick(target: HTMLElement): void {
    const popupElement = this.embeddedViewRef?.rootNodes[0] as HTMLElement;

    // 팝업 외부를 클릭한 경우에만 닫기
    if (this.isVisible && !this.hostElement.nativeElement.contains(target) && popupElement && !popupElement.contains(target)) {
      this.closePopup();
    }
  }

  /**
   * 팝업을 화면에 표시하고 초기 위치를 설정합니다.
   */
  public showPopup(): void {
    // TemplateRef 타입의 createEmbeddedView 메소드는 템플릿을 동적으로 DOM에 추가할 수 있도록 view instance를 생성한다.
    this.embeddedViewRef = this.content.createEmbeddedView({});
    // 생성된 embeddedViewRef를 ApplicationRef에 추가하여 Angular의 변경 감지 기능을 적용받도록 수행
    // 이 뷰를 애플리케이션에 연결하여 Angular의 생명 주기 안에서 관리하게 함
    this.appRef.attachView(this.embeddedViewRef);

    // embeddedViewRef에서 실제 DOM 요소를 가져온다.
    // rootNodes 배열은 이 뷰의 루트 노드들을 포함하고 있으며, 일반적으로 첫번째 요소가 해당 뷰의 주요 DOM 요소가 된다.
    const domElem = this.embeddedViewRef.rootNodes[0] as HTMLElement;
    // domElem을 document.body에 추가하여 화면에 표시한다.
    // document는 브라우저에서 항상 존재하는 전역 객체이기 때문에 별도의 의존성 주입 없이 접근이 가능하다.
    document.body.appendChild(domElem);

    // 처음 팝업이 뜰 때 위치값 설정
    domElem.style.position = 'fixed';
    domElem.style.top = '50%';
    domElem.style.left = '50%';
    domElem.style.transform = 'translate(-50%, -50%)';

    // 드래그 이벤트 리스너 추가
    // 팝업 요소에서 mousedown 이벤트가 발생할 때, onMouseDown 메소드를 호출하도록 이벤트 리스너를 추가한다.
    this.renderer.listen(domElem, 'mousedown', this.onMouseDown.bind(this));
  }

  /**
   * 팝업을 닫고 뷰를 Angular의 변경 감지에서 분리하여 메모리 누수를 방지합니다.
   */
  public closePopup(): void {
    if (this.embeddedViewRef) {
      this.appRef.detachView(this.embeddedViewRef);
      const domElem = this.embeddedViewRef.rootNodes[0] as HTMLElement;
      if (domElem && domElem.parentNode) {
        domElem.parentNode.removeChild(domElem);
      }
      this.isVisible = false;
    }
  }

  /**
   * 드래그 시작 위치와 마우스와 팝업 간의 오프셋을 설정하여 팝업이 부드럽게 이동할 수 있도록 합니다.
   * @param event 마우스 이벤트
   */
  private onMouseDown(event: MouseEvent): void {
    // mouse event의 기본 동작(텍스트 선택 등)이 방지됨
    // 드래그 중 발생할 수 있는 예기치 않은 동작을 방지하고, 드래그가 부드럽게 동작하도록 돕는다.
    event.preventDefault();

    // 팝업 요소의 DOM 참조를 가져와 드래그 작업에 사용할 수 있게 합니다.
    const popupElement = this.embeddedViewRef?.rootNodes[0] as HTMLElement;

    // 중앙에서 시작 좌표를 고정하고 transform 제거
    // 팝업 요소의 현재 위치와 크기를 가져오는 getBoundingClientRect 함수를 호출하여 요소의 위치 및 크기 정보를 얻음
    const rect = popupElement.getBoundingClientRect();
    popupElement.style.top = `${rect.top}px`;
    popupElement.style.left = `${rect.left}px`;
    // transform을 제거하여 이동 가능하도록 함
    // transform은 드래그할 때, 위치 계산에 복잡성을 높이기 때문임. 따라서 transform을 제거하고, 현재 위치를 top, left 속성에 고정시켜 드래그할 때 단순하게 움직일 수 있음
    popupElement.style.transform = '';

    // offset: 마우스 위치와 팝업의 왼쪽 모서리 간의 거리
    // offset을 사용하는 이유: 드래그 중에 마우스 포인터와 팝업이 일정한 상대 위치를 유지할 수 있게하기 위함
    // 마우스를 팝업의 중간 부분에서 클릭해서 드래그를 시작했다면, 드래그 중에도 팝업의 중간 부분이 마우스 포인터에 계속 붙어 있도록 하기 위함임.
    // 만약 오프셋이 없다면, 드래그할 때 팝업이 마우스를 따라다니면서 움직임이 부자연스러울 수 있음
    this.offsetX = event.clientX - rect.left;
    this.offsetY = event.clientY - rect.top;
    this.isDragging = true;

    document.addEventListener('mousemove', this.onMouseMove);
    document.addEventListener('mouseup', this.onMouseUp);
  }

  /**
   * 드래그 중 팝업 위치를 마우스 위치에 맞추어 이동합니다.
   * @param event 마우스 이벤트
   */
  private onMouseMove: (event: MouseEvent) => void = (event: MouseEvent) => {
    if (this.isDragging) {
      const popupElement = this.embeddedViewRef?.rootNodes[0] as HTMLElement;

      if (popupElement) {
        popupElement.style.position = 'absolute';
        popupElement.style.left = `${event.clientX - this.offsetX}px`;
        popupElement.style.top = `${event.clientY - this.offsetY}px`;
      }
    }
  };

  /**
   * 드래그 상태를 종료하고 `mousemove`와 `mouseup` 이벤트 리스너를 제거합니다.
   */
  private onMouseUp: () => void = () => {
    this.isDragging = false;
    document.removeEventListener('mousemove', this.onMouseMove);
    document.removeEventListener('mouseup', this.onMouseUp);
  };
}