직장에서 디자인을 위해 아코디언 UI 를 구현해야하는 상황이었습니다.
구현과정에서 알게된 점들을 적어봅니다.
1. height 속성에 Transition 효과 적용하기
CSS height 속성은 %
와 같은 상대값이 아닌 px
과 같은 절대값을 부여해야 트랜지션 효과가 적용됩니다.
문제는 클라이언트의 디바이스, 폰트 등의 요소에 따라서 height 값이 달라질 수 있기때문에 px
값을 미리 정의해두는 것은 불가능합니다.
때문에, max-height
값에다가 트랜지션 효과를 부여하는 대안이 있었습니다.
// open
max-height: 100vh (혹은 엄청나게 큰 절대 값, 999px)
// cloase
max-height: 0
그러나 열리는 모션과 닫히는 모션이 동시에 실행되는 경우, 타이밍이 맞지 않는 문제가 있었습니다.
원인은 간단한데요, 예를들어 열고자 하는 컨텐츠의 높이가 200px, 100vh 가 900px 이라고 가정해보겠습니다.
위 그림에서 트랜지션 효과가 9초 동안 지속되는 경우, 열리는 동작(0 => 200px)은 바로 시작되며 2초뒤 끝이납니다.
반대로 닫히는 동작(200px => 0)은 7초가 지난 뒤 시작하게 됩니다. (max-height 값이 줄어들다가 실제 컨텐츠 높이인 200px 보다 작아져야 닫히기 때문에)
이러한 문제 때문에 다른 방법을 찾게되었습니다.
화면이 렌더링된 후 height 값을 읽어서 동적으로 height 값을 부여하는 방식을 고려하게 되었습니다.
height 값을 읽기 위해, scrollHeight 속성을 활용했습니다.
Accordion UI 가 닫히는 경우,
height: 0;
overflow: hidden;
속성을 줘서 화면에 표시되지 않도록 처리했습니다.
이때, scrollHeight
를 통해 그려지지 않은 컨텐츠의 height 값을 읽어올 수 있습니다.
다음과 같은 로직을 통해 화면이 열리는 동작을 구현했습니다.
렌더링 이후 화면이 열릴 수 있도록 open 하는 로직은 requestAnimationFrame
으로 감싸서 비동기처리 하였습니다. ( 닫히는 모션은 반대로 하면 됩니다. )
이렇게 코드가 실행되는 경우, height 값의 변화에 transition 효과를 적용할 수 있게 됩니다.
2. 트랜지션 예쁘게 주기
트랜지션을 예쁘게 주기 위해 두가지를 고려했습니다.
하나, Transition 지속 시간
위와 같이 각각 800px, 400px 의 높이를 가진 아코디언이 있다고 가정해보겠습니다.
만약 둘다 트랜지션 지속시간이 1초라면? 800px 이 닫히는 속도가 400px 의 두배가 돼야할 것 입니다.
때문에, 높이에 맞는 적절한 트랜지션 지속시간이 부여돼야합니다. 여기서 적절한 지속시간이 계산되도록 하는 함수는 material-ui 에서 사용중인 기준을 가져왔습니다.
위 그래프에서 Y축이 시간이고, X축이 높이를 36으로 나눈 값이라고 보시면 됩니다. 얼핏보면 로그함수와 비슷한 그래프를 갖는 것 같습니다.
둘, Transition 속도
트랜지션에서는 생동감있는 모션을 위해 easing-function 을 활용합니다.
다음과 같은 모양의 easing-function 을 활용하여 생동감을 부여해봤습니다.
cubic-bezier(0.33, 1, 0.68, 1);
전체 코드는 다음과 같습니다. ( VueJS 로 작성했습니다 )
<template>
<component :is="props.el" ref="accordionRef" :style="style" class="accordion">
<slot />
</component>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue';
const props = withDefaults(
defineProps<{
el: string;
expanded: boolean;
}>(),
{ el: 'div' },
);
const accordionRef = ref<HTMLElement | null>(null);
const style = ref<Record<string, string>>(props.expanded ? {} : { height: '0px' });
watch(
() => props.expanded,
(isExpanding) => {
if (isExpanding) {
style.value = {
willChange: 'height',
overflow: 'hidden',
height: '0px',
};
requestAnimationFrame(() => {
style.value = {
...getHeightStyles(accordionRef.value?.scrollHeight),
};
});
} else {
style.value = {
...style.value,
...getHeightStyles(accordionRef.value?.scrollHeight),
willChange: 'height',
};
requestAnimationFrame(() => {
style.value = {
...style.value,
overflow: 'hidden',
height: '0px',
};
});
}
},
);
const getAutoDuration = (height = 0) => {
if (height === 0) {
return 0;
}
const constant = height / 36;
return Math.round((4 + 15 * constant ** 0.25 + constant / 5) * 10);
};
const getHeightStyles = (height = 0) => {
return {
'--ac-auto-duration': `${getAutoDuration(height)}ms`,
height: `${height}px`,
};
};
</script>
<style lang="scss" scoped>
.accordion {
transition: height var(--ac-auto-duration) cubic-bezier(0.33, 1, 0.68, 1);
}
</style>
'프론트엔드' 카테고리의 다른 글
줄바꿈 되는 Input Text ( Textarea ) 입력창 만들기 (0) | 2024.02.09 |
---|---|
Sentry 를 알아보자 - (2) Transaction, Span 직접 생성해보기 (0) | 2024.02.05 |
Sentry 를 알아보자 - (1) Trace, Transaction, Span 이란? (0) | 2024.02.04 |
모노레포의 개념, 장단점, 종류 (1) | 2024.02.01 |
Docker-Compose Nginx, Certbot 컨테이너로 HTTPS 웹서버 설정하기 (0) | 2022.01.23 |