프론트엔드

접었다 폈다. 자연스러운 Accordian UI 만들기!

CodeBoyEd 2024. 2. 14. 00:13

직장에서 디자인을 위해 아코디언 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 값을 읽어올 수 있습니다.
다음과 같은 로직을 통해 화면이 열리는 동작을 구현했습니다.

2대지 1

렌더링 이후 화면이 열릴 수 있도록 open 하는 로직은 requestAnimationFrame 으로 감싸서 비동기처리 하였습니다. ( 닫히는 모션은 반대로 하면 됩니다. )
이렇게 코드가 실행되는 경우, height 값의 변화에 transition 효과를 적용할 수 있게 됩니다.

2. 트랜지션 예쁘게 주기

트랜지션을 예쁘게 주기 위해 두가지를 고려했습니다.

하나, Transition 지속 시간

3대지 1

위와 같이 각각 800px, 400px 의 높이를 가진 아코디언이 있다고 가정해보겠습니다.
만약 둘다 트랜지션 지속시간이 1초라면? 800px 이 닫히는 속도가 400px 의 두배가 돼야할 것 입니다.

때문에, 높이에 맞는 적절한 트랜지션 지속시간이 부여돼야합니다. 여기서 적절한 지속시간이 계산되도록 하는 함수는 material-ui 에서 사용중인 기준을 가져왔습니다.

image

위 그래프에서 Y축이 시간이고, X축이 높이를 36으로 나눈 값이라고 보시면 됩니다. 얼핏보면 로그함수와 비슷한 그래프를 갖는 것 같습니다.

둘, Transition 속도

트랜지션에서는 생동감있는 모션을 위해 easing-function 을 활용합니다.

다음과 같은 모양의 easing-function 을 활용하여 생동감을 부여해봤습니다.

cubic-bezier(0.33, 1, 0.68, 1);

image

전체 코드는 다음과 같습니다. ( 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>