FE/Angular

[Angular] Renderer2를 사용한 동적 사이드바 및 팝업 전환 Directive 구현

monkeykim 2024. 11. 15. 10:09

Angular에서 Renderer2와 ApplicationRef를 활용해 화면에 동적으로 생성되는 사이드바팝업 모드를 전환할 수 있는 기능을 구현해 보겠습니다. 이번 글에서는 사용자가 사이드바를 열고 닫을 수 있으며, 사이드바를 드래그하여 팝업 모드로 전환하거나 다시 사이드바로 돌아가는 방법을 다룹니다. 이를 통해 Angular의 커스텀 디렉티브와 DOM 조작을 효과적으로 사용하는 방법을 알아보겠습니다.


구현 목표

  1. 사이드바 열기와 닫기: 클릭 시 사이드바가 화면 오른쪽에서 열리고, 다시 클릭하면 닫히도록 합니다.
  2. 팝업 전환: 드래그하여 화면 중간으로 이동하면 사이드바가 팝업 모드로 전환되도록 합니다.
  3. 사이드바 복귀: 팝업을 오른쪽으로 드래그해 특정 위치에 오면 다시 사이드바 모드로 돌아갑니다.

주요 기술 요소

  • Renderer2: Angular에서 직접 DOM을 조작할 때 사용하며, 보안과 크로스 브라우징을 고려하여 DOM을 다룹니다.
  • ApplicationRef: 동적으로 생성한 Angular 뷰를 애플리케이션의 변경 감지 체계에 연결하거나 분리하는 데 사용합니다.
  • Directive: 커스텀 디렉티브를 통해 재사용 가능한 사이드바 컴포넌트를 구현할 수 있습니다.

실행 화면


코드 구현

1. 클래스 구조와 초기 상태 관리

먼저, 사이드바의 상태를 관리할 프로퍼티들을 정의합니다.

@Directive({
  selector: '[appSidebar]',
  standalone: true
})
export class SidebarDirective {
  @Input('appSidebar') content!: TemplateRef<any>;
  private embeddedViewRef!: EmbeddedViewRef<any>;
  private sidebarElement!: HTMLElement;
  private isVisible: boolean = false;
  private isPopupMode: boolean = false;
  private isDragging: boolean = false;
  private isTransitioning: boolean = false;

  private startX: number = 0;
  private startY: number = 0;
  private offsetX: number = 0;
  private offsetY: number = 0;

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

여기에서 주요 프로퍼티들은 다음과 같습니다:

  • isVisible: 사이드바가 열려 있는지 여부.
  • isPopupMode: 팝업 모드인지 여부.
  • isDragging: 드래그 중인지 여부.
  • isTransitioning: 애니메이션이 진행 중인지 여부.

2. 사이드바 열기와 닫기 기능

toggleSidebar() 함수에서 사이드바를 열거나 닫습니다. isTransitioning이 true이면 애니메이션 중이므로 동작하지 않습니다.

@HostListener('click', ['$event'])
toggleSidebar(): void {
  if (this.isTransitioning) return;

  this.isVisible = !this.isVisible;
  if (this.isVisible) {
    this.showSidebar();
  } else {
    this.closeSidebar();
  }
}

3. showSidebar 함수

사이드바를 화면에 표시합니다. ApplicationRef를 통해 content 템플릿을 뷰로 만들고, 이를 DOM에 추가하여 사이드바를 보여줍니다. 기본 스타일을 적용하는 setSidebarMode를 호출해 사이드바 모드를 활성화합니다.

showSidebar(): void {
  this.isTransitioning = true;
  this.embeddedViewRef = this.content.createEmbeddedView({});
  this.appRef.attachView(this.embeddedViewRef);

  this.sidebarElement = this.embeddedViewRef.rootNodes[0] as HTMLElement;
  document.body.appendChild(this.sidebarElement);

  this.setSidebarMode();

  const headerElement = this.sidebarElement.querySelector('.sidebar-header') as HTMLElement;
  if (headerElement) {
    this.renderer.listen(headerElement, 'mousedown', this.onDragStart.bind(this));
  }
}

4. closeSidebar 함수

사이드바를 닫을 때는 팝업 모드와 사이드바 모드를 구분하여 처리합니다. 사이드바 모드에서는 오른쪽으로 슬라이드하는 애니메이션을 적용합니다. 팝업 모드라면 애니메이션 없이 바로 removeSidebarElement()를 호출하여 DOM에서 제거합니다.

closeSidebar(): void {
  if (!this.embeddedViewRef) return;

  if (this.isPopupMode) {
    this.removeSidebarElement();
    this.isPopupMode = false;
  } else {
    this.isTransitioning = true;
    this.sidebarElement.style.transform = 'translateX(100%)';
    this.sidebarElement.addEventListener(
      'transitionend',
      () => {
        this.removeSidebarElement();
        this.isTransitioning = false;
      },
      { once: true }
    );
    this.isVisible = false;
  }
}

5. 드래그 시작, 이동, 종료 핸들러

  • onDragStart: 드래그 시작 시 호출됩니다. 마우스 위치를 기준으로 시작 위치와 오프셋 값을 설정합니다.
  • onDragMove: 드래그 중에는 마우스 위치에 따라 left와 top 값을 갱신하여 팝업 위치를 업데이트합니다. 오른쪽 끝으로 이동하면 setSidebarMode()로 다시 사이드바로 전환합니다.
  • onDragEnd: 드래그가 끝나면 이벤트 리스너를 제거하여 메모리 누수를 방지합니다.
onDragStart(event: MouseEvent): void {
  event.preventDefault();

  const headerElement = this.sidebarElement.querySelector('.sidebar-header') as HTMLElement;
  if (headerElement) {
    const rect = headerElement.getBoundingClientRect();
    this.offsetX = event.clientX - rect.left;
    this.offsetY = event.clientY - rect.top;
  }

  this.startX = event.clientX;
  this.startY = event.clientY;
  this.isDragging = true;

  document.addEventListener('mousemove', this.onDragMove);
  document.addEventListener('mouseup', this.onDragEnd);
}


드래그 중일 때의 코드:

private onDragMove: (event: MouseEvent) => void = (event: MouseEvent) => {
  const deltaX = event.clientX - this.startX;
  if (!this.isPopupMode && deltaX < -410) {
    this.setPopupMode();
  } else if (this.isPopupMode) {
    const moveX = event.clientX - this.offsetX;
    const moveY = event.clientY - this.offsetY;
    this.sidebarElement.style.left = `${moveX}px`;
    this.sidebarElement.style.top = `${moveY}px`;

    const screenWidth = window.innerWidth;
    if (moveX > screenWidth - 400) {
      this.setSidebarMode();
    }
  }
};


드래그 종료 시 코드:

private onDragEnd: () => void = () => {
  this.isDragging = false;
  document.removeEventListener('mousemove', this.onDragMove);
  document.removeEventListener('mouseup', this.onDragEnd);
};

6. 모드 전환

  • setSidebarMode: 사이드바 모드로 전환하여 오른쪽 고정 위치로 설정합니다.
  • setPopupMode: 팝업 모드로 전환하여 사용자가 자유롭게 이동할 수 있는 팝업 형태로 설정합니다.
setSidebarMode() {
  this.isPopupMode = false;

  this.sidebarElement.style.position = 'fixed';
  this.sidebarElement.style.top = '0';
  this.sidebarElement.style.right = '0';
  this.sidebarElement.style.width = '360px';
  this.sidebarElement.style.height = '100%';
  this.sidebarElement.style.backgroundColor = 'red';

  this.sidebarElement.style.left = '';

  this.sidebarElement.style.transform = 'translateX(100%)';
  this.sidebarElement.style.transition = 'transform 0.3s ease';

  requestAnimationFrame(() => {
    this.sidebarElement.style.transform = 'translateX(0%)';
    this.sidebarElement.addEventListener('transitionend', () => {
      this.isTransitioning = false;
    });
  });
}

 

팝업 모드 코드:

setPopupMode() {
  this.isPopupMode = true;

  this.sidebarElement.style.position = 'fixed';
  this.sidebarElement.style.top = `${this.startY - this.offsetY}px`;
  this.sidebarElement.style.left = `${this.startX - this.offsetX}px`;
  this.sidebarElement.style.width = '850px';
  this.sidebarElement.style.height = '650px';
  this.sidebarElement.style.transform = 'none';

  this.sidebarElement.style.backgroundColor = 'yellow';

  this.isDragging = true;
}

 

전체 코드

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

@Directive({
  selector: '[appSidebar]',
  standalone: true
})
export class SidebarDirective {
  @Input('appSidebar') content!: TemplateRef<any>;
  private embeddedViewRef!: EmbeddedViewRef<any>;
  private sidebarElement!: HTMLElement;
  private isVisible: boolean = false;
  private isPopupMode: boolean = false;
  private isDragging: boolean = false;
  private isTransitioning: boolean = false;

  private startX: number = 0;
  private startY: number = 0;
  private offsetX: number = 0;
  private offsetY: number = 0;

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

  @HostListener('click', ['$event'])
  toggleSidebar(): void {
    if (this.isTransitioning) return;

    this.isVisible = !this.isVisible;
    if (this.isVisible) {
      this.showSidebar();
    } else {
      this.closeSidebar();
    }
  }

  showSidebar(): void {
    this.isTransitioning = true;
    this.embeddedViewRef = this.content.createEmbeddedView({});
    this.appRef.attachView(this.embeddedViewRef);

    this.sidebarElement = this.embeddedViewRef.rootNodes[0] as HTMLElement;
    document.body.appendChild(this.sidebarElement);

    this.setSidebarMode();

    const headerElement = this.sidebarElement.querySelector('.sidebar-header') as HTMLElement;
    if (headerElement) {
      this.renderer.listen(headerElement, 'mousedown', this.onDragStart.bind(this));
    }
  }

  closeSidebar(): void {
    if (!this.embeddedViewRef) return;

    if (this.isPopupMode) {
      this.removeSidebarElement();
      this.isPopupMode = false;
    } else {
      this.isTransitioning = true;
      this.sidebarElement.style.transform = 'translateX(100%)';
      this.sidebarElement.addEventListener(
        'transitionend',
        () => {
          this.removeSidebarElement();
          this.isTransitioning = false;
        },
        { once: true }
      );
      this.isVisible = false;
    }
  }

  private removeSidebarElement(): void {
    this.appRef.detachView(this.embeddedViewRef);
    const domElement = this.embeddedViewRef.rootNodes[0] as HTMLElement;
    if (domElement && domElement.parentNode) {
      domElement.parentNode.removeChild(domElement);
    }
  }

  onDragStart(event: MouseEvent): void {
    event.preventDefault();

    const headerElement = this.sidebarElement.querySelector('.sidebar-header') as HTMLElement;
    if (headerElement) {
      const rect = headerElement.getBoundingClientRect();
      this.offsetX = event.clientX - rect.left;
      this.offsetY = event.clientY - rect.top;
    }

    this.startX = event.clientX;
    this.startY = event.clientY;
    this.isDragging = true;

    document.addEventListener('mousemove', this.onDragMove);
    document.addEventListener('mouseup', this.onDragEnd);
  }

  private onDragMove: (event: MouseEvent) => void = (event: MouseEvent) => {
    const deltaX = event.clientX - this.startX;
    if (!this.isPopupMode && deltaX < -410) {
      this.setPopupMode();
    } else if (this.isPopupMode) {
      const moveX = event.clientX - this.offsetX;
      const moveY = event.clientY - this.offsetY;
      this.sidebarElement.style.left = `${moveX}px`;
      this.sidebarElement.style.top = `${moveY}px`;

      const screenWidth = window.innerWidth;
      if (moveX > screenWidth - 400) {
        this.setSidebarMode();
      }
    }
  };

  private onDragEnd: () => void = () => {
    this.isDragging = false;
    document.removeEventListener('mousemove', this.onDragMove);
    document.removeEventListener('mouseup', this.onDragEnd);
  };

  setSidebarMode() {
    this.isPopupMode = false;

    this.sidebarElement.style.position = 'fixed';
    this.sidebarElement.style.top = '0';
    this.sidebarElement.style.right = '0';
    this.sidebarElement.style.width = '360px';
    this.sidebarElement.style.height = '100%';
    this.sidebarElement.style.backgroundColor = 'red';

    this.sidebarElement.style.left = '';

    this.sidebarElement.style.transform = 'translateX(100%)';
    this.sidebarElement.style.transition = 'transform 0.3s ease';

    requestAnimationFrame(() => {
      this.sidebarElement.style.transform = 'translateX(0%)';
      this.sidebarElement.addEventListener('transitionend', () => {
        this.isTransitioning = false;
      });
    });
  }

  setPopupMode() {
    this.isPopupMode = true;

    this.sidebarElement.style.position = 'fixed';
    this.sidebarElement.style.top = `${this.startY - this.offsetY}px`;
    this.sidebarElement.style.left = `${this.startX - this.offsetX}px`;
    this.sidebarElement.style.width = '850px';
    this.sidebarElement.style.height = '650px';
    this.sidebarElement.style.transform = 'none';

    this.sidebarElement.style.backgroundColor = 'yellow';

    this.isDragging = true;
  }
}