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);
};
}
'FE > Angular' 카테고리의 다른 글
[Angular] @ViewChild 알아보기 (0) | 2024.11.08 |
---|---|
[Angular] ngComponentOutlet과 ngSwitchCase로 동적 컴포넌트 표시 (0) | 2024.11.07 |
[Angular] Directive란? (0) | 2024.11.05 |
[Angular] ngOnChanges() 훅 제대로 이해하기 (0) | 2024.10.19 |
[Angular] @Input()과 @Output() (0) | 2024.10.18 |