최근에, 전에 개발했던 피쳐의 확장 및 개선을 위한 개발을 진행하며 기존에 개발해두었던 컴포넌트들도 더 멋있는 디자인을 입힐 필요가 생겼다. 특히 페이지 하단의 참여 완료를 위한 플로팅 위젯 형태의 컴포넌트는 디자이너님이 신경 써서 시안을 뽑아주신 만큼 멋드러졌고, 쉽지 않은 스타일 구현이 필요했다. 특히 플로팅 위젯은 sticky 형태로, 네비게이션처럼 사용자의 화면을 따라다니며 사용자기 미션을 수행하면서 추가로 스크롤을 하지 않아도 퀘스트에 바로 참여까지 완료할 수 있도록 해주는 인터랙티브한 형태가 필요했다.
다만, 여태 컴포넌트가 상단에 존재하고, 하단 스크롤에 따라오는 형태는 여럿 구현해본 경험이 있지만 반대로 컴포넌트가 하단에 존재해서 위에서 따라다니는 형태는 구현해본 경험이 없었다. 단순한 sticky로만 구현하는 것이 아닌 컴포넌트의 동적인 위치 변화를 정확하게 감지하고 이에 따라 스타일을 변경하는, 간만에 깊은 생각이 필요한 스타일링을 할 기회가 되었다.
우선, 회사에서는 Nuxt/Vue 프레임워크를 이용해 프론트엔드 애플리케이션의 개발을 진행하고 있다. 초기에는 이 플로팅 위젯의 ref를 이용해서 offsetTop의 값을 얻어서 기록하고, 이전의 값과 현재의 값을 비교해서 sticky 요소의 stuck 상태 별로 스타일을 적용하도록 로직을 구현했었다. 스크롤 이벤트에 부착함과 더불어 스로틀링도 적용하여 최대한 성능적인 부분도 놓치지 않게끔 노력했다.
const handleScroll = () => {
if (floatingBarRef.value) {
const { offsetTop } = floatingBarRef.value
isSticky.value = offsetTop === floatingBarOffsetTop.value
floatingBarOffsetTop.value = offsetTop
}
}
onMounted(() => {
window.addEventListener('scroll', useThrottleFn(handleScroll, 30))
handleScroll() // 초기 상태 지정
})
onBeforeMount(() => {
window.addEventListener('scroll', useThrottleFn(handleScroll, 30))
})
이 방식은 스크롤 이벤트에서는 곧잘 동작했다. 하지만 미션 컴포넌트를 펼치고 접거나 브라우저의 사이즈 변경에는 대응할 수 없었다. 그래서 커스텀 이벤트를 정의하고 필요한 부분에서 디스패치하는 방향을 생각해 보았으나, 최종적으로 인터랙션이 끝나는 위치에서의 계산이 필요했기 때문에 다른 방법이 필요했다.
그래서 고려한 것은 ResizeObserver API였다. 아마 IntersectionObserver API를 사용해본 경험이 있다면 익숙할 것이다. 이 ResizeObserver API는 브라우저의 크기 변화를 감지하는 resize 이벤트와는 조금 다른 부분이 있다. 바로 브라우저의 크기가 아닌, 특정 요소의 크기 변화를 감지할 수 있다는 것이다. 바로 필요한 페이지에 식별자를 추가하고, 옵저버를 연결했다.
onMounted(() => {
observer.value = new ResizeObserver(useThrottleFn(handleScroll, 30))
observer.value.observe(document.getElementById(PAGE_ID))
window.addEventListener('scroll', useThrottleFn(handleScroll, 30))
handleScroll()
})
onBeforeMount(() => {
window.addEventListener('scroll', useThrottleFn(handleScroll, 30))
observer.value?.disconnect()
})
얼추 동작하는 느낌은 들었으나, 정확도가 문제였다. 접혀 있던 미션을 펼쳐서 사이즈가 늘어났는데도 sticky 여부를 검사하는 로직이 제대로 동작하지 않는 것 같았다. 또한, 브라우저를 가로로 리사이징하면서도 offsetTop을 계산하고 저장하는 로직이 영향을 받아 정상적으로 동작하지 않았다. 다른 API를 엮을 것이 아닌, 계산 로직의 재설계가 필요한 순간이었다.
고민 속에서 getBoundingClientRect() API를 발견했다. 이 API는 요소의 크기와 뷰포트에 상대적인 위치 정보를 제공하는 객체를 반환해준다. sticky 상태일 때는 페이지 내에 붙어있을 것이라고 생각했고, 그렇다면 이 플로팅 위젯의 top 위치 값과 컴포넌트의 세로 길이인 height 값, 그리고 bottom으로부터 띄워놓은 간격만큼의 값을 더했을 때, window.innerHeight 값과의 비교를 통해 sticky 상태를 확인할 수 있을 거라 생각했다. 그리고 서체의 크기는 rem 단위를 사용하고 있는데, 고정 단위가 아니기 때문에 서체 크기를 얻는 로직(getRootElementFontSize)을 함께 작성했다. getComputedStyle API를 이용하면 쉽게 스타일 값을 얻을 수 있다.
const getRootElementFontSize = () => {
return parseFloat(
getComputedStyle(
document?.documentElement
).fontSize
)
}
const handleScroll = () => {
if (floatingBarRef.value) {
const { height, top } = floatingBarRef.value.getBoundingClientRect()
isSticky.value = height + top + getRootElementFontSize() !== window.innerHeight
}
}
onMounted(() => {
observer.value = new ResizeObserver(useThrottleFn(handleScroll, 30))
observer.value.observe(document.getElementById(PAGE_ID))
window.addEventListener('scroll', useThrottleFn(handleScroll, 30))
handleScroll() // 초기 상태 지정
})
onBeforeUnmount(() => {
window.addEventListener('scroll', useThrottleFn(handleScroll, 30))
observer.value?.disconnect()
})
이 로직에서는 플로팅 위젯의 위치를 뷰포트와 비교하며 얻어내어 계산하고 있으므로, 접힌 미션 컴포넌트의 펼침, 브라우저의 가로 리사이즈 및 CSS 브레이크포인트를 넘나드는 동작에도 예상한대로 동작하는 것을 확인할 수 있었다.
단순 스타일 구현과 API의 활용 수준에 가깝지만, 원하는 기획과 디자인을 구현해주었다는 뿌듯함을 얻은 건 기본이고, 스타일링에 대한 오만함을 내려두고, 목표와 현재의 상황을 기반으로 쌓아올린 지식 체계를 통해 가설을 세워 분석해나가는 사고가 정말 중요하다는 생각이 들었다.
В майбутньому я завжди буду залишатися скромним і розширювати свою знання, щоб мати можливість реалізувати будь-який вигляд сервісу, який хочуть побачити дизайнери та керівники проектів, при цьому використовуючи продуктивний підхід і чистий код.
Джерела
- https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver
- https://developer.mozilla.org/ko/docs/Web/API/Element/getBoundingClientRect
- https://developer.mozilla.org/ko/docs/Web/API/Window/getComputedStyle
Перекладено з: sticky 요소의 stuck 상태 별 동적 스타일 적용하기