soma0sd

코딩 & 과학 & 교육

[TypeScript/JavaScript] 직접 벽돌쌓기 형식의 리스트 레이아웃 만들기

반응형

조적조(Masonry) 형식의 레이아웃은 핀터레스트(Pinterest)형식으로 폭이 같지만 높이가 다른 여러 객체를 쌓아나가는 형태의 레이아웃입니다. Masonry라는 자바스크립트 라이브러리를 사용해서 레이아웃을 정렬하는 방법을 많이 사용합니다. 여기서는 라이브러리를 사용하지 않고 직접 스크립트를 작성합니다.

기본 원리

작업을 시작하기 전에 배치할 리스트의 컨테이너와 아이템의 전제를 살펴봅니다.

  • 상대적인 위치를 가진 컨테이너에 배치할 아이템은 절대 좌표를 사용합니다.
  • 각 아이템은 폰트와 이미지를 모두 로드한 뒤에는 높이가 변하지 않습니다.
  • 아이템 배치는 컨테이너의 너비를 벗어나면 안됩니다.
  • 뷰포트의 크기가 변하면 아이템의 너비가 바뀔 수 있습니다.

나중에 스타일을 스크립트로 추가할 수도 있지만 스타일시트를 먼저 작성합니다.

.list-container {
  position: relative;
  overflow: hidden;
  transition: height 0.3s ease-in;
}
.list-container .list-item {
  position: absolute;
  width: calc(100% / 3);
  box-sizing: border-box;
  opacity: 0;
  transition: opacity 0.8s ease-in;
}
.list-container .list-item.show {
  opacity: 1;
}

레이아웃 아이템 사이의 간격을 조정하고 싶다면 배치할 실제 요소를 .list-item로 감싸고 .list-item에는 padding 속성을 추가합니다. 이제 배치 절차를 생각합니다.

// listContainerQuery: 컨테이너 선택자, listItemQuery: 아이템 선택자
function MasonryLayout(listContainerQuery: string, listItemQuery: string) {
  // 1. 레이아웃 함수
  //   1-1. 컨테이너에 몇 행의 아이템이 들어갈 수 있는지 계산
  //   1-2. 각 행의 높이를 담아두는 배열 생성
  //   1-3. 각 아이템 요소 루프
  //     1-3-1. 이미지 로딩 대기
  //     1-3-2. 아이템을 배치할 가장 작은 값의 행 찾기
  //     1-3-3. 아이템의 배치 속성 변경(top, left)
  //     1-3-4. 현재 행의 높이에 아이템의 높이 합산
  //     1-3-5. 높이 행에서 가장 큰 값으로 컨테이너 높이를 변경
  // 2. 레이아웃 함수를 뷰포트 사이즈 변경 이벤트에 실행
}

타입스크립트

컴파일을 거쳐야 하는 불편함이 있지만 개발할 때는 이쪽을 추천합니다.

See the Pen Untitled by SeungWan Jin (@soma0sd) on CodePen.

function MasonryLayout(listContainerQuery: string, listItemQuery: string) {
  // 선택자로부터 요소를 얻음
  const elemContainer = document.querySelector<HTMLElement>(listContainerQuery);
  const elemFirstItem = elemContainer.querySelector<HTMLElement>(listItemQuery);
  // 배열 중 가장 작은 값의 인덱스
  const getArrayMinIndex = (arr: any[]) =>
    arr.reduce((r, v, i, a) => (v >= a[r] ? r : i), -1);
  // 배열 중 가장 큰 값의 인덱스
  const getArrayMaxIndex = (arr: any[]) =>
    arr.reduce((r, v, i, a) => (v <= a[r] ? r : i), -1);
  // 아이템 내부의 이미지가 로드될 때까지 대기
  const waitImageLoad = async (elem: HTMLElement) => {
    for (const img of elem.querySelectorAll<HTMLImageElement>("img"))
      await img.decode();
  };
  // 1. 레이아웃 함수
  const LayoutSetup = async () => {
    // 1-1. 컨테이너에 몇 행의 아이템이 들어갈 수 있는지 계산
    const widthWrapper = elemContainer.offsetWidth;
    const widthItem = elemFirstItem.offsetWidth;
    // 1-2. 각 행의 높이를 담아두는 배열 생성
    let layoutTopArr = new Array(parseInt(`${widthWrapper / widthItem}`));
    layoutTopArr.fill(0);
    // 1-3. 각 아이템 요소 루프
    for (const elem of elemContainer.querySelectorAll<HTMLElement>(listItemQuery)) {
      // 1-3-1. 이미지 로딩 대기
      await waitImageLoad(elem);
      // 1-3-2. 아이템을 배치할 가장 작은 값의 행 찾기
      let topMinIndex = getArrayMinIndex(layoutTopArr);
      // 1-3-3. 아이템의 배치 속성 변경(top, left)
      elem.style.left = topMinIndex * widthItem + "px";
      elem.style.top = layoutTopArr[topMinIndex] + "px";
      // 1-3-4. 숨겼던 아이템을 표시
      elem.classList.add("show");
      // 1-3-5. 현재 행의 높이에 아이템의 높이 합산
      layoutTopArr[topMinIndex] += elem.offsetHeight;
      // 1-3-6. 높이 행에서 가장 큰 값으로 컨테이너 높이를 변경
      elemContainer.style.height =
        layoutTopArr[getArrayMaxIndex(layoutTopArr)] + "px";
    }
  };
  LayoutSetup();
  // 2. 레이아웃 함수를 뷰포트 사이즈 변경 이벤트에 실행
  window.addEventListener("resize", LayoutSetup);
}

MasonryLayout(".list-container", ".list-item");

자바스크립트

function MasonryLayout(listContainerQuery, listItemQuery) {
    const elemContainer = document.querySelector(listContainerQuery);
    const elemFirstItem = elemContainer.querySelector(listItemQuery);
    const getArrayMinIndex = (arr) => arr.reduce((r, v, i, a) => (v >= a[r] ? r : i), -1);
    const getArrayMaxIndex = (arr) => arr.reduce((r, v, i, a) => (v <= a[r] ? r : i), -1);
    const waitImageLoad = async (elem) => {
        for (const img of elem.querySelectorAll("img"))
            await img.decode();
    };
    const LayoutSetup = async () => {
        const widthWrapper = elemContainer.offsetWidth;
        const widthItem = elemFirstItem.offsetWidth;
        let layoutTopArr = new Array(parseInt(`${widthWrapper / widthItem}`));
        layoutTopArr.fill(0);
        for (const elem of elemContainer.querySelectorAll(listItemQuery)) {
            await waitImageLoad(elem);
            let topMinIndex = getArrayMinIndex(layoutTopArr);
            elem.style.left = topMinIndex * widthItem + "px";
            elem.style.top = layoutTopArr[topMinIndex] + "px";
            elem.classList.add("show");
            layoutTopArr[topMinIndex] += elem.offsetHeight;
            elemContainer.style.height =
                layoutTopArr[getArrayMaxIndex(layoutTopArr)] + "px";
        }
    };
    LayoutSetup();
    window.addEventListener("resize", LayoutSetup);
}
MasonryLayout(".list-container", ".list-item");
반응형
태그:

댓글

End of content

No more pages to load