Angular에서 Renderer2와 ApplicationRef를 활용해 화면에 동적으로 생성되는 사이드바와 팝업 모드를 전환할 수 있는 기능을 구현해 보겠습니다. 이번 글에서는 사용자가 사이드바를 열고 닫을 수 있으며, 사이드바를 드래그하여 팝업 모드로 전환하거나 다시 사이드바로 돌아가는 방법을 다룹니다. 이를 통해 Angular의 커스텀 디렉티브와 DOM 조작을 효과적으로 사용하는 방법을 알아보겠습니다.
구현 목표
- 사이드바 열기와 닫기: 클릭 시 사이드바가 화면 오른쪽에서 열리고, 다시 클릭하면 닫히도록 합니다.
- 팝업 전환: 드래그하여 화면 중간으로 이동하면 사이드바가 팝업 모드로 전환되도록 합니다.
- 사이드바 복귀: 팝업을 오른쪽으로 드래그해 특정 위치에 오면 다시 사이드바 모드로 돌아갑니다.
주요 기술 요소
- 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;
}
}
'FE > Angular' 카테고리의 다른 글
[Angular] Ionic에서 Popover가 Sidebar 뒤에 렌더링되는 문제 (0) | 2024.11.20 |
---|---|
[Angular] GraphQL과 gql을 사용한 서버 요청 (0) | 2024.11.12 |
[Angular] RxJS에서 Observables와 Subjects 사용하기 (0) | 2024.11.09 |
[Angular] @ViewChild 알아보기 (0) | 2024.11.08 |
[Angular] ngComponentOutlet과 ngSwitchCase로 동적 컴포넌트 표시 (0) | 2024.11.07 |