details, summary 태그를 사용해 만든 아코디언(Accordion)에 애니메이션 입히기

hyeonQyu·2022년 8월 3일
4
post-thumbnail

간단한 아코디언(Accordion)

details, summary 태그를 사용하면 자바스크립트 코드 없이 아코디언을 만들 수 있습니다.
아래와 같이 작성하기만 하면 됩니다.

HTML

<details>
  <summary>간단한 아코디언. 클릭하면 열립니다.</summary>
  <div>열렸습니다!<br/>열렸습니다!</div>
</details>

결과

아마 details, summary 태그의 존재를 몰랐다면 직접 자바스크립트 코드를 작성해서 개발했겠죠.

그런데 상당히 밋밋합니다!
열리고 닫힐 때 애니메이션이 있다면 훨씬 보기 좋을 것 같아요.

그래서 애니메이션을 넣는 작업을 하게 되었는데.. 엥? 이게 생각보다 까다로운 일이라는 것을 알게 되어 글로 남기게 되었습니다.

애니메이션(animation or transition) 넣기

참고했던 사이트

처음에는 어떻게 css를 적용해야 할지 감이 잘 안와서 구글링을 먼저 해봤습니다.
https://dev.to/pixmy/animate-details-tag-with-pure-css-52j7
위 링크에서 코드를 그대로 가져왔어요.

HTML

<details>
  <summary>This is a detail tag</summary>
  <p>
    Random content: Fat new smallness few supposing suspicion two. Course
    sir people worthy horses add entire suffer. How one dull get busy dare
    far. At principle perfectly by sweetness do. As mr started arrival
    subject by believe. Strictly numerous outlived kindness whatever on we
    no on addition.
  </p>
</details>

CSS

details[open] summary ~ * {
  animation: open 0.5s ease-in-out;
}

details[close] summary ~ * {
  animation: close 0.5s ease-in-out;
}

@keyframes open {
  0% {
    opacity: 0;
    margin-left: -20px;
  }
  100% {
    opacity: 1;
    margin-left: 0px;
  }
}

@keyframes close {
  0% {
    opacity: 1;
    margin-left: 0px;
  }
  100% {
    opacity: 0;
    margin-left: -20px;
  }
}

실행 결과

음? 처음에 열때는 애니메이션이 잘 실행되는것 같더니 닫히는 애니메이션은 안보이고, 두번째 열때부터는 애니메이션이 간헐적으로 실행이 되는 것을 볼 수 있습니다.

제가 원했던 결과는 아니어서 더 찾아봤어요.


다음으로 참고했던 사이트입니다. 역시 코드는 그대로 가져왔습니다.
https://css-tricks.com/how-to-animate-the-details-element/
이번에는 결과부터 보겠습니다.

실행 결과

!!! 제가 원했던 결과군요.
열리고 닫힐때 애니메이션이 잘 실행되고,
항상 실행됩니다.

근데 구현방법을 보니 이 방법도 마음에 들지 않았어요.
아래는 코드입니다. 훑어만 보세요!

HTML

<details>
  <summary>I can change this too.</summary>
  <div class="content">
    <p>
      Lorem, ipsum dolor sit amet consectetur adipisicing elit. Modi unde, ex rem voluptates autem aliquid veniam quis temporibus repudiandae illo, nostrum, pariatur quae! At animi modi dignissimos corrupti placeat voluptatum!
    </p>
    <img src="https://placebear.com/400/200" alt="">
    <p>
      Facilis ducimus iure officia quos possimus quaerat iusto, quas, laboriosam sapiente autem ab assumenda eligendi voluptatum nisi eius cumque, tempore reprehenderit optio placeat praesentium non sint repellendus consequuntur? Nihil, soluta.
    </p>
  </div>
</details>
<details>
  <summary>Click to expand this details with a WAAPI sliding effect</summary>
  <div class="content">
    <p>
      Lorem, ipsum dolor sit amet consectetur adipisicing elit. Modi unde, ex rem voluptates autem aliquid veniam quis temporibus repudiandae illo, nostrum, pariatur quae! At animi modi dignissimos corrupti placeat voluptatum!
    </p>
    <img src="https://placebear.com/400/200" alt="">
    <p>
      Facilis ducimus iure officia quos possimus quaerat iusto, quas, laboriosam sapiente autem ab assumenda eligendi voluptatum nisi eius cumque, tempore reprehenderit optio placeat praesentium non sint repellendus consequuntur? Nihil, soluta.
    </p>
  </div>
</details>
<details>
  <summary>Click to expand this details with a WAAPI sliding effect</summary>
  <div class="content">
    <p>
      Lorem, ipsum dolor sit amet consectetur adipisicing elit. Modi unde, ex rem voluptates autem aliquid veniam quis temporibus repudiandae illo, nostrum, pariatur quae! At animi modi dignissimos corrupti placeat voluptatum!
    </p>
    <img src="https://placebear.com/400/200" alt="">
    <p>
      Facilis ducimus iure officia quos possimus quaerat iusto, quas, laboriosam sapiente autem ab assumenda eligendi voluptatum nisi eius cumque, tempore reprehenderit optio placeat praesentium non sint repellendus consequuntur? Nihil, soluta.
    </p>
  </div>
</details>

CSS

summary {
  border: 4px solid transparent;
  outline: none;
  padding: 1rem;
  display: block;
  background: #444;
  color: white;
  padding-left: 2.2rem;
  position: relative;
  cursor: pointer;
}
summary:focus {
  border-color: black;
}

details {
  max-width: 500px;
  box-sizing: border-box;
  margin-top: 5px;
  background: white;
}
details summary::-webkit-details-marker {
  display: none;
}
details[open] > summary:before {
  transform: rotate(90deg);
}
summary:before {
  content: "";
  border-width: 0.4rem;
  border-style: solid;
  border-color: transparent transparent transparent #fff;
  position: absolute;
  top: 1.3rem;
  left: 1rem;
  transform: rotate(0);
  transform-origin: 0.2rem 50%;
  transition: 0.25s transform ease;
}

.content {
  border-top: none;
  padding: 10px;
  border: 2px solid #888;
  border-top: none;
}

p {
  margin: 0;
  padding-bottom: 10px;
}
p:last-child {
  padding: 0;
}
img {
  max-width: 100%;
}

Javascript

import "./styles.css";

class Accordion {
  constructor(el) {
    // Store the <details> element
    this.el = el;
    // Store the <summary> element
    this.summary = el.querySelector("summary");
    // Store the <div class="content"> element
    this.content = el.querySelector(".content");

    // Store the animation object (so we can cancel it if needed)
    this.animation = null;
    // Store if the element is closing
    this.isClosing = false;
    // Store if the element is expanding
    this.isExpanding = false;
    // Detect user clicks on the summary element
    this.summary.addEventListener("click", (e) => this.onClick(e));
  }

  onClick(e) {
    // Stop default behaviour from the browser
    e.preventDefault();
    // Add an overflow on the <details> to avoid content overflowing
    this.el.style.overflow = "hidden";
    // Check if the element is being closed or is already closed
    if (this.isClosing || !this.el.open) {
      this.open();
      // Check if the element is being openned or is already open
    } else if (this.isExpanding || this.el.open) {
      this.shrink();
    }
  }

  shrink() {
    // Set the element as "being closed"
    this.isClosing = true;

    // Store the current height of the element
    const startHeight = `${this.el.offsetHeight}px`;
    // Calculate the height of the summary
    const endHeight = `${this.summary.offsetHeight}px`;

    // If there is already an animation running
    if (this.animation) {
      // Cancel the current animation
      this.animation.cancel();
    }

    // Start a WAAPI animation
    this.animation = this.el.animate(
      {
        // Set the keyframes from the startHeight to endHeight
        height: [startHeight, endHeight]
      },
      {
        duration: 400,
        easing: "ease-out"
      }
    );

    // When the animation is complete, call onAnimationFinish()
    this.animation.onfinish = () => this.onAnimationFinish(false);
    // If the animation is cancelled, isClosing variable is set to false
    this.animation.oncancel = () => (this.isClosing = false);
  }

  open() {
    // Apply a fixed height on the element
    this.el.style.height = `${this.el.offsetHeight}px`;
    // Force the [open] attribute on the details element
    this.el.open = true;
    // Wait for the next frame to call the expand function
    window.requestAnimationFrame(() => this.expand());
  }

  expand() {
    // Set the element as "being expanding"
    this.isExpanding = true;
    // Get the current fixed height of the element
    const startHeight = `${this.el.offsetHeight}px`;
    // Calculate the open height of the element (summary height + content height)
    const endHeight = `${
      this.summary.offsetHeight + this.content.offsetHeight
    }px`;

    // If there is already an animation running
    if (this.animation) {
      // Cancel the current animation
      this.animation.cancel();
    }

    // Start a WAAPI animation
    this.animation = this.el.animate(
      {
        // Set the keyframes from the startHeight to endHeight
        height: [startHeight, endHeight]
      },
      {
        duration: 400,
        easing: "ease-out"
      }
    );
    // When the animation is complete, call onAnimationFinish()
    this.animation.onfinish = () => this.onAnimationFinish(true);
    // If the animation is cancelled, isExpanding variable is set to false
    this.animation.oncancel = () => (this.isExpanding = false);
  }

  onAnimationFinish(open) {
    // Set the open attribute based on the parameter
    this.el.open = open;
    // Clear the stored animation
    this.animation = null;
    // Reset isClosing & isExpanding
    this.isClosing = false;
    this.isExpanding = false;
    // Remove the overflow hidden and the fixed height
    this.el.style.height = this.el.style.overflow = "";
  }
}

document.querySelectorAll("details").forEach((el) => {
  new Accordion(el);
});

일단 가장 마음에 안들었던 부분은 자바스크립트 코드가 길다는 것이었습니다.
deatils, summary 태그를 사용함으로써 자바스크립트 코드가 불필요하다고 했었는데 애니메이션을 넣기 위해서는 자바스크립트 코드가 필요하다는게.. 이 무슨 배보다 배꼽이 더 큰 경우???
그럴거면 차라리 열고 닫히는 부분을 자바스크립트로 작성하고 나머지 애니메이션은 css로만 처리하는게 나을 것 같네요..;

직접 작성

참고한 코드를 그대로 사용할 수는 없었지만 어떤 식으로 css를 작성해야 하는지 감은 잡히기 시작했어요.
그래서 직접 코드를 작성해보았습니다.

우선 제가 원했던 것은 2가지였습니다.

  1. 열고 닫을 때 항상 애니메이션이 잘 실행된다.
  2. 자바스크립트 코드를 쓰지 않는다.

1번 조건은 완전히 충족시킬 수 있었으나... 2번은 그렇지는 못했습니다.(이유는 밑에서 설명할게요.)

먼저 실행 결과 화면입니다.

실행 결과


열고 닫을 때 항상 애니메이션이 잘 실행되는 것을 확인할 수 있습니다.

다음은 코드입니다. Next.js로 개발하고 있었기에 html과 css, js 모두 한 파일 안에 작성했습니다.

JSX

import { useEffect, useRef, useState } from "react";

function Accordion(props) {
  const { summary, children } = props;
  const contentRef = useRef();
  const [contentHeight, setContentHeight] = useState(0);

  useEffect(() => {
    setContentHeight(contentRef?.current?.clientHeight ?? 0);
  }, []);

  return (
    <>
      <div className={"accordion"}>
        <details>
          <summary>
            <span>{summary}</span>
            <div className={"arrow"}></div>
          </summary>
        </details>
        <div className={"content-wrapper"}>
          <div className={"content"} ref={contentRef}>
            {children}
          </div>
        </div>
      </div>

      <style jsx>{`
        .accordion {
          border-bottom: 1px solid #e5e8eb;
          padding: 20px 0;
          width: 100%;
        }

        details {
          cursor: pointer;
        }

        summary {
          display: flex;
          align-items: center;
          justify-content: space-between;
        }

        .content-wrapper {
          overflow: hidden;
        }

        .content {
          transition: 0.3s ease;
          margin-top: -${contentHeight}px;
          opacity: 0;
        }

        details[open] + .content-wrapper > .content {
          margin-top: 20px;
          opacity: 1;
        }

        .arrow {
          transition: transform 0.3s;
          width: 10px;
          height: 10px;
          border-top: 2px solid grey;
          border-right: 2px solid grey;
          transform: rotate(135deg);
        }

        details[open] .arrow {
          transform: rotate(315deg);
        }
      `}</style>
    </>
  );
}

export default Accordion;

여기에서 유효한 자바스크립트 코드(리액트 코드라고 해도 되겠네요)

const contentRef = useRef();
const [contentHeight, setContentHeight] = useState(0);

useEffect(() => {
  setContentHeight(contentRef?.current?.clientHeight ?? 0);
}, []);

이정도입니다.

자바스크립트 코드를 아예 사용하고 싶지 않았지만 쓰게 된 이유는

.content {
	transition: 0.3s ease;
    margin-top: -${contentHeight}px;
    opacity: 0;
}

바로 이 부분 때문입니다ㅠㅠ

contentHeight는 아코디언을 열었을때 펼쳐지는 내용 부분의 높이값인데 이를 동적으로 주지 않고 -100%-100px같이 정적으로 값을 주게 되면 아래와 같은 문제가 생깁니다.

혹시 문제가 보이시나요??
아까 정상적으로 작동한 결과 화면(아래)과 비교해 보면 느끼실 것 같네요.

여기서 발견되는 문제점은 다음과 같습니다.

  1. margin-top: -100px로 적용한 경우 펼쳐지는 내용이 길어지면 접혀있을 때 아코디언의 높이가 커진다.
  2. margin-top: -100%로 적용한 경우 열릴 때 애니메이션이 바로 실행되지 않는 것처럼 보인다.

1번의 경우는 margin-top으로 준 값보다 펼쳐지는 내용의 크기가 더 큰 경우 발생하고,
2번의 경우는 펼쳐지는 내용이 처음에 너무 위로 올라가있어서 내려오는데 시간이 걸리기 때문에 발생하는 문제였습니다.



그래서 펼쳐지는 내용의 높이값을 구하는 최소한의 자바스크립트 코드만을 추가하여 원하는 모양의 아코디언을 구현할 수 있었습니다.

profile
백엔드가 하고 싶었던 프론트엔드 개발자

0개의 댓글